台湾无障碍网页规范TVAWC在交互设计上并不只关注鼠标或触控,而是要求键盘、屏幕阅读器、开关设备、语音指令等输入方式都能完成核心操作。传统做法通常将原生事件直接塞进业务组件,事件对象的类型边界模糊,导致焦点可见性、辅助技术播报和语义动作经常遗漏。借助TypeScript的判别联合、映射类型与泛型约束,可以在编译阶段把多模态交互抽象成统一的事件管线,同时保留每种输入来源的原始数据结构。这篇内容将围绕类型封装、事件归一化和状态同步展开,给出可以直接用于组件库或业务项目的实现思路。

一、多模态交互为什么要独立建模
键盘事件、指针事件、触摸事件和语音指令在浏览器里的结构差异非常大。KeyboardEvent带有key和code字段,PointerEvent携带坐标和压力,TouchEvent以touches列表描述多点触点,而语音识别通常来自自定义消息或SpeechRecognition回调。如果只是把这些事件直接交给业务层,往往会出现两种极端:要么大量使用any,要么把参数声明成Event然后不断做不安全的断言。TVAWC合规审计强调操作可达性,键盘焦点必须可见,屏幕阅读器需要收到准确的状态播报,如果类型层没有区分输入来源,开发者很容易漏掉某一种模态的处理分支。
更合理的做法是把交互拆成三个层次:原始事件层负责捕获浏览器事件,语义交互层负责归一化输入来源和意图,UI状态层负责维护焦点可见性、ARIA属性和辅助技术播报。这样每一层只依赖上一层的稳定类型,不直接耦合DOM事件。类型系统可以在新增输入源时给出编译错误,提示所有switch或if分支需要同步更新,避免运行期才发现某些设备无法操作。
type InputSource = 'keyboard' | 'pointer' | 'touch' | 'voice' | 'switch' | 'screenreader';
interface BaseInteraction<T extends InputSource = InputSource> {
source: T;
timestamp: number;
target: HTMLElement | null;
metadata?: Record<string, unknown>;
}
interface KeyboardInteraction extends BaseInteraction<'keyboard'> {
key: string;
code: string;
repeat: boolean;
modifierKeys: ReadonlyArray<string>;
}
interface PointerInteraction extends BaseInteraction<'pointer'> {
pointerId: number;
clientX: number;
clientY: number;
pointerType: string;
isPrimary: boolean;
}
interface TouchInteraction extends BaseInteraction<'touch'> {
touches: ReadonlyArray<{
identifier: number;
clientX: number;
clientY: number;
force: number;
}>;
}
interface VoiceInteraction extends BaseInteraction<'voice'> {
transcript: string;
confidence: number;
command?: string;
}
interface SwitchInteraction extends BaseInteraction<'switch'> {
switchId: string;
pressed: boolean;
}
interface ScreenReaderInteraction extends BaseInteraction<'screenreader'> {
focusMode: 'browse' | 'focus' | 'application';
virtualCursorText: string;
}
上面的代码先定义输入来源的字符串字面量联合,再用BaseInteraction作为所有交互的共同基础。每个具体交互通过extends继承source字段和通用元数据,同时保留自己的独有字段。metadata使用Record<string, unknown>而不是any,这样既能临时挂载数据,又强制开发者在读取时做类型收窄。InputSource作为泛型参数的默认值,让BaseInteraction可以单独使用,也方便映射类型去派生完整交互联合。
二、事件归一化与类型守卫
原生事件并不能直接变成这些交互对象,中间需要一层适配器。适配器接收浏览器Event,根据事件类型和实例判断输入来源,把它转换成AnyInteraction。这个过程必须显式处理每个分支,因为TVAWC规范要求多种输入方式不能只靠鼠标完成点击,键盘和开关设备也要能触发相同动作。通过类型守卫,转换后的对象可以安全地交给下游状态机,而不需要再调用as unknown as这种强制断言。
下面的事件归一化函数处理了键盘、指针、触摸和自定义语音事件。需要注意TouchEvent在部分运行环境中可能不存在,因此先做typeof检查。语音事件可以通过CustomEvent携带transcript字段,实际项目中也可以接入SpeechRecognition的onresult回调。函数返回null表示当前事件不属于可处理的交互,调用方可以直接忽略。
type InteractionMap = {
keyboard: KeyboardInteraction;
pointer: PointerInteraction;
touch: TouchInteraction;
voice: VoiceInteraction;
switch: SwitchInteraction;
screenreader: ScreenReaderInteraction;
};
type AnyInteraction = InteractionMap[keyof InteractionMap];
function normalizeEvent(event: Event): AnyInteraction | null {
const target = event.target instanceof HTMLElement ? event.target : null;
if (event instanceof KeyboardEvent) {
return {
source: 'keyboard',
timestamp: event.timeStamp,
target,
key: event.key,
code: event.code,
repeat: event.repeat,
modifierKeys: ['Shift', 'Alt', 'Control', 'Meta'].filter((key) =>
event.getModifierState(key)
)
};
}
if (event instanceof PointerEvent) {
return {
source: 'pointer',
timestamp: event.timeStamp,
target,
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
pointerType: event.pointerType,
isPrimary: event.isPrimary
};
}
if (typeof TouchEvent !== 'undefined' && event instanceof TouchEvent && event.touches.length > 0) {
return {
source: 'touch',
timestamp: event.timeStamp,
target,
touches: Array.from(event.touches).map((touch) => ({
identifier: touch.identifier,
clientX: touch.clientX,
clientY: touch.clientY,
force: touch.force
}))
};
}
if (event instanceof CustomEvent && typeof event.detail?.transcript === 'string') {
return {
source: 'voice',
timestamp: event.timeStamp,
target,
transcript: event.detail.transcript,
confidence: typeof event.detail.confidence === 'number' ? event.detail.confidence : 0,
command: undefined
};
}
return null;
}
InteractionMap和AnyInteraction是典型的映射类型用法。当以后需要增加一种输入源,比如眼动追踪或脑机接口,只需要在InputSource里加一个字面量,再在InteractionMap中补一条属性,所有使用AnyInteraction的switch语句都会在编译期报错,提醒开发者补齐处理逻辑。normalizeEvent返回联合类型后,调用方可以通过source字段收窄,而不再依赖instanceof反复判断,这让业务代码与DOM事件解耦。
三、焦点可见性与辅助技术状态同步
TVAWC规范特别强调键盘焦点不能丢失可见样式,鼠标悬停与键盘焦点需要区分处理。浏览器默认在键盘导航时会触发:focus-visible,但如果我们从类型层不记录当前输入模式,就很难在复杂的SPA组件里保持一致。FocusVisibleState可以用mode字段记录最近一次交互来源,当来源是键盘、开关设备或屏幕阅读器时,焦点环必须保持可见;当来源是指针时,焦点环可以隐藏,但不能影响真实焦点。
除了焦点,屏幕阅读器播报也依赖准确的ARIA状态。把liveRegion和expandedIds放在AriaState里,可以让状态更新函数根据交互来源决定是否触发播报。例如键盘导航到某个菜单项后,不仅要把焦点元素记录下来,还要检查aria-expanded是否需要更新。使用类型约束后,这些更新逻辑不必散落在各个事件回调中,而是集中到reducer函数里。
interface FocusVisibleState {
mode: 'keyboard' | 'pointer' | 'programmatic';
lastFocusedElement: HTMLElement | null;
focusRingVisible: boolean;
}
interface AriaState {
liveRegion: 'polite' | 'assertive' | null;
announcement?: string;
expandedIds: ReadonlyArray<string>;
}
interface SessionState {
focus: FocusVisibleState;
aria: AriaState;
}
function updateFocusState(state: FocusVisibleState, interaction: AnyInteraction): FocusVisibleState {
if (
interaction.source === 'keyboard' ||
interaction.source === 'switch' ||
interaction.source === 'screenreader'
) {
return {
...state,
mode: interaction.source,
focusRingVisible: true,
lastFocusedElement: interaction.target
};
}
if (interaction.source === 'pointer') {
return {
...state,
mode: 'pointer',
focusRingVisible: false
};
}
return state;
}
function reduceSession(state: SessionState, interaction: AnyInteraction): SessionState {
switch (interaction.source) {
case 'keyboard':
return { ...state, focus: updateFocusState(state.focus, interaction) };
case 'pointer':
return { ...state, focus: updateFocusState(state.focus, interaction) };
case 'touch':
return { ...state, focus: { ...state.focus, mode: 'pointer', focusRingVisible: false } };
case 'voice':
return {
...state,
aria: {
...state.aria,
liveRegion: 'polite',
announcement: `已接收語音指令:${interaction.transcript}`
}
};
case 'switch':
return { ...state, focus: updateFocusState(state.focus, interaction) };
case 'screenreader':
return { ...state, focus: updateFocusState(state.focus, interaction) };
default:
return assertNever(interaction);
}
}
function assertNever(value: never): never {
throw new Error(`未處理的輸入來源:${(value as AnyInteraction).source}`);
}
这段代码展示了状态机如何随着交互来源变化。updateFocusState只处理焦点可见性,reduceSession则负责把不同输入源映射到合适的焦点和ARIA状态变化。assertNever利用never类型做穷尽性检查,当AnyInteraction增加新成员而reduceSession没有对应case时,编译器会直接报错。对于语音指令,目前只做了播报,实际项目还可以继续用命令映射把自然语言转换成受控动作,例如next、previous、submit等。
四、在React组件和原生监听中的落地
类型封装最终要落到组件层才有意义。在React中可以通过自定义Hook把事件监听和状态更新收拢起来,业务组件只消费SessionState,不再直接接触原生事件。这样做的好处是,同一个组件可以同时服务键盘用户、触摸用户和语音用户,而不需要写三套几乎相同的事件处理逻辑。useEffect里注册keydown、pointerdown和touchstart监听,卸载时清理,避免内存泄漏。
下面是一个简化版Hook,演示如何把normalizeEvent和reduceSession组合起来。真实项目里还需要处理事件委托、React合成事件与原生事件的差异,以及某些组件需要阻止默认行为的情况。类型层面已经保证了handler收到的interaction不是null时必然属于AnyInteraction,因此setSession里的reducer可以安全使用。
import { useEffect, useState, RefObject } from 'react';
const initialSession: SessionState = {
focus: {
mode: 'programmatic',
lastFocusedElement: null,
focusRingVisible: false
},
aria: {
liveRegion: null,
expandedIds: []
}
};
function useTVAWCInteractions(ref: RefObject<HTMLElement>) {
const [session, setSession] = useState<SessionState>(initialSession);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handler = (event: Event) => {
const interaction = normalizeEvent(event);
if (interaction) {
setSession((prev) => reduceSession(prev, interaction));
}
};
element.addEventListener('keydown', handler);
element.addEventListener('pointerdown', handler);
element.addEventListener('touchstart', handler, { passive: true });
element.addEventListener('voice-command', handler);
return () => {
element.removeEventListener('keydown', handler);
element.removeEventListener('pointerdown', handler);
element.removeEventListener('touchstart', handler);
element.removeEventListener('voice-command', handler);
};
}, [ref]);
return session;
}
原生DOM监听同样可以使用这套类型,只需要在初始化组件时挂载监听,把事件交给normalizeEvent和reduceSession处理。对于开关设备和屏幕阅读器,通常需要结合辅助技术API或模拟事件来触发,例如通过代码派发CustomEvent来模拟语音命令。类型封装让这些输入方式的差异被限制在适配器层,业务状态更新不再关心事件来自哪里。
从TVAWC合规审计角度看,类型安全并不能代替真实设备测试,但它能把常见的分支遗漏和焦点样式回退问题提前暴露在编译阶段。当团队新增一种输入方式时,类型系统会强制更新所有switch分支和状态映射,减少人工检查成本。把多模态交互的复杂性沉淀到类型层,是长期维护无障碍组件库时值得投入的一项工作。
TypeScriptTVAWC多模态交互修改时间:2026-09-21 05:57:25