Skip to content

国际化探索实践

随着项目的不断迭代,国际化多语言的维护变得越来越困难。手动维护多语言文案在初期可能可行,但随着项目规模的扩大,手动维护的方式会变得越来越繁琐和容易出错,严重影响开发效率。从手动文案管理到发展出一套全自动化的多语言管理系统非常有必要。全自动化的多语言管理系统,可以有效提升多语言项目的开发效率,解决多语言应用开发中遇到的常见问题,包括但不限于代码中的语义清晰性、文案维护的高效率,以及性能优化等挑战。通过这一系列的改进,能够为全球用户提供更加流畅和响应迅速的使用体验,同时也为多语言应用开发提供了宝贵的实践经验和启示。

  • i18next 是一个用于前端国际化的 JavaScript 库。它提供了一个简单易用的 API,可以帮助开发人员将应用程序本地化到多种语言。它提供了一种简洁的方式来加载翻译资源,并且支持多种资源格式(如 JSON、PO 等)。同时,也支持按需动态加载和缓存翻译资源,以提高性能和用户体验。

  • react-i18next 是基于 i18next 的国际化框架,支持 React、React Native、SSR,提供了一套用于在 React 应用程序中实现国际化的组件和高阶组件(TranswithTranslation)。提供 useTranslation,方便在函数组件中使用国际化。

  • vue-i18n 是 Vue.js 的国际化插件。还支持复数、数字、日期时间等本地化。支持多种语言切换策略,包括 URL 参数、浏览器语言设置和自定义逻辑。同时也支持动态按需加载和异步加载翻译资源,以提高性能和用户体验。

这些国际化库的用法实际上有比较多的相似点,大体上都是在代码中内置多语种文案,在业务代码中通过调用 i18n 方法,并传入对应文案的 key。编译的时候,会根据当前语种,读取 key 对应的文案并渲染。

手动维护

手动维护多语言存在以下困难:

  • 写法复杂,效率低t('key') 的写法需要开发者时刻记住每个键对应的翻译内容,增加了心智负担。手动维护多个语言文件,容易出错,特别是当项目规模变大时。
  • 不符合语意化:代码中充斥着大量的 key,缺乏语意化,影响代码的可读性和可维护性。产生较强的割裂感,代码的连贯性被破坏。
  • 回溯困难:定位问题文案需要先找到 key,再通过映射关系找到内容,增加了调试难度。文案的修改和更新需要多次查找和替换,容易遗漏,这一点在实际开发中尤其影响开发者
  • 维护困难:内置的文案如果需要修改,必须改动代码,增加了开发人员的心智负担。多语言文件的同步和更新需要手动操作,容易出错。
  • 代码冗余、影响性能:一个模块内的内容被重复引用,引入了不必要的文案,增加了代码体积。多语言文件的加载和解析会影响应用的启动时间和性能。
  • 无法有效支持所有端:Web、Android、IOS、服务端等都需要自己维护一套多语言,维护困难、代码冗余、没有标准和统一。
  • 项目迁移难度大:一个原先国内的项目要接入多语言需要做大量的文本兼容和调整。项目迁移过程中容易引入新的问题,增加开发和测试的工作量。

一般的 i18n 插件已经提高了一部分效率,但还是存在 key 的问题,比如代码入侵必须写 key、不符合语义可读性差、文案定位繁琐和遗漏、代码冗余、多端无法复用文案等问题。中央化、自动化的国际化多语言文案管理系统(例如使用在线平台 Crowdin、Locize 来管理多语言文案)可以有效解决上述问题。

多语言管理系统

  1. 建立国际化管理平台。用于存储、更新和检索所有语言的文本,为不同项目、不同平台(Web、Android、IOS、服务端等)提供中央化的维护和统一的文案资源。
  2. 多语种文案长度对比及约束。支持实时预览同一文案在不同语言下的文案长度,以便翻译人员调整文案,确保各语种版本在长度上尽可能一致,避免不同语种下产生的样式表现问题。
  3. 多语言文案翻译上下文和场景表达。支持开发在备注说明当前文案所处的使用场景以便于翻译表达准确。
  4. Excel 批量导出导出文案。通过自动化插件脚本从项目代码中导出所有或指定路径的中文文案到 Excel 表格,批量上传到管理平台。也支持从平台批量导出翻译后再上传从而批量更新。
  5. 支持机器翻译。管理平台支持 Google、百度或者 LLM 等进行自动化翻译及翻译优化。
  6. 集成飞书机器人。开发者上传完待翻译文案后,通过飞书机器人推送消息给翻译及 Review 人员。
  7. 版本控制及上传 CDN。使用版本控制来管理国际化文本,确保更改的可追溯性,完成后发版并上传 CDN。

有了多语言管理平台,开发流程则如下:

  1. 在多语言管理平台录入文案,或者通过自动化插件批量导出文案并上传到平台,批量录入(重复的会被过滤)。
  2. 机器人 bot 通知翻译人员翻译。支持机器翻译,翻译人员 Review 即可。
  3. 确认后上传到 CDN。
  4. 前端代码中直接写 $i18n('中文'),而不是 Key,编译阶段通过 babel-plugin-i18n 插件自动替换对应的 key。

i18n1

通过 Babel 插件分析抽象语法树(AST),识别出代码中的 $i18n('中文') 表达式,并将其替换成 t('module:key') 格式。这里的 key 其实不需要语义化的 key,只需要平台在该项目空间下生成唯一的且较短的标识,也不需要关注不同版本的 key 是否被修改或者不同,比如使用 nanoid 类似的短 ID 生成器,甚至数字索引。同时将嵌套的键结构扁平化,减少解析层数。

在代码提交前,通过 commit 钩子扫描修改过的代码,确定新的或修改过的文案,并将其自动上传到多语言管理平台。文案上传后,自动触发平台的发布流程,更新文案版本号。

实际在使用中,避免不了一些动态文案场景的出现(如评论区),可以把这些文案节点打上一个动态标识,在运行时调用机器翻译。在可枚举的动态文案也可以由前端和后端约定翻译平台开放接口的使用,达到可枚举文案能够走精确翻译的模式(比方说跨境电商商品名场景)。

核心实现

目标

js
const name = '张三';

// 纯文本写法
const text1 = $i18n('纯中文');
console.log(text1); // 输出: "纯中文" 或者其他语言的翻译

// 带变量的文本写法
const text2 = $i18n('你好!%1', { 1: name });
console.log(text2); // 输出: "你好!张三"

// 模块键值对写法
const text3 = $i18n({ module: 'shop', key: 'dress' });
console.log(text3); // 输出: "连衣裙" 或者其他语言的翻译

// 多语言组件写法
const text4 = $i18n({ text: '你好!<1>%1</1>', components: { 1: 'span' }, values: { 1: name }});
console.log(text4); // 输出: "你好!<span>张三</span>"

babel-plugin-i18n

通过 Babel 插件分析抽象语法树(AST),识别出代码中的 $i18n('中文') 表达式,并将其替换成 t('key') 格式。也可以使用 jscodeshift 这种库。

多语言资源文件 i18nResources,通过 i18nPlugin 加载 CDN 资源获取,见下一节。

js
const fs = require('fs');
const path = require('path');
const { types: t } = require('@babel/core');

// 读取缓存文件
function readCacheFile(filePath) {
  try {
    const data = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(data);
  } catch (err) {
    // 如果文件不存在或解析失败,创建一个新的空缓存文件
    writeCacheFile(filePath, { version: '', resources: {} });
    return { version: '', resources: {} };
  }
}

// 写入缓存文件
function writeCacheFile(filePath, data) {
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}

// 辅助函数:查找对象属性
function findProperty(properties, key) {
  return properties.find(prop => prop.key.name === key);
}

// Babel 插件定义
module.exports = function({ types: t, opts }) {
  const options = {
    i18nFunctionName: opts.i18nFunctionName || '$i18n',
    translationFunctionName: opts.translationFunctionName || 't',
    cacheFilePath: opts.cacheFilePath || path.join(__dirname, 'i18n-cache.json')
  };

  let i18nResources = {};

  return {
    name: 'i18n-transformer',
    pre(file) {
      // 插件在 pre 钩子中加载缓存文件,以提高效率并避免每次编译时都读取语言资源
      const cache = readCacheFile(options.cacheFilePath);
      i18nResources = cache.resources || {};
    },
    visitor: {
      CallExpression(path) {
        // 检查是否是 $i18n 调用
        if (path.node.callee.type === 'Identifier' && path.node.callee.name === options.i18nFunctionName) {
          const [arg] = path.node.arguments;

          if (t.isStringLiteral(arg)) {
            // 纯文本写法
            const text = arg.value;
            const key = Object.keys(i18nResources).find(key => i18nResources[key] === text);
            if (key) {
              path.replaceWith(
                t.callExpression(
                  t.identifier(options.translationFunctionName),
                  [t.stringLiteral(key)]
                )
              );
            }
          } else if (t.isObjectExpression(arg)) {
            const properties = arg.properties;
            const hasComponentAttr = findProperty(properties, 'components');
            const keyFind = findProperty(properties, 'key');
            const moduleFind = findProperty(properties, 'module');
            const textFind = findProperty(properties, 'text');
            const valuesFind = findProperty(properties, 'values');

            if (moduleFind && keyFind) {
              // 模块键值对写法
              const module = moduleFind.value.value;
              const key = keyFind.value.value;
              const languageText = i18nResources[module]?.[key];
              if (languageText) {
                path.replaceWith(
                  t.callExpression(
                    t.identifier(options.translationFunctionName),
                    [t.stringLiteral(languageText)]
                  )
                );
              }
            } else if (textFind) {
              // 带变量的文本写法
              const text = textFind.value.value;
              const values = valuesFind ? valuesFind.value : {};

              if (typeof values === 'object') {
                let formattedText = text;
                Object.keys(values).forEach(key => {
                  formattedText = formattedText.replace(`%${key}`, values[key]);
                });

                path.replaceWith(
                  t.stringLiteral(formattedText)
                );
              }
            } else if (hasComponentAttr) {
              // 多语言组件写法
              const text = textFind.value.value;
              const components = findProperty(properties, 'components').value;
              const values = valuesFind ? valuesFind.value : {};

              const reactComponents = [];
              let currentIndex = 0;

              text.split(/(<\d+>)/).forEach((part, index) => {
                if (part.startsWith('<')) {
                  const componentKey = part.slice(1, -1);
                  const componentName = components[componentKey].name;
                  const componentValue = values[componentKey];
                  reactComponents.push(
                    t.jsxElement(
                      t.jsxOpeningElement(t.jsxIdentifier(componentName), []),
                      t.jsxClosingElement(t.jsxIdentifier(componentName)),
                      [t.jsxText(componentValue)]
                    )
                  );
                } else {
                  reactComponents.push(t.jsxText(part));
                }
              });

              path.replaceWith(
                t.jsxFragment(
                  t.jsxOpeningFragment(),
                  t.JSXClosingFragment(),
                  reactComponents
                )
              );
            }
          }
        }
      }
    }
  };
};

i18nPlugin

以 Webpack Plugin 为例子,在 compiler.hooks.watchRun.tapAsync('I18nPlugin', (compilation, callback) => {} 中拉取和更新本地多语言资源并缓存文件。

js
const path = require('path');
const fs = require('fs');
const fetch = require('node-fetch');

// 读取缓存文件
function readCacheFile(filePath) {
  try {
    const data = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(data);
  } catch (err) {
    // 如果文件不存在或解析失败,创建一个新的空缓存文件
    writeCacheFile(filePath, { version: '', resources: {} });
    return { version: '', resources: {} };
  }
}

// 写入缓存文件
function writeCacheFile(filePath, data) {
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}

// 获取最新版本号
async function fetchLatestVersion(versionUrl) {
  const response = await fetch(versionUrl);
  if (!response.ok) {
    throw new Error(`Failed to fetch version: ${response.status} ${response.statusText}`);
  }
  const { version } = await response.json();
  return version;
}

// 获取多语言资源
async function fetchI18nResources(resourcesUrl, version) {
  const response = await fetch(`${resourcesUrl}/${version}.json`);
  if (!response.ok) {
    throw new Error(`Failed to fetch i18n resources: ${response.status} ${response.statusText}`);
  }
  return response.json();
}

class I18nPlugin {
  constructor(options) {
    this.options = {
      id: options.id,
      versionUrl: options.versionUrl,
      resourcesUrl: options.resourcesUrl,
      cacheFilePath: options.cacheFilePath || path.resolve(__dirname, 'i18n-cache.json')
    };
  }

  apply(compiler) {
    compiler.hooks.watchRun.tapAsync('I18nPlugin', (compilation, callback) => {
      this.handleWorkflow(callback);
    });
  }

  async handleWorkflow(callback) {
    try {
      // 读取缓存文件
      const cache = readCacheFile(this.options.cacheFilePath);
      const cachedVersion = cache.version;

      // 获取最新版本号
      const latestVersion = await fetchLatestVersion(this.options.versionUrl);

      if (cachedVersion !== latestVersion) {
        console.log('Updating i18n resources...');

        // 获取最新多语言资源
        const resources = await fetchI18nResources(this.options.resourcesUrl, latestVersion);

        // 更新缓存文件
        writeCacheFile(this.options.cacheFilePath, { version: latestVersion, resources });

        console.log('i18n resources updated.');
      } else {
        console.log('i18n resources are up to date.');
      }

      callback();
    } catch (error) {
      console.error('Error handling workflow:', error);
      callback();
    }
  }
}

module.exports = { I18nPlugin };

使用

js
const { I18nPlugin } = require('i18n');
const path = require('path');

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            plugins: [
              ['babel-plugin-i18n', {
                i18nFunctionName: '$i18n',
                translationFunctionName: 't',
                cacheFilePath: path.resolve(__dirname, 'i18n-cache.json')
              }]
            ]
          }
        }
      }
    ]
  },
  plugins: [
    new I18nPlugin({
      id: 190, // 项目空间 id
      versionUrl: 'https://your-custom-cdn-url.com/i18n/190/version.json',
      resourcesUrl: 'https://your-custom-cdn-url.com/i18n/190',
      cacheFilePath: path.resolve(__dirname, 'i18n-cache.json')
    })
  ],
  // 其他配置项
};

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { I18nPlugin } from 'I18nPlugin';
import babel from '@rollup/plugin-babel';
import path from 'path';

export default defineConfig({
  plugins: [
    react(),
    I18nPlugin({
      id: 190,
      versionUrl: 'https://your-custom-cdn-url.com/i18n/190/version.json',
      resourcesUrl: 'https://your-custom-cdn-url.com/i18n/190',
      cacheFilePath: path.resolve(__dirname, 'i18n-cache.json')
    }),
    babel({
      babelHelpers: 'bundled',
      plugins: [
        ['babel-plugin-i18n', {
          i18nFunctionName: '$i18n',
          translationFunctionName: 't',
          cacheFilePath: path.resolve(__dirname, 'i18n-cache.json')
        }]
      ]
    })
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
});

优化

一般的 i18n 插件已经提高了一部分效率,但还是存在 key 的问题,比如代码入侵必须写 key、不符合语义可读性差、文案定位繁琐和遗漏、代码冗余、多端无法复用文案、多个地方公用的 key 文案如果修改后影响范围广等问题。中央化、自动化的国际化多语言文案管理系统(例如使用在线平台 Crowdin、Locize 来管理多语言文案)可以有效解决上述问题。但多语言文案管理系统也会引入一些新的问题:

  • 随着项目的迭代,如何解决本地文案新增、修改或删除后,如何解决未翻译,漏翻译问题?如何解决多语言平台资源同步和翻译更新的问题?
  • 本地文案新增、修改或删除后,开发人员需要手动维护文案,效率低,如何优化?
  • 本地文案新增、修改或删除后,如何同步资源后,进行调试?
  • 国际化多语言如何更新版本?如何使得多语言切换及时生效?
  • 多端复用的多语言资源,如何区分,若某一端文案新增、修改或删除后,如何不影响其他端?
  • 加载多语言资源是否影响性能,如何优化?
  • 将所有语种文案打包进项目中,这增加了首屏加载时间,如何优化?
  • 引入 react-i18next 类似这样的库,导致项目体积增加,如何优化?
  • 必须等待多语言库初始化完成后,才能进行最终渲染,影响用户体验,如何优化?

自动化文案管理

随着项目的迭代,本地文案新增、修改后,由于使用的是$i18n('你好')这种方案,无法借助类似 vue-i18n 的 vscode 插件识别文案是否已翻译,如何解决未翻译文案识别的问题?如何未翻译,漏翻译问题?如何解决多语言平台资源同步和翻译更新的问题?

通过自动化工具提取代码中$i18n('你好')的表达式,收集起一份本地文案的中文文案资源,然后与本地缓存的多语言文件(其实就是多语言平台中已翻译的文件)全量对比,生成未翻译文件并上传到平台,翻译后发布,生成最新的多语言资源文件。

那删除的文案怎么办呢?多端复用的多语言资源,如何区分,若某一端文案新增、修改或删除后,如何不影响其他端?再多语言平台上除了key、中文、英文、项目空间、可以加一个平台字段,表示该文案在哪些平台中有用到,例如,['H5','RN'],表示在 H5、RN 中有用到,若数组为空,表示该字段可以删除了,并且多语言资源上传 CDN 时不会上传。

  • 自动提取:可以通过执行 npm 命令触发对应的脚本,例如:npm run i18n; 也可以在代码提交时通过 git commit 钩子扫描修改过的代码。通过这两种方式,与缓存文件进行对比,以确定新的或修改过的文案。然后,将这些文案自动上传到多语言管理平台。
  • 发布更新:文案上传后,翻译并 review 后,触发平台的发布流程,主要更新文案版本号,确保在代码的热更新过程中,如果文案发生变化,文案自动替换阶段能够识别并拉取最新的文案资源

文案中嵌入变量如何生效

js
const callee = path.node.callee;
const argument = path.node.arguments[0];

// 纯文本 将 $t('登录') 替换为 $t('user.login.btn')
const text = argument.value;
const key = Object.keys(i18nResources).find(key => i18nResources[key] === text);
path.replaceWith(
  t.callExpression(t.identifier('$t'), [t.stringLiteral(key)])
);

// 动态  $t(你好!{name}, {name:'张三' })替换 为 $t(user.info.name, {name:'张三' })
function getTranslationKeyByText(obj, text) {
  for (const key in obj) {
    if (typeof obj[key] === 'string' && obj[key] === text) {
      return key;
    } else if (typeof obj[key] === 'object') {
      const nestedKey = getTranslationKeyByText(obj[key], text);
      if (nestedKey) {
        return `${key}.${nestedKey}`;
      }
    }
  }
  return null;
}

多语言资源加载如何优化

  • 为每个语种创建独立的静态资源构建包,通过这种多构建产物方案,我们旨在显著提高项目的加载速度和运行效率,同时维持开发过程的自动化和高效性,为用户提供更加流畅和响应快速的体验。
  • 懒加载多语言资源,在用户需要某个特定语言时,才动态加载对应的语言包。这种方法可以有效地减小初始加载的体积,并且可以在不影响首屏渲染的情况下,延迟加载多语言资源。使用 import() 动态加载语言包,并在语言初始化完成后进行渲染
  • 延迟语言初始化(优先渲染默认语言),尽量延迟初始化多语言功能,或者先渲染一个简化版本的页面(比如使用默认语言)。在后台异步加载多语言包时,可以并行渲染页面内容,等语言加载完成后再进行更新。

一文带你全方位深入国际化开发

云音乐前端国际化多语言探索实践