在全球化的浪潮下,越来越多的企业需要支持多语言、多区域的数据平台工具。数栈产品作为一款成熟的数据开发与治理平台,早已实现系统级的国际化(i18n)支持。数栈产品团队深入挖掘多语言使用场景,打造了一套完整的国际化解决方案,让产品真正实现无障碍全球使用。本文将详细介绍数栈产品在国际化方面的实现机制与使用方式,帮助用户更好地理解和应用该功能。
一、什么是国际化?为什么如此重要?
国际化(Internationalization,简称 i18n)是指软件开发过程中,将产品与特定语言及地区脱钩的过程,以便产品能够轻松适应不同语言和地区需求。当产品需要进入全球市场时,国际化能够大幅降低本地化成本,提升用户体验。
数栈产品的国际化工作涵盖了界面文本、日期时间、货币格式、数字格式等多个方面,确保无论用户来自哪个国家地区,都能获得自然流畅的使用体验。
二、国际化实现整体思路
数栈产品的国际化实现主要分为两个核心阶段:开发阶段的静态文本提取和运行时的动态文本渲染。通过这两个阶段的配合,我们实现了产品界面的中英文无缝切换。
第一阶段:开发阶段 - 中文文本提取与替换
在开发过程中,我们使用专门的中文提取工具,将产品界面中的所有中文文本进行统一提取和标准化处理:
这个阶段的关键在于:
●识别代码中的所有中文文本
●生成统一的国际化键值对(如 I18N.XXX)
●保持代码的可读性和可维护性
第二阶段:运行时 - 中英文文本动态渲染
在产品运行期间,我们通过 dt-intl 工具实现多语言文本的动态渲染:
运行机制:
1.初始化检测:页面加载时读取当前语言配置(中文或英文)
2.语言包加载:根据配置加载对应的语言资源文件
3.实时渲染:将 I18N.XXX 占位符替换为实际的目标语言文本
三、如何实现国际化?
接下来我们详细介绍实现国际化的具体操作。
(一)确定产品中文类型
先需要明确项目中可能存在中文的情况有哪些?
虽然有很多情况下会出现中文,在代码中存在的时候大部分是 string 或者模版字符串,在 react 中的时候一个是 dom 的子节点还是一种是 prop 上的属性包含中文。
const a = '霜序';
const b = `霜序`;
const c = `${isBoolean} ? "霜序" : "FBB"`;
const obj = { a: '霜序' };
enum Status {
Todo = "未完成",
Complete = "完成"
}
// enum Status {
// "未完成",
// "完成"
// }
const dom = <div>霜序</div>;
const dom1 = <Customer name="霜序" />;
复制代码
类型一:StringLiteral
// const a = '霜序';
{
"type": "StringLiteral",
"start": 10,
"end": 14,
"extra": {
"rawValue": "霜序",
"raw": "'霜序'"
},
"value": "霜序
}
复制代码
对应的 AST 节点为 StringLiteral,需要去遍历所有的 StringLiteral 节点,将当前的节点替换为我们需要的 I18N.key 这种节点。
类型二:TemplateLiteral
// const b = `${finalRoles}(质量项目:${projects})`
{
"type": "TemplateLiteral",
"start": 10,
"end": 43,
"expressions": [
{
"type": "Identifier",
"start": 13,
"end": 23,
"name": "finalRoles"
},
{
"type": "Identifier",
"start": 32,
"end": 40,
"name": "projects"
}
],
"quasis": [
{
"type": "TemplateElement",
"start": 11,
"end": 11,
"value": {
"raw": "",
"cooked": ""
}
},
{
"type": "TemplateElement",
"start": 24,
"end": 30,
"value": {
"raw": "(质量项目:",
"cooked": "(质量项目:"
}
},
{
"type": "TemplateElement",
"start": 41,
"end": 42,
"value": {
"raw": ")",
"cooked": ")"
}
}
]
}
复制代码
相对于字符串情况会复杂一些,TemplateLiteral 中会出现变量的情况,能够看到在 TemplateLiteral 节点中存在 expressions 和 quasis 两个字段分别表示变量和字符串。
其实可以发现对于字符串来说全部都在 TemplateElement 节点上,那么是否可以直接遍历所有的 TemplateElement 节点,和 StringLiteral 一样。
直接遍历 TemplateElement 的时候,处理之后的效果如下:
const b = `${finalRoles}(质量项目:${projects})`
const b = `${finalRoles}${I18N.K}${projects})`
// I18N.K = "(质量项目:"
复制代码
那么这种只提取中文不管变量的情况,会导致翻译不到的问题,上下文很缺失。
最后我们会处理成{val1}(质量项目:{val2})的情况,将对应 val1 和 val2 传入:
I18N.get(I18N.K, {
val1: finalRoles,
val2: projects,
})
复制代码
类型三:JSXText
{
"type": "JSXElement",
"start": 12,
"end": 25,
"children": [
{
"type": "JSXText",
"start": 17,
"end": 19,
"extra": {
"rawValue": "霜序",
"raw": "霜序"
},
"value": "霜序"
}
]
}
复制代码
对应的 AST 节点为 JSXText,去遍历 JSXElement 节点,在遍历对应的 children 中的 JSXText 处理中文文本。
类型四:JSXAttribute
{
"type": "JSXOpeningElement",
"start": 13,
"end": 35,
"name": {
"type": "JSXIdentifier",
"start": 14,
"end": 22,
"name": "Customer"
},
"attributes": [
{
"type": "JSXAttribute",
"start": 23,
"end": 32,
"name": {
"type": "JSXIdentifier",
"start": 23,
"end": 27,
"name": "name"
},
"value": {
"type": "StringLiteral",
"start": 28,
"end": 32,
"extra": {
"rawValue": "霜序",
"raw": "\"霜序\""
},
"value": "霜序"
}
}
],
"selfClosing": true
}
复制代码
对应的 AST 节点为 JSXAttribute,中文存在的节点还是 StringLiteral,但是在处理的时候还是特殊处理 JSXAttribute 中的 StringLiteral,因为对于这种 JSX 中的数据来说我们需要包裹{},不是直接做文本替换的。
(二)用工具(Babel)转换中文
使用 Babel 做转换,其核心逻辑如下图:
步骤一:转化源码
使用 @babel/parser 将源代码转译为 AST
const plugins: ParserOptions['plugins'] = [
'decorators-legacy',
'typescript',
];
if (fileName.endsWith('jsx') || fileName.endsWith('tsx')) {
plugins.push('jsx');
}
const ast = parse(sourceCode, {
sourceType: 'module',
plugins,
});
复制代码
步骤二:处理 AST
(1)@babel/traverse 特殊处理上述的节点,转化 AST
babelTraverse(ast, {
StringLiteral(path) {
const { node } = path;
const { value } = node;
if (
!value.match(DOUBLE_BYTE_REGEX) ||
(path.parentPath.node.type === 'CallExpression' &&
path.parentPath.toString().includes('console'))
) {
return;
}
path.replaceWithMultiple(template.ast(`I18N.${key}`));
},
TemplateLiteral(path) {
const { node } = path;
const { start, end } = node;
if (!start || !end) return;
let templateContent = sourceCode.slice(start + 1, end - 1);
if (
!templateContent.match(DOUBLE_BYTE_REGEX) ||
(path.parentPath.node.type === 'CallExpression' &&
path.parentPath.toString().includes('console')) ||
path.parentPath.node.type === 'TaggedTemplateExpression'
) {
return;
}
if (!node.expressions.length) {
path.replaceWithMultiple(template.ast(`I18N.${key}`));
path.skip();
return;
}
const expressions = node.expressions.map((expression) => {
const { start, end } = expression;
if (!start || !end) return;
return sourceCode.slice(start, end);
});
const kvPair = expressions.map((expression, index) => {
templateContent = templateContent.replace(
`\${${expression}}`,
`{val${index + 1}}`,
);
return `val${index + 1}: ${expression}`;
});
path.replaceWithMultiple(
template.ast(
`I18N.get(I18N.${key},{${kvPair.join(',\n')}})`,
),
);
},
JSXElement(path) {
const children = path.node.children;
const newChild = children.map((child) => {
if (babelTypes.isJSXText(child)) {
const { value } = child;
if (value.match(DOUBLE_BYTE_REGEX)) {
const newExpression = babelTypes.jsxExpressionContainer(
babelTypes.identifier(`I18N.${key}`),
);
return newExpression;
}
}
return child;
});
path.node.children = newChild;
},
JSXAttribute(path) {
const { node } = path;
if (
babelTypes.isStringLiteral(node.value) &&
node.value.value.match(DOUBLE_BYTE_REGEX)
) {
const expression = babelTypes.jsxExpressionContainer(
babelTypes.memberExpression(
babelTypes.identifier('I18N'),
babelTypes.identifier(`${key}`),
),
);
node.value = expression;
}
},
});
复制代码
对于 TemplateLiteral 来说需要处理 expression,通过截取的方式获取到对应的模版字符串 templateContent:
●如果不存在 expressions,直接类似 StringLiteral 处理
●如果存在 expressions,遍历 expressions 通过 ${val(index)}替换掉 templateContent 中的 expression,最后使用 I18N.get 的方式获取对应的值
const name = `${a}霜序`;
// const name = I18N.get(I18N.test.A, { val1: a });
const name1 = `${a ? "霜" : "序"}霜序`;
// const name1 = I18N.get(I18N.test.B, { val1: a ? I18N.test.C : I18N.test.D });
复制代码
对于 TemplateLiteral 节点来说,如果是嵌套的情况,会出现问题。
const name1 = `${a ? `霜` : `序`}霜序`;
// const name1 = I18N.get(I18N.test.B, { val1: a ? `霜` : `序` });
复制代码
🤔 为何对于 TemplateLiteral 中嵌套的 StringLiteral 会处理,而 TemplateLiteral 就不处理呢?
💡 原因是 babel 不会自动递归处理 TemplateLiteral 的子级嵌套模板。
(2)上述的代码中通过遍历一些 AST 处理完了之后,我们需要统一引入当前 I18N 这个变量。
我们需要在当前文件的 AST 顶部的 import 语句后插入当前的 importStatement:
Program: {
exit(path) {
const importStatement = projectConfig.importStatement;
const result = importStatement
.replace(/^import\s+|\s+from\s+/g, ',')
.split(',')
.filter(Boolean);
// 判断当前的文件中是否存在 importStatement 语句
const existingImport = path.node.body.find((node) => {
return (
babelTypes.isImportDeclaration(node) &&
node.source.value === result[1]
);
});
if (!existingImport) {
const importDeclaration = babelTypes.importDeclaration(
[
babelTypes.importDefaultSpecifier(
babelTypes.identifier(result[0]),
),
],
babelTypes.stringLiteral(result[1]),
);
path.node.body.unshift(importDeclaration);
}
},
}
复制代码
步骤三:转回代码
const { code } = generate(ast, {
retainLines: true,
comments: true,
});
复制代码
处理完之后的代码如下图:
(三)读取国际化 key
开发 dt-intl 仓库,主要是为了做通过 I18N.xxx/I18N.get(I18N.xxx)到对应文本
dt-intl 默认导出一个方法,仅支持 init 方法
import dtIntl from 'dt-intl';
const I18N = dtIntl.init<I18NType>(currentLang, langs, LangEnum.zhCN);
复制代码
返回的 I18N 是一个响应式对象,通过 Object.defineProperty/Proxy 实现属性的响应式变化
const defineReactive = (obj, key, defaultKey) => {
let childObj = observe(obj[key]);
Object.defineProperty(obj, key, {
get() {
if (obj.__data__[key]) {
return getProxyObj(obj.__data__[key]);
} else if (obj.__metas__[defaultKey][key]) {
return getProxyObj(obj.__metas__[defaultKey][key]);
} else {
return getDefaultProxyString();
}
},
set(newVal) {
if (obj[key] === newVal) {
return;
}
// 如果值有变化的话,做一些操作
obj[key] = newVal;
// 执行回调
const cb = obj.callback[key];
cb.call(obj);
// 如果set进来的值为复杂类型,再递归它,加上set/get
childObj = observe(newVal);
},
});
};
复制代码
可以直接通过 I18N.xxx 获取到对应的文本
还提供 template/get 的方式处理带有参数的文案,template 处理简单的模版字符串,get 支持 IntlMessageFormat 处理复杂的模版字符串:
template(str, args) {
if (!str) {
return '';
}
return str.replace(/\{(.+?)\}/g, (match, p1) => {
return this.getProp({ ...this.__data__, ...args }, p1);
});
}
get(str, args?) {
let msg = lodashGet(this.__data__, str);
if (!msg) {
msg = lodashGet(this.__metas__[this.__defaultKey__ || 'zh-CN'], str, str);
}
if (args) {
msg = new IntlMessageFormat(msg, this.__lang__);
msg = msg.format(args);
return msg;
} else {
return msg;
}
}
复制代码
(四)最后接入国际化
import { getCurrentLang, LangEnum } from 'xxx/src/utils/i18n';
import dtIntl from 'dt-intl';
// 所提出来的中文文本
import zhCNData from '../locales/zh-CN/index';
export type I18NType = typeof zhCNData;
// 当前的语言
const currentLang = getCurrentLang() || LangEnum.zhCN;
const langs = {
[currentLang]: require(`../locales/${currentLang}/index.ts`).default,
[LangEnum.zhCN]: zhCNData,
};
const I18N = dtIntl.init<I18NType>(currentLang, langs);
export default I18N;
复制代码
提供了 changeLanguage 统一处理切换语言的情况:
export const changeLanguage = (lang: LangType) => {
localStorage.setItem(LOCALE_KEY, lang);
const hash = window.location.hash;
const [path, queryString] = hash.split('?');
if (!queryString) {
window.location.reload();
return;
}
const params = new URLSearchParams(queryString);
params.delete('lang');
const size = Array.from(params).length;
window.location.hash = `${path}${size ? `?${params.toString()}` : ''}`;
window.location.reload();
};
复制代码
最后实现效果如下:
四、结语
国际化不是简单的文本翻译,而是一个系统工程,需要从前端到后端全面考虑。数栈产品通过完善的国际化架构和实践经验,为全球用户提供了无缝的使用体验。
在全球化浪潮下,提前布局国际化能力,将为产品的国际市场拓展奠定坚实基础。希望数栈产品的国际化实践能为您的项目提供有益参考!
以上就是数栈产品如何实现国际化的所有内容~
未来,数栈还将持续迭代国际化能力,根据不同区域用户的反馈优化交互体验,让技术真正 “无国界”。如果你在使用数栈国际化版本时遇到问题,或有新的需求建议,欢迎在评论区留言,我们一起让数据工具更懂世界,也让世界更易用好数据~
最后,别忘了把数栈的国际化实践分享给更多需要的朋友,一起见证数据技术的全球生长!
评论