React 中的无障碍性

在我们的最后一篇教程文章中,我们将重点关注 (双关语) 无障碍性,包括 React 中的焦点管理,它可以提高可用性并减少键盘用户和屏幕阅读器用户的困惑。

先决条件

熟悉核心 HTMLCSSJavaScript 语言,了解 终端/命令行

目标 学习如何在 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

jsx
import { useRef, useState } from "react";

useRef() 创建一个具有单个属性的对象:current。引用可以存储我们希望它们存储的任何值,我们以后可以查找这些值。我们甚至可以存储对 DOM 元素的引用,这正是我们将在本文中要做的。

接下来,在 Todo() 函数中的 useState() 钩子下方创建两个新的常量。每个都应该是一个引用——一个用于查看模板中的“编辑”按钮,另一个用于编辑模板中的编辑字段。

jsx
const editFieldRef = useRef(null);
const editButtonRef = useRef(null);

这些引用默认值为 null,以表明它们在被附加到其 DOM 元素之前将为空。为了将它们附加到其元素,我们将向每个元素的 JSX 添加特殊的 ref 属性,并将这些属性的值设置为相应的 ref 对象。

更新编辑模板中的 <input>,使其看起来像这样

jsx
<input
  id={props.id}
  className="todo-text"
  type="text"
  value={newName}
  onChange={handleChange}
  ref={editFieldRef}
/>

更新查看模板中的“编辑”按钮,使其看起来像这样

jsx
<button
  type="button"
  className="btn"
  onClick={() => setEditing(true)}
  ref={editButtonRef}>
  Edit <span className="visually-hidden">{props.name}</span>
</button>

这样做将用它们附加到的 DOM 元素的引用填充我们的 editFieldRefeditButtonRef,但只有在 React 渲染完组件之后。亲自测试一下:在 Todo() 函数的主体中,在初始化 editButtonRef 的位置下方,添加以下代码行

jsx
console.log(editButtonRef.current);

你会看到,当组件第一次渲染时,editButtonRef.current 的值为 null,但如果你点击“编辑”按钮,它会将 <input> 元素记录到控制台中。这是因为引用只有在组件渲染后才会被填充,而点击“编辑”按钮会导致组件重新渲染。在继续之前,请确保删除此日志。

注意:你的日志将出现 6 次,因为我们的应用程序中有 3 个 <Todo /> 实例,并且 React 在开发过程中会渲染我们的组件两次。

我们越来越接近了!为了利用我们新引用的元素,我们需要使用另一个 React 钩子:useEffect()

实现 useEffect()

useEffect() 的名称来源于它运行我们想要添加到渲染过程中的任何副作用,但这些副作用不能在主函数体内运行。useEffect() 在组件渲染后立即运行,这意味着我们在上一节中引用的 DOM 元素将可供我们使用。

再次更改 Todo.jsx 的导入语句,添加 useEffect

jsx
import { useEffect, useRef, useState } from "react";

useEffect() 接受一个函数作为参数;此函数在组件渲染执行。为了演示这一点,将以下 useEffect() 调用放在 Todo() 函数主体中的 return 语句正上方,并将一个将“副作用”记录到你的控制台的函数传递给它

jsx
useEffect(() => {
  console.log("side effect");
});

为了说明主渲染过程和 useEffect() 内部运行的代码之间的区别,请添加另一个日志——将此日志放在之前的添加内容下方

jsx
console.log("main render");

现在,在你的浏览器中打开应用程序。你应该在你的控制台中看到两条消息,每条消息重复多次。注意“主渲染”如何先记录,而“副作用”如何后记录,即使“副作用”日志在代码中先出现。

main render                                     Todo.jsx
side effect                                     Todo.jsx

同样,日志以这种方式排序是因为 useEffect() 内部的代码在组件渲染运行。这需要一些时间来适应,在前进过程中请记住这一点。现在,删除 console.log("主渲染"),我们将继续执行我们的焦点管理。

聚焦于我们的编辑字段

现在我们知道 useEffect() 钩子有效了,我们可以用它来管理焦点。作为提醒,我们希望在切换到编辑模板时将焦点放在编辑字段上。

更新你现有的 useEffect() 钩子,使其看起来像这样

jsx
useEffect(() => {
  if (isEditing) {
    editFieldRef.current.focus();
  }
}, [isEditing]);

这些更改使 React 在 isEditing 为真时读取 editFieldRef 的当前值,并将浏览器焦点移动到该值。我们还将一个数组作为第二个参数传递给 useEffect()。这个数组是 useEffect() 应该依赖的值列表。通过包含这些值,useEffect() 只有在这些值之一发生更改时才会运行。我们只希望在 isEditing 的值发生更改时更改焦点。

现在试试:使用 Tab 键导航到一个“编辑”按钮,然后按 Enter 键。你应该看到 <Todo /> 组件切换到其编辑模板,并且浏览器焦点指示器应该出现在 <input> 元素周围!

将焦点移回编辑按钮

乍一看,让 React 在保存或取消编辑时将焦点移回我们的“编辑”按钮似乎非常容易。我们当然可以向我们的 useEffect 添加一个条件,如果 isEditingfalse,则将焦点放在编辑按钮上?现在让我们试一下——更新你的 useEffect() 调用,使其如下所示

jsx
useEffect(() => {
  if (isEditing) {
    editFieldRef.current.focus();
  } else {
    editButtonRef.current.focus();
  }
}, [isEditing]);

这有点有效。如果你使用键盘触发“编辑”按钮(记住:用 Tab 键定位它,然后按 Enter 键),你会发现你的焦点会在开始和结束编辑时在“编辑”<input> 和“编辑”按钮之间移动。但是,你可能已经注意到一个新问题——最后一个 <Todo /> 组件中的“编辑”按钮在我们甚至与应用程序交互之前就立即获得了焦点!

我们的 useEffect() 钩子完全按照我们的设计运行:它在组件渲染后立即运行,看到 isEditingfalse,并使“编辑”按钮获得焦点。<Todo /> 有三个实例,最后一个渲染的实例的“编辑”按钮获得了焦点。

我们需要重构我们的方法,以便只有当 isEditing 的值从一个值更改为另一个值时,焦点才会更改。

更强大的焦点管理

为了满足我们改进后的标准,我们需要知道的不仅仅是 isEditing 的值,还要知道何时更改了该值。为此,我们需要能够读取 isEditing 常量的先前值。使用伪代码,我们的逻辑应该类似于以下内容

jsx
if (wasNotEditingBefore && isEditingNow) {
  focusOnEditField();
} else if (wasEditingBefore && isNotEditingNow) {
  focusOnEditButton();
}

React 团队讨论了 获取组件先前状态的方法,并提供了一个我们可以用来完成此工作的示例钩子。

输入 usePrevious()

将以下代码粘贴到 Todo.jsx 顶部,位于 Todo() 函数上方。

jsx
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

usePrevious() 是一个自定义钩子,它跟踪跨渲染的值。它

  1. 使用 useRef() 钩子创建一个空 ref
  2. refcurrent 值返回给调用它的组件。
  3. 调用 useEffect() 并更新存储在 ref.current 中的值,以响应调用组件的每次渲染。

useEffect() 的行为是此功能的关键。因为 ref.currentuseEffect() 调用中更新,所以它总是比组件主渲染周期中的任何值落后一步——因此得名 usePrevious()

使用 usePrevious()

现在我们可以定义一个 wasEditing 常量来跟踪 isEditing 的前一个值;这是通过将 isEditing 作为参数调用 usePrevious 来实现的。将以下内容添加到 Todo() 中,位于 useRef 行下方

jsx
const wasEditing = usePrevious(isEditing);

你可以通过在此行下方添加一个控制台日志来查看 usePrevious() 的行为

jsx
console.log(wasEditing);

在此日志中,wasEditingcurrent 值将始终是 isEditing 的前一个值。点击“编辑”和“取消”按钮几次,观察它的变化,然后在你准备好继续时删除此日志。

有了这个 wasEditing 常量,我们可以更新 useEffect() 钩子以实现我们之前讨论的伪代码

jsx
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 中——你将在下面需要这两个钩子

jsx
import { useState, useRef, useEffect } from "react";

接下来,在 App() 函数中声明一个新的 ref,就在 return 语句上方

jsx
const listHeadingRef = useRef(null);

准备标题

像我们 <h2> 这样的标题元素通常不可聚焦。这不是问题——我们可以通过添加属性 tabindex="-1" 来使任何元素以编程方式可聚焦。这意味着只能用 JavaScript 聚焦。你不能像对 <button><a> 元素那样按下 Tab 来聚焦具有 -1 tabindex 的元素(这可以通过 tabindex="0" 来完成,但这在这种情况下不合适)。

让我们将 tabindex 属性(在 JSX 中写为 tabIndex)添加到我们任务列表上方的标题,以及我们的 listHeadingRef

jsx
<h2 id="list-heading" tabIndex="-1" ref={listHeadingRef}>
  {headingText}
</h2>

注意:tabindex 属性非常适合可访问性边缘情况,但你应该非常小心不要过度使用它。只有当你确定使元素可聚焦将对你的用户有所帮助时,才将其应用于元素。在大多数情况下,你应该使用可以自然地获取焦点的元素,例如按钮、锚点和输入。不负责任地使用 tabindex 会对键盘和屏幕阅读器用户产生非常负面的影响!

获取上一个状态

我们希望仅当用户从他们的列表中删除任务时,才将焦点放在与我们的 ref 关联的元素(通过 ref 属性)上。这将需要我们之前使用的 usePrevious() 钩子。将其添加到 App.jsx 文件的顶部,就在导入语句下方

jsx
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

现在添加以下内容,就在 App() 函数中的 return 语句上方

jsx
const prevTaskLength = usePrevious(tasks.length);

在这里,我们调用 usePrevious() 来跟踪任务数组的上一个长度。

注意:由于我们现在在两个文件中使用 usePrevious(),因此将其移动到单独的文件中、从该文件导出并将其导入到需要它的位置可能更有效。在你完成之后,尝试这样做作为练习。

使用 useEffect() 来控制标题焦点

现在我们已经存储了我们以前有多少个任务,我们可以设置一个 useEffect() 钩子,以便在我们的任务数量发生变化时运行,如果我们现在的任务数量少于之前,则会将焦点放在标题上——也就是说,我们删除了一个任务!

将以下内容添加到 App() 函数的正文中,就在你之前的添加内容下方

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