《互联网产品的客户端架构·端形态与体验基础》8.3 可访问性与交互边界

可访问性不是上线前的一次性检查,而是可以被数据描述的交互边界。本节把「回声」的焦点顺序、键盘可达、角色与标签、触摸目标尺寸写成纯数据规则,用 TypeScript 实现不依赖 DOM 的检查器,并用真实测试说明正 tabIndex、图标按钮缺名称、小触摸目标分别怎样被拦住。

可访问性是交互契约的一部分

组件契约里最容易被当成“加分项”的,是可访问性。很多团队把它的检查放在上线前,靠人工点一遍键盘、开一次读屏软件,发现问题再补。这样做的问题是覆盖不稳定:同一个组件,这次测了按钮,下次改了图标,没人记得再测一遍。

可访问性的大部分规则其实是结构性的,可以在没有浏览器的情况下检查:焦点顺序是否跟随视觉顺序,可交互元素有没有可访问名称,键盘能不能触发指针能触发的操作,触摸目标够不够大。这些判断的输入是节点的角色、名称、尺寸、键盘支持,而不是 DOM API。把它们写成纯数据规则,就能像其他单元测试一样反复运行。

「回声」的搜索框、消息列表和底部操作栏都涉及这些边界。本节不讨论颜色对比度和读屏软件的具体发音,那需要真实渲染环境;本节讨论的是在组件设计阶段就能确定的结构约束。

焦点顺序

焦点顺序的第一原则是跟随 DOM 顺序,也就是视觉顺序。用户按 Tab 时,焦点应该从左到右、从上到下移动,而不是在页面里跳跃。正 tabIndex 会打破这个原则:tabIndex="1" 的元素会排到所有自然焦点元素之前,多个正值之间还要按数值排序,维护成本极高。

负 tabIndex 的语义不同。tabIndex="-1" 表示“可以用脚本聚焦,但不进入 Tab 序列”,常用于对话框标题或跳转锚点。它不应该被当成“隐藏元素”使用,也不应该出现在普通按钮上。

隐藏与禁用的元素必须从 Tab 序列里排除。隐藏元素仍可能被脚本聚焦,这是一个常见缺陷;禁用元素在多数浏览器里会自动跳过,但数据模型里仍要显式过滤,避免不同平台行为不一致。

键盘可达

“能用鼠标点”不等于“能用键盘操作”。一个绑定了点击处理的 div,如果没有角色、没有键盘处理,鼠标用户能用,键盘用户完全够不到。检查器要能同时发现两件事:静态角色绑定了指针处理器,以及交互角色缺少激活键。

不同角色的激活键不同。按钮需要 Enter 和 Space,链接只需要 Enter,复选框和开关需要 Space。检查器不应该要求所有角色都支持全部按键,而应该按角色查表。对于没有内置语义的自定义角色,至少要求 Enter,因为它是最通用的激活键。

还有一个容易混淆的点:文本框不需要“激活键”,它需要的是可输入。把文本框放进激活键检查会制造大量误报。规则应该只检查绑定了指针处理器的节点,因为只有这类节点存在“鼠标能点、键盘不能触发”的风险。

角色与标签

角色告诉辅助技术“这是什么”,标签告诉它“这个叫什么”。两者缺一不可。一个只有图标的关闭按钮,如果没有可访问名称,读屏软件只会念“按钮”,用户不知道关的是什么。

可访问名称的来源可以有很多:可见文本、aria-label、aria-labelledby、关联的 <label>。本节的数据模型把它简化成一个 name 字段,重点是检查“有没有”,而不是“从哪来”。真实实现里,名称计算是一个有优先级的过程,组件库应该把这个过程封装起来,而不是让每个调用方自己拼字符串。

纯空格和空字符串都算缺失。很多实现只检查 name !== undefined,结果 name=" " 通过了校验,读屏软件却什么也念不出来。

触摸目标尺寸

触摸目标的最小尺寸没有全球统一标准。Apple 的人机界面指南建议 44×44 CSS 像素,Material Design 建议 48×48。本节默认用 44,并允许调用方通过 minTarget 覆盖。这个数字是案例假设值,不是行业定律;真正重要的是团队选定一个值并一致执行。

尺寸检查只在触摸平台生效。桌面端的鼠标指针精度高,小按钮仍然可用;但触摸屏没有悬停,手指接触面积大,小目标会显著提高误触率。同一个组件在两种平台上的判定不同,这不是双重标准,而是输入方式不同。

缺少尺寸数据时,检查器不应该默认通过。无法证明合规,就应该标记为“未知”,让调用方补数据。这一点和 8.1 节的契约校验一致:缺失不等于合法。

把规则写成纯数据检查器

下面的实现定义节点与屏幕的数据结构,提供 focusOrder 计算焦点序列,提供 checkA11y 返回问题列表。它不读取 DOM、不依赖浏览器,输入是普通对象,输出是普通数组。

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

export type Platform = "touch" | "pointer";
export type IssueLevel = "error" | "warning";

export interface A11yNode {
  id: string;
  role: string;
  name?: string;
  /** DOM 中出现的先后顺序,数值小的在前 */
  order: number;
  tabIndex?: number;
  disabled?: boolean;
  hidden?: boolean;
  pointerHandler?: boolean;
  keyboard?: string[];
  width?: number;
  height?: number;
}

export interface A11yScreen {
  platform: Platform;
  nodes: A11yNode[];
  minTarget?: number;
}

export interface A11yIssue {
  code: string;
  nodeId: string;
  level: IssueLevel;
  message: string;
}

const INTERACTIVE_ROLES = new Set([
  "button", "link", "textbox", "searchbox", "checkbox", "radio",
  "switch", "combobox", "menuitem", "tab", "slider", "spinbutton",
]);

const ACTIVATION_KEYS: Record<string, string[]> = {
  button: ["Enter", "Space"],
  link: ["Enter"],
  checkbox: ["Space"],
  radio: ["Space"],
  switch: ["Space"],
  menuitem: ["Enter"],
  tab: ["Enter", "Space"],
};

const DEFAULT_MIN_TARGET = 44;

function issue(
  code: string,
  nodeId: string,
  level: IssueLevel,
  message: string,
): A11yIssue {
  return { code, nodeId, level, message };
}

function isFocusable(node: A11yNode): boolean {
  return !node.hidden && !node.disabled && (node.tabIndex === undefined || node.tabIndex >= 0);
}

/** 焦点顺序:正 tabIndex 先按数值升序,其余按 DOM 顺序,跳过隐藏与禁用。 */
export function focusOrder(screen: A11yScreen): string[] {
  const eligible = screen.nodes.filter(isFocusable);
  const forced = eligible
    .filter((n) => (n.tabIndex ?? 0) > 0)
    .sort((a, b) => (a.tabIndex as number) - (b.tabIndex as number) || a.order - b.order);
  const natural = eligible
    .filter((n) => (n.tabIndex ?? 0) === 0)
    .sort((a, b) => a.order - b.order);
  return [...forced, ...natural].map((n) => n.id);
}

function activationKeys(role: string): string[] {
  return ACTIVATION_KEYS[role] ?? ["Enter"];
}

function missingKeys(node: A11yNode): string[] {
  const pressed = new Set(node.keyboard ?? []);
  return activationKeys(node.role).filter((key) => !pressed.has(key));
}

/** 纯数据规则检查,不依赖 DOM、浏览器或具体框架。 */
export function checkA11y(screen: A11yScreen): A11yIssue[] {
  const issues: A11yIssue[] = [];
  const seen = new Set<string>();
  const minTarget = screen.minTarget ?? DEFAULT_MIN_TARGET;

  for (const node of [...screen.nodes].sort((a, b) => a.order - b.order)) {
    if (seen.has(node.id)) {
      issues.push(issue("duplicate-id", node.id, "error", "节点 id 重复"));
    }
    seen.add(node.id);

    if ((node.tabIndex ?? 0) > 0) {
      issues.push(issue("positive-tabindex", node.id, "warning", "正 tabIndex 会打乱自然焦点顺序"));
    }
    if (node.hidden && (node.tabIndex ?? 0) >= 0) {
      issues.push(issue("hidden-focusable", node.id, "warning", "隐藏节点仍可能被聚焦"));
    }

    const interactive = INTERACTIVE_ROLES.has(node.role) || node.pointerHandler === true;
    if (!interactive) continue;

    if (!node.name || node.name.trim() === "") {
      issues.push(issue("missing-accessible-name", node.id, "error", "交互节点缺少可访问名称"));
    }
    if (node.pointerHandler && !INTERACTIVE_ROLES.has(node.role)) {
      issues.push(issue("pointer-on-static", node.id, "error", "静态角色绑定了指针处理器"));
    }
    if (node.pointerHandler && missingKeys(node).length > 0) {
      issues.push(issue(
        "pointer-without-keyboard",
        node.id,
        "error",
        `键盘缺少 ${missingKeys(node).join("/")}`,
      ));
    }
    if (screen.platform === "touch") {
      if (node.width === undefined || node.height === undefined) {
        issues.push(issue("touch-target-unknown", node.id, "warning", "未提供触摸目标尺寸"));
      } else if (node.width < minTarget || node.height < minTarget) {
        issues.push(issue("touch-target-small", node.id, "warning", "触摸目标小于最小尺寸"));
      }
    }
  }
  return issues.sort((a, b) => a.nodeId.localeCompare(b.nodeId) || a.code.localeCompare(b.code));
}

focusOrder 的排序规则分两段:正 tabIndex 先按数值升序、同值按 DOM 顺序;其余元素按 DOM 顺序。正值的元素会被提前,这正是它的问题所在,所以 checkA11y 同时给出一条 positive-tabindex 警告。检查器不替调用方决定要不要用正值,它只保证这个决定被记录下来。

checkA11y 按 DOM 顺序遍历,先检查 id 重复,再检查焦点相关的问题,最后才判断交互与尺寸。顺序很重要:如果同一节点既有重复 id 又缺名称,两条问题都应该被报告,而不是在第一个错误处提前返回。

尺寸检查区分 touch-target-unknown 与 touch-target-small。前者表示没有测量数据,后者表示测了但不够大。把两者混成一个错误码,会让调用方分不清“补数据”和“改设计”。

规则的严重级别也做了区分。可访问名称缺失、静态角色绑指针、键盘缺激活键是 error;正 tabIndex、隐藏仍可聚焦、触摸目标偏小是 warning。这个分级不是绝对的:如果产品面向公共服务或受无障碍法规约束,触摸目标偏小也应该提升为 error。检查器提供的是分类能力,不是最终政策。

测试与真实输出

测试覆盖合规界面、焦点排序、正 tabIndex、缺名称、静态角色绑点击、按钮缺 Space、触摸尺寸、尺寸未知、隐藏可聚焦与重复 id。完整测试文件如下:

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

import { test } from "node:test";
import assert from "node:assert/strict";
import { checkA11y, focusOrder } from "../../src/view/a11y.ts";
import type { A11yNode, A11yScreen } from "../../src/view/a11y.ts";

const node = (patch: Partial<A11yNode> & Pick<A11yNode, "id" | "role" | "order">): A11yNode => ({
  name: patch.id,
  width: 48,
  height: 48,
  ...patch,
});

const screen = (nodes: A11yNode[], platform: "touch" | "pointer" = "touch"): A11yScreen => ({
  platform,
  nodes,
});

test("合规界面没有告警", () => {
  const nodes = [
    node({ id: "search", role: "searchbox", order: 1, keyboard: ["Enter"] }),
    node({ id: "submit", role: "button", order: 2, pointerHandler: true, keyboard: ["Enter", "Space"] }),
    node({ id: "help", role: "link", order: 3, pointerHandler: true, keyboard: ["Enter"] }),
  ];
  assert.deepEqual(checkA11y(screen(nodes)), []);
});

test("焦点顺序跳过隐藏、禁用与负 tabIndex", () => {
  const nodes = [
    node({ id: "a", role: "button", order: 1 }),
    node({ id: "b", role: "button", order: 2, hidden: true }),
    node({ id: "c", role: "button", order: 3, disabled: true }),
    node({ id: "d", role: "button", order: 4, tabIndex: -1 }),
    node({ id: "e", role: "link", order: 5 }),
  ];
  assert.deepEqual(focusOrder(screen(nodes)), ["a", "e"]);
});

test("正 tabIndex 被提前并给出告警", () => {
  const nodes = [
    node({ id: "first", role: "button", order: 1 }),
    node({ id: "second", role: "button", order: 2, tabIndex: 2 }),
  ];
  assert.deepEqual(focusOrder(screen(nodes)), ["second", "first"]);
  const codes = checkA11y(screen(nodes)).map((i) => i.code);
  assert.ok(codes.includes("positive-tabindex"));
});

test("图标按钮缺少可访问名称会失败", () => {
  const nodes = [node({ id: "close", role: "button", order: 1, name: "  " })];
  const found = checkA11y(screen(nodes));
  assert.ok(found.some((i) => i.code === "missing-accessible-name"));
});

test("给 div 绑点击既没有角色也没有键盘处理", () => {
  const nodes = [node({ id: "fake", role: "generic", order: 1, pointerHandler: true })];
  const codes = checkA11y(screen(nodes)).map((i) => i.code);
  assert.ok(codes.includes("pointer-on-static"));
  assert.ok(codes.includes("pointer-without-keyboard"));
});

test("按钮只处理 Enter 时仍缺 Space", () => {
  const nodes = [
    node({ id: "ok", role: "button", order: 1, pointerHandler: true, keyboard: ["Enter"] }),
  ];
  const found = checkA11y(screen(nodes));
  assert.ok(found.some((i) => i.code === "pointer-without-keyboard" && i.message.includes("Space")));
});

test("触摸平台检查目标尺寸,指针平台跳过", () => {
  const nodes = [node({ id: "tiny", role: "button", order: 1, width: 32, height: 40 })];
  assert.ok(checkA11y(screen(nodes, "touch")).some((i) => i.code === "touch-target-small"));
  assert.deepEqual(checkA11y(screen(nodes, "pointer")), []);
});

test("缺少尺寸不会被当成合规", () => {
  const nodes = [node({ id: "unknown", role: "button", order: 1, width: undefined, height: undefined })];
  assert.ok(checkA11y(screen(nodes)).some((i) => i.code === "touch-target-unknown"));
});

test("隐藏但仍可聚焦的节点会被提示", () => {
  const nodes = [node({ id: "ghost", role: "button", order: 1, hidden: true })];
  const found = checkA11y(screen(nodes));
  assert.ok(found.some((i) => i.code === "hidden-focusable"));
  assert.deepEqual(focusOrder(screen(nodes)), []);
});

test("重复 id 会被拒绝", () => {
  const nodes = [
    node({ id: "same", role: "button", order: 1 }),
    node({ id: "same", role: "link", order: 2 }),
  ];
  assert.ok(checkA11y(screen(nodes)).some((i) => i.code === "duplicate-id"));
});

在实验工程根目录执行:

node --test test/view/a11y.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 合规界面没有告警 (0.916ms)
✔ 焦点顺序跳过隐藏、禁用与负 tabIndex (0.685792ms)
✔ 正 tabIndex 被提前并给出告警 (0.1625ms)
✔ 图标按钮缺少可访问名称会失败 (0.084458ms)
✔ 给 div 绑点击既没有角色也没有键盘处理 (8.510791ms)
✔ 按钮只处理 Enter 时仍缺 Space (0.086333ms)
✔ 触摸平台检查目标尺寸,指针平台跳过 (0.078834ms)
✔ 缺少尺寸不会被当成合规 (0.063833ms)
✔ 隐藏但仍可聚焦的节点会被提示 (0.102208ms)
✔ 重复 id 会被拒绝 (0.11675ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 167.093

合规界面没有告警 这个用例值得注意:它同时包含搜索框、按钮和链接,三者都有名称、键盘支持和足够尺寸。只有“违规能被发现”还不够,检查器还必须证明“合规不会被误报”,否则团队会逐渐忽略告警。

一次图标按钮的回归

某次改版把底部操作栏的“更多”按钮从文字换成了图标。视觉上更干净,代码也简单:一个 div 加点击处理,里面放 SVG。评审时没人觉得有问题,因为鼠标点得动,样式也对。

上线后收到反馈:键盘用户无法打开更多菜单,读屏用户听到的是“分组”而不是“更多”。复盘发现三个问题同时存在:div 没有 button 角色,没有可访问名称,也没有键盘处理。检查器会在同一个节点上报出 pointer-on-static、missing-accessible-name 和 pointer-without-keyboard 三条错误。

修复方式不是给 div 补一堆 ARIA,而是换回原生 button,补上 aria-label 和键盘处理。原生元素自带角色、焦点与激活行为,能少写很多代码,也能少犯很多错。检查器的作用不是鼓励用 ARIA 修补一切,而是让“这里需要语义”在编码阶段就暴露出来。

小结

可访问性的结构约束可以在没有 DOM 的情况下检查:焦点顺序跟随视觉顺序,隐藏与禁用元素退出 Tab 序列,交互节点有名称,指针操作有键盘等价物,触摸目标不小于团队选定的阈值。

纯数据检查器的价值在于可重复。它不能替代真实读屏测试,也不能检查颜色对比度,但它能把最常见的结构问题变成 CI 里的稳定断言。先保证结构正确,再谈体验细节,顺序不能反。

阅读导航:上一节:8.2 受控与非受控 · 下一节:9.1 打包、分包与按需加载 。

常见错误与边界

  • 用正 tabIndex 调整焦点顺序。它会打破自然顺序,维护成本高,应该改用 DOM 顺序。
  • 把 tabIndex="-1" 当成隐藏。它只是退出 Tab 序列,仍然可以被脚本聚焦。
  • 只检查 name !== undefined。空字符串和纯空格同样不可用。
  • 给静态角色绑点击处理。鼠标能用、键盘不能用的组件,本质上是不可访问的。
  • 要求所有角色支持全部激活键。按钮、链接、复选框的键位不同,应该按角色查表。
  • 把文本框放进激活键检查。文本框需要的是可输入,不是 Enter/Space 激活。
  • 触摸目标尺寸只在一端检查。缺少尺寸数据时应标记为未知,而不是默认通过。
  • 把 44 像素当成行业标准。它是案例假设值,团队应选定阈值并一致执行。
  • 认为检查器能替代真实测试。颜色对比度、读屏发音、焦点陷阱仍需要真实环境验证。
  • 用 ARIA 修补一切。能用原生元素表达语义时,优先用原生元素。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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