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 资源列表,你可以用它来进一步学习。