组件库设计实战系列:国际化方案

4,287 阅读9分钟

放眼全球,中国整体的互联网技术实力毫无疑问仅次于美国并领先剩余所有的国家一大截。但如果我们非要找出一个中国互联网公司做得不够优秀的地方,那么产品国际化一定是其中之一。虽然我们也拥有诸如 AliExpress,天猫国际等成功案例,但不得不说大部分中国公司在选择出海后,都没有能够收获到与预期相匹配的回报。这其中原因自然很多,然而缺乏一套可以平台化,产品化的通用国际化方案一直都是其中一个非常重要的原因。

曾经笔者也天真地认为国际化不过是几个 json 文件的键值对匹配,但在深入了解了一些产品的国际化需求后,笔者才意识到要做一套好的国际化方案并没有那么简单。

服务端国际化

对于前端工程师而言,国际化所要面临的第一个挑战就是,并不是所有的数据都可以在前端做国际化。常见的例子如电商类产品的货品或商家信息,这些都是有强更新需求,需要存储在后端数据库中,通过产品后台进行更新的。如果一个商品要销往美国,德国,法国,西班牙,泰国,印度尼西亚,而运营人员又只想维护一套以中文为基准的商品信息,那么这类数据的国际化我们就需要将其做在服务端。

我们当然可以麻烦后端工程师帮助我们根据每个请求的域名或 HTTP header 中的 content-language 来返回不同表中的翻译,但如果你是一位致力于向全栈方向发展的前端工程师,不妨可以尝试将国际化这一需求服务化,使用 Node.js 来封装一个国际化中间件,在每个请求返回前对其返回值进行翻译处理。

因为每个公司的技术架构不同,我们暂且略过技术细节不表。但我们需要知道的是,相较于前端国际化,后端接口的国际化其实更为关键与重要。因为这涉及到我们是否能将我们的核心数据以用户可理解的语言展现出来,而国际化也绝不仅仅是将几个字符串翻译为对应语言那样简单。

哪些数据需要做国际化

在讨论具体的国际化方案之前,我们首先要明确一个问题,那就是产品中的哪些数据是需要做国际化的。

简而言之,除去后端返回的数据,所有在前端渲染的单词,语句,以及嵌套在其中的数据,都需要做相应的国际化。对应到代码层面,需要保证代码中没有任何一行硬编码的字符串与符号。不论是大到一个区块标题,还是小到一个确认按钮的文案,所有的展示信息都需要做国际化。

键值对匹配与多语言支持

回到前端,让我们从最简单的国际化场景说起。

例如下拉列表输入框中的“选择”占位符,假设我们需要同时将其翻译为英文与法文,首先我们需要引入两个语言文件:

// en-US.json
{
  "web_select": "Select"
}

// fr-FR.json
{
  "web_select": "Sélectionner"
}

并提供一个全局的 localeUtil.js,支持传入语言类型与 key 值,并返回相应的翻译。

这里提供两点最佳实践。

一是将不同语言的翻译存在独立的 json 文件中。虽然我们可以使用嵌套的数据结构将所有翻译都存储在一个 locale.json 里面,但考虑到生产环境中语言文件一般都是按需加载的,所以根据不同的语言存在对应的独立的的 json 文件中显然是一个更好的选择。

二是同一语言中 key 值的命名,同样不建议采取嵌套的结构。扁平化的语言文件可读性更强,取值时的效率也更高,同时也可以使用下划线来区别不同的层级,如 web_homepage_banner_title,即平台_页面_模块_值,当然具体情况也可以按需调整。

模板匹配与条件运算符

了解了最简单的场景,我们再来考虑一个复杂些的用例。

在显示商品价格时,为了可扩展性等多方面的考虑,后端在设计表结构时,是不会将商品价格直接存储为字符串的,而是拆分为货币符号(string 类型)及价格(float 类型)。而在前端显示时,我们经常会遇到要将其渲染为一句促销语的场景,如:

2017年9月1日前购买,只需100元。

对于时间类数据的国际化方案,我们这里先暂时按下不表,有兴趣的同学可以研究一下 moment.js 的实现,moment.js 也是目前前端届日期国际化的代表。

由于100元是一个动态的变量,所以我们的 localeUtil.js 还需要支持传入变量,这里一个常用的调用可以为:

localeGet(
  'en-US', // locale
  'web_merchantPage_item_promotion', // key
  { currency: item.currency, promoPrice: item.promoPrice }, // variable
);

语言文件中的模板可以为:

"web_merchantPage_item_promotion": "Before YYYY/MM/DD, purchase at {currency} {price}."

另一个常见的场景为英文名词的单复数问题,这里我们选择通过条件运算符的思路来解:

优惠将于3天后结束。
"web_merchantPage_item_promotion_condition": "Promotion will end in {count, =1{# day} other{# days}}",

数据国际化

除去日期,货币外,数字也是字符串之外另一个国际化的难点,我们来看下面这个例子。

阿里巴巴向印度尼西亚电商网站 Tokopedia 注资11亿美金。
Alibaba leads $1.1b investment in Indonesia’s Tokopedia.

这里我们需要将“11亿美金”翻译为“$1.1b”,为了达到这一目的,我们首先需要在各个语言文件中建立对应语言的基础单位 mapping,如:

// zh-CN
"hundred": "百",
"thousand": "千",
"ten_thousand": "万",
"million": "百万",
"hundred_million": "亿",
"billion": "十亿",

// en-US
"hundred": "hundred",
"thousand": "thousand",
"thousand_abbr": "k",
"million": "million",
"million_abbr": "m",
"billion": "billion",
"billion_abbr": "b",

然后我们需要实现一个可以将浮点数进行纯数字与单位转换的函数,返回纯数字与所使用语言的单位 key 值:

function formatNum(num, isAbbr = false) {
  ...
  return {
    number: number, // 1.1
    unit: unit, // "billion_abbr"
  }
}

接着就可以调用 localeGet 来获得相应的翻译:

localeGet(
  'en-US',
  'news_tilte',
  {
   number: 1.1,
   unit: localeGet('billion_abbr'),
   currency: localeGet('currency_symbol'),
  },
)

语言文件中的模板如下:

// zh-CN
"news_tilte": "阿里巴巴向印度尼西亚电商网站 Tokopedia 注资{number}{unit}{currency}。"

// en-US
"news_tilte: "Alibaba leads {currency}{number}{unit} investment in Indonesia's Tokopedia."

在整个过程中,我们可以抽象出两种解决问题的思路。

一是拆分并抽象出基础数据,如单位等。

二是灵活运用模板与变量,将其调整为最符合当地用户阅读习惯的翻译。

类似的思想也可以推广到处理日期,小数,分数,百分数等。

React 下的国际化方案

正如前文中所提到的,按需加载语言文件是国际化方案中必要的一环。简而言之,我们可以在项目的入口文件中加载所需的语言文件,但考虑到整体项目的统一性,我们最好可以将语言文件挂载在全局 redux store 下的一个分支,以使得每个页面都可以通过 props 方便地进行取值。而且,在 redux store 的层面加载语言文件,可以保证所有页面使用的都是同一份语言文件,后续也不需要在 localeGet 函数中传入具体的 locale 值。

示例代码如下:

import enUS from 'i18n/en-US.json';

function updateIntl(locale = 'en-US', file = enUS) {
  store.dispatch({
    type: 'UPDATE_INTL',
    payload: {
      locale,
      file,
    },
  });
}

这样我们就可以方便地将语言文件挂载在 redux store 的一个分支下:

const mapStateToProps = (state) => ({
  intl: state.intl,
});

// usage
localeGet(this.props.intl, 'web_select');

// with defaultValue to prevent undefined return
localeGet(this.props.intl, 'web_select', 'Select');

其他

除了上述提到的这些问题之外,在生产环境中我们还需要注意以下两点:

  • HTML 转义字符
  • 特殊语言的 unicode 转码,如简体中文,繁体中文,泰语等

正如开篇时提到的,国际化是一个通用的系统性工程,以上提到的这些点也难免挂一漏万,更多的最佳实践还需要在实际开发工作中持续提炼,总结,沉淀。

小结

对于任何一家希望开拓国际市场的公司来说,产品国际化都是一个刚需。从技术人员的角度来讲,我们当然可以满足于一个 Node.js 中间件或一个前端的 npm 包来通用地解决这一问题。但事实上,我们还可以再向前一步,那就是将国际化这个服务做成一个完整的 SASS 产品,这方面成功的案例如:OneSky

OneSky 所提供的额外功能,如云端存储,多文件类型支持,多人实时翻译协作等,每一个功能单拿出来都又是一个新的领域,而这也正是服务产品化的难点所在。

举例来说,前文中提到的国际化方案,都是默认所有翻译工作已经完成且 json 化完毕,可以直接 import 到项目中使用,而这也就是技术人员经常会陷入的一个思维盲区。翻译数量庞大的语言文件本身就是一件非常困难的事情,如何让身处世界各地的非技术背景的翻译人员进行协作并方便生产环境中的产品实时更新语言文件,这些问题都只有在把国际化服务做成一个成熟的商业产品之后才会被考虑到。

事实上,目前在各大互联网公司中,技术服务产品化已经成为了一股不可阻挡的趋势,许多技术出身的工程师都已经开始意识到一套只有技术人员才能理解并使用的解决方案是不够的,只有将这些“高深莫测”的技术服务产品化,傻瓜化,才能够打开一片更大的战场,使技术真正服务于商业产品并在现实世界中产生更大的价值。