雲音樂前端國際化多語言探索實踐

云音乐技术团队發表於2024-02-29
本文作者: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/

相關文章