为什么要用类型系统管理焦点指示器
特立尼达和多巴哥在推动政府与公共服务网站无障碍化的过程中,参考了WCAG的核心条款,形成了本地化的网络无障碍指南,业界常简称其为IWACG或T&T无障碍指南。其中关于焦点指示器的要求非常具体:任何可交互元素在键盘聚焦时,必须有清晰可见的视觉指示,且指示器与背景之间需要满足最低对比度要求。这些要求在规范文档里读起来很清楚,但落到代码层面就容易出现问题。
常见的现状是,焦点样式散落在各个组件的CSS文件中,有的用outline,有的用box-shadow,有的干脆为了视觉美观用outline: none直接移除。JavaScript层面也没有统一的焦点状态管理,每个组件各自为政。一旦审计人员依据指南进行检查,整改工作量会成倍放大。TypeScript的类型系统恰好能在这里发挥作用:把焦点指示器的样式规则、对比度阈值、启用状态都建模为类型,让违规写法在编译期就被拦截。
换句话说,类型定义本身就是一份可执行的规范文档。当团队成员写下FocusIndicatorConfig类型的对象时,编辑器会立刻提示哪些字段必填、哪些取值合法,这比口头约定或Wiki文档有效得多。

定义核心类型:FocusIndicator与其配置
首先我们把焦点指示器抽象为一个完整的类型模型。一个合格的焦点指示器至少包含三个要素:视觉样式描述、对比度保障、以及适用范围。下面是一份基础的类型定义:
/** 焦点指示器的视觉呈现方式 */
type FocusStyle =
| { type: 'outline'; width: number; color: string; offset?: number }
| { type: 'boxShadow'; spread: number; color: string }
| { type: 'border'; width: number; color: string };
/** 对比度档位,对应指南中的最低要求 */
type ContrastLevel = 'AA' | 'AAA';
/** 焦点指示器配置 */
interface FocusIndicatorConfig {
/** 指示器唯一标识,用于审计追踪 */
id: string;
style: FocusStyle;
/** 聚焦时指示器与背景的最低对比度要求 */
contrast: {
level: ContrastLevel;
/** 计算出的对比度比值,AA要求不低于3 */
ratio: number;
};
/** 是否在鼠标点击时隐藏指示器 */
hideOnMouse: boolean;
/** 适用选择器列表 */
targets: string[];
}
/** 完整的焦点指示器实例,包含运行时状态 */
interface FocusIndicator extends FocusIndicatorConfig {
/** 当前是否有元素使用该指示器获得焦点 */
active: boolean;
/** 上次聚焦的元素选择器 */
lastFocusedSelector: string | null;
}这里使用了可辨识联合类型来描述FocusStyle,好处是当我们写渲染逻辑时,通过switch判断type字段,TypeScript能自动收窄类型,确保每种样式分支只访问自己拥有的属性。比如outline分支才有offset,其他分支访问它会被编译器直接报错。
对比度字段的设计也值得说明。指南要求普通文本类元素聚焦指示对比度不低于3:1,图形类元素则要求更高档位。我们把AA与AAA建模为字符串字面量联合,而不是随便一个string,这样'A'这种拼错档位在编译期就会暴露。同时保留ratio数值字段,方便审计工具回填实测值。
编写工厂函数与运行时类型守卫
有了类型定义,接下来需要一个工厂函数来统一创建焦点指示器配置,避免各处手写对象造成不一致。同时,当配置来自接口请求或用户输入时,必须用类型守卫验证数据合法性:
/** 计算两个颜色之间的相对亮度对比度 */
function contrastRatio(fg: string, bg: string): number {
const luminance = (hex: string): number => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const adj = (c: number) =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * adj(r) + 0.7152 * adj(g) + 0.0722 * adj(b);
};
const l1 = luminance(fg);
const l2 = luminance(bg);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/** 创建焦点指示器配置的工厂函数 */
function createFocusIndicator(
config: Omit<FocusIndicatorConfig, 'contrast'> & {
fgColor: string;
bgColor: string;
}
): FocusIndicatorConfig {
const ratio = contrastRatio(config.fgColor, config.bgColor);
const minRatio = config.contrast.level === 'AAA' ? 4.5 : 3;
if (ratio < minRatio) {
throw new Error(
`焦点指示器 ${config.id} 对比度 ${ratio.toFixed(2)} 低于 ${config.contrast.level} 要求的 ${minRatio}`
);
}
const { fgColor, bgColor, ...rest } = config;
return { ...rest, contrast: { level: config.contrast.level, ratio } };
}
/** 运行时类型守卫:判断未知数据是否为合法的焦点样式 */
function isFocusStyle(value: unknown): value is FocusStyle {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
if (v.type === 'outline') {
return typeof v.width === 'number' && typeof v.color === 'string';
}
if (v.type === 'boxShadow') {
return typeof v.spread === 'number' && typeof v.color === 'string';
}
if (v.type === 'border') {
return typeof v.width === 'number' && typeof v.color === 'string';
}
return false;
}工厂函数里有几个细节值得展开。第一,它接受前景色与背景色,内部调用contrastRatio按WCAG的相对亮度公式计算比值,不达标直接抛错。这意味着开发者只要使用工厂函数,就永远不可能产出一个对比度不合规的指示器配置,规范被物理性地固化进了代码。第二,返回类型使用Omit工具类型剔除内部使用的颜色字段,保证对外暴露的接口干净。
类型守卫isFocusStyle则解决了unknown类型数据的问题。当配置通过HTTP接口或本地存储加载时,JSON解析结果是不可信的,直接断言类型有风险。守卫函数逐一校验每个联合分支的必要字段,返回值签名value is FocusStyle让TypeScript在if分支内自动获得正确的类型推断,既安全又不需要额外的类型断言。
与组件体系集成并生成审计报告
类型和工具函数就绪后,最后一步是把它们接入组件体系。比较实用的做法是实现一个焦点管理器,统一监听document级的focus事件,并根据配置渲染指示器或生成审计日志:
class FocusIndicatorManager {
private indicators: FocusIndicator[] = [];
private auditLog: Array<{
id: string;
timestamp: number;
passed: boolean;
note: string;
}> = [];
register(config: FocusIndicatorConfig): void {
this.indicators.push({ ...config, active: false, lastFocusedSelector: null });
document.addEventListener('focusin', this.handleFocusIn);
}
private handleFocusIn = (event: FocusEvent): void => {
const target = event.target as HTMLElement;
const matched = this.indicators.find(ind =>
ind.targets.some(selector => target.matches(selector))
);
if (!matched) {
// 聚焦到未注册指示器的元素,记录审计告警
this.auditLog.push({
id: 'unregistered',
timestamp: Date.now(),
passed: false,
note: `元素 ${target.tagName.toLowerCase()} 缺少焦点指示器配置`
});
return;
}
this.indicators.forEach(ind => (ind.active = false));
matched.active = true;
matched.lastFocusedSelector = target.getAttribute('data-audit') ?? target.tagName.toLowerCase();
};
/** 导出审计报告,供无障碍检查使用 */
exportAudit(): string {
return JSON.stringify(
{
standard: 'Trinidad and Tobago Web Accessibility Guideline',
checkedAt: new Date().toISOString(),
totalIndicators: this.indicators.length,
issues: this.auditLog
},
null,
2
);
}
}这个管理器把规范执行从静态类型延伸到了运行时。任何可聚焦元素如果不在targets列表中,聚焦瞬间就会被记录到审计日志,测试或QA人员只需调用exportAudit就能拿到一份依据指南格式组织的检查报告。这种设计让无障碍合规从被动整改变成了主动监控。
在React或Vue组件中使用时,还可以进一步封装一个高阶模式:在组件库根组件中初始化管理器,把注册逻辑通过Hook或Provide模式下发给子组件。由于所有配置都遵循FocusIndicatorConfig类型,跨团队协作时接口完全可预期。再配合CI流程中调用exportAudit并断言issues为空,无障碍回归检查就能自动化运转起来。
总结来说,用TypeScript封装焦点指示器类型的价值不在于代码本身多精巧,而在于它把特立尼达和多巴哥网络无障碍指南中关于焦点可见性的条款,转化成了编译期约束与运行时校验的双重防线。样式不再靠约定,合规不再靠人肉审查,这才是类型驱动开发在无障碍工程领域最实在的落地方式。
TypeScript焦点指示器网络无障碍修改时间:2026-09-06 03:27:33