今天,我们来研究下实现高质量 Web App 的关键一步——无障碍功能 (Accessibility)。

实现 Accessible Web App 的途径

首先,我们得先明确以下问题:

  • 无障碍功能是什么?
  • 为什么我们需要无障碍功能?
  • 如何实现无障碍功能?

我们知道,大多数人主要通过视觉信息了解世界,所以现今的人机交互界面主要是通过提供视觉信息来反馈计算机内部的执行状态。

这种用户界面的设计无疑获得了巨大的成功,现在用户广泛使用的操作系统,包括 Windows、macOS、Linux,都使用了这种设计理念——所见即所得 (What You See Is What You Get)。

正是操作系统为软件提供了可视化界面,用户才能更方便地使用软件改善自己的生活与工作方式。

而作为软件工程师,如何通过可视化界面设计一款用户易于使用的软件就是其工作非常重要的一个环节。

在 Web App 的设计中,要构建其可视化界面,我们一般会通过 HTML + CSS + JavaScript 来实现一个可视化界面。

  • HTML 是描述界面结构的一门标记语言,我们通过 HTML 元素来标记界面结构与内容。
  • CSS 是描述界面样式的一门语言,我们通过 CSS 属性来描述界面的样式信息。
  • JavaScript 是响应用户操作并修改界面结构或样式的一门编程语言,我们通过 JavaScript 编程特性来编写响应与操作界面的程序。

但我们也知道,存在着用户无法通过视觉信息来了解世界的情况,那么我们该如何让他们能使用 Web App 呢?

这就是我们为什么需要无障碍功能 (Accessibility) 的原因了。

无障碍功能 (Accessibility),指的是为残障人士提供辅助访问功能,通过为所有人设计可访问的 Web App,增加 Web App 的用户量,提升 Web App 的使用体验。

如果你想了解更多,可以阅读我的另一篇博文——Web Accessibility。

实现无障碍功能是一个很复杂的难题,但幸运的是操作系统与浏览器都帮我们提供了底层技术的支持,因此,我们可以不去考虑底层系统的实现细节,而关注于如何提供 Web 内容。

但我们还有一个问题亟须解答:不使用可视化界面,用户该如何与计算机交互呢?

事实上,如果用户不能通过视觉获取信息,那就可以使用其他感官,比如听觉器官,获取音频信息。

那么我们需要将视觉信息转换为音频信息,提供给用户吗?

答案是不用,因为这可以由其他软件来将视觉信息转换成音频信息,屏幕阅读器 (Screen Reader) 就是这样一种软件,把屏幕上显示的内容转换为语音。

屏幕阅读器要能够将屏幕上显示的内容转换为语音,那么屏幕阅读器就必须先能够理解界面内容。这就很有意思了,屏幕阅读器只是一个软件,或者说应用程序,怎样才能理解界面上的内容呢?

这么说吧,其实屏幕之所以能够显示界面内容,也是靠软件在背后操作硬件渲染,而对于软件来说,“界面内容”就是一些数据结构的集合,或者说,包含属性的对象

而这些软件可以理解的数据结构 (或对象) 就是我们常说的应用程序编程接口 (Application Programming Interface, API)。

这样你就能明白了:

  • 用户通过 User Interface 理解屏幕内容
  • 软件通过 Application Programming Interface 理解屏幕内容

而 UI 与 API 都是由应用程序运行的平台提供给应用程序的,对于 Web App 来说,操作系统与浏览器就是支撑它运行的底层平台。

所以操作系统与浏览器就会提供必要的 API 给屏幕阅读器,使其可以理解屏幕内容,并讲屏幕内容转换成语音。

而作为 Web App 的构建者,我们需要做的就是按照浏览器的要求设计符合规范的 Web App 即可。所以要实现 Accessible Web App,我们往往需要关注以下几点:

  • 适当地标记页面结构
  • 为所有必要的非文字内容提供文字说明
  • 处理用户焦点

标记页面结构

使用合适的 HTML 元素来标记页面结构与内容是十分必要的。

一般来说,我们都会选择使用 a 元素来表示一个超链接。

<a href="https://sysiya.github.io/">Sysiya 的博客</a>

👆表示一个点击后将会跳转到Sysiya 的博客的超链接,链接地址为 https://sysiya.github.io/

为什么我们可以理解上面这个 HTML 元素所表示的意义?这是因为 HTML 用 a 元素来表示为超链接 (Anchor),其属性 href 表示用来表示点击后会跳转的链接地址。这就是 HTML 的语义 (Semantic)。

语义,指的是语言的含义,在这里,HTML 就是一门标记语言。

我们可以而且最好尽可能利用 HTML 的语义来描述希望构建的界面内容,因为浏览器也是通过 HTML 的语义来推断界面的内容,然后将其理解的内容通过 API 暴露给其他软件。

所以,我们应该避免使用以下标记。

<a onclick="alert('点击了确定按钮')">确定</a>

👆使用了一个 a 元素模仿了一个按钮,但从语义上来说,这应该是一个超链接,而不是一个按钮。也就是说,我们创建了一个跟语义矛盾的 UI 元素,从视觉效果上看,它可能跟一个按钮并没有什么差别,但本质上,它并不是一个按钮,浏览器也不能推断出它的正确用途。那么浏览器会把它当作一个超链接通过 API 暴露给屏幕阅读器,而屏幕阅读器就会告诉用户,这是一个链接!而这应该不是我们希望看到的。

而使用 button 元素更能体现我们的设计意图,这符合 HTML 的语义,浏览器也能正确理解该 UI 元素。

<button>确定</button>

除了 HTML 元素的原生语义外,浏览器还支持 ARIA

ARIA,全名为 Accessible Rich Internet Applications,为 Web 内容的无障碍功能定义了完整的语义。

简单地说,ARIA 允许我们扩展 HTML 元素语义。

<a role="button">确定</a>

👆仍然是一个使用 a 元素模拟的按钮,但是我们注意到一个明显的不同:

  • 属性 role="button",这是一个 ARIA 的角色 (role),支持 ARIA 的浏览器现在可以识别该 UI 元素是一个按钮而不是超链接。

role 属性是一个全局属性,即所有的 HTML 元素都支持该属性,因此我们可以在任何一个 HTML 元素上使用 role 属性来重新定义其语义,但实际上并不是每个 HTML 元素都支持的 ARIA 定义的所有角色。

a 元素支持的 ARIA 角色只有:

  • button
  • checkbox
  • menuitem
  • menuitemcheckbox
  • menuitemradio
  • option
  • radio
  • switch
  • tab
  • treeitem

而 ARIA 定义的角色,远比这多得多,你可以阅读 ARIA 规范中角色分类章节了解所有的 ARIA 角色。

每一种 ARIA 角色都有其支持的状态 (state) 和属性 (property)。

  • 状态表示 UI 元素的交互状态
  • 属性表示 UI 元素的内在性质

ARIA 角色 button 就支持以下状态:

  • aria-expanded: 如果角色 button 控制一个群组元素,aria-expanded="true" 表示该群组元素已展开aria-expanded="false" 表示该群组元素已折叠
  • aria-pressed: 如果角色 button 表示一个开关按钮,aria-pressed="true" 表示该按钮已按下aria-pressed="false" 表示该按钮未按下

除了上面两个状态外,button 还支持所有 ARIA 角色都支持的状态与属性,即全局特性 (global attributes):

  • aria-atomic
  • aria-busy (state)
  • aria-controls
  • aria-current (state)
  • aria-describedby
  • aria-details
  • aria-disabled (state)
  • aria-dropeffect
  • aria-errormessage
  • aria-flowto
  • aria-grabbed (state)
  • aria-haspopup
  • aria-hidden (state)
  • aria-invalid (state)
  • aria-keyshortcuts
  • aria-label
  • aria-labelledby
  • aria-live
  • aria-owns
  • aria-relevant
  • aria-roledescription

特性 (Attribute) 在这里是 ARIA 的状态 (state) 和属性 (property) 的统称。

我就不一一介绍每个特性的作用了,感兴趣的话,不妨阅读下该章节内容

之前我们讨论了使用 a 元素和 ARIA 角色来模拟 button 元素的语义的情况,但这并不是 ARIA 的本意,我们也应该尽量避免这样与原生语义矛盾的用法。

那么 ARIA 的正确使用方式是什么呢?

ARIA 的真正意图是扩展 HTML 语义里所不包含的语义,尽管它也定义了 HTML 语义里已包含的语义。

让我们来看看👇:

<ul role="tree">
<li role="treeitem">
<span>音乐</span>
<ul role="group">
<li role="treeitem">十年.mp3</li>
<li role="treeitem">朋友.mp3</li>
</ul>
</li>
<li role="treeitem">
<span>电影</span>
<ul role="group">
<li role="treeitem">肖申克的救赎.mp4</li>
<li role="treeitem">辛德勒的名单.mp4</li>
</ul>
</li>
</ul>

这实际上是一个在 Web App 中常见的 Tree 控件,因为 HTML 并没有直接提供一个可以描述 Tree 控件语义的元素,所以我们使用了 ul 元素和 li 元素来描述该 Tree 控件的结构,但 ulli 元素的语义并不是我们实际构建的 UI 组件的真实含义,因此,我们使用了 ARIA 的 treetreeitemgroup 三种角色来反映 Tree 控件的「真实」结构。

这也是 ARIA 在 HTML 中更真实的应用场景。

为非文字内容提供文字替代

在 Web App 设计时,我们常常会使用图片、视频、音频等增强用户体验,但同样存在用户无法正常访问图片、视频和音频的情况。

HTML 分别使用 imgvideoaudio 元素来表示图片、视频和音频,并提供了媒体资源不可用时定义替代文字的方法。

<img alt="头像" src="hand.png">
<video src="video.mp4">这是一个视频</video>
<audio src="audio.mp3">这是一个音频</audio>

HTML 元素 imgalt 属性允许我们设定图片不可用时的替代文字,包括使用屏幕阅读器的情况。但 video 元素与 audio 元素并没有 alt 属性,标签内的文本内容也只会在浏览器不支持 <video><audio> 标签时才会显示,屏幕阅读器也无法读取该内容。

控制焦点

我们已经实现了:

  • 标记页面结构,让每个人都能理解页面结构。
  • 转换非文字内容,让每个人都能理解媒体元素。

距离完全实现 Web Accessibility,我们还差最后一块拼图——导航

导航本意指的是指引载具到达某一个具体地点,GPS 导航系统就是我们熟知的导航例子。在 Web App 中,导航指的是指引用户到达某一个具体的内容,比如另一个页面或同一个页面中的特定位置。

为什么用户会需要内容导航?因为 Web 内容包含的信息量比较大,而用户在某一时段使用 Web App 可能只是想关注特定的内容,因此 Web App 应该提供便捷的导航方法,尽快定位到用户关注的内容。这也是为什么你打开电商网站会看到那么多的分门别类的导航链接与内容块的原因。

Web App 一般会提供以下常用的导航方式:

  • 鼠标
  • 触摸板触摸屏
  • 键盘

通过鼠标滚轮,我们可以控制网页内容的滚动,通过鼠标移动与点击操作可以点击特定的网页内容,可能激活一些特殊效果,比如点击链接将会使页面跳转到同一页面或另一页面的特定位置;点击按钮将播放视频或音乐;点击某一图标将改变页面的内容呈现等。

触摸板是笔记本电脑上代替鼠标的输入设备,而触摸屏则是智能手机上常见的输入设备,这两者很相似,都是通过滑动和点按操作来模拟鼠标操作。

而键盘是必备的输入设备,无论是物理键盘还是虚拟键盘,都是通过按键来实现输入与控制。其中 Tab 键是 Web App 中最常用的导航键。

使用鼠标(包括触摸板和触摸屏)操作都是比较直观的导航,即滚动页面,而键盘导航相对就不是那么直观了。

浏览器原生支持使用 Tab 键导航 Web 内容,但并不是所有的 Web 内容都能够使用 Tab 键导航,一般需要与用户交互的元素才默认可以使用 Tab 键导航。

  • a 元素
  • input 元素
  • button 元素
  • textarea 元素
  • select 元素

注意: Safari 不会导航 a 元素。

上述这些元素大部分都是构成表单的元素,而表单是用户填写信息的主要途径,所以浏览器内建的表单处理机制已完美支持使用键盘来导航和操作这些元素。

而上述这些浏览器原生支持使用 Tab 键导航的元素,有一个更直观的名称——隐式可聚焦元素

可聚焦指的可以成为用户焦点 (focus),即用户的关注点,而隐式也暗示着可以显式指定元素成为用户焦点。

一个复杂的 Web App 不只是包含表单这一种交互方式,比如打开与关闭导航栏的按钮或图标,弹出与关闭模态窗口的按钮等。这些都是用户频繁交互的组件,我们可能使用其他元素来实现这些交互组件,那么浏览器就应该提供一套显式指定可聚焦元素的方式。

浏览器通过所有 HTML 元素的 tabindex 属性和 DOM 的 focus() 方法来显式控制焦点

tabindex

tabindex 是一个全局属性,即所有的 HTML 元素都拥有该属性,它用来指定 Tab 键的导航顺序。

Tab 键的导航顺序即连续按下 Tab 键的导航路径。

<button>保存</button>
<button>删除</button>
<button>编辑</button>

如果你连续按三次 Tab 键,你会发现上面三个按钮会依次高亮,顺序与元素的顺序一致。

<button>保存</button>
<button tabindex="-1">删除</button>
<button>编辑</button>

通过设置第二个按钮的 tabindex="-1",将把第二个按钮从 Tab 键的导航顺序中移除。此时你按 Tab 键导航时,将跳过第二个按钮。

<button>保存</button>
<button tabindex="-1">删除</button>
<button tabindex="1">编辑</button>

设置第三个按钮:tabindex="1",将把第三个按钮的导航顺序提前,或者说提高了优先级。此时你按 Tab 键导航时,会发现导航顺序是「编辑」->「保存」。

<button tabindex="1">保存</button>
<button tabindex="-1">删除</button>
<button tabindex="1">编辑</button>

再设置第一个按钮:tabindex="1",将第一个按钮的优先级提高到与第三个按钮一致时,你会发现,导航顺序又变成了「保存」->「编辑」。

这几个例子应该足够帮助你弄清楚 tabindex 的作用了,当然,你可能也已经发现了我并没有讨论 tabindex="0" 的情况。这是因为默认情况下,button 元素的 tabindex 的取值就是 "0"

现在,我们可以总结下 tabindex 的取值与其含义。

tabindex 表示的含义
< 0 该元素无法通过 Tab 键导航
> 0 该元素可以通过 Tab 键导航,且 tabindex 的取值越大,优先级就越高,同一优先级下比较其出现在文档中的顺序

由此,我们不难发现,默认情况下,所有的隐式可聚焦元素tabindex="0",而其他的元素 (显式可聚焦元素) 的 tabindex="-1"显式可聚焦元素必须显式设定 tabindex 的值大于或等于 0 才可以出现在 Tab 键的导航路径里。

通过 tabindex 的设置与使用 Tab 键,我们可以在多个元素间导航,而正到达的元素即用户的焦点 (focus)。

focus

HTMLElement.prototype.focus() 方法允许在 JavaScript 脚本中以编程的方式控制焦点

你也可以通过 document.activeElement 属性来查看当前页面的焦点。

调用所有隐式可聚焦元素focus() 方法,将设置当前页面的焦点为该元素。

然而直接调用显式可聚焦元素focus() 方法,并不能设置当前页面的焦点为该元素。你必须显式设置 tabindex 为有效的数字时,才能通过调用 focus() 方法使该元素成为当前页面的焦点。

<p>Hello, World.</p>
<p tabindex="">Hello, World.</p>
<p tabindex="-1">Hello, World.</p>

如果你直接调用上面两个 p 元素的 focus() 方法,并不能聚焦该元素;而调用最后一个 p 元素的 focus() 方法是可以聚焦该元素的。这看起来是浏览器实现上出现的陷阱,但所幸所有的浏览器都具有这个陷阱,所以你可以打消心存侥幸的念头了。