i18n 居然还能玩出花

6,747 阅读5分钟

痛点

众所周知,前端项目想接入国际化,基本就是 i18n 这个方案。

吐槽一下,i18n 的官网居然不支持国际化,仿佛买剪刀来拆包装盒,发现剪刀外面也有包装

i18n 的使用方式如下:

public/
│   └─ locales/
│       ├── en.json       # 英文语言文件
│       └── zh.json       # 中文语言文件
// en.json
{
  "title": "Welcome to the i18n Demo",
  "description": "This is a simple i18n demo in React."
}
// zh.json
{
  "title": "欢迎来到 i18n 演示",
  "description": "这是一个简单的 React i18n 演示。"
}
const App = () => {
  const { t } = useTranslation();
  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">{t("title")}</h1>
      <p className="mb-2">{t("description")}</p>
    </div>
  );
};

但是这样有几个缺点:

  1. 中文和英文被分开,无法第一时间对应
  2. 要起很多变量名, title/description 等
  3. 看到变量名有时候也想不起来这里原本是什么文案,需要二次搜索
  4. 假如变量名书写错误,网页会直接显示变量名而非 英文/中文 名
  5. 冗余很多,比如上述的 title 要写三遍,不管是改还是写都很麻烦,尤其是需要翻译的东西很多的时候,是真痛苦吧
  6. 默认就要额外加载 json

如果你项目中遇到了这些痛点,不妨继续看下去

设想

稍加思索一下,如果将架构改造成中英文键值对形式,变量名直接使用中文,也许更简单好用

public/
│   └─ locales/
│       ├── default.json       # 语言文件
{
  "欢迎来到 i18n 演示": "Welcome to the i18n Demo",
  "这是一个简单的 React i18n 演示。": "This is a simple i18n demo in React."
}
const App = () => {
  const { t } = useTranslation();
  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">{t("欢迎来到 i18n 演示")}</h1>
      <p className="mb-2">{t("这是一个简单的 React i18n 演示。")}</p>
    </div>
  );
};

这种方案有一下优势:

  1. 不需要多个文件来回切换,在一个文件中写中英文

  2. 中英文对照明确

  3. 在国际化失败的情况下仍然正常能显示中文而非变量名

  4. 不需要起乱七八糟的变量名

  5. 改造原有代码非常快捷,只需要套一层 t 函数即可

方案调研

那么好,要怎么实现才能达成这种效果呢?我本来想自己写个脚本将键值对转化为对应的 json,但是咨询过 ai 后,发现有更简单的方案。

我们可以在 i18n 上找到init 函数文档,其负责初始化工作

所以要做的就是在 init 中打入自己的逻辑。

受益于 i18n 漂亮的设计,我们可以在 parse 处理掉大部分转化工作,参考如下代码

import i18n, { InitOptions } from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

const i18nConfig: InitOptions = {
  supportedLngs: ['zh', 'en'],
  load: 'languageOnly',
  backend: {
    loadPath: (lng: string) => {
      if (lng.includes('zh')) {
        return ''; // 中文的时候不加载 json,直接让他显示变量名
      }
      return '/locales/{{ns}}.json';
    },
    parse: (data: string) => {
      const parsed = JSON.parse(data) as Record<string, string>;
      const lang = i18n.language;

      const resolveTranslations = (translations: Record<string, string>) => {
        return Object.keys(translations).reduce(
          (acc, key) => {
            if (lang.includes('zh')) {
              acc[key] = key; // 中文显示键
            } else {
              acc[key] = translations[key]; // 英文显示值
            }
            return acc;
          },
          {} as Record<string, string>,
        );
      };

      return resolveTranslations(parsed);
    },
  },
};
i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init(i18nConfig);

实现

这样的国际化写起来极其方便且不易出错,贴个项目中的图

而且通过设定了加载路径,让中文去请求空路径,实现取消掉中文的 json 请求

可以观察到,我们在中文情况下完全不会去加载 json

当切换成英文时,才会进行额外的资源加载

快捷提取

最后还有一个扫描函数,可以提取 目标文件夹 中的所有文件的 t函数

const fs = require('fs');
const path = require('path');

const outputPath = path.join(__dirname, 'output.json');
const tempPath = path.join(__dirname, 'output_temp.json');
const collectedKeys = new Set();

// 匹配 t('xxx') / t("xxx") / t(`xxx`),支持换行
const tRegex = /\bt\s*\(\s*(['"])([\s\S]*?)\1\s*,?\s*\)/g;
/**
 * 从文件中提取 t('...') 字符串
 */
function extractStringsFromFile(filePath) {
  const content = fs.readFileSync(filePath, 'utf-8');
  const keys = new Set();

  let match;
  while ((match = tRegex.exec(content)) !== null) {
    const key = match[2].trim(); // 去除多余空格和换行
    keys.add(key);
    collectedKeys.add(key);
  }

  if (keys.size > 0) {
    const obj = {};
    Array.from(keys).forEach((key) => {
      obj[key] = '';
    });

    fs.appendFileSync(tempPath, `${JSON.stringify(obj, null, 2)}\n\n`);
  }
}

/**
 * 递归遍历目录
 */
function traverseDir(dirPath) {
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });

  entries.forEach((entry) => {
    const fullPath = path.join(dirPath, entry.name);
    if (entry.isDirectory()) {
      traverseDir(fullPath);
    } else if (entry.isFile()) {
      extractStringsFromFile(fullPath);
    }
  });
}

/**
 * 读取已有的翻译文件(json)
 * @param {string} filePath
 * @returns {object}
 */
function readExistingTranslations(filePath) {
  if (fs.existsSync(filePath)) {
    try {
      const content = fs.readFileSync(filePath, 'utf-8');
      return JSON.parse(content);
    } catch (e) {
      console.warn(`⚠️ 读取已有翻译文件失败: ${e.message}`);
    }
  }
  return {};
}

/**
 * 写入最终去重后的 output.json
 * @param {string} existingTranslationPath - 可选,已有翻译文件路径
 */
function writeFinalOutput(existingTranslationPath) {
  const finalOutput = {};
  const existingTranslations = readExistingTranslations(existingTranslationPath);

  Array.from(collectedKeys).forEach((key) => {
    finalOutput[key] = existingTranslations[key] || '';
  });

  fs.writeFileSync(outputPath, JSON.stringify(finalOutput, null, 2), 'utf-8');

  if (fs.existsSync(tempPath)) {
    fs.unlinkSync(tempPath);
  }
}

/**
 * 主程序入口
 * @param {string} targetDir - 需要扫描的目录
 * @param {string} [existingTranslationPath] - 可选,已有翻译 JSON 文件路径
 */
function main(targetDir, existingTranslationPath) {
  fs.writeFileSync(tempPath, '', 'utf-8');
  traverseDir(targetDir);
  writeFinalOutput(existingTranslationPath);

  console.log('✅ 提取完成,结果已保存至 output.json');
}

// 调用示例(请根据实际替换路径)
const targetDir = 'xxxx';
const existingTranslationPath = 'xxxxxx';

main(targetDir, existingTranslationPath);

image.png

如此一来,我们做 i18n 只需要三步:

  1. 给所有需要翻译的地方,使用 t 函数包裹目标文案
  2. node 跑一下这个提取函数(目标文件夹设定为 src 即可),就能获得完整 json
  3. 将 output 文件翻译并应用(翻译这一步可以用 cursor 之类的,巨快)

另外,如果想更新而非新建,可以在提取函数中添加已有翻译 JSON 文件路径,这样就可以完成更新,没有翻译的键会置空,翻译过的键会保留。

使用这个方案,可以大大加速项目中的 i18n 进程~

25-0731 更新

看了一下大家的讨论,这个方案的缺点有二

  1. key 不可控,很容易出现奇怪的键(带表情、带空格)。
  2. 没有额外去处理其他语言

所以只推荐作为临时方案,或者小团队、对多语言要求不高的使用。

作者目前也切换成了更普适性的方案,专门做了个平台用于收录多语言,产品在这个平台定好 key、翻译文案等,然后写了个脚本用于拉取平台的多语言并注入到 json 文件中。如此一来,只要我们写的 key 在平台中能对应上就 ok 了。