jiangshanmeta.github.io
jiangshanmeta.github.io copied to clipboard
类型安全的多语言
我们先看最初多语言是怎么实现的:
const zhCN = {
CONSTANT_VALUE: '常量值',
DETAIL_PROPERTY_NUMBER_RESERVE: '保留{{num}}位',
};
const enUS = {
CONSTANT_VALUE: 'Value',
DETAIL_PROPERTY_NUMBER_RESERVE: 'Retain {{num}} decimal place(s)',
}
type LocalKey = keyof (typeof zhCN);
type LocaleType = 'zh' | 'zh-CN' | 'en' | 'en-US';
export const getLocalText = (locale: LocaleType, key: LocalKey, options?: Record<string, string>)=>{
let text: string = key;
if ((locale === 'en' || locale === 'en-US') && enUS[key]) {
text = enUS[key];
} else if (zhCN[key]) {
text = zhCN[key];
}
// 替换多语言文案中的动态变量,例如'数量:{{num}}'
if (options && Object.keys(options).length > 0) {
Object.keys(options).forEach((_key) => {
text = text.replace(`{{${_key}}}`, options[_key]);
});
}
return text;
};
这么实现有个问题,options可以随意传,比如以下调用:
// 需要参数,但是没传
getLocalText('zh','DETAIL_PROPERTY_NUMBER_RESERVE')
// 需要参数,但是传了一个迷之参数
getLocalText('zh','DETAIL_PROPERTY_NUMBER_RESERVE',{
meow:'1'
})
这就意味着,我们只有在运行时,看页面才能知道出错了。能否利用类型系统及早发现调用错误?
首先要做的解析出多语言模板中需要的变量:
type ParseTemplate<S extends string, R = {}> = S extends `${string}{{${infer U}}}${infer T}`
? ParseTemplate<T, R & { [K in U]: string }>
: R;
这样就能解析出来模版需要的变量了,但是有点小瑕疵,如果我们在字符串模版里 花括号中间 有空格, 通常这么些只是为了美观,我们需要去除左右的空格,也就是需要实现一个类型版本的Trim:
type Space = ' ' | '\n' | '\t';
type TrimLeft<S extends string> = S extends `${Space}${infer T}` ? TrimLeft<T> : S;
type TrimRight<S extends string> = S extends `${infer T}${Space}` ? TrimRight<T> : S;
type Trim<S extends string> = TrimRight<TrimLeft<S>>;
我们的解析函数就变成了这样:
type ParseTemplate<S extends string, R = {}> = S extends `${string}{{${infer U}}}${infer T}`
? ParseTemplate<T, R & { [K in U as Trim<K>]: string }>
: R;
然后我们需要处理多语言配置文件:
const zhCN = {
CONSTANT_VALUE: '常量值',
DETAIL_PROPERTY_NUMBER_RESERVE: '保留{{num}}位',
} as const;
const enUS = {
CONSTANT_VALUE: 'Value',
DETAIL_PROPERTY_NUMBER_RESERVE: 'Retain {{num}} decimal place(s)',
} as const;
之前TS对这些模板文字的类型推导为string,使用const断言之后,可以把类型收窄为具体的字符串字面量类型。
接下来我们要处理的getLocalText的类型,
type GetLocalText<LocaleData extends Record<string, string>> = {
<K extends keyof LocaleData>(locale: LocaleType, key: K, args:ParseTemplate<LocaleData[K]> ): string;
};
export const getLocalText:GetLocalText<typeof zhCN> = (locale, key, options?: Record<string, string>)=>{
let text: string = key;
if ((locale === 'en' || locale === 'en-US') && enUS[key]) {
text = enUS[key];
} else if (zhCN[key]) {
text = zhCN[key];
}
// 替换多语言文案中的动态变量,例如'数量:{{num}}'
if (options && Object.keys(options).length > 0) {
Object.keys(options).forEach((_key) => {
text = text.replace(`{{${_key}}}`, options[_key]);
});
}
return text;
};
这样我们前面的两个例子,一个是少传参数,一个是传错参数,都能靠类型直接发现了。
当然这还有些问题,我们的多语言配置,通常是不需要额外传递参数的,但是目前的实现对于这个场景会强制要求传递第三个参数:
// 不需要额外参数,但是这样类型报错 需要第三个参数
getLocalText('zh','CONSTANT_VALUE')
// 必需要传一个额外的空对象
getLocalText('zh','CONSTANT_VALUE',{})
我们期望的是当模板不需要参数时,就不需要第三个参数了。
我们需要两个辅助类型:
type IsEmptyObject<T extends Record<string, any>> = [keyof T] extends [never] ? true : false;
type GetFormatRestArg<S extends string, T = ParseTemplate<S>> = IsEmptyObject<T> extends true ? [] : [T];
然后修改函数类型签名:
// LocaleData 这里就是 zhCN const 断言后的类型
// 注意这里的rest参数, 最终当模板不需要参数时,函数需要两个参数 需要参数时,函数需要三个参数
type GetLocalText<LocaleData extends Record<string, string>> = {
<K extends keyof LocaleData>(locale: LocaleType, key: K, ...rest: GetFormatRestArg<LocaleData[K]>): string;
};
这样上面两个非法调用ts就会提示错误了。
还有一个比较严重的问题,上面的检验都是基于zh的,我们如何保证中英文的配置时一致的?如果有一个配置,中文配的需要一个参数A,英文配的需要参数B,我们能否检验出来?
所以我们需要从zh和en的类型中,映射出来一个新类型,这个新类型能表征多语言模板需要的参数。
type GetLocaleEigenvalues<LocaleData extends Record<string,string>> = {
[K in keyof LocaleData]:ParseTemplate<LocaleData[K]>
}
比较辅助函数
export type ExpectTrue<T extends true> = T;
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
比较:
export type EditorLocaleEqual = ExpectTrue<
Equal<GetLocaleEigenvalues<typeof zhCN>, GetLocaleEigenvalues<typeof enUS>>
>;