5
头图
本文作者:atie,时浅

本文深入探讨了云音乐海外项目在实现多语言支持过程中的探索和实践,从最初的手动文案管理到发展出一套全自动化的多语言管理系统——千语平台的演变过程。文章介绍了云音乐海外团队如何通过技术创新和流程优化,有效提升了多语言项目的开发效率,解决了多语言应用开发中遇到的常见问题,包括但不限于代码中的语义清晰性、文案维护的高效率,以及性能优化等挑战。通过这一系列的改进,云音乐海外项目能够为全球用户提供更加流畅和响应迅速的使用体验,同时也为多语言应用开发提供了宝贵的实践经验和启示。

背景

一个国际化的产品,要在不同的国家和地区使用,就必须在设计软件时仔细考虑如何使产品的文本贴合当地的语种。为每个地区单独开发一个版本当然也是一个选择,但是这样做势必浪费人力,资源。云音乐海外项目一直在探索如何更好更优地渲染不同语种的前端文本,目前得出的一个较优的做法是将软件与特定的语种及地区分离,使得软件被移植到不同的语种及地区时,其本身不用做内部工程上的改变或修正就可以将文案,图片等从源码中提取出来,渲染并显示给相应的用户。

本文侧重于分享我们在开发多语言文案消费端(用户端)时的经验,包括开发效率、项目优化的思考与实践。

一些流行的语言多语言库

在介绍云音乐海外的多语言方案之前,我们先了解下当前一些流行的多语言库以及一些常规的做法

i18next及react-i18next

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

react-i18next 则是基于 i18next 的一个 React 绑定库,提供了一套用于在 React 应用程序中实现国际化的组件和高阶组件。它能够无缝集成到 React 应用程序中,并且提供了方便的 API 来处理语言切换、翻译文本和处理复数等国际化相关任务

用法

初始化 i18next,并在入口文件引入

// i18n.js
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";

i18n
  .use(LanguageDetector)
  // 注入 react-i18next 实例
  .use(initReactI18next)
  // 初始化 i18next
  .init({
    debug: true,
    fallbackLng: "en",
    interpolation: {
      escapeValue: false,
    },
    resources: {
      en: {
        translation: {
          // 这里是我们的翻译文本
          welcome: "Welcome to my website",
        },
      },
      zh: {
        translation: {
          // 这里是我们的翻译文本
          welcome: "欢迎来到我的网站",
        },
      },
    },
  });

export default i18n;
// app.js
import { useTranslation, Trans } from "react-i18next";

function App() {
  const { t } = useTranslation();
  return (
    <div>
      <main>
        <p>{t("welcome")}</p>
      </main>
    </div>
  );
}

export default App;

vue-i18n

vue-i18n 是一个用于在 Vue.js 应用程序中实现国际化的库。它同样提供了一种简单易用的方式来处理对多语言的支持,使开发人员能够轻松地将应用程序本地化到不同的语言。vue-i18n 支持多种语言切换策略,包括 URL 参数、浏览器语言设置和自定义逻辑。同时它还支持动态加载和异步加载翻译资源,以提高性能和用户体验。

用法

// 准备翻译的语言环境信息
const messages = {
  en: {
    message: {
      hello: 'hello world'
    }
  },
  ja: {
    message: {
      hello: 'こんにちは、世界'
    }
  }
}

// 通过选项创建 VueI18n 实例
const i18n = new VueI18n({
  locale: 'ja', // 设置地区
  messages, // 设置地区信息
})

<div id="app">
  <p>{{ $t("message.hello") }}</p>
</div>

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

一开始,云音乐海外采用的也是与上述流行库类类似的用法来解决多语言的方案,但用得越久,我们发现的问题越多,诸如:

  • 1、写法复杂,效率低t('key') 的写法需要思考映射内容
  • 2、不符合语意化,代码中一堆的 key,会产生较强的割裂感
  • 3、回溯困难,定位问题文案需要先找 key,再通过映射关系找到内容
  • 4、维护困难,内置的文案,如果需要修改,会需要改代码增加开发人员的心智负担
  • 5、代码冗余、影响性能,一个模块内的内容被重复引用,引入了不必要的文案
  • 6、项目迁移难度大,一个原先国内的项目要接入多语言需要做大量的文本兼容

诸如此类,上述的问题一度困扰了我们很长一段时间,而经过一年多时间的沉淀,目前海外的多语言方案已经能够较好地解决上述我们所面临的各种问题。下面,我们将会介绍我们是如何从文案管理到文案录入再到回归国内业务开发习惯(抛弃 t('key') 写法)以及性能优化等一步步形成云音乐海外国际化的方案。

方案的演变

1. 千语管理平台

云音乐海外项目启动后,iOS、android、前端和服务端都需要为多语言的切换做准备。在最开始的阶段海外团队尝试过用 Excel 来统一填写维护文案。但是通过 Excel 会存在如下问题:

  • 复用率低下:传统的开发模式,各端本地存放国际化语言文本,难以重复利用;
  • 维护成本高:不同开发修改容易导致出错、命名冲突等问题且没有修改记录,无法追溯,维护成本大;
  • 沟通困难:产品运营和技术通过邮件、企业通讯工具等沟通配合难度大;

所以我们萌生了以下几个想法,以优化多语言支持的流程和维护:

  1. 建立统一的国际化管理平台:开发一个中央化的国际化(i18n)管理系统,用于存储、更新和检索所有语言的文本。这个平台可以为所有端(iOS、Android、前端、服务端,flutter等)提供统一的文案资源。
  2. 通知翻译: 开发者录入完文案之后,可以通过推送,将对应待翻译文案通过企业通讯工具推送给翻译同学。
  3. 多语种文案长度对比功能:这一功能支持实时预览同一文案在不同语言下的文案长度,以便翻译人员调整文案,确保各语种版本在长度上尽可能一致,避免不同语种下产生的样式表现问题。
  4. Excel批量处理功能:平台支持通过 Excel 进行文案的批量导入和导出,以便于高效地管理和更新大量的文本内容。
  5. 集成翻译服务:考虑集成专业的翻译服务或机器翻译API,以提高翻译效率和质量。
  6. 版本控制:使用版本控制来管理国际化文本,确保更改的可追溯性。
  7. 角色和权限管理:在国际化管理平台中实现角色和权限管理,确保产品运营、翻译人员和开发人员能够在适当的权限下进行工作。

上述的这些方案与想法最终集合成了云音乐海外多语言文案管理平台——千语千语的落地,极大地提高了多语言项目的效率和质量,同时降低维护成本和沟通难度。

使用流程

  1. 创建应用(每个工程,或某个 App 都可创建一个应用)
  2. 创建模块(每个应用下,可以创建多个模块,一般我们把每个独立页面,或者某一个玩法活动归笼到某一个模块下)
  3. 创建文案
  4. 发布(发布到 CDN)
对于多语言文案生产端的设计与实现,本文不做详细讨论。市面上已经有一些对外提供服务的多语言管理平台产品,大家可以参考他们的设计与实现。

2. 千语自动化

背景

一开始云音乐海外C端多语言方案是使用的 i18nextreact-i18next 这两个库实现的。

该技术方案与上面介绍的 i18nextreact-i18next 库的用法一致,区别在于一个是我们文案不是写死在代码中,而是通过 CDN 来获取文案内容,二是为了项目管理方便,我们的“key”是由项目模块module(module 可以理解为一个命名空间,不同的页面可以单独定一个 module,不同的应用也可以定一个 module)以及唯一键 key(key 可以理解为一个文案的唯一标识) 组成,具体方案大致如下:

  1. 千语平台发布前端文案到 CDN 上
  2. 前端请求 CDN 获取多语言文案(由 key 跟文本组成的 JSON),并用 i18next 初始化
    image.png
  3. 业务代码中使用 react-i18nextuseTranslation,文案通过编写 t('module:key'),也即 react-i18nextt('key') 来获取对应模块下的文本映射
  4. 最终渲染页面

我们开发流程大致如下:

  1. 千语平台上录入文案
  2. 通知翻译同学翻译文案
  3. 发布文案到 CDN,更新 CDN 版本
  4. 修改代码中的CDN版本号,这样我们的文案才能请求到指定版本的文案
  5. 前端代码中文案通过书写 t('module:key')

2.1 千语自动化1.0

在经历多次需求迭代后,我们发现当前的多语言方案效率不佳。工作流程中需要频繁切换平台和 IDE,并且涉及修改 CDN 资源的版本号来确保获取最新的 CDN 资源。另外,代码中使用的 t('module:key') 缺少清晰的语义表达,这降低了其易理解性和维护性。因此,我们开始考虑实施多语言文案的自动化策略,以提升效率和代码质量。

梳理可自动化流程

为了提高云音乐海外项目的工作流程效率,经过深入讨论,我们决定对现有流程进行以下优化:

  1. 简化代码书写:不再使用传统的指定 modulekey 的方法编写国际化代码,改为直接使用 $i18n('中文') 进行书写,简化开发过程并提高代码的可读性。
  2. 自动化文案管理:开发人员无需手动在千语平台的文案管理页面创建录入文案。千语自动化插件将自动提取代码中的待翻译中文文案并自行创建唯一键 key 并上传,减少人工操作和潜在的错误。
  3. 自动发布文案:一旦文案上传完成,系统将自动触发发布流程,将文案推送至 CDN,无需开发人员手动介入,提高发布效率。
  4. 自动化版本管理:取消手动修改 CDN 版本号的步骤,通过读取缓存中的版本号,确保流程的连贯性和准确性。

经过这些流程的优化,开发人员在编码时只需简单地使用 $i18n() 包裹中文文案,剩余的翻译上传、发布到 CDN 以及版本管理等流程均由自动化工具完成。这样不仅极大地提升了开发效率,也保证了流程的一致性和准确性,让团队能够更专注于核心开发工作。

实现方案

架构图

为了提升工作效率并实现国际化文案的自动化管理,我们设计了一个两阶段的自动化方案:

第一阶段:文案自动替换

  • 技术实现:利用自开发的 babel 插件,这个插件通过分析抽象语法树(AST),识别出代码中的 $i18n('你好') 表达式。同时插件会以当前项目设定的模块 module 自动查询多语言平台,找到对应的 module 下“你好”这个文本的 key,然后将原始的 AST 节点 $i18n('你好') 替换成 t('module:key') 格式。
  • 迭代更新:在后续的版本迭代中,我们增加了对直接使用中文文案的支持(也即摒弃了$i18n()方法包裹的形式,通过 babel 插件直接识别代码中的中文文案,如“你好”),进一步简化了开发过程。

第二阶段:文案自动提取与上传

  • 过程描述:在代码提交前,通过 commit 钩子扫描修改过的代码。该过程与之前在文案自动替换阶段创建的缓存文件进行对比,以确定新的或修改过的文案。然后,将这些文案自动上传到多语言管理平台。
  • 自动触发发布:文案上传后,自动触发平台的发布流程,主要更新文案版本号。这确保了在代码的热更新过程中,如果文案发生变化,文案自动替换阶段能够识别并拉取最新的文案资源。

通过这个方案,我们极大地简化了国际化文案的管理流程,从手动操作转向自动化处理,显著提升了开发效率并减少了人为错误,使得团队能够更加专注于产品的核心功能开发。

重点部分

资源缓存

工具包会缓存版本号跟文案资源到包中。初始化的时候,会先对比版本号是否一致,如果不一致,拉取平台最新文案,并缓存到本地,供后面 babel-plugin 文案替换使用。

技术方案中比较复杂的部分涉及到 AST,一个是 babel-plugin,一个是 commit 的时候的执行的 node 脚本。下面我将提供阉割过的代码,带大家了解下 AST 部分的实现。

babel-plugin

{
  return {
    visitor: {
      Program: {
        enter(programPath, { filename }) {
          programPath.traverse({
            // 拦截纯中文的节点
            StringLiteral(path) {
              visitorCallback(path, filename);
            },
            // 拦截纯中文的节点
            JSXText(path) {
              visitorCallback(path, filename);
            },
            // 拦截 $i18n() 的节点
            CallExpression(path) {
              ExpressionCallback(path, filename);
            },
          });
        },
      },
    },
  };
}

上面三个节点,分别对应我们代码中的五种写法。

  • 纯中文写法
  • $I18n() 写法(万能写法,支持很多功能)

    • $i18n('纯中文')
    • 文案中带有变量$I18n('你好!%1', { 1: name }),%1会被替换 name 对应的值
    • $i18n({ module: 'shop', key: 'dress' }),支持 module key 的写法
    • $i18n({ text: '你好!<1>%1</1>', components: { 1: <span>}, values: { 1: name }})多语言组件写法,例子最终会被替换为你好<span>{name}</span>。比如 name 需要通过标签来修改他的样式。

visitorCallback

纯中文节点处理逻辑

function visitorCallback(path, filename) {
  const CNValue = path.node.value.trim();
  // 先判断是否中文 [yes] 已验证匹配到了所有中文
  if (!(isChinese(CNValue) && !isIgnoreNode(path))) return;
  // 第一种情况是打包时携带对应的语种进来
  const languageModules = DefaultLangObj;
  // 找到匹配到对应模块的module:key
  const currentModuleName = getModuleNameByRelativePath(
    Path.relative(i18nConfig.rootPath, filename),
  );
  const currentCNObj = LOCAL_DOC?.["zh-CN"]?.[currentModuleName] || {};
  const textKey = Object.keys(currentCNObj).find(
    (key) => currentCNObj[key] === CNValue,
  );
  // 替换原来的中文文案节点为当前语种对应的文案节点
  const languageText =
    languageModules?.[currentModuleName]?.[textKey] || CNValue;
  path.replaceWith(t.stringLiteral(languageText));
}
  1. 通过拦截的中文,找到对应中文在千语平台上的 module 和 key
  2. 在对应语种文案集合中通过 module 和 key 找到对应的文案
  3. 文案替换

ExpressionCallback

$i18n() 写法处理逻辑

function ExpressionCallback(path, filename) {
  // 如果里面是对象 对应 $i18n({})
  if (t.isObjectExpression(node?.arguments[0])) {
    // 没有components属性,代表是$i18n({ module, key }) 写法
    if (!hasComponentAttr && keyFind && moduleFind) {
      const languageModules = DefaultLangObj;
      const key = keyFind.value.value;
      const module = moduleFind.value.value;
      // 找到匹配到对应模块的module:key
      const languageText = languageModules?.[module]?.[key];

      const valuesProps = findProperty(properties, VALUES);
      // 有本地文件的处理方式

      const newLiteral = t.stringLiteral(languageText);
      // ... 一堆代码逻辑
      // 通过上面的module key 从缓存文件中找到对应语种的文案,并替换
      path.replaceWith(newLiteral);
      path.skip();
    }
    // 如果里面有components属性,代表是多语言组件写法
    if (hasComponentAttr) {
      const CNAttr = findProperty(properties, TEXT);
      const valuesProp = findProperty(properties, VALUES);
      // ... 一堆代码
      // 封装成一个react组件返回
    }
  }
  // 如果里面是文本
  if (t.isLiteral(node?.arguments[0])) {
    // 主逻辑大致同上面纯文本visitorCallback的逻辑,只是多了一些逻辑的判断,兜底语种等功能
  }
}
  1. 通过拦截的中文,找到对应中文在千语平台上的 module 和 key
  2. 在对应语种文案集合中通过 module 和 key 找到对应的文案
  3. 判断不同的写法类型,转化成相应的内容

接入指南

const { I18nPlugin } = require("@music/i18n");

webpackChain: (chain) => {
  chain.plugin("i18n").use(I18nPlugin, [{ id: 190 }]); // id 对应千语多语言平台的应用id
};

使用指南

对于那些好奇如何在文案中嵌入变量或从接口动态获取数据的同学,这里提供了几种主要的使用方式来适应不同的场景:

  1. 直接使用中文:当文案中不包含变量时,书写纯中文即可。
<p>你好</p>
  1. 嵌入变量的文案:使用 $i18n('我有一个%1', { 1: apple }) 的格式来插入变量。例如,$i18n('%1 world', { 1: 'hello' }) 允许你将 hello 作为变量动态插入到文案中。
  2. 使用已有文案的引用:通过 $i18n({ key, module, fallbackText }) 格式引用千语系统中已存在的文案。其中,fallbackText 作为未成功匹配文案时的备选内容。
  3. 组件中的复杂文案

    $i18n({
      text: "价格<1>%1</1>商品名<2>%2</2>",
      components: {
        1: <p style={{ margin: "0 5px", color: "#FDE020" }} />,
        2: <p style={{ color: "#FDE020" }} />,
      },
      values: {
        1: price || "",
        2: name || "",
      },
    });

    这种方法允许在文案中嵌入React组件,并通过 values 传递变量。

我们也在不断探索更优的用法来进一步提升开发体验。近期,我们计划引入基于字符串模板的变量嵌入方式,如通过 ${hello} world 的形式来实现。这将使得带变量的文案书写更加直观和便捷,为开发者带来更佳的开发体验。

2.2 千语自动化2.0:性能优化方案

项目性能同样是海外项目的一个重要的考量因素。虽然基于 i18nextreact-i18next 实现的自动化方案有效提升了开发效率,解决了一系列的效率问题,但它并未充分解决由多语言支持引入的各种性能挑战:

  1. 多语言资源加载:项目需要从CDN预加载多语言资源,或将所有语种文案打包进项目中,这增加了首屏加载时间。
  2. 库依赖:引入 i18nextreact-i18next 两个库,导致项目体积增加。
  3. 渲染延迟:项目必须等待多语言库初始化完成后,才能进行最终渲染,影响用户体验。
  4. 静态站点生成(SSG)不友好:当前方案不支持 SSG 预构建,无法为不同语种国家提供同一份预构建的产品(因为不同国家的语言不同)。

2.2.1 解决方案探索🤔️

为了克服这些性能问题,我们决定跳出现有自动化方案的限制,采用一种新的思路:为每个语种创建独立的构建包。这个构建包将仅包含所需的语种文案,无需携带多余的语种信息或依赖 i18nextreact-i18next 库。这样,我们可以针对不同的语种提供精简且高效的构建产物,避免不必要的资源加载和库依赖,同时解决SSG预构建的问题。

通过这种多构建产物方案,我们旨在显著提高项目的加载速度和运行效率,同时维持开发过程的自动化和高效性,为用户提供更加流畅和响应快速的体验。

2.2.2 技术方案

为了提升项目性能并解决多语言支持带来的挑战,我们对原有的自动化方案进行了多次优化和调整:

2.2.3 生产产物的优化

编译阶段的改进

  • 引入了 I18N_LANGUAGE 环境变量,在构建过程中指定当前构建目标的语种。
  • 利用自定义的 babel 插件,在AST分析阶段将代码中的纯中文或通过 i18n() 方法包裹的文案,直接替换为当前构建语种对应的文案。这一步骤实现了在源代码层面的语言特定优化。

    • 前一阶段可以简单理解为 中文/$i18n('中文')** 通过babel转成 **$i18n('module:key') ===> 对应语种文案
    • 现阶段直接越过了中间阶段,直接将中文文案编译成对应语种文案

例子

平台文案

{
  'zh-CN': {
    hello: '你好'
  },
  'en-US': {
    hello: 'hello'
  }
}

源代码

import React from "react";

const Main = () => {
  return <div>你好</div>;
};

如果构建的时候,指定了英语语种,源代码会被转换成

import React from "react";

const Main = () => {
  return <div>hello</div>;
};
构建产物实际是编译过的代码,上面的代码只是为了说明文案原地替换

产物输出阶段的调整

  • 调整了构建产物的 publicPath 设置为 dist/${I18N_LANGUAGE},确保每个语种的构建产物被放置在独立的目录中。这样,dist 目录下将组织有针对不同语种的构建包,使得资源管理更为清晰和高效。

构建出来的 dist 目录如下

.
├── en-US
├── id-ID
├── tr-TR
└── zh-CN
...

这样不同语种的路径如 /heatup/en-US/pageA,就会指向到en-US构建产物中的pageA页面。

2.2.4 消费产物的变更

访问路径的调整

  • 我们从原先直接访问如 /pageA 的方式,转变为访问指定语种的路径,例如 /${language}/pageA。这意味着,客户端在加载某个WebView页面时,会根据APP当前选择的语种,自动将链接调整为对应的语种版本,如访问 /en-US/pageA
  • 通过这种方式,资源请求直接指向 dist/en-US 下的构建包,从而实现了语种特定的资源加载,减少了不必要的资源请求和加载时间,提升了页面响应速度和用户体验。

通过上述改动,我们不仅提升了项目的运行效率,减少了不必要的资源负担,也实现了更加灵活和高效的多语言支持方案。这些优化确保了项目在全球多语种环境下的性能表现同时保证了海外的用户体验。

总结

尽管本文未能覆盖所有细节,但已概述了云音乐海外项目在多语言上的探索实践以及目前云音乐海外多语言自动化最终方案的核心理念。与早期手动处理相比,目前该方案显著提高了开发效率,解决了多个长期存在的问题比如频繁手动输入文案的繁琐、代码中文案缺乏清晰语义以及文案重复输入等问题。此外,它还克服了传统方法导致的项目体积膨胀,以及随之而来的性能挑战。

通过自动化处理流程的引入和优化,云音乐海外项目不仅提升了工作流的效率,还确保了项目的轻量化和高性能运行,从而为海外用户提供了更加流畅和响应迅速的体验。云音乐海外多语言方案使得团队能够更专注于创新和提升产品质量,同时为用户带来更优质的服务。而于此同时我们也面临着更多的挑战,对多语言项目的优化、提升,仍是云音乐海外项目组需要不断思考与探索的课题。

最后

更多岗位,可进入网易招聘官网查看 https://hr.163.com/


云音乐技术团队
3.6k 声望3.5k 粉丝

网易云音乐技术团队