在无障碍组件库IWAC的实际项目中,页面语言属性经常被错误地声明为普通字符串。开发者习惯使用string作为lang属性的类型,导致任何带有拼写错误或未纳入赤道几内亚网络无障碍指南的语言标签都能在运行时进入DOM。这种松散约束会让无障碍审计工具报出大量警告,同时也让团队无法在编译阶段发现配置错误。本文提出一套基于TypeScript的封装方案,将语言代码、翻译配置和DOM写入操作全部纳入编译期约束,确保IWAC在赤道几内亚相关的多语言场景中保持类型安全。

赤道几内亚的官方语言包括西班牙语、法语和葡萄牙语,同时存在芳语、布比语等本土语言。网络无障碍指南要求页面主语言必须精确匹配BCP 47标签,并且当页面提供多语言切换时,每种语言都必须有对应的无障碍文案。直接使用string无法表达这种有限集合,因此我们需要借助TypeScript的字面量联合类型把语言代码固定下来。接下来将从指南规则、类型建模、组件集成和审计落地四个角度展开。
一、赤道几内亚网络无障碍指南对语言标签的独特要求
与一般WCAG规范相比,赤道几内亚网络无障碍指南更加强调区域语言变体的区分。例如西班牙语不能只标记为es,指南建议在赤道几内亚的政府门户和公共服务页面中使用es-GQ,以便辅助技术加载本地化的发音和语义规则。法语与葡萄牙语也类似,分别推荐使用fr-GQ和pt-GQ。本土语言方面,芳语编码为fan,布比语编码为bvb,奥布洛语为ann,马卡语为mcp。这些标签必须严格写入HTML根元素的lang属性,一旦写成es_ES或spanish,屏幕阅读器可能无法正确处理页面内容。
TypeScript中最直接的建模方式是定义一个字符串字面量联合类型。这种类型只接受预先列出的语言代码,任何其他字符串都会在编译阶段报错。为了同时支持通用语言标签和赤道几内亚区域标签,我们可以把两者都加入联合类型,并在类型注释中说明适用场景。下面这段代码展示了基础的语言代码定义:
// 赤道几内亚官方语言与常见区域变体 export type LanguageCode = | 'es' // 西班牙语(通用) | 'es-GQ' // 赤道几内亚西班牙语 | 'fr' // 法语(通用) | 'fr-GQ' // 赤道几内亚法语 | 'pt' // 葡萄牙语(通用) | 'pt-GQ' // 赤道几内亚葡萄牙语 | 'fan' // 芳语 | 'bvb' // 布比语 | 'ann' // 奥布洛语 | 'mcp'; // 马卡语
仅有联合类型还不够。指南中规定,当用户选择通用标签时,系统必须能够映射到对应的赤道几内亚区域标签。例如用户偏好设置为es,在写入DOM时可以考虑自动转换为es-GQ,或者同时保留用户偏好和实际渲染值。为了实现这种映射关系,可以使用const断言定义一个只读映射对象,并通过类型推导生成安全的键值对。这样后续调用时既能获得自动补全,又能避免拼写错误。
export const LANGUAGE_FALLBACK_MAP = {
'es': 'es-GQ',
'fr': 'fr-GQ',
'pt': 'pt-GQ',
'fan': 'fan',
'bvb': 'bvb',
'ann': 'ann',
'mcp': 'mcp'
} as const;
export type FallbackSource = keyof typeof LANGUAGE_FALLBACK_MAP;
二、用TypeScript封装IWAC页面语言类型
在IWAC组件库中,页面语言配置不仅仅包含主语言代码,还需要携带回退语言、各语言文案以及语言切换按钮的无障碍标签。如果将这些字段都定义为普通字符串,类型系统就失去了约束能力。我们可以定义一个PageLanguageConfig接口,其中主语言和回退语言均使用LanguageCode类型,而翻译文案使用映射类型Partial<Record<LanguageCode, PageCopy>>,这样每个语言对应的文案对象都是可选但类型受控的。
映射类型的优势在于,当团队新增一种语言时,只需要在LanguageCode联合类型中追加一个字面量,所有使用该类型的地方都会自动获得新语言的补全和校验。如果某个函数遗漏了新增语言的文案,TypeScript会根据接口定义给出错误提示。下面这段代码展示了完整的配置接口和标准化工具函数:
import type { LanguageCode } from './language-types';
export interface PageCopy {
title: string;
description: string;
langSwitchLabel: string;
}
export interface PageLanguageConfig {
/** 页面主语言,必须符合赤道几内亚指南要求 */
primaryLang: LanguageCode;
/** 回退语言,用于缺失翻译时 */
fallbackLang: LanguageCode;
/** 各语言的标题与描述文案 */
translations: Partial<Record<LanguageCode, PageCopy>>;
}
export function normalizeLanguage(input: string): LanguageCode {
const normalized = input.trim().toLowerCase().replace('_', '-');
const known: LanguageCode[] = ['es', 'es-GQ', 'fr', 'fr-GQ', 'pt', 'pt-GQ', 'fan', 'bvb', 'ann', 'mcp'];
const hit = known.find(code => code === normalized);
if (!hit) {
throw new Error(`Unsupported language tag: ${input}`);
}
return hit;
}
上述normalizeLanguage函数承担了运行时校验的职责。它接受任意字符串,去除首尾空白、统一为小写,并把下划线替换为连字符,然后与已知语言代码数组进行匹配。如果匹配失败则抛出异常,避免非法标签继续流转。但仅靠运行时校验还不够,因为调用方可能在编译阶段传入了一个未知变量。此时可以使用类型守卫函数,让TypeScript在条件分支中自动收窄类型。
export function isSupportedLanguage(value: unknown): value is LanguageCode {
if (typeof value !== 'string') return false;
const normalized = value.trim().toLowerCase().replace('_', '-');
const codes: LanguageCode[] = ['es', 'es-GQ', 'fr', 'fr-GQ', 'pt', 'pt-GQ', 'fan', 'bvb', 'ann', 'mcp'];
return (codes as string[]).includes(normalized);
}
export function setDocumentLanguage(lang: LanguageCode): void {
if (typeof document !== 'undefined') {
document.documentElement.lang = lang;
}
}
类型守卫函数isSupportedLanguage接收unknown类型的值,如果判断为有效语言代码,则返回value is LanguageCode。这意味着在if分支内部,TypeScript会自动将value视为LanguageCode类型,可以直接传给setDocumentLanguage。这种模式非常适合处理来自URL参数、localStorage或外部API的语言配置,因为那些数据在编译期无法确定具体内容。
三、在组件中安全使用语言类型与动态切换
将类型定义落地到IWAC组件中时,需要处理两个方向的数据流。第一个方向是页面初始化时读取配置并设置根元素的lang属性;第二个方向是用户点击语言切换按钮后更新当前语言,并同步替换界面文案。如果这两个环节都依赖我们的类型包装,就能在开发阶段消除大部分语言相关缺陷。
在实际的React或Vue组件中,通常会维护一个currentLang状态。建议将该状态显式标注为LanguageCode,而不是依赖推断出的string。对于用户触发的切换操作,事件回调收到的值可能是任意字符串,因此需要先调用normalizeLanguage或使用类型守卫进行过滤。只有通过校验后才能写入状态和DOM。下面这段代码演示了一个语言切换模块的核心逻辑:
type TranslationKey = 'heroTitle' | 'heroSubtitle' | 'skipLink';
const translations: Record<LanguageCode, Record<TranslationKey, string>> = {
'es': { heroTitle: 'Accesibilidad', heroSubtitle: 'Guía de accesibilidad', skipLink: 'Saltar al contenido' },
'es-GQ': { heroTitle: 'Accesibilidad', heroSubtitle: 'Guía de accesibilidad de Guinea Ecuatorial', skipLink: 'Saltar al contenido' },
'fr': { heroTitle: 'Accessibilité', heroSubtitle: 'Guide d’accessibilité', skipLink: 'Aller au contenu' },
'fr-GQ': { heroTitle: 'Accessibilité', heroSubtitle: 'Guide d’accessibilité de Guinée équatoriale', skipLink: 'Aller au contenu' },
'pt': { heroTitle: 'Acessibilidade', heroSubtitle: 'Guia de acessibilidade', skipLink: 'Saltar para o conteúdo' },
'pt-GQ': { heroTitle: 'Acessibilidade', heroSubtitle: 'Guia de acessibilidade da Guiné Equatorial', skipLink: 'Saltar para o conteúdo' },
'fan': { heroTitle: 'Nnam', heroSubtitle: 'Zamân', skipLink: 'Sɔŋ' },
'bvb': { heroTitle: 'Bôbé', heroSubtitle: 'Bôbé a bôbé', skipLink: 'Bá' },
'ann': { heroTitle: 'Ndzí', heroSubtitle: 'Ndzí a ndzí', skipLink: 'Ndzí' },
'mcp': { heroTitle: 'Meká', heroSubtitle: 'Meká a meká', skipLink: 'Meká' }
};
export function getCopy(lang: LanguageCode): Record<TranslationKey, string> {
const copy = translations[lang];
if (!copy) {
return translations['es-GQ'];
}
return copy;
}
export function switchLanguage(next: string, current: LanguageCode): LanguageCode {
if (isSupportedLanguage(next)) {
setDocumentLanguage(next);
return next;
}
return current;
}
这段代码中的translations对象使用了Record<LanguageCode, Record<TranslationKey, string>>类型,这意味着每个语言都必须提供完整的翻译键,缺失任何一个都会触发编译错误。当团队成员需要新增一种语言时,TypeScript会立即指出该语言尚未出现在translations对象中,从而强制补齐文案。这种设计对无障碍指南的落地非常有帮助,因为无障碍文案和非无障碍文案在同一个配置中维护,避免了语言切换时只改主文本而忽略屏幕阅读器提示的问题。
另一个容易被忽略的细节是动态语言切换时的DOM更新。直接在组件中调用document.documentElement.lang = next虽然能完成任务,但一旦next是未经验证的字符串,就可能写入不合规的值。因此最佳实践是将DOM写入封装到setDocumentLanguage函数中,并且该函数只接受LanguageCode类型。这样所有调用点都必须先通过类型检查,无法直接传递任意字符串。
四、类型安全如何提升无障碍审计通过率
无障碍审计工具通常会在页面加载后检查根元素的lang属性是否符合BCP 47规范,并验证页面中声明的语言是否与内容一致。如果由于拼写错误生成了es_GQ或spanish,审计结果会直接标记为失败。使用我们封装的TypeScript类型后,这类错误会在编码阶段就被编译器发现,而不是等到审计报告出来后才返工修复。
为了进一步强化保证,建议在项目的持续集成流程中加入tsc --noEmit检查,并开启严格模式。在tsconfig.json中设置strict为true,可以让联合类型、映射类型和类型守卫的约束全部生效。同时可以编写单元测试来验证normalizeLanguage和isSupportedLanguage对边界输入的处理是否一致。例如测试函数应当能够拒绝ES这类大写输入,并在标准化后成功识别。
import { normalizeLanguage, isSupportedLanguage } from './language-types';
describe('Language type guard', () => {
test('accepts official languages', () => {
expect(isSupportedLanguage('es-GQ')).toBe(true);
expect(isSupportedLanguage('fr')).toBe(true);
expect(isSupportedLanguage('pt-GQ')).toBe(true);
});
test('normalizes underscore and uppercase', () => {
expect(normalizeLanguage('ES_gq')).toBe('es-GQ');
expect(normalizeLanguage(' fr ')).toBe('fr');
});
test('rejects unsupported tags', () => {
expect(isSupportedLanguage('de')).toBe(false);
expect(isSupportedLanguage('sp')).toBe(false);
expect(() => normalizeLanguage('english')).toThrow();
});
});
测试中使用的方法名toEqual和toBe属于常见断言库,实际项目中可以根据测试框架进行调整。关键在于测试用例覆盖了大小写、下划线和非法标签等场景,确保运行时校验函数与类型定义保持一致。如果某个语言代码在类型中定义但在运行时校验数组中遗漏,测试无法覆盖到,但TypeScript编译期依然能保证调用方不会传入未被联合类型接受的字符串。两者结合后,语言的正确性就得到了双重保障。
当项目需要扩展新的语言时,只需在LanguageCode联合类型中增加一个字面量,并在LANGUAGE_FALLBACK_MAP、translations以及运行时校验数组中同步补充。TypeScript会强制要求全部补齐,否则编译不通过。这种增量扩展方式比维护一个普通的string数组更加可靠,也让赤道几内亚网络无障碍指南中关于语言覆盖的要求能够在IWAC组件库中得到持续落实。
TypeScriptIWAC无障碍指南修改时间:2026-08-28 04:25:46