Redwood 无障碍(a11y)指南:内置路由可达性与焦点管理
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
Redwood 框架把无障碍(Accessibility,简称 a11y)作为开箱即用的核心特性:你不需要手写整套辅助技术支持逻辑——路由切换时的页面播报、焦点重置与跳转、跳过导航链接等能力都已内置在@redwoodjs/router中。本文基于 version-7.x 的官方 a11y 文档,结合 router 包源码 与单元测试,完整讲解路由播报(RouteAnnouncement)、焦点管理(RouteFocus / Skip Links)的设计原理与实战用法,读完即可在你的 Redwood 应用中落地键盘与屏幕阅读器友好的页面导航。
为什么无障碍要从路由开始
对于单页应用(SPA)而言,无障碍的起点是路由器。因为页面切换不经过整页刷新,屏幕阅读器用户无法像传统多页网站那样自然感知"我到了一个新页面"——如果不做任何处理,导航发生时没有任何播报。这不仅是小瑕疵,而是功能性缺陷:用户会迷失在内容流中,不知道当前身处何处。
传统做法要求开发者自己为屏幕阅读器用户播报"已导航到新页面"。这既繁琐又容易出错。Redwood 的解法是:只要你写出语义化、有内容的页面结构,路由器会自动完成播报。其内置的路由播报器在每次导航时按以下优先级寻找播报文本:
RouteAnnouncement组件(最具体、最优先)- 页面的
<h1>标题 document.titlelocation.pathname
这一顺序的设计理由是:播报内容应尽量具体、有描述性,这样用户不仅能定位自己、顺畅导航,还能在之后重新找到这个页面。
提示:如果不确定自己的页面描述是否足够清晰,可以参考 W3C 关于"提供描述性页面标题"的 WCAG 2.1 通用技巧 G88(
G88: Providing descriptive titles for Web pages)。
注意:即使 Redwood 优先查找RouteAnnouncement,你也不需要在每个页面都放置它——绝大多数情况下让<h1>作为播报内容完全够用。RouteAnnouncement是为"需要自定义播报文案"的场景准备的。
RouteAnnouncement:自定义路由播报
RouteAnnouncement的工作原理非常简单:它的子内容会被播报。它既可以包住页面上可见的内容(这样播报与视觉保持一致),也可以配合visuallyHidden属性提供一段仅对屏幕阅读器可见的隐藏文案。
播报可见内容
import { RouteAnnouncement } from '@redwoodjs/router' const HomePage = () => { return ( // 这段内容仍然可见 <RouteAnnouncement> <h1>Welcome to my site!</h1> </RouteAnnouncement> ) } export default HomePage播报视觉隐藏内容
import { RouteAnnouncement } from '@redwoodjs/router' const AboutPage = () => { return ( <> <h1>Welcome to my site!</h1> {/* 这段内容不可见,但会被播报 */} <RouteAnnouncement visuallyHidden> All about me </RouteAnnouncement> </> ) } export default AboutPage官方文档特别提醒:visuallyHidden不应是你最先考虑的手段——保持站点"视觉体验"与"听觉体验"的一致性很重要。但当你确实需要(例如播报一段与页面可见标题不同、更能说明页面用途的文案)时,它随时可用。
源码视角:RouteAnnouncement的实现
从源码看,route-announcement.tsx 的实现非常轻量:它渲染一个带有data-redwood-route-announcement标记的<div>。当visuallyHidden为 true 时,应用一套标准的"视觉隐藏"样式(绝对定位、1px 尺寸、clip: rect(0, 0, 0, 0)、overflow: hidden等),把内容移出视觉渲染但保留在无障碍树中:
const hiddenStyle: React.CSSProperties = { position: `absolute`, top: `0`, width: `1`, height: `1`, padding: `0`, overflow: `hidden`, clip: `rect(0, 0, 0, 0)`, whiteSpace: `nowrap`, border: `0`, }这个实现最初借鉴了 Gatsby 社区 madalyn 的成果(源码注释中保留了出处链接),是经过社区验证的标准做法。
播报优先级如何在源码中体现
getAnnouncement完整实现了文档描述的优先级链:先查[data-redwood-route-announcement]节点的textContent,没有则查页面第一个<h1>,再退到document.title,最后兜底返回new page at ${location.pathname}:
export const getAnnouncement = () => { const routeAnnouncement = globalThis?.document.querySelectorAll( '[data-redwood-route-announcement]', )?.[0] if (routeAnnouncement?.textContent) { return routeAnnouncement.textContent } const pageHeading = globalThis?.document.querySelector(`h1`) if (pageHeading?.textContent) { return pageHeading.textContent } if (globalThis?.document.title) { return document.title } return `new page at ${globalThis?.location.pathname}` }两个实现细节值得注意:一是用querySelectorAll(...)?.[0]取第一个匹配节点,说明页面中若放置多个RouteAnnouncement,只有第一个生效;二是只认非空文本——空<h1>会被跳过继续向下寻找,这与测试用例中"空 PageHeader 处理"(getAnnouncement handles empty PageHeader)的验证一致。
播报器的渲染与测试验证
播报文本由 active-route-loader.tsx 在路由加载时写入页面内置的 announcer 节点(id="redwood-announcer")。在 route-announcer.test.tsx 中,测试验证了播报器节点带有aria-live="assertive"与role="alert"属性——这是辅助技术感知"页面内容变化"的关键机制;同时逐个导航验证了优先级链的每一级回退,例如:
- 页面含
RouteAnnouncement时,getAnnouncement()返回其内容; - 不含时返回
<h1>文本; - 无
<h1>时返回document.title; - 两者皆无时返回
new page at /noH1OrTitle。
这也印证了官方文档中的建议:只要页面语义结构完整(有描述性的<h1>),你什么都不用做,播报自动生效。
页面切换后的焦点管理
每次页面切换时,Redwood Router 会把焦点重置到 DOM 顶部,让用户可以从新页面的起点开始浏览。这通常是符合预期的默认行为;但对某些页面——尤其是导航项很多的页面——用户每次都要按 Tab 键穿过一大段导航才能到达主要内容,体验十分繁琐(而且每次页面切换都会如此)。
官方文档给出两种缓解手段:跳过链接(Skip Links)与RouteFocus组件。
Skip Links:一键跳过导航
既然主内容通常不是页面上第一个元素,为键盘与屏幕阅读器用户提供一个"直接跳到主内容"的快捷方式是最佳实践。Redwood 在生成布局时直接支持该能力——使用--skipLink选项即可:
yarn rw g layout main --skipLink生成出的布局自带SkipNavLink与SkipNavContent组件:
import { SkipNavLink, SkipNavContent } from '@redwoodjs/router' import '@redwoodjs/router/skip-nav.css' const MainLayout = ({ children }) => { return ( <> <SkipNavLink /> <nav></nav> <SkipNavContent /> <main>{children}</main> </> ) } export default MainLayoutSkipNavLink渲染一个"聚焦前始终隐藏、获得焦点时才显示"的链接;SkipNavContent渲染一个div作为该链接的跳转目标。这套组件源自 Reach UI(仓库中因 React 18 的 peer dependency 问题将其内置到 skipNav.tsx,源码注释保留了原始出处)。对应的样式位于 skip-nav.css:默认状态下链接被clip: rect(0 0 0 0)裁剪隐藏,聚焦时则以固定定位出现在左上角(position: fixed; top: 10px; left: 10px),这正是"对所有人可见、对键盘用户可用"的经典实现。
自定义跳转目标
你可能希望把跳转链接指向特定内容区块。通过修改SkipNavLink的contentId与SkipNavContent的id即可实现(两者必须成对匹配):
<SkipNavLink contentId="main-content" /> {/* ... */} <SkipNavContent id="main-content" />从源码看,SkipNavLink的默认锚点是#reach-skip-nav(defaultId),它会把contentId拼进href;SkipNavContent默认渲染同名的id作为目标。因此只要改了一边,另一边必须同步改,否则跳转锚点会失效。
扩展阅读:如果你想实现完全自定义的跳过链接,可以参阅 Ben Myers 的 skip links 博客,其内容也覆盖了更广泛的无障碍实践。
RouteFocus:把焦点送到特定元素
有时你要做的不是"跳过导航",而是直接把用户送到某个位置。请谨慎使用——你确信该位置正是用户想要到达的地方才行,因为把用户送到意外位置比"送回顶部"更糟。
当某个页面切换后焦点确实应该落在特定元素上时,使用RouteFocus:
import { RouteFocus } from '@redwoodjs/router' const ContactPage = () => ( <> <nav> {/* 导航太多了... */} </nav> {/* 用户真正想交互的 contact 表单 */} <RouteFocus> <TextField name="name" /> </RouteFocus> </> ) export default ContactPageRouteFocus告诉路由器:页面切换时,把焦点交给它的第一个子元素。上面的例子中,用户导航到联系页后,焦点会直接落在表单的姓名输入框上——也就是用户来这里要填写的第一个字段。
源码视角:getFocus与resetFocus
getFocus会查找页面上第一个带data-redwood-route-focus标记的节点,并校验其子元素是否可聚焦:
export const getFocus = () => { const routeFocus = globalThis?.document.querySelectorAll( '[data-redwood-route-focus]', )?.[0] if ( !routeFocus?.children.length || (routeFocus.children[0] as HTMLElement).tabIndex < 0 ) { return null } return routeFocus.children[0] as HTMLElement }route-focus.tsx 本身只是一个打上data-redwood-route-focus标记的<div>,真正的逻辑全在getFocus:若RouteFocus没有子元素、或子元素tabIndex < 0(不可聚焦),则返回null。对应测试 route-focus.test.tsx 覆盖了这些边界场景(无 RouteFocus、无子元素、纯文本节点、子元素不可聚焦等)。
当getFocus返回null时,active-route-loader.tsx 的useEffect会调用resetFocus()把焦点重置回顶部;否则调用routeFocus.focus()聚焦到目标元素。resetFocus的实现值得一提——它通过给<body>临时设置tabindex="-1"再聚焦,从而在不打断 Tab 焦点流的前提下把焦点移回页面顶部(源码注释说明:直接调用document.activeElement.blur()无法重置焦点流):
export const resetFocus = () => { globalThis?.document.body.setAttribute('tabindex', '-1') globalThis?.document.body.focus() globalThis?.document.body.removeAttribute('tabindex') }另外,active-route-loader.tsx 中还包含一个细节:若页面渲染在 iframe 中(inIframe()为真),上述焦点与播报逻辑会跳过,避免干扰嵌入场景。
总结
Redwood 将无障碍视为"从第一天就内置"的核心能力而非锦上添花。围绕路由,框架提供了完整的三层机制:
| 能力 | 组件 / 机制 | 作用 |
|---|---|---|
| 页面播报 | RouteAnnouncement(可选)→<h1>→document.title→location.pathname | 屏幕阅读器用户感知页面切换 |
| 跳过导航 | SkipNavLink/SkipNavContent(yarn rw g layout main --skipLink) | 键盘用户直达主内容 |
| 焦点定向 | RouteFocus(否则重置到顶部) | 页面切换后焦点落在关键元素 |
源码层面,这些能力集中在 packages/router/src 的a11yUtils.ts、route-announcement.tsx、route-focus.tsx、skipNav.tsx与active-route-loader.tsx中,并有route-announcer.test.tsx、route-focus.test.tsx等测试兜底。工具(如自动化无障碍扫描)并不能替代人工测试——但 Redwood 提供的这套内置机制,至少保证了"路由切换播报、焦点管理、跳过链接"这三件最容易被遗漏的事,默认就是对的。
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考