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 的狀態和屬性來控制渲染的內容。但是,為了管理焦點,我們需要能夠定位特定的 DOM 元素。
這就是 useRef() 鉤子的用武之地。
首先,更改 Todo.jsx 頂部匯入語句,使其包含 useRef
import { useRef, useState } from "react";
useRef() 建立一個具有單個屬性的物件:current。引用可以儲存我們希望它們儲存的任何值,我們以後可以查詢這些值。我們甚至可以儲存對 DOM 元素的引用,這正是我們將在本文中要做的。
接下來,在 Todo() 函式中的 useState() 鉤子下方建立兩個新的常量。每個都應該是一個引用——一個用於檢視模板中的“編輯”按鈕,另一個用於編輯模板中的編輯欄位。
const editFieldRef = useRef(null);
const editButtonRef = useRef(null);
這些引用預設值為 null,以表明它們在被附加到其 DOM 元素之前將為空。為了將它們附加到其元素,我們將向每個元素的 JSX 新增特殊的 ref 屬性,並將這些屬性的值設定為相應的 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,但如果你點選“編輯”按鈕,它會將 <input> 元素記錄到控制檯中。這是因為引用只有在元件渲染後才會被填充,而點選“編輯”按鈕會導致元件重新渲染。在繼續之前,請確保刪除此日誌。
注意:你的日誌將出現 6 次,因為我們的應用程式中有 3 個 <Todo /> 例項,並且 React 在開發過程中會渲染我們的元件兩次。
我們越來越接近了!為了利用我們新引用的元素,我們需要使用另一個 React 鉤子:useEffect()。
實現 useEffect()
useEffect() 的名稱來源於它執行我們想要新增到渲染過程中的任何副作用,但這些副作用不能在主函式體內執行。useEffect() 在元件渲染後立即執行,這意味著我們在上一節中引用的 DOM 元素將可供我們使用。
再次更改 Todo.jsx 的匯入語句,新增 useEffect
import { useEffect, useRef, useState } from "react";
useEffect() 接受一個函式作為引數;此函式在元件渲染後執行。為了演示這一點,將以下 useEffect() 呼叫放在 Todo() 函式主體中的 return 語句正上方,並將一個將“副作用”記錄到你的控制檯的函式傳遞給它
useEffect(() => {
console.log("side effect");
});
為了說明主渲染過程和 useEffect() 內部執行的程式碼之間的區別,請新增另一個日誌——將此日誌放在之前的新增內容下方
console.log("main render");
現在,在你的瀏覽器中開啟應用程式。你應該在你的控制檯中看到兩條訊息,每條訊息重複多次。注意“主渲染”如何先記錄,而“副作用”如何後記錄,即使“副作用”日誌在程式碼中先出現。
main render Todo.jsx side effect Todo.jsx
同樣,日誌以這種方式排序是因為 useEffect() 內部的程式碼在元件渲染後執行。這需要一些時間來適應,在前進過程中請記住這一點。現在,刪除 console.log("主渲染"),我們將繼續執行我們的焦點管理。
聚焦於我們的編輯欄位
現在我們知道 useEffect() 鉤子有效了,我們可以用它來管理焦點。作為提醒,我們希望在切換到編輯模板時將焦點放在編輯欄位上。
更新你現有的 useEffect() 鉤子,使其看起來像這樣
useEffect(() => {
if (isEditing) {
editFieldRef.current.focus();
}
}, [isEditing]);
這些更改使 React 在 isEditing 為真時讀取 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() 鉤子完全按照我們的設計執行:它在元件渲染後立即執行,看到 isEditing 為 false,並使“編輯”按鈕獲得焦點。<Todo /> 有三個例項,最後一個渲染的例項的“編輯”按鈕獲得了焦點。
我們需要重構我們的方法,以便只有當 isEditing 的值從一個值更改為另一個值時,焦點才會更改。
更強大的焦點管理
為了滿足我們改進後的標準,我們需要知道的不僅僅是 isEditing 的值,還要知道何時更改了該值。為此,我們需要能夠讀取 isEditing 常量的先前值。使用虛擬碼,我們的邏輯應該類似於以下內容
if (wasNotEditingBefore && isEditingNow) {
focusOnEditField();
} else if (wasEditingBefore && isNotEditingNow) {
focusOnEditButton();
}
React 團隊討論了 獲取元件先前狀態的方法,並提供了一個我們可以用來完成此工作的示例鉤子。
輸入 usePrevious()
將以下程式碼貼上到 Todo.jsx 頂部,位於 Todo() 函式上方。
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
usePrevious() 是一個自定義鉤子,它跟蹤跨渲染的值。它
- 使用
useRef()鉤子建立一個空ref。 - 將
ref的current值返回給呼叫它的元件。 - 呼叫
useEffect()並更新儲存在ref.current中的值,以響應呼叫元件的每次渲染。
useEffect() 的行為是此功能的關鍵。因為 ref.current 在 useEffect() 呼叫中更新,所以它總是比元件主渲染週期中的任何值落後一步——因此得名 usePrevious()。
使用 usePrevious()
現在我們可以定義一個 wasEditing 常量來跟蹤 isEditing 的前一個值;這是透過將 isEditing 作為引數呼叫 usePrevious 來實現的。將以下內容新增到 Todo() 中,位於 useRef 行下方
const wasEditing = usePrevious(isEditing);
你可以透過在此行下方新增一個控制檯日誌來檢視 usePrevious() 的行為
console.log(wasEditing);
在此日誌中,wasEditing 的 current 值將始終是 isEditing 的前一個值。點選“編輯”和“取消”按鈕幾次,觀察它的變化,然後在你準備好繼續時刪除此日誌。
有了這個 wasEditing 常量,我們可以更新 useEffect() 鉤子以實現我們之前討論的虛擬碼
useEffect(() => {
if (!wasEditing && isEditing) {
editFieldRef.current.focus();
} else if (wasEditing && !isEditing) {
editButtonRef.current.focus();
}
}, [wasEditing, isEditing]);
請注意,useEffect() 的邏輯現在取決於 wasEditing,因此我們在依賴項陣列中提供它。
嘗試使用鍵盤在 <Todo /> 元件中啟用“編輯”和“取消”按鈕;你會看到瀏覽器的焦點指示器適當地移動,而不會出現我們本節開頭討論的問題。
當用戶刪除任務時聚焦
還有一個鍵盤體驗差距:當用戶從列表中刪除任務時,焦點消失。我們將遵循與之前更改類似的模式:我們將建立一個新的 ref,並利用我們的 usePrevious() 鉤子,以便我們可以在使用者刪除任務時將焦點放在列表標題上。
為什麼是列表標題?
有時,我們想要將焦點發送到的位置是顯而易見的:當我們切換 <Todo /> 模板時,我們有一個“返回”的原點——“編輯”按鈕。但是,在這種情況下,由於我們完全從 DOM 中刪除了元素,因此我們沒有可以返回的位置。下一個最佳位置是附近的一個直觀位置。列表標題是我們最好的選擇,因為它靠近使用者將刪除的列表項,並且將焦點放在它上面會告訴使用者還剩下多少任務。
建立我們的 ref
將 useRef() 和 useEffect() 鉤子匯入到 App.jsx 中——你將在下面需要這兩個鉤子
import { useState, useRef, useEffect } from "react";
接下來,在 App() 函式中宣告一個新的 ref,就在 return 語句上方
const listHeadingRef = useRef(null);
準備標題
像我們 <h2> 這樣的標題元素通常不可聚焦。這不是問題——我們可以透過新增屬性 tabindex="-1" 來使任何元素以程式設計方式可聚焦。這意味著只能用 JavaScript 聚焦。你不能像對 <button> 或 <a> 元素那樣按下 Tab 來聚焦具有 -1 tabindex 的元素(這可以透過 tabindex="0" 來完成,但這在這種情況下不合適)。
讓我們將 tabindex 屬性(在 JSX 中寫為 tabIndex)新增到我們任務列表上方的標題,以及我們的 listHeadingRef
<h2 id="list-heading" tabIndex="-1" ref={listHeadingRef}>
{headingText}
</h2>
注意:tabindex 屬性非常適合可訪問性邊緣情況,但你應該非常小心不要過度使用它。只有當你確定使元素可聚焦將對你的使用者有所幫助時,才將其應用於元素。在大多數情況下,你應該使用可以自然地獲取焦點的元素,例如按鈕、錨點和輸入。不負責任地使用 tabindex 會對鍵盤和螢幕閱讀器使用者產生非常負面的影響!
獲取上一個狀態
我們希望僅當用戶從他們的列表中刪除任務時,才將焦點放在與我們的 ref 關聯的元素(透過 ref 屬性)上。這將需要我們之前使用的 usePrevious() 鉤子。將其新增到 App.jsx 檔案的頂部,就在匯入語句下方
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
現在新增以下內容,就在 App() 函式中的 return 語句上方
const prevTaskLength = usePrevious(tasks.length);
在這裡,我們呼叫 usePrevious() 來跟蹤任務陣列的上一個長度。
注意:由於我們現在在兩個檔案中使用 usePrevious(),因此將其移動到單獨的檔案中、從該檔案匯出並將其匯入到需要它的位置可能更有效。在你完成之後,嘗試這樣做作為練習。
使用 useEffect() 來控制標題焦點
現在我們已經儲存了我們以前有多少個任務,我們可以設定一個 useEffect() 鉤子,以便在我們的任務數量發生變化時執行,如果我們現在的任務數量少於之前,則會將焦點放在標題上——也就是說,我們刪除了一個任務!
將以下內容新增到 App() 函式的正文中,就在你之前的新增內容下方
useEffect(() => {
if (tasks.length < prevTaskLength) {
listHeadingRef.current.focus();
}
}, [tasks.length, prevTaskLength]);
我們只有在我們現在的任務數量少於之前時,才會嘗試將焦點放在我們的列表標題上。傳遞給此鉤子的依賴項確保它只會嘗試在任何一個值(當前任務數量或以前的任務數量)發生變化時重新執行。
現在,當你使用鍵盤刪除瀏覽器中的任務時,你會看到我們的虛線焦點輪廓出現在列表上方的標題周圍。
完成!
你剛剛完成了從頭開始構建 React 應用程式!恭喜!你在此處學習的技能將成為你在繼續使用 React 時構建的良好基礎。
大多數情況下,即使你所做的一切只是仔細思考元件及其狀態和屬性,你也可以成為 React 專案的有效貢獻者。記住始終編寫最佳的 HTML。
useRef() 和 useEffect() 是比較高階的功能,你應該為自己使用它們而感到自豪!尋找練習它們的機會,因為這樣做將使你能夠為使用者建立包容性的體驗。記住:如果沒有它們,我們的應用程式對鍵盤使用者來說是不可訪問的!
注意:如果你需要將你的程式碼與我們的版本進行比較,你可以在我們的 todo-react 儲存庫 中找到示例 React 應用程式程式碼的完成版本。對於執行的即時版本,請參見 https://mdn.github.io/todo-react/。
在最後一篇文章中,我們將向你提供一個 React 資源列表,你可以使用這些資源來進一步學習。