最近我们的某个项目需要支持多语言了。作为一个从 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 的那个方法改一改是否可行?

是可行的,而且这样写更容易理解……感觉一天前的我可能遭遇了降智攻击……