问题与使用场景
完整页面跳转会替换文档及其标题。SPA 软跳转继续使用同一文档,焦点可能留在一个周围内容已经消失的链接上。屏幕阅读器用户可能不知道新的商品页、结账步骤或错误页已经就绪。
策略需要区分五类变化:首次加载、用户发起的新路由、当前任务内的筛选或排序、被替换或取消的导航,以及浏览器后退/前进。它还要与弹窗等组件自己的焦点协议共存。目标是提供可预测的上下文,而非每次 URL 变化都移动焦点。
面试官考察什么
面试官首先看候选人能否建立语义决策模型。“每次路由变化都聚焦标题”看似无障碍,却会破坏筛选、锚点变化、首次注水和历史恢复。强回答先判断用户的任务是否改变。
其次是浏览器基础:tabIndex={-1} 可让标题接收程序化焦点,又不会把它加入顺序 Tab 路径;focus() 默认可能滚动元素;preventScroll 能拆分焦点恢复和滚动恢复;正数 tabindex 会制造脆弱的焦点顺序。
最后是并发处理。如果路由 B 比 C 更晚完成,B 不能改回标题或抢走焦点。自动化能验证活动元素、标题和顺序,却不能证明每一种浏览器与辅助技术组合会如何播报。
回答前的澄清问题
- 哪些变化会开始新任务? 从商品页进入结账是新任务,调整排序通常不是。这决定焦点移动还是留在触发控件。
- 何时算路由提交完成? 只在胜出的路由已经渲染最终主标题后聚焦,不在点击、URL 变化或骨架屏出现时执行。
- 浮层由谁管理? 弹窗负责自己的初始焦点、焦点约束和关闭后恢复;路由策略不能与它竞争。
- 历史记录要恢复什么? 明确每个历史条目恢复焦点、滚动位置或二者。两者必须协调,避免连续跳动。
- 支持哪些浏览器和辅助技术? 播报行为存在差异,发布测试矩阵必须明确。
- 每个路由能否提供描述性标题和唯一主标题? 把它们设为路由契约;
main地标只作受控降级。
30 秒回答框架
“我先对导航分类,再决定是否移动焦点。首次加载不强制聚焦。用户发起的新任务路由只有在最终提交后才更新文档标题,并聚焦设置了 tabIndex=-1 的描述性 H1。同一任务内的筛选保留触发控件,通过持久的礼貌状态区播报完成结果。每次导航都有令牌,只有最新且已提交的路由能修改标题和焦点。后退/前进优先恢复该历史条目保存的有效语义目标,否则回退到 H1,并与滚动恢复协调。测试覆盖首次加载、快速连续跳转、错误、筛选、历史、键盘顺序、可见焦点及真实屏幕阅读器。”
分步深入分析
先建立转换决策表:
| 转换 | 焦点动作 | 播报方式 |
|---|---|---|
| 首次加载或注水 | 不强制移动 | 使用原生文档与标题行为 |
| PUSH 到新任务 | 聚焦已提交路由的 H1 | 更新标题并聚焦标题 |
| 同任务筛选、排序、分页 | 保留触发控件 | 必要时礼貌播报结果或状态 |
| 后退/前进 POP | 恢复有效目标,否则 H1 | 恢复上下文或聚焦标题 |
| 重定向或错误路由 | 聚焦该路由最终 H1 | 使用最终标题和主标题 |
| 弹窗打开或关闭 | 交给弹窗协议 | 弹窗名称与返回目标 |
路由契约提供最终文档标题、描述性 H1 引用和语义路由键。H1 使用 tabIndex={-1},可被代码聚焦但不增加一个 Tab 停靠点,并保留清晰的焦点样式。跳过导航链接仍应是首个可聚焦控件并指向 main;它解决重复导航绕过问题,与路由焦点并不冲突。
焦点动作属于提交边界。为每次导航尝试分配递增令牌。数据和界面完成后,仅当令牌仍为当前值且 H1 已挂载时才执行副作用。不要依赖固定延时,网络和渲染速度会让它产生竞态。
beginNavigation(kind):
token = nextToken()
rememberCurrentFocus(historyEntryKey)
commitRoute(token, kind, title, heading):
if token != currentToken or heading is not connected:
return
document.title = title
if kind is PUSH or semantic-task REPLACE:
heading.focus()
else if kind is POP:
focus(validSavedTarget(historyEntryKey) or heading)保存稳定的路由内焦点 ID,不保存自动生成的 CSS 路径。POP 时只恢复仍存在、可见、可用且在恢复状态中有意义的元素,否则聚焦 H1。若路由器独立恢复滚动位置,可使用 focus({ preventScroll: true }) 后只恢复一次滚动;普通 PUSH 通常让聚焦自然显示 H1 更清楚。
避免重复播报。先更新标题再聚焦新 H1,通常已经提供足够上下文,但具体语音取决于辅助技术。默认不要再用实时区域重复同一标题。筛选操作保留焦点时,可用持久的 role="status" 或 aria-live="polite" 播报最终结果数。assertive 可能中断当前语音,只用于真正紧急的信息。
测试判定应直接来自策略:首次注水不能抢焦点;PUSH 最终提交后,document.activeElement 是新 H1、标题已更新,下一次 Tab 到达第一个合乎逻辑的交互控件;筛选后触发控件仍有焦点,状态只更新一次;A→B→C 竞态中即使 B 最后完成,C 仍拥有标题和焦点;错误与重定向聚焦各自标题;POP 恢复有效目标或确定性降级。
自动化覆盖上述 DOM 断言、目标卸载、缩放及减少动态效果布局。随后用键盘和支持矩阵中的真实组合手测,例如 VoiceOver/Safari,以及 NVDA 或 JAWS 与支持的浏览器。核对焦点可见、顺序合理、滚动不突兀、播报可理解,并记录版本,因为语音结果是集成行为,不是单靠 DOM 就能保证的结果。
高质量示例回答
“我会把焦点行为纳入路由的语义契约。每个路由提供最终文档标题和描述性 H1 引用。首次注水不移动焦点;用户发起且改变任务的 PUSH 在最终提交后聚焦 H1;筛选和排序保留触发控件;POP 优先恢复稳定的历史目标,失败时回退到 H1。
H1 使用 tabIndex=-1,因此能被程序化聚焦而不会增加 Tab 停靠点。我保留可见焦点样式和指向 main 的跳过导航链接。标题和焦点只在胜出的路由提交后一起更新。每次导航有一个令牌,过期异步结果会被忽略,取消的路由就无法抢焦点。
历史条目保存路由内语义焦点 ID,只恢复已连接、可见且可用的目标。若滚动单独恢复,则用 preventScroll 聚焦后只处理一次滚动。同任务异步更新使用持久的礼貌状态区;不会在标题和实时区域重复播报路由名称。
自动化验证活动元素、标题、Tab 顺序、筛选保留、A→B→C 快速导航、重定向、错误和 POP 降级。最后用支持的键盘与屏幕阅读器矩阵验证真实语音、焦点样式和滚动。这样能区分确定的 DOM 行为与必须实际观察的辅助技术行为。”
常见错误
- 每次 URL 变化都聚焦 → 筛选和锚点变化打断当前任务 → 先按语义分类。
- 点击链接时立即聚焦 → 目标可能尚不存在或被取消 → 只处理胜出的已提交路由。
- 使用固定延时 → 不同渲染速度产生竞态 → 使用路由生命周期和导航令牌。
- 聚焦
body或使用正数tabindex→ 上下文和顺序不明确 → 聚焦设置tabIndex=-1的描述性 H1。 - POP 总是跳到 H1 → 后退会丢失用户位置 → 恢复有效历史目标并提供确定性降级。
- 重复播报标题 → 用户听到冗余语音 → 路由优先用聚焦标题,同页更新才用状态区。
- 把自动扫描当成验收 → 扫描无法验证真实语音 → 加入键盘和辅助技术手测。
追问与回答
追问 1:为什么不总是聚焦 main 地标?
H1 通常能更具体地命名新任务。路由无法提供标题时,main 可作为降级,但要求每个路由提供主标题能同时改善可见结构、文档层级和可测试性。
追问 2:鼠标点击导航后也要移动焦点吗?
用户操作替换主任务时,一致的上下文对不同输入方式都有价值,包括会点击的屏幕阅读器用户。应根据语义导航决策,不要猜测用户是否只用键盘。后台刷新不能移动焦点。
追问 3:快速跳转时 B 比 C 更晚完成怎么办?
B 的令牌已不是当前值,其提交副作用直接返回,不能修改标题、焦点或状态。中止 B 的请求可以节省资源,但取消可能太晚或不受支持,因此仍需令牌检查。
追问 4:焦点恢复和滚动恢复如何配合?
普通 focus() 可能把目标滚动到视口。POP 若由路由器恢复保存的滚动位置,就用 preventScroll 验证并聚焦目标,再执行一次滚动恢复。只能有一个明确的滚动所有者。
追问 5:什么时候使用 assertive 实时区域?
仅在延迟听到会造成严重问题时使用,例如紧急会话或安全消息。结果数量和完成状态使用 polite。路由上下文通常由最终标题和聚焦 H1 提供,避免第二次播报。
追问 6:做到这些就符合 WCAG 吗?
不能单凭这一项下结论。该策略有助于动态应用维持可理解的焦点顺序和上下文,完整符合性还取决于语义、名称、键盘操作、对比度、错误处理等要求,应按产品目标检查完整用户旅程。