前言
2021 – 2022,前端发展可谓日新月异,各个方向不断涌现后起之秀:打包工具 Vite、前端视图框架 SolidJS、后端框架 Remix,此外全新的 CSS 特性和 TC39 提案被各大浏览器所支持,毫无疑问,2022 年将是 JavaScript 出道以来,迄今为止最繁荣的一年。
我所在的 TikTok 杭州团队,主要负责直播间的营收活动,通过上榜、做任务、1v1 PK 等玩法激励用户和主播充值送礼,活动投放地区涉及中东、欧洲、东南亚……,所以国际化是研发流程中不可或缺的一环。
传统的 RTL 布局、时区转换、本地化解决方案在新特性的加持下,会发生怎样奇妙的化学反应,这篇文章就来聊一聊~
幻灯片预览:浅谈新特性下的国际化活动
如何判断 RTL
我们会根据投放的国家地区,提供对应的活动链接,往 URL Query Params 加上 lang
属性,代表当地语言:
- 中国地区:example.com?lang=zh
- 中东地区:example.com?lang=ar
目前判断是否应用 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.9 向 Intl.Locale
中新增了 textInfo
对象,它能返回一些关于语言敏感的文字信息,其中 direction
属性,被用于 HTML dir attribute 和 CSS 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
的左右内边距为 10px
,padding
作为 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
.
查看 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
,浏览器会自动调整好逻辑方向。
查看 CodePen 上的在线例子:codepen.io/b2d1/pen/qB…
padding-inline-start
会根据 direction
变化成物理方向上的 padding-left
和 padding-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 存在的问题:
- 不支持除用户当地时间以外的时区
- 计算 API 缺失
在业务上,只能借助于 moment.js、day.js 等社区开源库,实现复杂的时区转换、时间计算。
TC39 组织也意识到 Date
对象该淘汰了,于是邀请了 moment.js 的作者 Maggie,由她担任新特性 Temporal 的主力设计,目前该提案处于 Stage 3.
一个完整的 Temporal 由三部分组成:
- ISO 8601 国际时间格式,T 作为日期 (2020-08-05) 和时间 (20:06:13) 的分隔符,+ 表示东时区,- 表示西时区
- 时区名称(日本东京)
- 日历(日本历法)
有了 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 开发者所遵循的信条。