在全球化的浪潮下,越来越多的企业需要支持多语言、多区域的数据平台工具。数栈产品作为一款成熟的数据开发与治理平台,早已实现系统级的国际化(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();};
复制代码
最后实现效果如下:
四、结语
国际化不是简单的文本翻译,而是一个系统工程,需要从前端到后端全面考虑。数栈产品通过完善的国际化架构和实践经验,为全球用户提供了无缝的使用体验。
在全球化浪潮下,提前布局国际化能力,将为产品的国际市场拓展奠定坚实基础。希望数栈产品的国际化实践能为您的项目提供有益参考!
以上就是数栈产品如何实现国际化的所有内容~
未来,数栈还将持续迭代国际化能力,根据不同区域用户的反馈优化交互体验,让技术真正 “无国界”。如果你在使用数栈国际化版本时遇到问题,或有新的需求建议,欢迎在评论区留言,我们一起让数据工具更懂世界,也让世界更易用好数据~
最后,别忘了把数栈的国际化实践分享给更多需要的朋友,一起见证数据技术的全球生长!
评论