马耳他网络无障碍指南(Malta Web Accessibility Guidelines)在涉及列表语义的部分,对有序列表、无序列表、定义列表以及列表标记的呈现方式提出了明确要求。在MWAC这类无障碍检测工具的工程实现里,如果这些规则对应的标记结构没有统一的类型定义,各条校验规则就只能各自为政地解析DOM节点,时间一长就会出现解析逻辑重复、字段命名不一致、规则之间难以复用的问题。本文介绍如何用TypeScript为这套指南中的列表标记封装一套完整的类型定义,并说明如何把它组织成可维护、可扩展的模块。

先梳理指南中列表标记的结构模型
动手写代码之前,先要把马耳他指南里关于列表的要求拆成结构化的模型。指南通常把列表分成三类:无序列表<ul>配合<li>使用,适合并列关系;有序列表<ol>除了<li>之外还允许通过type或start属性影响编号方式;定义列表<dl>由<dt>和<dd>构成,用于术语与解释的配对。校验器需要拿到的是统一的输入结构,而不是原始的DOM。
因此类型层的第一步是定义一个可辨识联合,用kind字段区分三种列表。相比一个巨大而字段全部可选的接口,可辨识联合能让编译器在switch分支中自动收窄类型,哪个分支能访问哪些属性一目了然。下面的代码给出基础模型:
// 列表标记的可辨识联合类型定义
export type ListKind = 'unordered' | 'ordered' | 'definition';
export interface ListItemNode {
tagName: 'li';
children: AccessibilityNode[];
}
export interface UnorderedList {
kind: 'unordered';
tagName: 'ul';
items: ListItemNode[];
}
export interface OrderedList {
kind: 'ordered';
tagName: 'ol';
reversed: boolean;
start: number;
items: ListItemNode[];
}
export interface DefinitionList {
kind: 'definition';
tagName: 'dl';
groups: Array<{ term: AccessibilityNode; description: AccessibilityNode[] }>;
}
export type ListMarker = UnorderedList | OrderedList | DefinitionList;这样定义的好处在于,任何校验规则拿到ListMarker之后,先检查kind,再进入对应分支,就能获得精确的字段提示。如果后续指南新增了某种列表形式,只需要增加一个联合成员,不需要改动已有分支。
把指南条款编号映射进类型系统
马耳他指南的每一条列表要求都有对应的条款编号,校验结果必须能追溯到具体条款,否则报告对开发者没有说服力。可以用const对象加派生类型的方式,把条款编号收敛成受控的字面量集合:
// 条款编号的受控映射
export const GuidelineProvision = {
ListStructure: 'MT-LIST-01',
OrderedMarker: 'MT-LIST-02',
DefinitionPairs: 'MT-LIST-03',
} as const;
export type ProvisionId = typeof GuidelineProvision[keyof typeof GuidelineProvision];
export interface Violation {
provision: ProvisionId;
message: string;
node: ListMarker;
remediation: string;
}用as const推导出字面量类型后,任何拼写错误的条款编号都会在编译期报错,避免了字符串散落在各处造成的拼写漂移。Violation的provision字段只接受合法的条款ID,规则实现者不可能填入一个不存在的编号。
接着为规则本身建模。每条规则是一个接收ListMarker、返回违例数组的纯函数,配合泛型可以约束规则只处理特定类型的列表:
// 泛型规则接口,限定规则能处理的列表类型
export interface ListRule<T extends ListKind = ListKind> {
id: string;
provision: ProvisionId;
appliesTo: readonly T[];
check(node: Extract<ListMarker, { kind: T }>): Violation[];
}
// 示例:只针对有序列表的编号规则
export const orderedMarkerRule: ListRule<'ordered'> = {
id: 'check-ol-marker',
provision: GuidelineProvision.OrderedMarker,
appliesTo: ['ordered'],
check(node) {
if (node.items.length > 1 && node.start !== 1 && !node.reversed) {
return [{
provision: this.provision,
message: '有序列表起始值异常,可能影响屏幕阅读器朗读顺序',
node,
remediation: '检查start属性是否必要,或改用reversed',
}];
}
return [];
},
};这里的关键是Extract<ListMarker, { kind: T }>,它根据appliesTo中声明的种类自动把参数收窄到对应的联合成员。规则作者在写check函数时,node的类型是精确的OrderedList,访问items、start等字段都有完整的类型提示。
类型守卫与模块封装的落地实践
类型定义再好,如果DOM到ListMarker的转换环节不可靠,后面的强类型就是空中楼阁。转换层需要一组类型守卫,把不确定的输入收窄为ListMarker:
// 类型守卫:从通用节点收窄为列表标记
export function isListMarker(node: unknown): node is ListMarker {
if (typeof node !== 'object' || node === null) return false;
const kind = (node as { kind?: unknown }).kind;
return kind === 'unordered' || kind === 'ordered' || kind === 'definition';
}
// 统一的规则执行入口
export function runListRules(node: unknown, rules: ListRule[]): Violation[] {
if (!isListMarker(node)) return [];
return rules
.filter(rule => rule.appliesTo.includes(node.kind))
.flatMap(rule => {
const narrowed = node as Extract<ListMarker, { kind: typeof node.kind }>;
return rule.check(narrowed);
});
}封装时建议把整个模块拆成三个文件:types.ts放纯类型与GuidelineProvision常量,guards.ts放类型守卫与转换逻辑,rules.ts放具体校验规则。对外只暴露一个index.ts做桶导出,使用方拿到的都是明确的接口。这么做还有一个实际收益:types.ts没有任何运行时代码,可以被安全地用在纯类型导入场景,配合import type还能减少打包体积。
最后不要忽略测试。类型只能保证结构正确,语义正确要靠单元测试兜底。可以为每条规则准备合法与非法两份夹具,用tsd或简单的编译断言验证泛型收窄是否符合预期,再用普通测试框架验证check函数的输出。类型定义、类型守卫、测试三层配合起来,马耳他指南的列表条款就真正沉淀成了MWAC中可复用、可追溯、可演进的资产。
TypeScript无障碍MWAC修改时间:2026-09-13 04:40:27