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