《互联网产品的客户端架构·规模化与架构演进》10.1 语义结构与焦点顺序

读屏用户听到一连串「按钮」,键盘用户按 Tab 从页头直接跳到页脚,弹窗打开后焦点再也出不去。本节把角色、名称、状态与语义节点树写成可校验模型,用 tabIndex 与 DOM 顺序计算焦点顺序,并把跳过、陷阱、丢失三类异常变成可执行的检测函数。

一条读屏工单里的三种失序

某电商客户端收到一条工单。用户用读屏打开订单详情,连续听到「按钮、按钮、按钮、按钮」,不知道哪一个才是「申请退款」。同一页面上,键盘用户按 Tab,焦点从页头直接跳到页脚,中间的地址筛选器完全被跳过。运营同学打开退款弹窗后,Tab 在三个按钮之间循环,按 Escape 也退不出去,只能刷新页面。

三个现象看起来是三个 bug,其实是同一条链路上的三次失序:

  • 控件没有名称,读屏只能报角色,用户无法判断用途。
  • 可聚焦元素被 tabindex="-1" 移出顺序,键盘用户无法到达。
  • 弹窗把焦点锁在一个子集里,却没有提供退出路径。

角色、名称与状态:语义模型的三根柱子

读屏软件读到的不是 DOM,而是一棵语义树。每个节点至少回答三个问题:

维度回答的问题常见错误检测方式
角色这是什么控件用 div 加点击事件模拟按钮角色是否要求在册名称
名称它用来做什么图标按钮没有 aria-label名称去空白后是否为空
状态它现在是什么状态按钮上乱挂 aria-checked状态是否属于该角色

角色决定读屏的播报方式,名称决定用户能否决策,状态决定用户是否知道「现在按下去会发生什么」。三者缺一,控件就退化成「一个可以点的地方」。

名称不是文案。同一个「删除」按钮,在列表里应该读成「删除订单 1024」,否则用户听到十个「删除」,仍然不知道会删哪一条。可访问名称要携带上下文,而不是把视觉上的图标说明原样搬过去。

语义节点树不是 DOM 树

语义树会剪掉两类节点:隐藏的子树,以及只负责布局的 role="none" 包装层。剪枝之后,父子关系才反映用户实际感知到的结构。

这条规则带来两个直接后果。第一,display: none 的弹窗不会进入语义树,检测时不必为它编造名称。第二,包装层被跳过但子节点保留,所以「卡片里套按钮」不会因为多了一层 div 就丢失按钮。

嵌套交互控件是另一类问题。链接里再放按钮,读屏会把两个角色压在一起,键盘也会出现「按 Enter 到底触发哪一个」的歧义。检测器需要带着祖先角色向下遍历,而不是只检查单个节点。

焦点顺序是一次计算,不是一次预览

焦点顺序由三组信息共同决定:

输入规则例子
正 tabIndex数值升序优先跳过导航的「跳到主内容」
tabIndex=0 或原生可聚焦按 DOM 顺序表单、链接、按钮
负数、隐藏、禁用不进入 Tab 顺序自定义下拉的内部项

顺序算出来之后,还要检测三类异常:

  • 跳过:交互控件被隐藏、禁用或负 tabIndex 移出顺序,用户无法到达。
  • 丢失:显式指定的下一个焦点指向不存在的节点,或指向一个不可聚焦的节点。
  • 陷阱:焦点在一组控件内循环,组外仍有可聚焦控件,用户无法通过 Tab 离开。

把模型写成代码

下面的模块不依赖浏览器,也不解析 HTML。它接收已经抽取好的语义节点与焦点停靠点,输出问题清单、Tab 顺序与异常。这样同一套规则既能进单元测试,也能进构建期静态检查。

// file: src/a11y/a11y-semantics.ts
// tier: L1
// verify: node --test test/a11y/a11y-semantics.test.ts

export type A11yRole =
  | "button" | "link" | "textbox" | "checkbox"
  | "dialog" | "list" | "heading" | "none";

export type A11yState = {
  disabled?: boolean;
  checked?: boolean;
  expanded?: boolean;
  selected?: boolean;
  required?: boolean;
  invalid?: boolean;
};

export type SemanticNode = {
  id: string;
  role: A11yRole;
  name: string;
  state?: A11yState;
  children?: SemanticNode[];
  hidden?: boolean;
};

export type SemanticIssue = {
  code:
    | "missing-name" | "empty-heading" | "invalid-state"
    | "duplicate-id" | "nested-interactive";
  id: string;
  detail: string;
};

export type FocusStop = {
  id: string;
  order: number;
  tabIndex?: number;
  interactive?: boolean;
  hidden?: boolean;
  disabled?: boolean;
  group?: string;
  nextId?: string;
};

export type FocusAnomaly = {
  kind: "skipped" | "trap" | "lost";
  id: string;
  detail: string;
};

const NAMED_ROLES: A11yRole[] = ["button", "link", "textbox", "checkbox", "dialog", "heading"];

const INTERACTIVE_ROLES: A11yRole[] = ["button", "link", "textbox", "checkbox"];

export function roleRequiresName(role: A11yRole): boolean {
  return NAMED_ROLES.includes(role);
}

export function accessibleName(node: SemanticNode): string {
  return node.name.trim();
}

export function flattenSemantics(nodes: SemanticNode[]): SemanticNode[] {
  const out: SemanticNode[] = [];
  const walk = (list: SemanticNode[]): void => {
    for (const node of list) {
      if (node.hidden === true) continue;
      if (node.role !== "none") out.push(node);
      if (node.children) walk(node.children);
    }
  };
  walk(nodes);
  return out;
}

export function countByRole(nodes: SemanticNode[]): Record<A11yRole, number> {
  const out: Record<A11yRole, number> = {
    button: 0, link: 0, textbox: 0, checkbox: 0,
    dialog: 0, list: 0, heading: 0, none: 0,
  };
  for (const node of flattenSemantics(nodes)) out[node.role] += 1;
  return out;
}

function stateIssues(node: SemanticNode): SemanticIssue[] {
  const state = node.state;
  if (!state) return [];
  const issues: SemanticIssue[] = [];
  const bad = (field: string, allowed: A11yRole[]): void => {
    if (allowed.includes(node.role)) return;
    issues.push({ code: "invalid-state", id: node.id, detail: `角色 ${node.role} 不应暴露 ${field}` });
  };
  if (state.checked === true) bad("checked", ["checkbox"]);
  if (state.expanded === true) bad("expanded", ["button"]);
  if (state.selected === true) bad("selected", []);
  if (state.required === true) bad("required", ["textbox", "checkbox"]);
  if (state.invalid === true) bad("invalid", ["textbox"]);
  if (state.disabled === true && !INTERACTIVE_ROLES.includes(node.role)) bad("disabled", INTERACTIVE_ROLES);
  return issues;
}

export function validateSemantics(nodes: SemanticNode[]): SemanticIssue[] {
  const issues: SemanticIssue[] = [];
  const seen = new Set<string>();
  const walk = (list: SemanticNode[], interactiveAncestor: boolean): void => {
    for (const node of list) {
      if (node.hidden === true) continue;
      if (seen.has(node.id)) {
        issues.push({ code: "duplicate-id", id: node.id, detail: `语义节点 id 重复:${node.id}` });
      } else {
        seen.add(node.id);
      }
      if (roleRequiresName(node.role) && accessibleName(node) === "") {
        issues.push(
          node.role === "heading"
            ? { code: "empty-heading", id: node.id, detail: "标题没有可朗读文本" }
            : { code: "missing-name", id: node.id, detail: `角色 ${node.role} 缺少可访问名称` },
        );
      }
      const isInteractive = INTERACTIVE_ROLES.includes(node.role);
      if (interactiveAncestor && isInteractive) {
        issues.push({ code: "nested-interactive", id: node.id, detail: "交互控件嵌套,读屏与键盘语义会冲突" });
      }
      issues.push(...stateIssues(node));
      if (node.children) walk(node.children, interactiveAncestor || isInteractive);
    }
  };
  walk(nodes, false);
  return issues;
}

export function effectiveTabIndex(stop: FocusStop): number | undefined {
  if (stop.hidden === true || stop.disabled === true) return undefined;
  if (stop.tabIndex !== undefined) return stop.tabIndex;
  return stop.interactive === true ? 0 : undefined;
}

export function tabOrder(stops: FocusStop[]): string[] {
  const eligible: Array<{ stop: FocusStop; index: number }> = [];
  for (const stop of stops) {
    const index = effectiveTabIndex(stop);
    if (index !== undefined && index >= 0) eligible.push({ stop, index });
  }
  const positive = eligible
    .filter((entry) => entry.index > 0)
    .sort((a, b) => a.index - b.index || a.stop.order - b.stop.order);
  const zero = eligible
    .filter((entry) => entry.index === 0)
    .sort((a, b) => a.stop.order - b.stop.order);
  return [...positive, ...zero].map((entry) => entry.stop.id);
}

export function detectSkipped(stops: FocusStop[]): FocusAnomaly[] {
  const out: FocusAnomaly[] = [];
  for (const stop of stops) {
    if (stop.interactive !== true) continue;
    if (stop.hidden === true) {
      out.push({ kind: "skipped", id: stop.id, detail: "交互控件被隐藏,键盘无法到达" });
    } else if (stop.disabled === true) {
      out.push({ kind: "skipped", id: stop.id, detail: "交互控件被禁用,需确认禁用理由对读屏可见" });
    } else if (stop.tabIndex !== undefined && stop.tabIndex < 0) {
      out.push({ kind: "skipped", id: stop.id, detail: `tabIndex=${stop.tabIndex} 把控件移出了 Tab 顺序` });
    }
  }
  return out;
}

export function detectLost(stops: FocusStop[]): FocusAnomaly[] {
  const order = new Set(tabOrder(stops));
  const byId = new Set(stops.map((stop) => stop.id));
  const out: FocusAnomaly[] = [];
  for (const stop of stops) {
    if (stop.hidden === true || stop.disabled === true) continue;
    if (stop.nextId === undefined) continue;
    const target = stop.nextId;
    if (target === "") {
      out.push({ kind: "lost", id: stop.id, detail: "Tab 目标为空,焦点会落回文档开头" });
      continue;
    }
    if (!order.has(target)) {
      const reason = byId.has(target) ? "目标不可聚焦" : "目标不存在";
      out.push({ kind: "lost", id: stop.id, detail: `Tab 指向 ${target},${reason}` });
    }
  }
  return out;
}

export function detectTrap(stops: FocusStop[]): FocusAnomaly[] {
  const order = tabOrder(stops);
  if (order.length < 2) return [];
  const byId = new Map(stops.map((stop) => [stop.id, stop]));
  const position = new Map(order.map((id, index) => [id, index]));
  const successor = (id: string): string | undefined => {
    const stop = byId.get(id);
    if (stop && stop.nextId !== undefined) return stop.nextId === "" ? undefined : stop.nextId;
    const index = position.get(id);
    if (index === undefined) return undefined;
    return order[(index + 1) % order.length];
  };
  const state = new Map<string, number>();
  const cycles: string[][] = [];
  for (const start of order) {
    if (state.get(start) === 2) continue;
    const path: string[] = [];
    let cursor: string | undefined = start;
    while (cursor !== undefined) {
      const mark = state.get(cursor) ?? 0;
      if (mark === 2) break;
      if (mark === 1) {
        const at = path.indexOf(cursor);
        if (at >= 0) cycles.push(path.slice(at));
        break;
      }
      state.set(cursor, 1);
      path.push(cursor);
      cursor = successor(cursor);
    }
    for (const id of path) state.set(id, 2);
  }
  const out: FocusAnomaly[] = [];
  const seen = new Set<string>();
  for (const cycle of cycles) {
    if (cycle.length >= order.length) continue;
    const groups = new Set(cycle.map((id) => byId.get(id)?.group ?? "default"));
    const scope = groups.size === 1 ? `分组 ${[...groups][0]}` : "多个分组";
    for (const id of cycle) {
      if (seen.has(id)) continue;
      seen.add(id);
      out.push({ kind: "trap", id, detail: `焦点在${scope}内 ${cycle.length} 个控件间循环,另有 ${order.length - cycle.length} 个控件无法通过 Tab 到达` });
    }
  }
  return out;
}

export function auditFocus(stops: FocusStop[]): FocusAnomaly[] {
  return [...detectSkipped(stops), ...detectLost(stops), ...detectTrap(stops)];
}

export function focusCoverage(stops: FocusStop[]): number {
  const interactive = stops.filter((stop) => stop.interactive === true && stop.hidden !== true);
  if (interactive.length === 0) return 1;
  return Math.round((tabOrder(stops).length / interactive.length) * 1000) / 1000;
}

validateSemantics 负责角色、名称、状态与嵌套;tabOrder 只做排序;三类异常各自独立,最后用 auditFocus 汇总。拆开的好处是失败信息具体:开发者能立刻知道是缺名称、顺序错,还是弹窗没给出口。

真实测试输出

本机运行 node --test test/a11y/a11y-semantics.test.ts 的结果如下,耗时随机器变化:

✔ 按钮、链接与输入框都要求可访问名称 (0.573959ms)
✔ 可访问名称去掉首尾空白 (0.08875ms)
✔ 展开语义树跳过隐藏子树 (0.419042ms)
✔ role none 不进入语义树但保留子节点 (0.075625ms)
✔ 按角色统计语义节点 (0.096708ms)
✔ 缺少名称的按钮产生 missing-name (0.155833ms)
✔ 空标题产生 empty-heading 而不是 missing-name (0.067625ms)
✔ 重复的语义节点 id 被发现 (0.061667ms)
✔ 按钮暴露 checked 是非法状态 (0.12725ms)
✔ 输入框暴露 required 与 invalid 合法 (0.1125ms)
✔ 选中状态在本书角色集里没有合法落点 (0.0635ms)
✔ 交互控件嵌套会被标记 (0.075792ms)
✔ 隐藏节点不参与语义校验 (0.054291ms)
✔ 隐藏与禁用节点的有效 tabIndex 为空 (0.08075ms)
✔ 正 tabIndex 优先且按数值升序 (0.122083ms)
✔ tabIndex 相同时按 DOM 顺序排列 (0.05975ms)
✔ 负数、隐藏与禁用节点不进入 Tab 顺序 (0.05925ms)
✔ 跳过检测覆盖隐藏、禁用与负 tabIndex (0.092292ms)
✔ Tab 目标不存在时判定为焦点丢失 (0.101416ms)
✔ Tab 目标存在但不可聚焦时同样判定为丢失 (0.050083ms)
✔ 显式回环形成子集循环时判定为陷阱 (0.153875ms)
✔ 没有显式回环时默认顺序覆盖全部控件,不算陷阱 (0.038916ms)
✔ auditFocus 按跳过、丢失、陷阱的顺序汇总 (0.08375ms)
✔ 焦点覆盖率按可见交互控件计算 (0.052083ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 143.914834

二十四个用例里,最值得关注的是「显式回环形成子集循环时判定为陷阱」。默认的 Tab 顺序会自然形成覆盖全部控件的整圈,那不是陷阱;只有显式 nextId 把焦点锁进子集,才说明用户失去了离开路径。

判据与反例

判断一个界面的语义与焦点是否合格,不看它「能不能用鼠标点」,看下面五条能否自动回答:

判据通过的样子反例
名称可判定每个交互控件都有带上下文的名称十个按钮都叫「编辑」
状态有归属状态只出现在合法角色上按钮挂 aria-checked
隐藏可解释隐藏节点不进入语义树隐藏弹窗仍被读屏读到
顺序可重现同一 DOM 得到同一 Tab 顺序正负 tabIndex 混用
异常可定位输出到具体节点与原因只报「存在无障碍问题」

第一个反例是「图标即名称」。设计稿上只有一个垃圾桶图标,前端直接放一个空按钮,鼠标用户靠位置猜,读屏用户只能听到「按钮」。补 aria-label="删除订单 1024" 才算完成,而不是补一个 title 就结束。

第二个反例是「用 CSS 控制可见性」。opacity: 0 与 visibility: hidden 的元素仍在语义树里,读屏会读到,键盘也可能聚焦。隐藏要么用 display: none,要么同时设置 hidden 与 aria-hidden,并且确认没有把仍需要播报的状态一起藏掉。

小结

语义与焦点是无障碍的地基,不是上线前的补丁。

  • 角色、名称、状态三者共同决定读屏能否理解一个控件,缺一项就会退化成「一个可以点的地方」。
  • 语义树要剪掉隐藏子树与布局包装层,但保留包装层里的真实控件。
  • 焦点顺序由正 tabIndex、DOM 顺序与可聚焦性共同算出,异常分为跳过、丢失、陷阱三类。

先把结构算清楚,再谈读屏文案与视觉偏好。顺序反了,后面所有优化都建立在错误的树上。

阅读导航:上一节:9.3 区域能力差异与降级 · 下一节:10.2 读屏、键盘与动效偏好 。

常见错误与边界

  • 用 div 加 onClick 模拟按钮。鼠标能点,键盘不能聚焦,读屏只报「文本」。
  • 图标按钮只写 title。移动端读屏与键盘用户未必能稳定拿到提示。
  • 给按钮挂 aria-checked 或 aria-selected。状态与角色不匹配,播报会自相矛盾。
  • 用 opacity: 0 或 visibility: hidden 隐藏内容。元素仍在语义树里,读屏照样会读。
  • 正负 tabIndex 混用却不检查顺序。视觉顺序与 Tab 顺序分叉,用户会「跳着走」。
  • 弹窗只移入焦点,不归还。关闭后用户被扔回页头,上下文丢失。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查