2022,国际化活动可以做哪些技术优化?

1,444 阅读8分钟

前言

2021 - 2022,前端发展可谓日新月异,各个方向不断涌现后起之秀:打包工具 Vite、前端视图框架 SolidJS、后端框架 Remix,此外全新的 CSS 特性和 TC39 提案被各大浏览器所支持,毫无疑问,2022 年将是 JavaScript 出道以来,迄今为止最繁荣的一年。

我所在的 TikTok 杭州团队,主要负责直播间的营收活动,通过上榜、做任务、1v1 PK 等玩法激励用户和主播充值送礼,活动投放地区涉及中东、欧洲、东南亚……,所以国际化是研发流程中不可或缺的一环。

传统的 RTL 布局、时区转换、本地化解决方案在新特性的加持下,会发生怎样奇妙的化学反应,这篇文章就来聊一聊~

幻灯片预览:浅谈新特性下的国际化活动

如何判断 RTL

我们会根据投放的国家地区,提供对应的活动链接,往 URL Query Params 加上 lang 属性,代表当地语言:

目前判断是否应用 RTL 布局的逻辑如下所示:

const isRTL = () => {
  const { lang } = getQueryParams();

  return lang === "ar";
};

<div dir={isRTL() ? "rtl" : "ltr"}></div>;

乍一看,代码没什么问题,但从健壮性和可维护性的角度出发,它依然存在缺陷:lang === ar 为硬编码。

除了常规的 ar 表示阿拉伯语,zh 表示中文,语言代码 (Language Code) 还由 [语言]-[国家/地区] 组成:

  • ar-EG 表示阿拉伯语(埃及)
  • ar-IQ 表示阿拉伯语(伊拉克)

完整的语言代码数据可查阅 ISO Language Code Table.

未来活动会细分至中东地区的各个国家,然而 ar-EG === ar 返回 false,身处埃及的用户只能看到 LTR 布局,此时硬编码的弊端暴露无遗。

有一种优化手段是通过正则匹配:

const RTLRegExp = /^ar/;

RTLRegExp.test("ar"); // true
RTLRegExp.test("ar-EG"); // true
RTLRegExp.test("ar-IQ"); // true

但是有没有更好的方式呢?

JavaScript V8 v9.9Intl.Locale 中新增了 textInfo 对象,它能返回一些关于语言敏感的文字信息,其中 direction 属性,被用于 HTML dir attributeCSS direction property.

Chrome 99 版本起便可在控制台体验到:

new Intl.Locale("ar").textInfo;
// => {direction: 'rtl'}

new Intl.Locale("ar-EG").textInfo;
// => {direction: 'rtl'}

new Intl.Locale("he").textInfo;
// => {direction: 'rtl'}

new Intl.Locale("zh-TW").textInfo;
// => {direction: 'ltr'}

new Intl.Locale("ja-JP").textInfo;
// => {direction: 'ltr'}

通过 JavaScript Built-in API,如何判断 RTL 的问题迎刃而解。

RTL 布局适配终极方案

之前,我介绍了 利用 Sass 优雅解决 RTL 语言布局适配,思路是通过 HTML dir 属性,给需要适配 RTL 的元素加上 RTL 样式,覆盖默认 (LTR) 样式。

.demo {
  left: 10px;

  @include dir("rtl") {
    left: unset;
    right: 10px;
  }
}

编译后的 CSS:

.demo {
  left: 10px;
}

[dir="rtl"] .demo {
  left: unset;
  right: 10px;
}

虽然 Sass 提供的 @mixin and @include 能让开发者编写 CSS 时,减少重复编码,但从编译后的 CSS 产物来看,这些代码并不会凭空消失。

不仅如此,每个要适配 RTL 的元素,都得写一份镜像样式,并将之前的样式清除,无疑增加了我们的负担。

诚然,从实用性、兼容性方面考虑,以上已经是最”优雅”的方案。

某天我在浏览 MDN 时,看到了 CSS Logical Properties and Values,而这也是 RTL 适配布局的终极解决方案。

照搬官方文档介绍:CSS 逻辑属性与值是 CSS 的一个模块,其引入的属性与值能做从逻辑角度控制布局,而不是从物理、方向或维度来控制

我举个 🌰 ,现在 UI 要求我们设置 div.box 的左右内边距为 10pxpadding 作为 Shorthand properties 之一,为了方便,可以使用简写:

div.box {
  padding: 0 10px;
}

显然,div.box 的上下 padding 被设置为 0,如果在复杂的场景中会出现样式覆盖问题,这不是我们所期望的。

此时,CSS Logical Properties and Values 中的 padding-inline 便能派上用场,它同样也是一个简写属性,由以下两个属性构成:

  • padding-inline-end
  • padding-inline-start

它定义一个元素,在逻辑层面的内联方向上的起始内边距和末尾内边距。

HTML 元素大致分为内联元素 (inline) 和块级元素 (block),内联是指同一行,而块级是指在同一列,尝试在 HTML 中定义多个 <span>hello</span><p>hello</p>,观察它们各自的排布情况是不是分别以行列的形式存在。

div.box {
  /* 在没有其他规则的定义下,等同于 padding: 0 10px */
  padding-inline: 10px;
}

那这和 RTL 布局又有什么关系呢?先前的定义中提到一个词“逻辑层面”非常重要,因为逻辑方向是会发生改变的,而 top, right, bottom, left 是纯粹的物理方向,亘古不变。

我们知道,运动是相对的,必须得选择正确的参照物,才能判断一个物体是否在运动,参照物选择不同,结果就不同。

CSS 的逻辑属性也是这个原理,得看当前元素的“参照物“是什么,比如书写模式:writing mode,元素排列方向:direction.

image.png

查看 CodePen 上的在线例子:codepen.io/b2d1/pen/vY…

可以看到,默认布局下 padding-inline-start 表现为 padding-left,而当声明 writing-mode: vertical-lr 后,padding-inline-start 表现为 padding-top,这便是 CSS Logical Properties and Values 的亮眼之处。

.text {
  padding-inline-start: 20px;
  border: 1px solid;
}

.text-vertical {
  padding-inline-start: 20px;
  writing-mode: vertical-lr;
  border: 1px solid;
}

于是 RTL 布局也能无感知的实现,不用再写复杂的镜像样式,只需要给定 dir,浏览器会自动调整好逻辑方向。

image.png

查看 CodePen 上的在线例子:codepen.io/b2d1/pen/qB…

padding-inline-start 会根据 direction 变化成物理方向上的 padding-leftpadding-right.

.text {
  padding-inline-start: 20px;
  border: 1px solid;
}

.text-rtl {
  direction: rtl;
  padding-inline-start: 20px;
  border: 1px solid;
}

关于 INTL

INTL 的全称是 internationalization,中文译为国际化,单词 i 和 n 之间有 18 个单词,故又被称为 I18N.

JavaScript 作为 ECMA-262 规范的实现,其国际化能力被 ECMA-402 规范定义,由 JavaScript 标准内置全局对象 Intl 实现并提供一系列语言敏感的 API.

下面我列举几个用法,让大家浅尝一下。

使用中国大陆的连字符“和”来格式化列表:

new Intl.ListFormat("zh-CN", {
  type: "conjunction",
}).format(["夜色江南", "濛濛细雨", "油纸伞", "你"]);

// => 夜色江南、濛濛细雨、油纸伞和你

使用中国台湾的 12 小时制格式化时间:

new Intl.DateTimeFormat("zh-Hant-TW", {
  hour: "numeric",
  hourCycle: "h12",
}).format(new Date());
// => 上午1時

使用中国大陆的货币格式格式化数字:

new Intl.NumberFormat("zh-CN", {
  style: "currency",
  currency: "CNY",
}).format(19980521);
// => ¥19,980,521.00

获取中国大陆的传统日历:

new Intl.DisplayNames(["zh-CN"], { type: "calendar" }).of("chinese");
// => 农历

获取中国香港的时区名称:

new Intl.Locale("zh-HK").timeZones;
// => ['Asia/Hong_Kong']

如何转换时区

假设活动投放在日本 (GMT+9),此时 Unix 时间戳 timestamp = 1643767200000,即 2022/02/02 02:00 (GMT+0).

在中国 (GMT+8) 访问日本的活动页面时,要求显示日本当地的时间 (2022/02/02 11:00),而不是中国时间 (2022/2/2 10:00),所以我们需要通过工具函数进行时区切换:

export function formatUtcToLocal(timestamp: number, utcValue: number) {
  const timeOffset = new Date().getTimezoneOffset() * 60;
  const result =
    Math.floor(timestamp / 1000 + timeOffset + (utcValue || 0) * 60) * 1000;
  return result;
}

new Date(formatUtcToLocal(1643767200000, 9 * 60)).toLocaleString();

// => 2022/2/2 11:00:00

🧐 代码虽然只有几行,但是很难读懂,而且这只是一个简单的基础场景。

毫无疑问,JavaScript 内置的 Date 对象是一个糟糕的设计。

Date 存在的问题:

  1. 不支持除用户当地时间以外的时区
  2. 计算 API 缺失

在业务上,只能借助于 moment.jsday.js 等社区开源库,实现复杂的时区转换、时间计算。

TC39 组织也意识到 Date 对象该淘汰了,于是邀请了 moment.js 的作者 Maggie,由她担任新特性 Temporal 的主力设计,目前该提案处于 Stage 3.

一个完整的 Temporal 由三部分组成:

  1. ISO 8601 国际时间格式,T 作为日期 (2020-08-05) 和时间 (20:06:13) 的分隔符,+ 表示东时区,- 表示西时区
  2. 时区名称(日本东京)
  3. 日历(日本历法)

有了 Temporal 的加持,获取日本的当地时间变得轻而易举:

Temporal.Instant.from(new Date(1643767200000).toISOString())
  .toZonedDateTimeISO("+09:00")
  .toString();

// => 2022-02-02T11:00:00+09:00

使用 Temporal 的计算 API:

dt = Temporal.PlainDateTime.from("1995-12-07T03:24:30");
dt.add({ years: 20, months: 4, hours: 5, minutes: 6 });

// => 2016-04-07T08:30:30
const duration = Temporal.Duration.from({
  hours: 130,
  minutes: 20,
});

duration.total({ unit: "second" });

// => 469200

由于目前主流浏览器还不支持 Temporal 特性,你需要打开 tc39.es/proposal-te… 的控制台运行以上代码,该网站已引入 Polyfill.

结尾

虽然 CSS Logical Properties and Values、Intl 对浏览器兼容性要求不低,甚至 Temporal 现阶段不被支持……

在做好降级的前提下,我仍有信心在生产环境中投入使用,这也是我一直以来追求的极客之道,墨守成规从来就不是 Web 开发者所遵循的信条。