React lingui.js 多语言自动提取翻译键 - ast node
背景介绍
目前有个项目,页面已上百,突然说要做国际化,懵了,页面这么多,那不得累滩,之前接触过的国际化是 react-intl,原理:每个国家的语言维护一份js字典,字典里有很多key,都是唯一,使用时通过读取这个key就能拿到对应国家的语言文本内容
例如
import { IntlProvider, FormattedMessage } from 'react-intl'; const messages = { en: { greeting: 'Hello, {name}!', }, fr: { greeting: 'Bonjour, {name}!', }, }; const App = () => { return ( <IntlProvider locale="en" messages={messages['en']}> <h1><FormattedMessage id="greeting" values={{ name: 'John' }} /></h1> </IntlProvider> ); };
如果是前期一开始就考虑了做国际化,这么这个方案没什么问题,顶多在写固定文本时把数据维护进字典里面就好了,但是,现在上百个页面已经有了中文,用这种方式,成本太大了,要修改每个文件
并且它有个比较大的弊端,对于英文不懂的人来说,写 <FormattedMessage id="greeting" values={{ name: 'John' }} />都不懂啥意思,起码没中文看起来那么直观,于是乎,开启调研模式
前期调研
目前比较流行的国际化方案如下
1. react-intl
react-intl 是一个由 formatjs 提供的库,它是 React 中最流行的国际化解决方案之一。它提供了格式化日期、数字和消息的功能,以及支持在应用中切换语言。
特点:
支持日期、时间、数字格式化。
支持消息格式化,带有插值。
支持多语言切换。
支持本地化规则(如日期、货币、数字)。
使用方法
npm install react-intl
使用
import { IntlProvider, FormattedMessage } from 'react-intl'; const messages = { en: { greeting: 'Hello, {name}!', }, fr: { greeting: 'Bonjour, {name}!', }, }; const App = () => { return ( <IntlProvider locale="en" messages={messages['en']}> <h1><FormattedMessage id="greeting" values={{ name: 'John' }} /></h1> </IntlProvider> ); };
2. react-i18next
react-i18next 是一个基于 i18next 的 React 国际化库,具有灵活的配置和插件系统,支持语言切换、动态加载语言包等功能。
特点:
支持语言切换,自动切换 UI 语言。
支持动态加载语言包。
支持插值和后端加载。
插件丰富,支持多种扩展。
安装
npm install react-i18next i18next
使用
import { useTranslation } from 'react-i18next'; const App = () => { const { t, i18n } = useTranslation(); return ( <div> <h1>{t('greeting', { name: 'John' })}</h1> <button onClick={() => i18n.changeLanguage('fr')}>Switch to French</button> </div> ); };
配置
import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; i18n.use(initReactI18next).init({ resources: { en: { translation: { greeting: 'Hello, {{name}}!', }, }, fr: { translation: { greeting: 'Bonjour, {{name}}!', }, }, }, lng: 'en', fallbackLng: 'en', });
3. lingui.js
lingui.js 是一个轻量级的国际化库,专注于简单易用和优化性能。它支持提取翻译文件、按需加载等功能。
支持自动提取翻译键。
按需加载翻译文件。
性能较好,支持静态优化。
提供高效的格式化功能。
安装
npm install @lingui/react @lingui/core
使用
import { I18nProvider, Trans } from '@lingui/react'; import { i18n } from '@lingui/core'; import enMessages from './locales/en/messages'; import frMessages from './locales/fr/messages'; i18n.load({ en: enMessages, fr: frMessages, }); i18n.activate('en'); const App = () => { return ( <I18nProvider i18n={i18n}> <h1><Trans>greeting</Trans></h1> </I18nProvider> ); };
我的选择
在看到 lingui.js 时,有个支持自动提取翻译键,功能吸引到了我,什么叫自动提取翻译键,看下面代码
jsx源代码
<div title={`测试`} data-content={test(`测试`)}> 测试 {test(`测试`)} </div>
如果要使用 支持自动提取翻译键 功能,字符串用 t`测试` 方式包裹起来,节点内容则用 <Trans>测试</Trans> 包裹起来
<div title={t`测试`} data-content={test(t`测试`)}> <Trans>测试 {test(t`测试`)}</Trans> </div>
执行命令
npx lingui extract
它会自动提取 t`测试` <Trans>测试</Trans> 作为一个唯一key,然后对应生成两份对应的字典源代码,分别是中文和英文(我这里只配置了中文和英文)
生成的文件如下
中文文件 /locales/zh/messages.po
msgid "" msgstr "" "POT-Creation-Date: 2024-12-09 18:45+0800\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: @lingui/cli\n" "Language: en\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" "Plural-Forms: \n" #: src/pages/Test/Test2/index.tsx #: src/pages/Test/Test2/index.tsx msgid "测试" msgstr "测试"
英文文件 /locales/en/messages.po
msgid "" msgstr "" "POT-Creation-Date: 2024-12-09 18:45+0800\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: @lingui/cli\n" "Language: en\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" "Plural-Forms: \n" #: src/pages/Test/Test2/index.tsx #: src/pages/Test/Test2/index.tsx msgid "测试" msgstr "test"
字段解释
msgid:字典中的唯一标识,程序通过 t`测试` 来匹配msgid 值,然后提取对应的 msgstr 值
msgstr:字典翻译的内容
我们可以看到中文字典,msgstr 有对应的值,而英文字段里没有,因为我是以中文为默认语言,所以脚本自动补充了中文翻译,英文值空缺了,后续我们需要做的就是把英文翻译给补全
执行命令
npx lingui compile
它会将locales/*.po 文件转成对应的js文件(已压缩)
中文文件 /locales/zh/messages.ts
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0qIdia\":[\"测试\"]}")as Messages;
英文文件 /locales/en/messages.ts
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0qIdia\":[\"test\"]}")as Messages;
结论
最终其实我们只需要维护 locales/*.po 文件 ,然后通过 npx lingui compile 来编译输出我们所需已翻译好的js字典
抛出问题
如何让所有的中文都用 t`测试` 或者 <Trans>测试</Trans> 包裹呢?
需求分析
源代码
const Test = () => { const test = (t: string) => { return t; }; return ( <div> <div title="测试" data-content={test('测试')}> 测试 {test('测试')} </div> <QkProTable headerTitle="测试" /> </div> ); };
转换完之后的代码
import { useLingui, Trans } from '@lingui/react/macro'; const Test = () => { const { t } = useLingui(); const test = (t: string) => { return t; }; return ( <div> <div title={t`测试`} data-content={test(t`测试`)}> <Trans>测试 {test(t`测试`)}</Trans> </div> <QkProTable headerTitle={t`测试`} /> </div> ); };
分析
1. 将 x="xx" 转换成 x={t`xx`}
2. 将 <div>测试</div> 转换成 <div><Trans>测试</Trans></div>
3. 如果没有引入 import { useLingui, Trans } from '@lingui/react/macro'; 则引入
4. 组件如果没用hooks const { t } = useLingui(); 则需要使用hooks
5. 转换过的内容不能再继续转换
6. 引入过的插件不能再引入
7. hook使用了不能再使用,hook的使用针对的是组件不是文件
最终代码
transform-lingui-ast.mjs
import { createRequire } from 'module'; import * as parser from '@babel/parser'; import * as t from '@babel/types'; import fs from 'fs'; import path from 'path'; const require = createRequire(import.meta.url); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; // 判断是否为中文字符 function containsChinese(str) { return /[\u4e00-\u9fa5]/.test(str); } // 创建Trans元素 function createTransElement(text) { return t.jsxElement( t.jsxOpeningElement(t.jsxIdentifier('Trans'), [], false), t.jsxClosingElement(t.jsxIdentifier('Trans')), [t.jsxText(text)], false ); } // 创建t表达式 function createTExpression(text) { return t.taggedTemplateExpression( t.identifier('t'), t.templateLiteral( [t.templateElement({ raw: text, cooked: text }, true)], [] ) ); } // 创建i18n._(msg)表达式 function createMsgExpression(text) { return t.callExpression( t.memberExpression(t.identifier('i18n'), t.identifier('_')), [ t.taggedTemplateExpression( t.identifier('msg'), t.templateLiteral( [t.templateElement({ raw: text, cooked: text }, true)], [] ) ) ] ); } // 创建JSX表达式容器 function createJSXExpressionContainer(expression) { return t.jsxExpressionContainer(expression); } // 检查是否是React组件声明 function isReactComponentDeclaration(path) { // 检查是否在枚举声明中 let currentPath = path; while (currentPath) { if (currentPath.node.type === 'TSEnumDeclaration') { return false; } currentPath = currentPath.parentPath; } // 函数声明组件 if ( path.type === 'FunctionDeclaration' && path.node.id && /^[A-Z]/.test(path.node.id.name) ) { return true; } // 箭头函数组件 if ( path.type === 'VariableDeclarator' && path.node.id && /^[A-Z]/.test(path.node.id.name) && !t.isObjectExpression(path.node.init) ) { return true; } // forwardRef组件 - 函数声明形式 if ( path.type === 'FunctionExpression' && path.parent?.type === 'CallExpression' && path.parent.callee?.name === 'forwardRef' ) { return true; } // forwardRef组件 - 箭头函数形式 if ( path.type === 'ArrowFunctionExpression' && path.parent?.type === 'CallExpression' && path.parent.callee?.name === 'forwardRef' ) { return true; } // export default forwardRef组件 if ( (path.type === 'FunctionExpression' || path.type === 'ArrowFunctionExpression') && path.parent?.type === 'CallExpression' && path.parent.callee?.name === 'forwardRef' && path.parent.parent?.type === 'ExportDefaultDeclaration' ) { return true; } // 类组件 if ( path.type === 'ClassDeclaration' && path.node.superClass && ((path.node.superClass.type === 'MemberExpression' && path.node.superClass.object.name === 'React' && path.node.superClass.property.name === 'Component') || (path.node.superClass.type === 'Identifier' && path.node.superClass.name === 'Component')) ) { return true; } return false; } // 检查是否在React组件内部 function isInsideReactComponent(path) { let currentPath = path; while (currentPath) { // 检查是否在枚举声明中 if (currentPath.node.type === 'TSEnumDeclaration') { return false; } // 检查函数声明组件 if ( currentPath.node.type === 'FunctionDeclaration' && currentPath.node.id && /^[A-Z]/.test(currentPath.node.id.name) ) { return true; } // 检查箭头函数组件 if ( currentPath.node.type === 'VariableDeclarator' && currentPath.node.id && /^[A-Z]/.test(currentPath.node.id.name) && !t.isObjectExpression(currentPath.node.init) ) { return true; } // 检查forwardRef组件 - 函数声明形式 if ( currentPath.node.type === 'FunctionExpression' && currentPath.parent?.type === 'CallExpression' && currentPath.parent.callee?.name === 'forwardRef' ) { return true; } // 检查forwardRef组件 - 箭头函数形式 if ( currentPath.node.type === 'ArrowFunctionExpression' && currentPath.parent?.type === 'CallExpression' && currentPath.parent.callee?.name === 'forwardRef' ) { return true; } // 检查export default forwardRef组件 if ( (currentPath.node.type === 'FunctionExpression' || currentPath.node.type === 'ArrowFunctionExpression') && currentPath.parent?.type === 'CallExpression' && currentPath.parent.callee?.name === 'forwardRef' && currentPath.parent.parent?.type === 'ExportDefaultDeclaration' ) { return true; } // 检查类组件 if ( currentPath.node.type === 'ClassDeclaration' && currentPath.node.superClass && ((currentPath.node.superClass.type === 'MemberExpression' && currentPath.node.superClass.object.name === 'React' && currentPath.node.superClass.property.name === 'Component') || (currentPath.node.superClass.type === 'Identifier' && currentPath.node.superClass.name === 'Component')) ) { return true; } currentPath = currentPath.parentPath; } return false; } // 创建导入声明 function createImportDeclaration(specifiers, source) { return t.importDeclaration( specifiers.map((name) => t.importSpecifier(t.identifier(name), t.identifier(name)) ), t.stringLiteral(source) ); } // 创建useLingui hook声明 function createUseLinguiDeclaration() { return t.variableDeclaration('const', [ t.variableDeclarator( t.objectPattern([ t.objectProperty( t.identifier('i18n'), t.identifier('i18n'), false, true ), t.objectProperty(t.identifier('t'), t.identifier('t'), false, true) ]), t.callExpression(t.identifier('useLingui'), []) ) ]); } // 处理单个文件 function transformFile(filePath) { try { // 检查文件是否为 TypeScript/React 文件 if (!/\.(tsx?|jsx?)$/.test(filePath)) { return; } console.log('处理文件:', filePath); const sourceCode = fs.readFileSync(filePath, 'utf-8'); // 获取文件名(不带扩展名) const fileName = path.basename(filePath).split('.')[0]; // 解析代码生成 AST const ast = parser.parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], tokens: true, attachComment: true }); // 用于跟踪需要添加的导入和hooks let needsLinguiMacro = false; let needsLinguiReact = false; let needsLinguiCore = false; let hasI18nUsage = false; let componentsNeedingUseLingui = new Set(); let hasExistingLinguiImports = false; // 第一次遍历:检查代码中的 i18n 使用情况 traverse(ast, { MemberExpression(path) { if (path.node.object.name === 'i18n') { hasI18nUsage = true; needsLinguiCore = true; } }, CallExpression(path) { if ( path.node.callee?.type === 'MemberExpression' && path.node.callee.object.name === 'i18n' ) { hasI18nUsage = true; needsLinguiCore = true; } } }); // 第二次遍历:检查现有的导入并移除 traverse(ast, { ImportDeclaration(path) { const source = path.node.source.value; if ( source === '@lingui/macro' || source === '@lingui/react' || source === '@lingui/react/macro' || source === '@lingui/core/macro' || source === '@lingui/core' ) { hasExistingLinguiImports = true; path.remove(); } } }); // 转换JSX中的中文内容 traverse(ast, { // 处理组件声明 'FunctionDeclaration|VariableDeclarator|CallExpression'(path) { if (!isReactComponentDeclaration(path)) return; let componentName; if (path.type === 'FunctionDeclaration') { componentName = path.node.id.name; } else if (path.type === 'VariableDeclarator') { componentName = path.node.id.name; } else if ( path.type === 'CallExpression' && path.node.callee.name === 'forwardRef' ) { // 获取forwardRef组件的名称 if (path.parent?.type === 'VariableDeclarator') { componentName = path.parent.id.name; } } if (componentName) { componentsNeedingUseLingui.add(componentName); needsLinguiReact = true; } }, // 处理JSX属性中的中文 JSXAttribute: { exit(path) { if (!isInsideReactComponent(path)) return; const value = path.node.value; if ( value && value.type === 'StringLiteral' && containsChinese(value.value) ) { path.node.value = t.jsxExpressionContainer( createTExpression(value.value) ); needsLinguiMacro = true; needsLinguiReact = true; } } }, // 处理JSX文本中的中文 JSXText: { exit(path) { if (!isInsideReactComponent(path)) return; const text = path.node.value.trim(); if (containsChinese(text) && text.length > 0) { const parent = path.parent; if ( parent.type === 'JSXElement' && parent.openingElement.name.name === 'Trans' ) { needsLinguiMacro = true; // 即使已经是Trans标签,也需要确保导入 return; } path.replaceWith(createTransElement(path.node.value)); needsLinguiMacro = true; } } }, // 检查是否使用了Trans组件 JSXElement(path) { if (path.node.openingElement.name.name === 'Trans') { needsLinguiMacro = true; } }, // 处理字符串字面量 StringLiteral: { exit(path) { // 检查是否在枚举声明中 if (path.findParent((p) => p.type === 'TSEnumDeclaration')) { return; } if ( !path.node.value || !containsChinese(path.node.value) || path.findParent((p) => p.isImportDeclaration()) || path.findParent( (p) => p.isJSXElement() && p.node.openingElement.name.name === 'Trans' ) ) { if ( path.findParent( (p) => p.isJSXElement() && p.node.openingElement.name.name === 'Trans' ) ) { needsLinguiMacro = true; } return; } const isInComponent = isInsideReactComponent(path); if (isInComponent && path.parent.type === 'JSXAttribute') { path.replaceWith(createJSXExpressionContainer(createTExpression(path.node.value))); needsLinguiMacro = true; needsLinguiReact = true; return; } if (isInComponent) { path.replaceWith(createTExpression(path.node.value)); needsLinguiMacro = true; needsLinguiReact = true; } else { const msgExpression = createMsgExpression(path.node.value); // 如果在JSX属性中,需要包装在JSXExpressionContainer中 if (path.parent.type === 'JSXAttribute') { path.replaceWith(createJSXExpressionContainer(msgExpression)); } else { path.replaceWith(msgExpression); } needsLinguiCore = true; needsLinguiMacro = true; } } } }); // 第三次遍历:添加useLingui hook到需要的组件 traverse(ast, { 'FunctionDeclaration|ArrowFunctionExpression'(path) { let componentName; let isForwardRef = false; // 处理普通函数组件 if (path.node.type === 'FunctionDeclaration') { componentName = path.node.id?.name; } // 处理箭头函数组件 else if (path.parent?.type === 'VariableDeclarator') { componentName = path.parent.id?.name; } // 处理forwardRef组件 else if ( path.parent?.type === 'CallExpression' && path.parent.callee?.name === 'forwardRef' && path.parent.parent?.type === 'VariableDeclarator' ) { componentName = path.parent.parent.id?.name; isForwardRef = true; } if (!componentName || !componentsNeedingUseLingui.has(componentName)) return; const body = path.node.body; if (t.isBlockStatement(body)) { // 检查是否已经有useLingui声明 const hasUseLingui = body.body.some( (node) => t.isVariableDeclaration(node) && node.declarations.some( (dec) => dec.init?.type === 'CallExpression' && dec.init.callee.name === 'useLingui' ) ); if (!hasUseLingui) { body.body.unshift(createUseLinguiDeclaration()); } } else if (t.isJSXElement(body)) { // 如果直接返回JSX,需要包装在代码块中 path.node.body = t.blockStatement([ createUseLinguiDeclaration(), t.returnStatement(body) ]); } }, // 专门处理forwardRef的箭头函数组件 CallExpression(path) { if ( path.node.callee.name === 'forwardRef' && path.node.arguments.length > 0 ) { const functionArg = path.node.arguments[0]; let componentName; // 获取组件名称 if (path.parent?.type === 'VariableDeclarator') { componentName = path.parent.id.name; } else if (path.parent?.type === 'ExportDefaultDeclaration') { // 对于export default的情况,使用文件名作为组件名 componentName = fileName; } if (componentName) { componentsNeedingUseLingui.add(componentName); needsLinguiReact = true; // 处理函数体 if (t.isArrowFunctionExpression(functionArg) || t.isFunctionExpression(functionArg)) { const body = functionArg.body; if (t.isBlockStatement(body)) { // 检查是否已经有useLingui声明 const hasUseLingui = body.body.some( (node) => t.isVariableDeclaration(node) && node.declarations.some( (dec) => dec.init?.type === 'CallExpression' && dec.init.callee.name === 'useLingui' ) ); if (!hasUseLingui) { body.body.unshift(createUseLinguiDeclaration()); } } else if (t.isJSXElement(body)) { // 如果直接返回JSX,需要包装在代码块中 functionArg.body = t.blockStatement([ createUseLinguiDeclaration(), t.returnStatement(body) ]); } } } } } }); // 添加必要的导入语句 const imports = []; // 如果有组件外部的 i18n 使用,确保添加相关导入 if (hasI18nUsage || needsLinguiCore) { imports.push( createImportDeclaration(['msg'], '@lingui/core/macro'), createImportDeclaration(['i18n'], '@lingui/core') ); } if (needsLinguiMacro || needsLinguiReact) { imports.push( createImportDeclaration(['useLingui', 'Trans'], '@lingui/react/macro') ); } // 在文件开头添加导入语句 if (imports.length > 0) { ast.program.body.unshift(...imports); } // 生成转换后的代码 const output = generate(ast, { retainLines: true, compact: 'auto', concise: false, jsescOption: { minimal: true }, sourceMaps: false, comments: true }); // 直接覆盖源文件 fs.writeFileSync(filePath, output.code); console.log('已更新文件:', filePath); } catch (error) { console.error(`处理文件 ${filePath} 时出错:`, error); } } // 处理文件夹 function transformDirectory(dirPath) { try { const files = fs.readdirSync(dirPath); files.forEach((file) => { const fullPath = path.join(dirPath, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { // 递归处理子文件夹 transformDirectory(fullPath); } else { // 处理文件 transformFile(fullPath); } }); } catch (error) { console.error(`处理文件夹 ${dirPath} 时出错:`, error); } } // 获取命令行参数 const targetPath = process.argv[2]; if (!targetPath) { console.error('请提供文件或文件夹路径!'); console.log('使用方法: node transform-lingui-ast.js <文件或文件夹路径>'); process.exit(1); } // 检查路径是否存在 if (!fs.existsSync(targetPath)) { console.error('指定的路径不存在!'); process.exit(1); } // 判断是文件还是文件夹 const stat = fs.statSync(targetPath); if (stat.isDirectory()) { transformDirectory(targetPath); } else { transformFile(targetPath); }
如何使用
执行下面命令,它就会把对应的中文用特定的标签进行包裹
npm run scripts/transform-lingui-ast.mjs
组件依赖版本
{ "scripts": { "i18n:extract": "lingui extract", "i18n:compile": "lingui compile" }, "dependencies": { "@lingui/core": "^5.1.0", "@lingui/macro": "^5.1.0", "@lingui/react": "^5.1.0", }, "devDependencies": { "@lingui/babel-plugin-lingui-macro": "^5.1.0", "@lingui/cli": "^5.1.0", "@lingui/vite-plugin": "^5.1.0", "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", } }