前端开发者学堂 - fedev.cn

A11Y 101: 构建可访问性React应用的技巧

发布于 大漠

众所周之,React目前是前端领域最为流行的JavaScript框架之一,很多Web开发都是基于React进行Web开发。但据 WebAIM Million统计分析可得知“使用React框架开发的Web应用或Web页面就可访问性方面而言,其错误要比一般的主页多出5.7%”。而且在社区中普遍认为,基于React开发的Web应用对于Web可访问性本来就差,而且开发者无法很好的基于React框架开发出具有可访问性的Web应用。事实上这是一种错误的认知,基于React能不能开发出具有可访问性的Web应用和React本身并没有太多的关系,因为开发一个具有可访问性的Web应用更多的是和HTML和WAI-ARIA有着紧密的关联。换句说,基于React开发具有可访问性的Web应用,应该注意些什么,以及如何更好的开发更具可访问性的Web应用。这是今天要和大家一起聊的话题。

为什么说基于React开发的应用更不具可访问性

为什么说基于React框架开发的Web应用或页面更不具可访问性呢?” 这除了源于错误的认知之外,还有一定的客观因素存在的。其中一个原因就是基于React框架开发Web应用时会(或者可能会)使用组件库。如果使用的组件库自身就不具有Web可访问性的话,就会造成React开发的应用不具Web可访问性。换句话说,使用其他组件(特别是第三方组件)进行开发,要改善Web可访问性而言是件痛苦的事情,而且成本也是极高的。因为在这个场景之下,要彻底优化Web可访问性就需要去修改组件。

要修改第三方组件,估计成本会极大,甚至是不太可能。

另外,那就是基于React框架开发者自身就存在一定的问题。比如 @BrittanyIRL 在2019年React Conf大会上分享的话题中提到的:

简单地说,很多开发者,特别是基于React这样的JavaScript框架开发者而言,已经习惯了只使用<div><span>来构建自己的模板(Template)。也就是说,不基于语义化的HTML进行Web开发,要让Web应用更具可访问性,难度是极大的,即使优化起来成本也很高。

另外一个原因正如 @estellevw 在Twitter上的吐槽:

用一句话来概括的话,就是:

在编写HTML的时候,对于无任何语义化的<div><span>标签应该被最后使用,即 找不到任何有语义化的标签时才使用这两个标签

如果要仅基于divspan进行开发,要开发具有Web可访问性的Web应用或Web页面就需要强度的依赖于WAI-ARIA相关的技术。

有关于WAI-ARIA更详细的介绍可以阅读《A11Y 101:WAI-ARIA初探》一文,或者阅读下面相关教程:

如果你曾经接触过Web可访问性相关的知识的话,你应该知道。针对于Web开发而言,构建可访问性的Web应用可以根据相关的规范(即 网络内容无障碍指南,也就是大家所说的WCAG指南)来做开发。另外和WCAG相关的规范还有:

基于React开发Web应用和普通Web应用开发有何不同

React框架还没有出现之前(或者说不基于任何JavaScript框架开发Web应用),都是基于HTML来进行开发。HTML是构建Web应用最基本的部分之一,但对于可访问的Web应用而言,在原有的DOM基础上会有另一个解析树,即 AOM树(可访问性树)。粗略地说,AOM是DOM树的一个子集。我们可以用下图来描述AOM和DOM之间的关系:

对于用户界面和辅助技术,也可以用AOM和DOM来描述它们之间的关系:

但是,基于React框架开发的Web应用和我们平时使用HTML开发的Web应用还是有很大区别的。在React中,它通过构建一个**“虚拟DOM”**来替代DOM,执行DOM更新的替代方法(直接更新DOM的开销可能相当大)。对于每个DOM对象,虚拟DOM中将有一个对应的对象。

因此,虽然虚拟DOM本质上只是DOM的克隆,但它没有修改用户所看到的内容的能力。这实际上使整个过程更快,因为可以“批处理”对虚拟DOM的更改,并且可以在事件循环结束时进行**差异(diff)**操作

当你渲染一个JSX元素时,每个虚拟DOM节点将被更新,然后在更新之前的虚拟DOM和更新之后的现在的虚拟DOM之间执行一个“差异操作”,并能够理解哪些对象被更改了。

在DOM中只修改已经更改的节点

虚拟DOM是一个非常重要也是非常复杂的概念,如果你想了解有关于虚拟DOM更多的知识,可以阅读:

先抛开虚拟DOM不聊,根据AOM和DOM之间的关系,我们可以把虚拟DOM结合进来,那么用下图来描述:

换句话说,即使你对虚拟DOM不太了解也不过于太担心,因为基于React构建可访问的Web应用和以往构建可访问的Web应用相同,采用的也是相同的规范,只不过在写模板(Template)时有所差异。即JSX语法编写模板。如果你不熟悉它,建议你花点时间阅读以下资源:

React中如何构建可访问的Web应用

有了这些基础,我们就来开始看看如何在React环境下构建更具可访问性的Web应用。

HTML属性和保留字

熟悉JSX或了解React开发的同学都知道,在React中编写HTML时要记住的一件事是,HTML属性需要用camelCase(驼峰)编写。例如,tabindex需要写成tabIndex。这个规则的例外是,任何data-*aria-*aria-*是ARIA中的属性集,比如aria-labelaria-hidden等)属性仍然按照以往的写法,不需要换成驼峰写法。

JSX是JavaScript中的一种扩展语法,而JavaScript中还有一些特定HTML属性名匹配的保留字。这些不能以你期望的方式来书写。比如for就是JavaScript中的一个保留字,在JavaScript中它用于循环遍历,而在React中<label>元素时,可以使用for属性与相应的表单控件绑定在一起,那么这个时候,在JSX中就需要把for换成htmlFor属性。另外,class也是JavaScript中的保留字,它在HTML中是用来给元素声明类名,那么在React中它就得换成className。除此之外,可能还有更多属性需要关注,但到目前为止,我发现JavaScript中保留字和HTML属性之间只有这些冲突。

<form>
    <div className="control">
        <label htmlFor="user">用户名:</label>
        <input type="text" aria-required={true} name="user" id="user" />
    </div>
</form>

语义化的HTML

语义化的HTML指的是具有语义的HTML标签,它使用针对其目的而全名的元素。语义化的HTML也是构建可访问Web应用的基础,利用多种HTML元素来强化你的Web应用中的信息通常可以使用你直接构建更具可访问性的Web应用。但正如前面 @BrittanyIRL提到的,如今天很多使用React(或类似于React框架,比如Vue)的开发者过度的依赖于<div><span>这种无语义化的HTML标签,甚至很多开发者都不知道如何在开发中使用有语义化的标签。

正因为,使用类似React框架开发的Web应用可能只能看到<div><span>标签,也让众多开发者造成一种误解:

使用React框架开发的Web应用,只能使用<div><span>

这也让React背上了不可构建可访问性Web应用的锅。换句话说,我们使用React框架来开发Web应该,不应该只使用这两个无语义的HTML标签,我们更应该在写模板(JSX模板)时考虑有语义的HTML。这样做有几个原因:

  • 屏幕阅读器能更好的理解每个元素的重要性
  • 对于开发人员来说,代码更易于理解和维护

为了了解为什么语义化HTML是有用的,让我先看看一个只使用div构建的无序列表组件:

// src/App.js
export default function App() {
    return (
        <div className="App">
            <div className="title">最喜欢的食物</div>
            <div className="list">
                <div className="item">- 寿司</div>
                <div className="item">- 披萨</div>
            </div>
        </div>
    );
}

就上面的示例而言,如果开启屏幕阅读器(比如iOS的“VoiceOver”)向用户呈现(朗读)的是:

最喜欢的食物
寿司
披萨

对于开发者而言,查看DOM结构中的类名(class)和相应的HTML结构可能了解到它们是一个列表,但对于视弱或视力存在一障碍的用户来说,就不能很好的弄清楚这个组件是什么。因为屏幕阅读器不甜美这些元素应该是什么,所以上面构建的这个列表是不具可访问性(不易于访问)。

基于上面的示例,我们换成一个更具语义化的HTML标签来构建:

export default function App() {
    return (
        <section>
            <h2>最喜欢的食物</h2>
            <ol>
                <li>寿司</li>
                <li>披萨</li>
            </ol>
        </section>
    );
}

VoiceOver呈现给用户的结果是:

最喜欢的食物,标题级别2
1,列表开头
寿司
2
披萨,列表结尾

正如上面示例所示,开发者可以清楚地看到这些元素是什么,屏幕阅读器也可以知道它们的用途。

通过上面这个简单示例我们可以得知,在React中构建组件时,应该尝试着使用div以外的元素。换句话说,使用具有语义化的HTML标签可以大大提高Web应用的可访问性和可读性。

创建一个健全的文档大纲

文档大纲是一种划分文档并使用这些划分创建具有清晰层次结构的方法。比如下图所示:

上图是小站《聊聊aria-labelaria-labelledbyaria-describedby》一文对应的文档大纲结构图。如果你想查看自己构建的Web应用的文档大纲,可以使用开发者工具来查看:

创建一个健全的文档大纲对于构建可访问性Web应用很重要。这是因为屏幕阅读器并不只是通过从上到下阅读页面上的内容,还有其他的导航方式,例如列出所有的标题(列表同一个页面中所有标题),然后直接跳到一个特定的标题。换句话说,屏幕阅读器可以获得所有标题的列表,级别是用标题的文本来宣布的(比如前面示例中屏幕阅读器对<h2>会朗读成“标题级别2”),以便用户更易于理解页面层次结构。

我们只需要遵守下面的两条原则,基本上可以构建出一个健全的文档大纲:

  • 同一个页面只有一个<h1>
  • 不能在上升的过程中跳过一级

对于第一条规则很好理解,这里来解释一下第二条规则。

正如前面的截图所示,标题的层次是一级一级往下的,比如说,你要到达<h4>,就必须先从<h1><h2><h3>,不能直接从<h2>跳到<h4>。比如:

上图中橙色标注的都是丢失的标题,即标题有跳级现象,也就是违背了第二条规则。而且有的有多个<h1>标题,这也违背了原则一。

比如说,我们从<h1><h2><h3>再到<h4>这是正常的也是符合构建文档大纲的结构,但是,你可以从<h4>跳过<h3>直接跳到<h3>,如下图所示:

刚才也提到过,构建健全的文档大纲对于那些通过屏幕阅读器用户以非视觉方式访问Web应用来说,通过标题进行搜索就更容易了。他们可以很容易地调出一个列表风格的大纲,甚至可以根据自己的喜好跳到一个特定的部分。事实上,根据WebAIM屏幕阅读器用户调查可得知,这种方式也是屏幕阅读用户最流行的浏览页面的方式。

如果你想深入的了解文档大纲相关的内容,还可以阅读下面这些教程:

但在在React中要做到这一点却不是件易事,特别是当你组件嵌套组件时。比如说,组件中的<h2>在某个地方是正确的,但嵌套在另一个地方可能是错误的。

比如我们有一个Card组件:

// Card.js
import React from "react";

const Card = ({ title, description, imgUrl, imgDes, ...rest }) => {
    return (
        <div className="card" {...rest}>
            <img className="card__object" src={imgUrl} alt={imgDes} />
            <div className="card__body">
                <h2 className="card__title">{title}</h2>
                <p className="card__description">{description}</p>
            </div>
        </div>
    );
};

export default Card;

Card组件中的标题是写死的<h2>,当该组件放置在不同的位置时,那么卡片的<h2>就会造成大纲的使用错误。

就该Card组件而言,如果在任何地方调用这个组件,能根据外部容器来决定自身标题级别,那就需要对组件做一些改造,比如说,通过传给Card组件一个headingLevel来判断组件在引用位置用的标题级别:

// Card.js
import React from "react";

const Card = ({
    headingLevel,
    title,
    description,
    imgUrl,
    imgDes,
    ...rest
}) => {
    const validHeadingLevels = ["h1", "h2", "h3", "h4", "h5", "h6"];
    const safeHeading = headingLevel ? headingLevel.toLowerCase() : "";
    const Title = validHeadingLevels.includes(safeHeading) ? safeHeading : "p";
    return (
        <div className="card" {...rest}>
            <img className="card__object" src={imgUrl} alt={imgDes} />
            <div className="card__body">
                <Title className="card__title">{title}</Title>
                <p className="card__description">{description}</p>
            </div>
        </div>
    );
};

export default Card;

这个时候,我们就可以更好的控置组件Card中标题级别:

从上面示例中,你可能已比发现了,如果在每个组件中为标题这样定制,对于开发者而言有较高的成本。因此,可以将标题部分抽取出来,将其创建成一个独立的组件。比如 Tenon UIHeading组件。该组件Heading.H开始,它对应的是<h1>,使用Heading.LevelBoundary可以让它标题级别降级,比如:

// App.js
import React from "react";
import {Heading} from '@tenon-io/tenon-ui'

export default function App() {
    return (
        <div>
            <Heading.H>标题级别1</Heading.H>

            <Heading.LevelBoundary>
                <Heading.H>标题级别2</Heading.H>

                <Heading.LevelBoundary>
                    <Heading.H>标题级别3</Heading.H>

                    <Heading.LevelBoundary>
                        <Heading.H>标题级别4</Heading.H>

                        <Heading.LevelBoundary>
                            <Heading.H>标题级别5</Heading.H>
                            
                        </Heading.LevelBoundary>
                    </Heading.LevelBoundary>
                </Heading.LevelBoundary>

                <Heading.H>标题级别2</Heading.H>
            </Heading.LevelBoundary>
        </div>
    );
}

渲染出来的结果如下:

按照上面的思路,可以在Card组件中使用Tenon UI的Heading组件:

// Card.js
import React from "react";
import { Heading } from "@tenon-io/tenon-ui";

const Card = ({ title, description, imgUrl, imgDes, ...rest }) => {
    return (
        <div className="card" {...rest}>
            <img className="card__object" src={imgUrl} alt={imgDes} />
            <div className="card__body">
                <Heading.H className="card__title">{title}</Heading.H>
                <p className="card__description">{description}</p>
            </div>
        </div>
    );
};

export default Card;

在使用的时候,根据需要的层级关系,将Card组件放在相应的<Heading.LevelBoundary>中:

设置页面标题

我们在使用浏览器访问一个Web页面的时候,浏览器对应的标签卡片会显示该Web页面的标题。该标题是由HTML中的<title>元素来决定的:

页面标题对于Web可访问性也是同样的重要。辅助技术(比如屏幕阅读)用户的常用导航技术是读取页面标题并推断页面包含的内容。简单地说,页面标题通常是页面加载时屏幕阅读器宣布的第一块内容。标题反映页面上的内容是非常重要的,这样依赖标题并首先遇到该内容的用户就会知道应该期待什么。

众所周之,React是单页应用程序,所以页面的标题总是一样的,即使有一些跳转,比如说从页面首页点击了“分享”按钮,跳到分享页。按照页面的使用规则来说,这样是不好的,特别是对于屏幕阅读器更是不好。

在React构建的Web应用中造成这个现象(所有页面的标题相同),那是因为它是一个单页应用程序,其页面标题(<title>)是在同一个文件中设置,比如public/index.html文件中(不同的构建工程可能设置文件有所不同,比如src/pages/index/index.ejs),之后就再也不会被触及。就拿Create React App来说,它构建的React应用,页面的<title>就是在public/index.html中设置:

如果我们想在React构建的Web应用中来改变页面的<title>,可以通过动态改变document.title的值来实现。只不过在React中实现的方式方法有多种。比如我们使用React的钩子函数useEffect()来给document.title设置值:

// public/index.html
<head>
    <title>如何动态设置页面标题</title>
</head>

// src/App.js
import React, { useEffect } from "react";

export default function App() {
    useEffect(() => {
        document.title = "动态改变了页面的标题";
    }, []);
    return (
        <div>
            <h1>Hello React & A11Y</h1>
        </div>
    );
}

这个时候,页面的标题变成了“动态改变了页面的标题”:

还可以创建一个自定义的React钩子函数useDocumentTitle(),这样就可以帮助我们重用组件之间的逻辑,即组件上设置文档标题。

// src/useDocumentTitleHooks.js
import React, { useEffect, useState } from "react";

const useDocumentTitle = (title) => {
    const [documentTitle, setDocumentTitle] = useState(title);

    useEffect(() => {
        document.title = documentTitle;
    }, [documentTitle]);

    return [documentTitle, setDocumentTitle];
};

export { useDocumentTitle };

// src/App.js
import React from "react";
import { useDocumentTitle } from "./useDocumentTitleHooks";
import "./styles.css";

export default function App() {
    const [documentTitle, setDocumentTitle] = useDocumentTitle("React & A11Y");
    return (
        <div>
            <h1>Hello React & A11Y</h1>
            <p>{documentTitle}</p>
            <button
                onClick={() => setDocumentTitle("Change Page Title with React Hooks")}
            >
                Change Page Title
            </button>
        </div>
    );
}

你会发现,页面加载时显示的页面标题会是public/index.html<title>中的内容(“如何动态设置页面标题”),随即页面标题会自动更换成“React & A11Y”,当你点击“Change Page Title”按钮之后,页面标题又变成了“Change Page Title with React Hooks”。如下图所示:

如果你觉得上面自定义React钩子函数不够灵活的话,还可以使用 react-routerreact-helmet来给页面动态设置页面标题(根据路由来设置页面标题)。

来看一个简单的示例。首先创建修改<title>的组件DocumentTitle

// src/DocumentTitle.js
import React from "react";
import Helmet from "react-helmet";

const DocumentTitle = ({ title }) => {
    var defaultTitle = "React App";
    return (
        <Helmet>
            <title>{title ? title : defaultTitle}</title>
        </Helmet>
    );
};

export default DocumentTitle;

接着创建一个新的组件,比如ChangeDocumentTitleDemo组件,使用react-router来实现页面的切换:

// src/ChangeDocumentTitleDemo.js
import React from "react";
import { Link, Switch, Route, BrowserRouter as Router } from "react-router-dom";
import DocumentTitle from "./DocumentTitle";

const Home = () => {
    return (
        <div className="page__home">
            <DocumentTitle title="欢迎来到W3cplus" />
            <h2>Home</h2>
        </div>
    );
};

const CSS = () => {
    return (
        <div className="page__css">
            <DocumentTitle title="W3cplus | CSS 教程" />
            <h2>CSS</h2>
        </div>
    );
};

const JavaScript = () => {
    return (
        <div className="page__javascript">
            <DocumentTitle title="W3cplus | JavaScript教程" />
            <h2>JavaScript</h2>
        </div>
    );
};

const ReactPage = () => {
    return (
        <div className="page__react">
            <DocumentTitle title="W3cplus | React教程" />
            <h2>React</h2>
        </div>
    );
};

const SVG = () => {
    return (
        <div className="page__svg">
            <DocumentTitle title="W3cplus | SVG教程" />
            <h2>SVG</h2>
        </div>
    );
};

const Mobile = () => {
    return (
        <div className="page__mobile">
            <DocumentTitle title="W3cplus | Mobile教程" />
            <h2>Mobile</h2>
        </div>
    );
};

const ChangeDocumentTitleDemo = () => {
    return (
        <Router>
            <main role="main" className="page__main">
                <nav role="navigation" className="page__nav">
                    <ul>
                        <li>
                            <Link to="/">首页</Link>
                        </li>
                        <li>
                            <Link to="/css">CSS</Link>
                        </li>
                        <li>
                            <Link to="/javascript">JavaScript</Link>
                        </li>
                        <li>
                            <Link to="/svg">SVG</Link>
                        </li>
                        <li>
                            <Link to="/react">React</Link>
                        </li>
                        <li>
                            <Link to="/mobile">Mobile</Link>
                        </li>
                    </ul>
                </nav>
                <Switch>
                    <Route exact path="/">
                        <Home />
                    </Route>
                    <Route path="/css">
                        <CSS />
                    </Route>
                    <Route path="/javascript">
                        <JavaScript />
                    </Route>
                    <Route path="/react">
                        <ReactPage />
                    </Route>
                    <Route path="/svg">
                        <SVG />
                    </Route>
                    <Route path="/mobile">
                        <Mobile />
                    </Route>
                </Switch>
            </main>
        </Router>
    );
};

export default ChangeDocumentTitleDemo;

App中引入ChangeDocumentTitleDemo组件,当你点击导航菜单项时,页面的标题也会有相应的变化:

正确的隐藏内容

在构建Web应用的时候,有的时候会使用图标来替代文本。对于视力正常的用户而言,Web图标可以很好的向用户呈现所要表达的信息,但对于视力有障碍的用户而言,仅图标的话无法很好向用户表达具体的信息。

同样的,在React构建的应用中也会使用Web图标:

不管是采用什么框架来构建Web应用,Web图标的使用都不可或缺!

换句话说,我们在Web中使用Web图标来替代相应的文本信息时,除了要给视力正常用户提供正确的信息之外,还需要为视力有障碍的用户提供正确的信息。为此,很多时候会像下面这样做:

<button>
    <span>保存</span>
    <svg>
        <path d= "..." />
    </svg>
</button>

但我们在Web应用中渲染的时候,期望的是:

  • 给视力正常的用户渲染图标
  • 给视力有障碍的用户(比如使用屏幕阅读器用户)提供是文本信息

为此,我们需要用正确的方式来隐藏文本,即 对视力正常的用户隐藏文本,对屏幕阅读器不隐藏文本。为了达到这个需求,我们可以使用CSS来实现:

<!-- HTML -->
<button>
    <span className="sr-only">保存</span>
    <svg>
        <path d="..." />
    </svg>
</button>

/* CSS */
.sr-only {
    border: 0;
    clip: rect(0  0 0 0);
    clip-path: inset(100%);
    height: 1px;
    width: 1px;
    margin: -1px;
    overflow: hidden;
    padding: 0;
    position: absolute;
    white-space: nowrap;
}

对于视力正常的用户看到的效果如下:

对于视力有障碍(屏幕阅读器),当按钮得到焦点时,会向用户呈现:

保存,按钮

正如《Web隐藏术》一文提到的,CSS有很多种方法可以用来隐藏内容。但并不是所有隐藏内容的方法对屏幕阅读器都友好:

有关于这方面的讨论还可以阅读:

在React中同样会碰到这样的需求:

// SvgButton.js

import React from "react";

const SvgButton = (props) => {
    const { buttonText, buttonHander, svgPath } = props;
    return (
        <div role="button" onClick={buttonHander} tabIndex={-1}>
            <span className="sr-only">{buttonText}</span>
            <svg
                className="icon"
                viewBox="0 0 1024 1024"
                xmlns="http://www.w3.org/2000/svg"
                width="200"
                height="200"
                aria-hidden={true}
                focusable="false"
            >
                <path d={svgPath} fill="currentColor" />
            </svg>
        </div>
    );
};

export default SvgButton;

为此,我们还可以基于React封装一个对屏幕阅读器友好的组件,比如VisuallyHidden

import React, { forwardRef } from "react";

const VisuallyHidden = React.forwardRef((props, ref) => {
    return (
        <span
        ref={ref}
        style={{
            border: 0,
            clip: "rect(0 0 0 0)",
            height: "1px",
            width: "1px",
            margin: "-1px",
            position: "absolute",
            overflow: "hidden",
            padding: 0,
            clipPath: "inset(100%)",
            whiteSpace: "nowrap"
        }}
        >
            {props.children}
        </span>
    );
});

export default VisuallyHidden;

这样就可以在需要正确隐藏内容的地方调用VisuallyHidden组件:

import React from "react";
import VisuallyHidden from "./VisuallyHidden";
const SvgButton = (props) => {
    const { buttonText, buttonHander, svgPath } = props;
    return (
        <div role="button" onClick={buttonHander} tabIndex={-1}>
            <VisuallyHidden>{buttonText}</VisuallyHidden>
            <svg
                className="icon"
                viewBox="0 0 1024 1024"
                xmlns="http://www.w3.org/2000/svg"
                width="200"
                height="200"
                aria-hidden={true}
                focusable="false"
            >
                <path d={svgPath} fill="currentColor" />
            </svg>
        </div>
    );
};

export default SvgButton;

使用<Fragment>避免无效的HTML

在React中,一个组件返回多个元素,它必须被包装在一个容器中,比如div。但有的时候随意插入一些HTML的标签元素会导致无效的HTML或破坏你的布局。比如像下面这样的示例:

// Columns.js
import React from "react"
const Columns = (props) => {
    return <div>
        <td>Hello</td>
        <td>World</td>
    </div>
}

// Table.js
import React from "react"
import Columns from "./Columns"
const Table (props) => {
    return <table>
        <tr>
            <Columns />
        </tr>
    </table>
}

编译出来的结果如下:

在这种情况下,我们应该使用<Fragment>来解决这种情况:

import React, { Fragment } from "react";

const Columns = (props) => {
    return (
        <Fragment>
            <td>Hellow</td>
            <td>World</td>
        </Fragment>
    );
};

另外,还可以使用<>来替代<Fragment>。还有,React中除了<table>之外碰到<ol><ul><dl>HTML以及一些数组遍历时常需要<Fragment>来避免使用一些无效的HTML。

import React, { Fragment } from "react";
const Glossary = (props) => {
    return <dl>
        {
            props.items.map((item, index) => (
                <Fragment key={item.id}>
                    <dt>{item.term}</dt>
                    <dd>{item.description}</dd>
                </Fragment>
            ))
        }
    </dl>
}

当不需要在<Fragment>中添加任何prop且你的工具支持的时候,还可以使用<Fragment>的短语法<>

const Layout = (props) => {
    return <>
        <HeaderComponent />
        <MainComponent />
        <FooterComponent />
    </>
}

正确的使用ARIA

正如文章开头所提到的,很多Web开发者使用React开发Web应用时习性的使用非语义化的HTML标签,比如divspan来开发组件。比如开发一个Button组件,没有使用<a>,也没有使用<button>标签元素,却使用了<div>,比如:

// Button.js
import React from "react"

const Button = (props) => {
    const {buttonText, clickHandler, buttonType, ...rest} = props
    return <div className={`button button__${buttonType}`} onClick={clickHandler} {...rest}>{buttonText}</div>
}

export default Button

加上一些样式,对于正常用户来说,它看上去像是一个按钮:

但对于使用屏幕阅读器的用户而言,Button组件并不友好。屏幕阅读器只会读出“保存”,但并不会告诉用户这是一个按钮:

如果希望让屏幕阅读器也知道这是一个按钮,那么在构建Button组件时就需要使用ARIA相关的特性,ARIA特性可以用来帮助各种设备读取HTML补充,比如说知道Button组件是一个按钮。

//Button.js
import React from "react";

const Button = (props) => {
    const { buttonText, clickHandler, buttonType, ...rest } = props;
    return (
        <div
        role="button"
        tabIndex={-1}
        className={`button button__${buttonType}`}
        onClick={clickHandler}
        {...rest}
        >
            {buttonText}
        </div>
    );
};

export default Button;

我们在div中使用role来告诉屏幕阅读器,该对象是一个按钮角色,同时使用tabIndex来处理焦点的事宜。这个时候,当焦点在按钮上时,屏幕阅读器会朗读:

保存,按钮

也就是告诉用户,这是一个“保存”按钮,对于使用屏幕阅读器的用户来,这个时候才具可访问性。

上面的示例是一个简单的示例。但很多时候,ARIA的使用也是复杂的,特别是使用React开发的组件,还需要考虑到ARIA的属性值根据一些状态动态变化,而且有些属性还会有一些绑定关系,比如aria-labelledbyaria-describedby就需要和元素的ID值相绑定在一起。虽然有些麻烦,但在React中,可以使用React的钩子函数来对它们进行管理,比如 @Ray Roman的《Managing ARIA attributes with React hooks》教程就是探讨这方面的。

除了自己手撸代码之外,还可以使用 Adobe 开发团队的提供的一个库:React Aria

来看React Aria的useButton的示例:

// Button.js
import React, { useRef } from "react";
import { useButton } from "@react-aria/button";

const Button = (props) => {
    let ref = useRef();
    let { buttonProps } = useButton(props, ref);
    let { children, buttonType } = props;
    return (
        <button
            {...buttonProps}
            ref={ref}
            className={`button button__${buttonType}`}
        >
            {children}
        </button>
    );
};

export default Button;

// App.js
import React from "react";
import Button from "./Button";
import "./styles.css";

export default function App() {
    return (
        <Button buttonType="primary" onPress={() => alert("Button Pressed!")}>
        保存
        </Button>
    );
}

这个时候,按钮采用的是<button>元素,是个真正的按钮,用它来构建的是真正的按钮,不需要额外的ARIA属性,也可以让屏幕阅读器具有较好的可访问性。只不过,useButton还可以指定非<button>的使用:

// Button.js
import React, { useRef } from "react";
import { useButton } from "@react-aria/button";

const Button = (props) => {
    let { children, buttonType } = props;
    let ref = useRef();
    let { buttonProps } = useButton(
        {
            ...props,
            elementType: "div"
        },
        ref
    );
    return (
        <div {...buttonProps} ref={ref} className={`button button__${buttonType}`}>
            {children}
        </div>
    );
};

export default Button;

如果不是使用<button>标签,这个时候useButton就会自动添加ARIA相关的属性,比如:

如果你对 React Aria感兴趣的话,还可以阅读:

<img>不有缺少alt的描述

WebAIM Million 报告中有过统计,近62%首页上的<img>都缺少alt信息,即 图像没有相应的描述信息。而<img>alt属性对于图像的使用是非常重要的,比如说,如果图像加载失败,浏览器就会把alt指定的值显示出来,只不过不同的浏览器下显示的效果有所差异:

另外,alt<img>指定上下文信息,在Web应用中可以派上很大的用场:

  • 用户有视力障碍,通过屏幕阅读器来浏览网页。事实上,给图片一个备选的描述文本对大多数用户都是很有用的
  • 就像上面所说的,你也许会把图片的路径或文件名拼错
  • 浏览器不支持该图片类型。某些用户仍在使用纯文本的浏览器,例如 Lynx,这些浏览器会把图片替换为描述文本
  • 你会想提供一些文字描述来给搜索引擎使用,例如搜索引擎可能会将图片的文字描述和查询条件进行匹配
  • 用户关闭的图片显示以减少数据的传输,这在手机上是十分普遍的,并且在一些国家带宽有限且昂贵

简单地说,alt除了能让<img>加载失败时可以显示描述图像的文本信息之外,还对读屏器也非常的友好,比如说:

<img src="//gw.alicdn.com/bao/uploaded/i4/2580703436/TB2LI8xyOpnpuFjSZFkXXc4ZpXa_!!2580703436.jpg_360x10000Q50.jpg" />

如果<img>上没有显式设置alt的值时,很多屏幕阅读器呈现给用户的是图像的文件名:

再来看带有alt<img>:

<img src="//gw.alicdn.com/bao/uploaded/i4/2580703436/TB2LI8xyOpnpuFjSZFkXXc4ZpXa_!!2580703436.jpg_360x10000Q50.jpg" alt="6条装,南韩珍珠吸水抹布">

alt的时候,屏幕阅读器会将alt中的信息呈现给用户:

这样的规则同样适用于使用React构建的Web应用程序。另外在给<img>添加alt时,我们可以按照下面的规则来做:

@Velir在他的文章《5 Common Mistakes People Make When Using Alt Tags (And How to Avoid Them)》中统计了开发者在给alt设置值常见的五种错误。

有关于<img>在Web中使用更详细的介绍还可以阅读:

焦点管理

让我们来讨论一下焦点管理,这是确保应用程序具有可访问性的重要因素之一。如果你的客户试图填写一个多页的表单,而你没有管理每个视图的焦点,这可能会导致混乱,并且如果他们使用辅助技术(屏幕阅读器),继续完成表单填写可能会太困难。这也可能会让你失去这些用户。

我们的 React 应用在运行时会持续更改 HTML DOM,有时这将会导致键盘焦点的丢失或者是被设置到了意料之外的元素上。为了修复这类问题,我们需要以编程的方式让键盘聚焦到正确的方向上。比方说,在一个弹窗被关闭的时候,重新设置键盘焦点到弹窗的打开按钮上。

我们可以用 DOM元素的Refs 在React中设置焦点。

用以上技术,我们先在一个 class 组件的 JSX 中创建一个元素的 ref

class CustomTextInput extends React.Component {
    constructor(props) {
        super(props);
        // 创造一个 textInput DOM 元素的 ref
        this.textInput = React.createRef();
    }
    render() {
        // 使用 `ref` 回调函数以在实例的一个变量中存储文本输入 DOM 元素
        //(比如,this.textInput)。
        return (
            <input
                type="text"
                ref={this.textInput}
            />
        );
    }
}

然后我们就可以在需要时于其他地方把焦点设置在这个组件上:

focus() {
    // 使用原始的 DOM API 显式地聚焦在 text input 上
    // 注意:我们通过访问 “current” 来获得 DOM 节点
    this.textInput.current.focus();
}

有时,父组件需要把焦点设置在其子组件的一个元素上。我们可以通过在子组件上设置一个特殊的 prop对父组件暴露 DOM Refs 从而把父组件的 ref 传向子节点的 DOM 节点。

function CustomTextInput(props) {
    return (
        <div>
            <input ref={props.inputRef} />
        </div>
    );
}

class Parent extends React.Component {
    constructor(props) {
        super(props);
        this.inputElement = React.createRef();
    }

    render() {
        return (
            <CustomTextInput inputRef={this.inputElement} />
        );
    }
}

// 现在你就可以在需要时设置焦点了
this.inputElement.current.focus();

当使用 HOC 来扩展组件时,我们建议使用 React 的 forwardRef 函数来向被包裹的组件转发 ref。如果第三方的 HOC 不支持转发 ref,上面的方法仍可以作为一种备选方案。

react-aria-modal 提供了一个很好的焦点管理的例子。 这是一个少有的完全无障碍的模态窗口的例子。它不仅仅把初始焦点设置在了取消按钮上(防止键盘用户意外激活成功操作)和把键盘焦点固定在了窗口之内, 关闭窗口时它也会把键盘焦点重置到打开窗口的那一个元素上。

有关于这方面更详细的介绍可以阅读下面这些教程:

更复杂的部件

一个更加复杂的用户体验并不意味着更加难以访问。通过尽可能接近 HTML 编程,无障碍访问会变得更加容易,即使最复杂的部件也可以实现无障碍访问。

这里我们需要了解 ARIA 角色ARIA 状态和属性 的知识。 其中有包含了多种 HTML 属性的工具箱,这些 HTML 属性被 JSX 完全支持并且可以帮助我们搭建完全无障碍,功能强大的 React 组件。

每一种部件都有一种特定的设计模式,并且用户和用户代理都会期待使用相似的方法使用它:

可访问性的检测和修复

其实在《可访问性审核的几种姿势》 和 《如何检测和修复可访问性》 中介绍了如何对Web应用的可访问性做检测和修复。虽然文章中的检测方式和修复方法不只是针对于React构建的Web应用,但里面涉及到的方法同样也适用于React构建的Web应用。这里就不做详细介绍了。如果你想进一步的了解或掌握如何快速修复React构建的Web应用,那就有必要花些时间阅读这两篇文章。

其他注意事项

另外,影响Web可访问性不仅仅局限于前文所提到的事项,他还涉及到很多细节,比如:

上面几篇文章分别从HTML、CSS以及Web颜色等方面向大家阐述构建Web可访问性时所需要掌握的知识和相关细节。这些细节同样适用于React构建的Web应用或Web页面。