React 中的無障礙性
在我們的最後一篇教程文章中,我們將專注於(雙關語)無障礙性,包括 React 中的焦點管理,這可以改善鍵盤使用者和螢幕閱讀器使用者的可用性並減少困惑。
| 預備知識 | 熟悉核心 HTML、CSS 和 JavaScript 語言,以及 終端/命令列。 |
|---|---|
| 學習成果 | 在 React 中實現鍵盤無障礙性。 |
包含鍵盤使用者
至此,我們已經實現了我們著手實現的所有功能。使用者可以新增新任務、勾選和取消勾選任務、刪除任務或編輯任務名稱。此外,他們還可以按全部、進行中或已完成的任務來篩選任務列表。
或者,至少,他們可以用滑鼠完成所有這些事情。不幸的是,這些功能對於僅使用鍵盤的使用者來說不是很方便。我們現在就來探討一下這個問題。
探索鍵盤可用性問題
首先點選應用頂部的輸入框,就像你要新增一個新任務一樣。你會看到該輸入框周圍有一個粗的虛線輪廓。這個輪廓是瀏覽器當前聚焦在該元素上的視覺指示。按下 Tab 鍵,你會看到輪廓出現在輸入框下方的“新增”按鈕周圍。這表明瀏覽器的焦點已經移動。
再按幾次 Tab 鍵,你會看到這個虛線焦點指示器在每個篩選按鈕之間移動。繼續按,直到焦點指示器圍繞在第一個“編輯”按鈕上。按 Enter。
<Todo /> 元件將切換模板,正如我們所設計的,你會看到一個讓我們編輯任務名稱的表單。
但是我們的焦點指示器去哪兒了?
當我們在 <Todo /> 元件中切換模板時,我們完全移除了舊模板的元素,並用新模板的元素替換它們。這意味著我們之前聚焦的元素不再存在,所以沒有視覺提示來顯示瀏覽器的焦點在哪裡。這可能會讓各種使用者感到困惑——尤其是依賴鍵盤的使用者或使用輔助技術的使用者。
為了改善鍵盤和輔助技術使用者的體驗,我們應該自己管理瀏覽器的焦點。
旁註:關於我們的焦點指示器
如果你用滑鼠點選“全部”、“進行中”或“已完成”篩選按鈕,你不會看到可見的焦點指示器,但如果你用鍵盤上的 Tab 鍵在它們之間移動,你就會看到。別擔心——你的程式碼沒有問題!
我們的 CSS 檔案使用 :focus-visible 偽類來為焦點指示器提供自定義樣式,而瀏覽器使用一套內部規則來決定何時向用戶顯示它。通常,瀏覽器會響應鍵盤輸入顯示焦點指示器,而可能會響應滑鼠輸入顯示它。<button> 元素不會響應滑鼠輸入顯示焦點指示器,而 <input> 元素會。
:focus-visible 的行為比你可能更熟悉的舊的 :focus 偽類更具選擇性。:focus 在更多情況下會顯示焦點指示器,如果你願意,可以代替或與 :focus-visible 結合使用。
在模板之間聚焦
當用戶將 <Todo /> 模板從檢視切換到編輯時,我們應該聚焦於用於重新命名它的 <input>;當他們從編輯切換回檢視時,我們應該將焦點移回“編輯”按鈕。
定位我們的元素
到目前為止,我們一直在編寫 JSX 元件,讓 React 在後臺構建最終的 DOM。大多數時候,我們不需要定位 DOM 中的特定元素,因為我們可以使用 React 的 state 和 props 來控制渲染的內容。然而,為了管理焦點,我們確實需要能夠定位特定的 DOM 元素。
這就是 useRef() Hook 的用武之地。
首先,更改 Todo.jsx 頂部的 import 語句,使其包含 useRef
import { useRef, useState } from "react";
useRef() 建立一個具有單一屬性的物件:current。Ref 可以儲存我們想要的任何值,我們稍後可以查詢這些值。我們甚至可以儲存對 DOM 元素的引用,這正是我們在這裡要做的。
接下來,在你的 Todo() 函式中的 useState() Hook 下建立兩個新的常量。每個都應該是一個 ref——一個用於檢視模板中的“編輯”按鈕,一個用於編輯模板中的編輯欄位。
const editFieldRef = useRef(null);
const editButtonRef = useRef(null);
這些 ref 的預設值為 null,以明確它們在附加到其 DOM 元素之前是空的。要將它們附加到其元素上,我們將特殊的 ref 屬性新增到每個元素的 JSX 中,並將這些屬性的值設定為相應命名的 ref 物件。
更新你的編輯模板中的 <input>,使其如下所示
<input
id={props.id}
className="todo-text"
type="text"
value={newName}
onChange={handleChange}
ref={editFieldRef}
/>
更新你的檢視模板中的“編輯”按鈕,使其如下所示
<button
type="button"
className="btn"
onClick={() => setEditing(true)}
ref={editButtonRef}>
Edit <span className="visually-hidden">{props.name}</span>
</button>
這樣做將用它們所附加的 DOM 元素的引用來填充我們的 editFieldRef 和 editButtonRef,但只有在 React 渲染了元件之後。你可以自己測試一下:在你的 Todo() 函式的主體中,在 editButtonRef 初始化之後的地方新增以下行
console.log(editButtonRef.current);
你會看到,當元件首次渲染時,editButtonRef.current 的值為 null,但如果你點選一個“編輯”按鈕,它會將 <button> 元素記錄到控制檯。這是因為 ref 只有在元件渲染後才會被填充,而點選“編輯”按鈕會導致元件重新渲染。在繼續之前,請務必刪除此日誌。
注意:你的日誌將出現 6 次,因為我們的應用中有 3 個 <Todo /> 例項,並且 React 在開發模式下會渲染我們的元件兩次。
我們越來越接近了!為了利用我們新引用的元素,我們需要使用另一個 React Hook:useEffect()。
實現 useEffect()
useEffect() 之所以這樣命名,是因為它執行我們想要新增到渲染過程中但不能在主函式體內執行的任何副作用。useEffect() 在元件渲染後立即執行,這意味著我們在上一節中引用的 DOM 元素將可供我們使用。
再次更改 Todo.jsx 的匯入語句以新增 useEffect
import { useEffect, useRef, useState } from "react";
useEffect() 接受一個函式作為引數;這個函式在元件渲染之後執行。為了演示這一點,將以下 useEffect() 呼叫放在 Todo() 主體中的 return 語句之上,並向其傳遞一個將“side effect”一詞記錄到控制檯的函式
useEffect(() => {
console.log("side effect");
});
為了說明主渲染過程和在 useEffect() 內部執行的程式碼之間的區別,再新增一個日誌——將其放在前一個新增的下方
console.log("main render");
現在,在瀏覽器中開啟應用。你應該在控制檯中看到兩條訊息,每條訊息重複多次。請注意,“main render”是如何先被記錄的,而“side effect”是後被記錄的,即使“side effect”日誌在程式碼中出現得更早。
main render Todo.jsx side effect Todo.jsx
再次說明,日誌之所以這樣排序,是因為 useEffect() 內部的程式碼在元件渲染之後執行。這需要一些時間來適應,在你繼續前進時請記住這一點。現在,刪除 console.log("main render"),我們將繼續實現我們的焦點管理。
聚焦於我們的編輯欄位
現在我們知道我們的 useEffect() Hook 可以工作了,我們可以用它來管理焦點。提醒一下,我們希望在切換到編輯模板時聚焦於編輯欄位。
更新你現有的 useEffect() Hook,使其如下所示
useEffect(() => {
if (isEditing) {
editFieldRef.current.focus();
}
}, [isEditing]);
這些更改使得,如果 isEditing 為 true,React 會讀取 editFieldRef 的當前值並將瀏覽器焦點移動到它。我們還向 useEffect() 傳遞一個數組作為第二個引數。這個陣列是 useEffect() 應依賴的值的列表。有了這些值,useEffect() 將只在其中一個值發生變化時執行。我們只想在 isEditing 的值發生變化時改變焦點。
現在試試吧:使用 Tab 鍵導航到一個“編輯”按鈕,然後按 Enter。你應該看到 <Todo /> 元件切換到其編輯模板,並且瀏覽器焦點指示器應該出現在 <input> 元素周圍!
將焦點移回編輯按鈕
乍一看,讓 React 在儲存或取消編輯時將焦點移回我們的“編輯”按鈕似乎非常容易。我們當然可以在我們的 useEffect 中新增一個條件,如果 isEditing 是 false 就聚焦於編輯按鈕?我們現在就來試試——像這樣更新你的 useEffect() 呼叫
useEffect(() => {
if (isEditing) {
editFieldRef.current.focus();
} else {
editButtonRef.current.focus();
}
}, [isEditing]);
這有點用。如果你使用鍵盤觸發“編輯”按鈕(記住:用 Tab 切換到它並按 Enter),你會看到當你開始和結束編輯時,你的焦點在編輯 <input> 和“編輯”按鈕之間移動。然而,你可能注意到了一個新問題——在頁面載入後,我們甚至還沒有與應用互動,最後一個 <Todo /> 元件中的“編輯”按鈕就立即獲得了焦點!
我們的 useEffect() Hook 的行為完全符合我們的設計:它在元件渲染後立即執行,看到 isEditing 是 false,然後聚焦於“編輯”按鈕。有三個 <Todo /> 例項,焦點被給予了最後一個渲染的那個例項的“編輯”按鈕。
我們需要重構我們的方法,以便只有當 isEditing 從一個值變為另一個值時才改變焦點。
更強大的焦點管理
為了滿足我們精煉的標準,我們不僅需要知道 isEditing 的值,還需要知道該值何時發生了變化。為此,我們需要能夠讀取 isEditing 常量的先前值。使用虛擬碼,我們的邏輯應該是這樣的
if (wasNotEditingBefore && isEditingNow) {
focusOnEditField();
} else if (wasEditingBefore && isNotEditingNow) {
focusOnEditButton();
}
React 團隊已經討論了獲取元件先前 state 的方法,並提供了一個我們可以用於這項工作的示例 Hook。
進入 usePrevious()
將以下程式碼貼上到 Todo.jsx 的頂部附近,在你的 Todo() 函式之上。
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
usePrevious() 是一個自定義 Hook,用於在渲染之間跟蹤一個值。它
- 使用
useRef()Hook 建立一個空的ref。 - 將
ref的current值返回給呼叫它的元件。 - 呼叫
useEffect()並在每次渲染呼叫元件後更新儲存在ref.current中的值。
useEffect() 的行為是此功能的關鍵。因為 ref.current 在 useEffect() 呼叫內部更新,所以它總是比元件主渲染週期中的任何值晚一步——因此得名 usePrevious()。
使用 usePrevious()
現在我們可以定義一個 wasEditing 常量來跟蹤 isEditing 的先前值;這是透過使用 isEditing 作為引數呼叫 usePrevious 來實現的。在 Todo() 內部,在 useRef 行的下方新增以下程式碼
const wasEditing = usePrevious(isEditing);
你可以透過在這行下面新增一個 console log 來看看 usePrevious() 的行為
console.log(wasEditing);
在這個日誌中,wasEditing 的 current 值將始終是 isEditing 的先前值。點選“編輯”和“取消”按鈕幾次,觀察它的變化,然後在準備好繼續時刪除此日誌。
有了這個 wasEditing 常量,我們可以更新我們的 useEffect() Hook 以實現我們之前討論的虛擬碼
useEffect(() => {
if (!wasEditing && isEditing) {
editFieldRef.current.focus();
} else if (wasEditing && !isEditing) {
editButtonRef.current.focus();
}
}, [wasEditing, isEditing]);
請注意,useEffect() 的邏輯現在依賴於 wasEditing,所以我們將其提供在依賴項陣列中。
嘗試使用鍵盤啟用 <Todo /> 元件中的“編輯”和“取消”按鈕;你會看到瀏覽器焦點指示器適當地移動,而沒有我們在本節開頭討論的問題。
使用者刪除任務時聚焦
還有一個最後的鍵盤體驗差距:當用戶從列表中刪除一個任務時,焦點消失了。我們將遵循與我們之前的更改類似的模式:我們將建立一個新的 ref,並利用我們的 usePrevious() Hook,這樣當用戶刪除一個任務時,我們就可以聚焦於列表標題。
為什麼是列表標題?
有時,我們想把焦點發送到的地方是顯而易見的:當我們切換 <Todo /> 模板時,我們有一個可以“返回”的起點——“編輯”按鈕。然而,在這種情況下,由於我們完全從 DOM 中移除了元素,我們沒有地方可以返回。次優的選擇是附近一個直觀的位置。列表標題是我們的最佳選擇,因為它靠近使用者將要刪除的列表項,並且聚焦於它可以告訴使用者還剩下多少任務。
建立我們的 ref
將 useRef() 和 useEffect() Hook 匯入到 App.jsx 中——你將在下面同時需要它們
import { useState, useRef, useEffect } from "react";
接下來,在 App() 函式內部,就在 return 語句之上宣告一個新的 ref
const listHeadingRef = useRef(null);
準備標題
像我們的 <h2> 這樣的標題元素通常是不可聚焦的。這不是問題——我們可以透過向任何元素新增 tabindex="-1" 屬性來使其以程式設計方式可聚焦。這意味著只能用 JavaScript 聚焦。你不能像使用 <button> 或 <a> 元素那樣按 Tab 聚焦於一個 tabindex 為 -1 的元素(這可以使用 tabindex="0" 來實現,但在這種情況下不合適)。
讓我們將 tabindex 屬性——在 JSX 中寫為 tabIndex——新增到我們任務列表上方的標題中,連同我們的 listHeadingRef
<h2 id="list-heading" tabIndex="-1" ref={listHeadingRef}>
{headingText}
</h2>
注意: tabindex 屬性對於無障礙性的邊緣情況非常有用,但你應該非常小心不要過度使用它。只有當你確定讓一個元素可聚焦會以某種方式對你的使用者有益時,才對它應用 tabindex。在大多數情況下,你應該利用那些可以自然獲得焦點的元素,比如按鈕、錨點和輸入框。不負責任地使用 tabindex 可能會對鍵盤和螢幕閱讀器使用者產生極其負面的影響!
獲取先前的 state
我們只想在使用者從列表中刪除任務時,聚焦於與我們的 ref 關聯的元素(透過 ref 屬性)。這將需要我們之前使用過的 usePrevious() Hook。將它新增到你的 App.jsx 檔案的頂部,就在匯入語句的下方
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
現在,在 App() 函式內部的 return 語句之上新增以下程式碼
const prevTaskLength = usePrevious(tasks.length);
這裡我們呼叫 usePrevious() 來跟蹤 tasks 陣列的先前長度。
注意:由於我們現在在兩個檔案中使用了 usePrevious(),將 usePrevious() 函式移動到它自己的檔案中,從該檔案中匯出,並在你需要的地方匯入它可能會更高效。當你完成本文後,可以嘗試將其作為一個練習。
使用 useEffect() 控制我們的標題焦點
現在我們已經儲存了我們之前有多少個任務,我們可以設定一個 useEffect() Hook,在我們的任務數量發生變化時執行,如果現在的任務數量少於之前的數量——也就是說,我們刪除了一個任務!——它將聚焦於標題。
將以下內容新增到你的 App() 函式的主體中,就在你之前的新增之後
useEffect(() => {
if (tasks.length < prevTaskLength) {
listHeadingRef.current.focus();
}
}, [tasks.length, prevTaskLength]);
我們只在當前任務數量少於之前時才嘗試聚焦於我們的列表標題。傳遞給此 Hook 的依賴項確保它只在這些值(當前任務數或先前任務數)中的任何一個發生變化時才會嘗試重新執行。
現在,當你在瀏覽器中使用鍵盤刪除一個任務時,你會看到我們的虛線焦點輪廓出現在列表上方的標題周圍。
完成!
你剛剛從頭開始構建了一個 React 應用!恭喜!你在這裡學到的技能將成為你繼續使用 React 工作的一個很好的基礎。
大多數時候,即使你所做的只是仔細考慮元件及其 state 和 props,你也可以成為 React 專案的有效貢獻者。記住要始終編寫你所能寫的最好的 HTML。
useRef() 和 useEffect() 是一些高階功能,你應該為自己使用了它們而感到自豪!尋找機會多加練習,因為這樣做將使你能夠為使用者創造包容性的體驗。記住:沒有它們,我們的應用對鍵盤使用者來說是無法訪問的!
注意:如果你需要將你的程式碼與我們的版本進行核對,你可以在我們的 todo-react 倉庫中找到 React 示例應用的最終版本。要檢視正在執行的即時版本,請訪問 https://mdn.github.io/todo-react/。
在最後一篇文章中,我們將向你展示一個 React 資源列表,你可以用它來進一步學習。