去年(2023年)启动了出海项目,在项目的过程中,遇到了不少做国内项目没遇到过的问题。
背景
项目中的所有页面都需要支持不同语言(汉语zh、英语en、阿语ar)的文案和对应的UI布局,需要找到更简单更通用的解决方案。
与国内项目比较,最主要的差异点有两个:
- 多语言文本展示
- UI排版布局差异
解决方案
多语言文本展示
经过调研,业界广泛使用的i18n方案是 i18next ,基于使用React技术栈的考虑,使用 react-i18next 库。
语言展示逻辑
基于业务规划,包括有APP和浏览器访问的页面,汇总出如下3个方案:
- URL参数方案: 与客户端约定,打开webview的url上拼接
lang=xx的指定语言参数。前端优先通过页面url上的lang参数获取用户当前语言。 - APP接口方案: 前端调用客户端提供的语言接口,获取用户当前语言。
- 浏览器/Webview接口方案: 前端调用浏览器api,获取用户当前语言。
首先,优先使用URL参数约定的方式;其次根据访问的环境(APP/外部浏览器),逐步降级使用APP接口、Web接口。
多语言文本
因为需要支持不同的语言,可以把展示的文本都汇总起来,定义各个文本的key,并根据不同的语言分别定义在json文件,文件命名格式为xxx_en.json / xxx_zh.json 等。
例如login_en.json包括的部分内容如下:
{
"login_terms_msg": "Agree to the agreements and terms before you can login ",
"login_sign": "Sign up",
"login_save": "Save",
"login_apple": "Sign in with Apple"
}
渲染方式
目前只需要CSR方案,使用上述提到的react-i18next开源库进行多语言文案展示。后续需要使用SSR方案的话,也同样适用,但需要做少量的差异化的改动。
UI排版布局差异
由于不同国家地区的阅读习惯方向不同,需要针对不同的语言,指定对应的布局(LTR、RTL)进行展示,同时尽可能做到减少额外的开发。
世界上绝大多数国家地区的主流阅读习惯都是LTR(从左到右),少数国家地区(阿拉伯语、希伯来语等)是RTL(从右到左),相应地,我们也需要专门针对这些语言的页面,把整体的UI排版布局做镜像调整。
候选方案:
- 方案一:CSS的direction属性
- 优势:简单粗暴
- 不足:非对称布局以及其他稍微复杂的布局无效,间隔边框等无效,都需要额外的代码进行适配
- 方案二:rtlcss开源库
- 优势:能支持间隔边框等指定方向的CSS属性的生成替换,能自定义属性和内容的生成替换
- 不足:需要工作流额外处理,会生成两份样式代码,内联样式无法生成替换
- 方案三:CSS的transform属性
- 优势:写样式可以完全不用关注RTL布局
- 不足:包含文案、图片等内容需要避免镜像反转的组件需要用翻转组件包裹,transform在移动端可能存在奇怪的兼容问题
最终,考虑到方案一/三都覆盖得不够全面,需要额外处理较多,选择了使用rtlcss的方案二。
选择使用 postcss-rtl 的npm包,它的具体的配置和运行原理如下:
项目配置
- npm包引入
- webpack配置引入
postcss-rtl插件
代码编译
webpack构建时执行插件,postcss-rtl插件对css代码中的涉及水平(左右)方向的属性进行rtl的补充修改并输出到css文件。
包括但不限于以下属性:
- border属性集
- padding属性集
- margin属性集
- direction
- float
- left
- right
- text-align
- transform属性集
代码举例:
scss源码
.box {
display: flex;
justify-content: space-between;
.onleft {
padding-left: 10px;
}
.onright {
padding-right: 15px;
}
}
编译输出css代码
.box {
display: flex;
justify-content: space-between
}
[dir=ltr] .box .onleft {
padding-left: 10px
}
[dir=rtl] .box .onleft {
padding-right: 10px
}
[dir=ltr] .box .onright {
padding-right: 15px
}
[dir=rtl] .box .onright {
padding-left: 15px
}
更多内容参考 rtlcss官方
运行时
需要在DOM根元素,手动添加针对不同语言对应的lang和dir属性
执行代码
/i18n/resources.js
获取 assets/static/locales/ 下的多语言文本。这里遇到一个问题,是 require.context 获取到的文件内容对象,同一个文件会获取到两次,对应的key分别是绝对路径和相对路径,因此需要过滤掉其中之一。
const langList = ['ar', 'en', 'zh']
const resourcesObj = {}
function importAll(ctx) {
const obj = {}
ctx.keys().map(item => {
if (item.indexOf('./') < 0) {
return
}
obj[item.replace('./', '')] = ctx(item)
})
return obj
}
const overall = importAll(require.context('assets/static/locales/', false, /^\.\/([a-zA-Z0-9_]+_)?[a-z]{2}\.json$/))
langList.forEach(lang => {
resourcesObj[lang] = {
translation: {}
}
Object.keys(overall).map(item => {
if ((new RegExp(`${lang}\.json$`)).test(item)) {
Object.assign(resourcesObj[lang].translation, overall[item])
}
})
})
export const resources = resourcesObj
/i18n/index.ts
获取当前语言,写入对应的布局相关设置,以及传入多语言文本对i18next进行初始化。
import i18n, { Resource } from 'i18next'
import { initReactI18next } from 'react-i18next'
import { getCurrentLang } from '../mobileApi'
import { getURLParam } from '../utils'
import { resources } from './resources'
const lang: string = getURLParam('lang') || ''
let langFromApp = ''
const RTL_LANG = ['ar', 'he']
const getLang = async (): Promise<void> => {
const res = await getCurrentLang()
langFromApp = (res && typeof res === 'string') ? res : 'en'
i18n.changeLanguage(langFromApp)
updateDirection(langFromApp)
}
export function updateDirection (lang: string) {
if (!lang && !langFromApp) getLang()
const dir = RTL_LANG.indexOf(lang || langFromApp) >= 0 ? 'rtl': 'ltr'
window.document.documentElement.lang = lang || langFromApp || 'en'
window.document.documentElement.dir = dir
return dir
}
export function changeLanguage (lang: string) {
i18n.changeLanguage(lang, () => {
updateDirection(lang)
})
}
i18n.use(initReactI18next)
.init({
debug: !__PROD__,
lng: lang,
fallbackLng: 'en',
resources: <Resource>resources,
interpolation: {
escapeValue: false
}
})
updateDirection(lang)
export default i18n
最后别忘了,在webpack config里面,添加 postcss-rtl 相关的配置。