最近我们的某个项目需要支持多语言了。作为一个从 Entry Task 开始就与多语言打过交道的人,我已经对这里面的套路很是熟悉。但是作为一个工程师,还是想着能不能做的更好一些。
我们是怎么做国际化的
对于大部分场景来说,国际化都相当简单:有若干语言包,每个语言包中是一堆 Key 和对应的文字模板,模板中可能会有占位符。一个语言包可能长这样:
{
"user_setting.list.total_users": "共 {count} 个用户。",
"user_setting.edit.confirm": "确认修改?",
}
代码中的用法可能是这样:
const { t } = useTranslation();
return (
<div className="total-users">
{t('user_setting.list.total_users', { count })}
</div>
<div className="modal">
<div className="modal-content">
{t('user_setting.edit.confirm')}
</div>
</div>
);
代码中的 useTranslation
函数可以自己写,也可以调用现成的 i18n 库。至于语言包从何而来,我们有专门的翻译平台,可以直接将项目的多语言数据下载为这种数据格式的 JSON 文件。
没错,多语言就是这么简单。但是它有一个很致命的痛点:如果我传入的 Key 在语言包中没有(可能是打错了),或者传入的占位符的值出了错(例如需要传 count
我却传了 total
),那么只有在界面真正渲染出来的时候,我才能知道这里出错了!这就不得不提到两种错误:编译时检查和运行时检查。
编译时检查与运行时检查
记得很久之前搞竞赛的时候,学长给我说过一句话:
编译成功并不是结束,而是噩梦的开始。
当时的我深有感触,因为水平太菜,好不容易通过了编译,但运行起来也会经常出问题,例如忘了设置递归边界导致栈溢出,或者莫名其妙就死循环了。那个时候我就有一个疑问:编译是机器来做,设计和运行测试用例是人来做,机器肯定比人做得更好、更全面,所以错误检查让编译器来做肯定是更好的;那么既然编译器可以拿到我的所有代码,为什么不能帮我检查出来这种错误呢?
后来我知道了一个残酷的现实:停机问题不可解。语言的灵活性,往往导致很多问题只能在运行时才会暴露出来。
然而最近两年,我看到了很多利用编译器来检查、优化代码的例子:
- 在一次学习 PWN 的过程中,我发现 GCC 在编译时会自动 inline 那些非递归的函数(因为生成物的符号表里没有这个函数了),这可以减少函数调用时的额外开销;
- Svelte 通过限制 JS 的赋值过程,以及使用模板来描述界面而不是 JSX,使得编译器可以直接在变量的修改后面加上修改 DOM 的代码,避免了运行时的 DOM Diff;
- Rust 通过定义“所有权”、“借用”等概念,使得编译器可以知道每个变量的内存何时该被回收,无需 Auto GC 也能保证绝大多数情况下不会出现内存泄漏问题;
- ESLint 通过严格地限定可用的 JS 语法,在编译期间甚至开发期间就可以避免掉很多错误(例如:不小心在
if
中赋值、被万恶的==
坑、对函数参数的意外修改、打错变量名而生成了全局变量等)。
看来,在特定的语法下,特定的“停机问题”是可以被解决的。那么 i18n 能不能利用编译器做检查呢?
基于 TypeScript 的 i18n 校验
既然我们的项目用的是 TypeScript,我又刚好对 TypeScript 的类型系统比较熟悉,就想到能否利用它来帮忙检查参数。
注意到刚刚说到的“特定语法”,不难想出,如果利用 TypeScript 的类型系统来检查参数,需要满足这个条件:参数 key
一定是静态的,不能通过字符串拼接或使用变量的方式。因为一旦这样,TypeScript 就会把参数类型推断成 string
,就无法通过具体的字符串来确定占位符类型了。我觉得这个也可以接受,毕竟如果想用 Webpack 做拆包,import
函数的参数也必须是静态字符串,而不能是拼接或变量。
至于具体的做法,当然是写一个类型生成器,利用语言包文件,生成 types.ts
文件咯!只要能把每个 Key 对应的占位符单独生成一个这样的 interface 就成功了一半:
interface I18nPlaceholder1 {
current: number | string;
total: number | string;
}
这个用正则来做其实非常简单(代码在文章末尾),然后想办法让第二个参数根据第一个参数来选择不同的 interface 即可。
先放一下效果图吧:
几次失败的尝试
“同一个函数支持不同类型的参数”,我的第一反应就是 Overload。但是尝试了一番之后,我发现 Overload 必须使用 function t()
的写法,无法使用箭头函数,并且在最后必须是具体的实现。这对于类型生成器来说是不可接受的。
于是我又想到了之前写表单组件的方法。我们的表单组件支持传入一个表单配置来生成具体的元素:
const formProps: FormProps = {
fields: [
{
type: 'input',
label: 'Name',
ctrlProps: {
placeholder: 'Input your name',
},
},
{
type: 'select',
label: 'Gender',
ctrlProps: {
options: [
{ label: 'Male', value: 0 },
{ label: 'Female', value: 1 },
{ label: 'Other', value: 2 },
],
},
},
],
};
其中 ctrlProps
可以随着 type
而改变。我们当时的写法是这样的:
interface FieldBaseProps {
label: string;
}
interface FieldInputProps extends FieldBaseProps {
type: 'input';
ctrlProps: {
placeholder?: string;
};
}
interface FieldSelectProps extends FieldBaseProps {
type: 'select';
ctrlProps: {
options: Array<{
label: string;
value: string;
}>;
};
}
type FieldProps = FieldInputProps
| FieldSelectProps
| ...;
interface FormProps {
fields: FieldProps[];
}
通过复合类型就可以方便地做到这点了。于是我如法炮制:
interface I18nPlaceholder1 { ... }
interface I18nPlaceholder2 { ... }
type _F1 = (key: 'key.1', value: I18nPlaceholder1) => string;
type _F2 = (key: 'key.2', value: I18nPlaceholder2) => string;
type _F3 = (key: 'key.3' | 'key.4') => string;
type TranslateFunc = _F1 | _F2 | _F3;
结果当我输入 t('
的时候,TypeScript 提示我“不能把字符串传给 never
”……看起来 TypeScript 对所有的情况取了交集。
一次成功的尝试
于是我又去想别的方法,用范型能否实现呢?对于没有占位符的情况来说,确实很简单:
interface I18nPlaceholder1 { ... }
interface I18nPlaceholder2 { ... }
type CombinedArgsWithPlaceholder = {
'key.1': I18nPlaceholder1;
'key.2': I18nPlaceholder2;
}
type TranslateFuncWithPlaceholder = <
T extends keyof CombinedArgsWithPlaceholder
>(
key: T,
value: CombinedArgsWithPlaceholder[T]
) => string;
这里利用了 T extends keyof
,以确保 key
既在指定的范围内,value
又一定随着 key
而改变。
那么对于没有占位符的元素呢?更简单了:
type CombinedArgsWithoutPlaceholder = 'key.3' | 'key.4';
type TranslateFuncWithoutPlaceholder = (
key: CombinedArgsWithoutPlaceholder
) => string;
// 一开始我用了 | 会报错,改成了 & 就可以了。
// TypeScript 把这个理解成了对函数的 Overload,
// 虽然官方文档里没有指出有这种写法……
type TranslateFunc = TranslateFuncWithPlaceholder & TranslateFuncWithoutPlaceholder;
于是就有了上面截图中的效果。
话不多说,上代码
import { resolve } from 'path';
import { output } from './common';
const commonWarningStr = `/**
* WARNING
* This file is auto-generated using:
* ts-node generate-i18n-types.ts
* Do not modify its content.
*/
`;
// This file must exists
const langPack = require('path/to/en_US.json');
const keysWithoutPlaceholder: string[] = [];
const interfaces: Record<string, string> = {};
const combinedArgsWithPlaceholder: string[] = [];
let combinedArgsWithoutPlaceholder = '';
// 工具函数
// 用于把 key(abc.def.ghi)转换为 interface name(AbcDefGhi)
const toInterfaceName = (key: string) => {
return key.replace(/(?:\.|^)(\w)/g, (_, $1) => $1.toUpperCase());
};
// 枚举每一个 key
for (const key in langPack) {
const value: string = langPack[key];
// 找到模板中所有花括号括起来的文字片段(占位符)
const match = value.match(/\{[^{}]+?\}/g);
if (match) {
// 如果有占位符,则生成一个 interfaces
const interfaceName = toInterfaceName(key);
interfaces[key] = [
`interface ${interfaceName} {`,
...match.map(t => ` ${t.substring(1, t.length - 1)}: string | number;`),
'}',
].join('\n');
combinedArgsWithPlaceholder.push(` '${key}': ${interfaceName};`);
} else {
// 否则,只是记录一下 key
keysWithoutPlaceholder.push(`'${key}'`);
}
}
// 剩下的代码都只是拼装了
if (keysWithoutPlaceholder.length) {
combinedArgsWithoutPlaceholder = `type CombinedArgsWithoutPlaceholder = ${keysWithoutPlaceholder.join(' | ')};`;
}
const contentStr = [
commonWarningStr,
Object.values(interfaces).join('\n'),
'type CombinedArgsWithPlaceholder = {',
combinedArgsWithPlaceholder.join('\n'),
'}',
combinedArgsWithoutPlaceholder,
'type TranslateFuncWithPlaceholder = <T extends keyof CombinedArgsWithPlaceholder>(key: T, value: CombinedArgsWithPlaceholder[T]) => string;',
'type TranslateFuncWithoutPlaceholder = (key: CombinedArgsWithoutPlaceholder) => string;',
'export type TranslateFunc = TranslateFuncWithPlaceholder & TranslateFuncWithoutPlaceholder;',
'', // the last \n
].join('\n');
output(
resolve(__dirname, 'types.ts'),
contentStr,
);
后续:突然意识到的问题
一天之后,我突然想到:既然 TypeScript 会把两个函数类型的 &
操作理解成对函数的 Overload,那把之前的 _F1
、_F2
的那个方法改一改是否可行?
是可行的,而且这样写更容易理解……感觉一天前的我可能遭遇了降智攻击……