在无障碍开发领域,WCAG对页面语言的定义常被团队忽视。许多国际化项目只做了文案翻译,却忘了同步更新HTML文档的lang属性,导致屏幕阅读器用英语发音规则去朗读葡萄牙语文本,用户体验直接崩塌。更麻烦的是,葡萄牙语本身存在pt、pt-PT、pt-BR等多种变体,简单用一个字符串变量管理语言类型极易出错。用TypeScript封装一套类型安全的页面语言管理方案,可以从编译期就杜绝大部分低级错误。

一、先弄懂WCAG对页面语言的要求与BCP 47规范
WCAG 2.1的成功标准3.1.1明确要求:每个页面的默认人类语言可以通过编程方式确定。落到技术层面,就是HTML根元素必须携带正确的lang属性。这不仅是给浏览器看的,更是NVDA、JAWS、VoiceOver等辅助技术的发音依据。当lang="pt"时,屏幕阅读器会切换到葡萄牙语语音引擎;如果属性缺失或写错,辅助技术只能猜测,葡语内容的字母缩写、重音符号会被逐字母拼读,可读性几乎为零。
lang属性的取值遵循BCP 47规范,也就是常说的语言标签(Language Tag)。它由多个子标签组成,用连字符分隔:第一部分是ISO 639-1语言代码,比如pt代表葡萄牙语;第二部分是ISO 3166-1区域代码,比如PT代表葡萄牙、BR代表巴西;后续还可以携带文字系统(如Latn、Hans)等扩展。pt-BR和pt-PT虽然同属葡语,但拼写、用词差异不小,无障碍场景下精确到区域变体是更专业的做法。此外,当页面中穿插外语片段时,WCAG 3.1.2还要求用行内lang标注局部语言变化,这为封装组件提出了局部切换的能力需求。
理解这些背景后,封装目标就清晰了:需要一个类型层定义合法的语言标签集合,一个运行时工具负责校验与切换,同时覆盖文档级与元素级两类场景。用TypeScript做这件事的优势在于,语言代码这种取值有限的集合,正是字面量联合类型最擅长约束的领域。
二、设计类型安全的LanguageCode类型与语言注册表
第一步是把合法语言标签收进类型系统。直接写type LanguageCode = string毫无意义,等于没约束。正确做法是用字面量联合类型枚举项目实际支持的语言,再配合映射类型派生区域变体。下面是一个完整示例:
/**
* 基础语言代码(ISO 639-1)
*/
export type BaseLanguage = 'pt' | 'en' | 'es' | 'zh';
/**
* 区域变体映射:每种基础语言支持的区域子标签
*/
export interface RegionMap {
pt: 'PT' | 'BR' | 'AO' | 'MZ';
en: 'US' | 'GB';
es: 'ES' | 'MX';
zh: 'CN' | 'TW';
}
/**
* 递归拼装完整语言标签类型,如 'pt' | 'pt-PT' | 'pt-BR' ...
*/
export type LanguageCode =
| BaseLanguage
| { [K in BaseLanguage]: `${K}-${RegionMap[K]}` }[BaseLanguage];
/**
* 语言元数据描述:为每个标签补充显示名与书写方向
*/
export interface LanguageMeta {
readonly code: LanguageCode;
readonly displayName: string;
readonly dir: 'ltr' | 'rtl';
}
export const LANGUAGE_REGISTRY: Record<LanguageCode, LanguageMeta> = {
'pt': { code: 'pt', displayName: 'Português', dir: 'ltr' },
'pt-PT': { code: 'pt-PT', displayName: 'Português (Portugal)', dir: 'ltr' },
'pt-BR': { code: 'pt-BR', displayName: 'Português (Brasil)', dir: 'ltr' },
'pt-AO': { code: 'pt-AO', displayName: 'Português (Angola)', dir: 'ltr' },
'pt-MZ': { code: 'pt-MZ', displayName: 'Português (Moçambique)', dir: 'ltr' },
// 其余语言条目按同样结构补充...
} as const;这套设计的巧妙之处在于,LanguageCode通过模板字面量类型自动生成了所有合法组合,任何试图传入'pt-XX'这类未注册标签的代码都会在编译期报错。LANGUAGE_REGISTRY则充当唯一事实来源,显示名称、文字方向等元数据集中管理,避免散落在各处的魔法字符串。需要注意类型体操不要过度,如果项目支持几十种语言,手写映射就够了,没必要追求全自动生成。
三、实现LangManager运行时工具类
类型只解决编译期问题,运行时还需要处理动态拼接的lang值、SSR水合、HTML属性同步等问题。封装一个LangManager类,对外暴露校验、切换、局部标注三个核心能力:
import { LANGUAGE_REGISTRY, LanguageCode } from './types';
/** 运行时校验:兼容外部传入的任意字符串(URL参数、接口返回值等) */
export function isValidLanguage(value: string): value is LanguageCode {
return Object.prototype.hasOwnProperty.call(LANGUAGE_REGISTRY, value);
}
export class LangManager {
private current: LanguageCode;
constructor(initial: LanguageCode) {
this.current = initial;
this.apply(initial);
}
/** 将语言同步到文档根元素 */
private apply(code: LanguageCode): void {
if (typeof document === 'undefined') return; // SSR环境直接跳过
const meta = LANGUAGE_REGISTRY[code];
document.documentElement.lang = code;
document.documentElement.dir = meta.dir;
}
/** 切换整页语言 */
setLanguage(code: LanguageCode): void {
if (code === this.current) return;
this.current = code;
this.apply(code);
}
/** 读取当前语言的基础部分,例如 'pt-BR' -> 'pt' */
get baseLanguage(): BaseLanguage {
return this.current.split('-')[0] as BaseLanguage;
}
/** 局部语言标注:为行内外语片段设置lang,满足WCAG 3.1.2 */
markForeignElement(el: HTMLElement, code: LanguageCode): void {
el.setAttribute('lang', code);
}
get language(): LanguageCode {
return this.current;
}
}几个实现细节值得展开。第一,document.documentElement.lang比document.documentElement.setAttribute('lang', ...)更简洁且语义等价,两者都会反映到DOM属性上。第二,dir属性的同步是容易遗漏的点,虽然葡语和英语都是左到右,但如果类型系统将来扩展阿拉伯语、希伯来语,dir="rtl"的联动切换能让组件直接复用。第三,isValidLanguage采用类型谓词写法,外部拿到URL查询参数后调用它收窄类型,就能安全地交给setLanguage,形成类型闭环。
局部标注方法对应的是WCAG 3.1.2页面局部语言这一进阶标准。举个例子,一篇葡语文章里引用了一句英文谚语,就应该对该元素单独设置lang="en"。这个方法在React或Vue项目里同样适用,只需在组件生命周期中调用,或封装成对应的指令与高阶组件。
四、路由集成与SSR场景的落地要点
在实际项目中,语言切换往往与路由绑定,比如/pt-BR/about这样的路径结构。下面演示如何在路由钩子中安全地消费LanguageCode:
const manager = new LangManager('pt-PT');
export function setupRouter(navigate: (path: string) => void): void {
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const anchor = target.closest('a[data-lang-switch]');
if (!anchor) return;
const raw = anchor.getAttribute('data-lang-switch') ?? '';
if (!isValidLanguage(raw)) {
console.warn(`非法语言代码: ${raw},已忽略`);
return;
}
// 类型收窄后,raw已是LanguageCode,可安全使用
manager.setLanguage(raw);
document.cookie = `site_lang=${raw};path=/;max-age=31536000;SameSite=Lax`;
navigate(`/${raw}/`);
});
}SSR场景有一个高频坑点:服务端渲染的HTML里lang属性必须与客户端首帧一致,否则会触发水合不匹配警告。解决方案是在服务端根据请求头Accept-Language或Cookie解析初始语言,直接渲染进HTML模板的根标签,客户端LangManager初始化时读取document.documentElement.lang作为初始值,而不是硬编码。此外,解析Accept-Language时记得处理q值权重排序和大小写规范化,HTTP头里的pt-BR可能写作pt-br,统一转小写再与注册表匹配即可。
最后提醒一点测试策略。语言切换属于典型的跨模块状态,建议编写单元测试覆盖三种情况:合法标签切换成功、非法标签被拒绝且状态不变、重复设置同一语言不触发DOM写入。可以借助jsdom的document.documentElement断言属性值,配合axe-core做无障碍合规扫描,验证lang相关规则全部通过。这套类型约束加运行时校验加测试的组合拳,能确保页面语言管理在项目演进过程中长期保持正确,为葡语用户以及其他语言的使用者提供符合WCAG标准的无障碍体验。
TypeScriptWCAG页面语言修改时间:2026-09-09 19:31:15