暴风龟武者 發表於 2025-4-7 11:29:00

基于AST实现国际化文本提取

<blockquote>
<p>我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。</p>
</blockquote>
<blockquote>
<p>本文作者:霜序</p>
</blockquote>
<h3 id="前言">前言</h3>
<p>在阅读本文之前,需要读者有一些 babel 的基础知识,babel 的架构图如下:</p>
<p><img src="https://img2024.cnblogs.com/other/2332333/202504/2332333-20250407112922995-809143616.png"></p>
<h3 id="确定中文范围">确定中文范围</h3>
<p>先需要明确项目中可能存在中文的情况有哪些?</p>
<pre><code class="language-js">const a = '霜序';
const b = `霜序`;
const c = `${isBoolean} ? "霜序" : "FBB"`;
const obj = { a: '霜序' };
// enum Status {
//   Todo = "未完成",
//   Complete = "完成"
// }
// enum Status {
//   "未完成",
//   "完成"
// }
const dom = &lt;div&gt;霜序&lt;/div&gt;;
const dom1 = &lt;Customer name="霜序" /&gt;;
</code></pre>
<p>虽然有很多情况下会出现中文,在代码中存在的时候大部分是<code>string</code>或者模版字符串,在<code>react</code>中的时候一个是<code>dom</code>的子节点还是一种是<code>props</code>上的属性包含中文。</p>
<pre><code class="language-json">// const a = '霜序';
{
"type": "StringLiteral",
"start": 10,
"end": 14,
"extra": {
    "rawValue": "霜序",
    "raw": "'霜序'"
},
"value": "霜序"
}
</code></pre>
<h4 id="stringliteral">StringLiteral</h4>
<p>对应的<code>AST</code>节点为<code>StringLiteral</code>,需要去遍历所有的<code>StringLiteral</code>节点,将当前的节点替换为我们需要的<code>I18N.key</code>这种节点。</p>
<pre><code class="language-json">// 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": ")"
      }
    }
]
}
</code></pre>
<h4 id="templateliteral">TemplateLiteral</h4>
<p>相对于字符串情况会复杂一些,<code>TemplateLiteral</code>中会出现变量的情况,能够看到在<code>TemplateLiteral</code>节点中存在<code>expressions</code>和<code>quasis</code>两个字段分别表示变量和字符串</p>
<p>其实可以发现对于字符串来说全部都在<code>TemplateElement</code>节点上,那么是否可以直接遍历所有的<code>TemplateElement</code>节点,和<code>StringLiteral</code>一样。</p>
<p>直接遍历<code>TemplateElement</code>的时候,处理之后的效果如下:</p>
<pre><code class="language-js">const b = `${finalRoles}(质量项目:${projects})`;

const b = `${finalRoles}${I18N.K}${projects})`;

// I18N.K = "(质量项目:"
</code></pre>
<p>那么这种只提取中文不管变量的情况,会导致翻译不到的问题,上下文很缺失。</p>
<p>最后我们会处理成<code>{val1}(质量项目:{val2})</code>的情况,将对应<code>val1</code>和<code>val2</code>传入</p>
<pre><code class="language-js">I18N.get(I18N.K, {
val1: finalRoles,
val2: projects,
});
</code></pre>
<h4 id="jsxtext">JSXText</h4>
<p>对应的<code>AST</code>节点为<code>JSXText</code>,去遍历<code>JSXElement</code>节点,在遍历对应的<code>children</code>中的<code>JSXText</code>处理中文文本</p>
<pre><code class="language-json">{
"type": "JSXElement",
"start": 12,
"end": 25,
"children": [
    {
      "type": "JSXText",
      "start": 17,
      "end": 19,
      "extra": {
      "rawValue": "霜序",
      "raw": "霜序"
      },
      "value": "霜序"
    }
]
}
</code></pre>
<h4 id="jsxattribute">JSXAttribute</h4>
<p>对应的<code>AST</code>节点为<code>JSXAttribute</code>,中文存在的节点还是<code>StringLiteral</code>,但是在处理的时候还是特殊处理<code>JSXAttribute</code>中的<code>StringLiteral</code>,因为对于这种<code>JSX</code>中的数据来说我们需要包裹<code>{}</code>,不是直接做文本替换的</p>
<pre><code class="language-json">{
"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
}
</code></pre>
<h3 id="使用-babel-处理">使用 Babel 处理</h3>
<p><img src="https://img2024.cnblogs.com/other/2332333/202504/2332333-20250407112923553-1334622772.png"></p>
<h4 id="使用-babelparser-将源代码转译为-ast">使用 @babel/parser 将源代码转译为 AST</h4>
<pre><code class="language-js">const plugins: ParserOptions['plugins'] = ['decorators-legacy', 'typescript'];
if (fileName.endsWith('text') || fileName.endsWith('text')) {
plugins.push('text');
}
const ast = parse(sourceCode, {
sourceType: 'module',
plugins,
});
</code></pre>
<h4 id="babeltraverse-特殊处理上述的节点转化-ast">@babel/traverse 特殊处理上述的节点,转化 AST</h4>
<pre><code class="language-js">babelTraverse(ast, {
StringLiteral(path) {
    const { node } = path;
    const { value } = node;
    if (
      !value.match(DOUBLE_BYTE_REGEX) ||
      (path.parentPath.node.type === 'CallExpression' &amp;&amp;
      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' &amp;&amp;
      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) =&gt; {
      const { start, end } = expression;
      if (!start || !end) return;
      return sourceCode.slice(start, end);
    });
    const kvPair = expressions.map((expression, index) =&gt; {
      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) =&gt; {
      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) &amp;&amp;
      node.value.value.match(DOUBLE_BYTE_REGEX)
    ) {
      const expression = babelTypes.jsxExpressionContainer(
      babelTypes.memberExpression(
          babelTypes.identifier('I18N'),
          babelTypes.identifier(`${key}`),
      ),
      );
      node.value = expression;
    }
},
});
</code></pre>
<p>对于<code>TemplateLiteral</code>来说需要处理<code>expression</code>,通过截取的方式获取到对应的模版字符串 <code>templateContent</code>,如果不存在<code>expressions</code>,直接类似<code>StringLiteral</code>处理;存在<code>expressions</code>的情况下,遍历<code>expressions</code>通过<code>${val(index)}</code>替换掉<code>templateContent</code>中的<code>expression</code>,最后使用<code>I18N.get</code>的方式获取对应的值</p>
<pre><code class="language-js">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 });
</code></pre>
<p>对于<code>TemplateLiteral</code>节点来说,如果是嵌套的情况,会出现问题。</p>
<pre><code class="language-js">const name1 = `${a ? `霜` : `序`}霜序`;
// const name1 = I18N.get(I18N.test.B, { val1: a ? `霜` : `序` });
</code></pre>
<blockquote>
<p>🤔&nbsp; 为何对于<code>TemplateLiteral</code>中嵌套的<code>StringLiteral</code>会处理,而<code>TemplateLiteral</code>就不处理呢?<br>
💡&nbsp; 导致原因为<code>babel</code>不会自动递归处理<code>TemplateLiteral</code>的子级嵌套模板。<br>
上述的代码中通过遍历一些<code>AST</code>处理完了之后,我们需要统一引入当前<code>I18N</code>这个变量。那么没我们需要在当前文件的<code>AST</code>顶部的<code>import</code>语句后插入当前的<code>importStatement</code></p>
</blockquote>
<pre><code class="language-js">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) =&gt; {
            return (
                babelTypes.isImportDeclaration(node) &amp;&amp;
                node.source.value === result
            );
      });
      if (!existingImport) {
            const importDeclaration = babelTypes.importDeclaration(
                [
                  babelTypes.importDefaultSpecifier(
                        babelTypes.identifier(result),
                  ),
                ],
                babelTypes.stringLiteral(result),
            );
            path.node.body.unshift(importDeclaration);
      }
    },
}
</code></pre>
<h4 id="转为代码">转为代码</h4>
<pre><code class="language-js">const { code } = generate(ast, {
retainLines: true,
comments: true,
});
</code></pre>
<p>因为我们的场景不适合将该功能封装成<code>plugin</code>,但是整体和写<code>plugin</code>的思路差不多。在<code>.babelrc</code>中配置对应的<code>plugin</code>即可</p>
<pre><code class="language-js">const i18nPlugin = () =&gt; {
return {
    visitor: {
      StringLiteral(path) {},
      TemplateLiteral(path) {},
      JSXElement(path) {},
      JSXAttribute(path) {},
      Program: {},
    },
};
};
</code></pre>
<h3 id="其他处理">其他处理</h3>
<p><strong>动态生成 key</strong></p>
<p>每一个中文生成<code>key</code>的方式都是固定的,类似<code>excel</code>列名</p>
<pre><code class="language-js">export const getSortKey = (n: number, extractMap = {}): string =&gt; {
let label = '';
let num = n;
while (num &gt; 0) {
    num--;
    label = String.fromCharCode((num % 26) + 65) + label;
    num = Math.floor(num / 26);
}
const key = `${label}`;
if (_.get(extractMap, key)) {
    return getSortKey(n + 1, extractMap);
}
return key;
};
</code></pre>
<p>每一个文件的前缀都是一定的,按着路径生成的,不会包含<code>extractDir</code>之前的内容</p>
<pre><code class="language-js">export const getFileKey = (filePath: string) =&gt; {
    const extractDir = getProjectConfig().extractDir;

    const basePath = path.resolve(process.cwd(), extractDir);

    const relativePath = path.relative(basePath, filePath);

    const names = slash(relativePath).split('/');
    const fileName = _.last(names) as any;
    let fileKey = fileName.split('.').slice(0, -1).join('.');
    const dir = names.slice(0, -1).join('.');
    if (dir) fileKey = names.slice(0, -1).concat(fileKey).join('.');
    return fileKey.replace(/-/g, '_');
};
</code></pre>
<h3 id="脚手架命令">脚手架命令</h3>
<p>i18n-extract-cli</p>
<p>目前支持命令如下:</p>
<pre><code>- init: 用于初始化配置化文件
- extract: 根据配置文件提取 extractDir 的中文写入到对应的文件
- extract:check: 检查 extractDir 文件夹中的中文是否提取完全
- extract:clear: 清理 extractDir 尚未使用的国际化文案
</code></pre>
<pre><code class="language-bash">npx i18n-extract-cli init
</code></pre>
<p>会初始化一份<code>i18n.config.json</code></p>
<pre><code class="language-json">{
"localeDir": "locales",
"extractDir": "./",
"importStatement": "import I18N from @/utils/i18n",
"excludeFile": [],
"excludeDir": []
}
</code></pre>
<p>执行如下命令,开始提取<code>extractDir</code>目录下的中文文本到<code>localeDir/zh-CN</code></p>
<pre><code class="language-bash">npx i18n-extract-cli extract
</code></pre>
<p>执行如下命令,检查 extractDir 文件夹中的中文是否提取完全,需要注意 console 中的中文也会被检查</p>
<pre><code class="language-bash">npx i18n-extract-cli extract:check
</code></pre>
<p>执行如下命令,清理 extractDir 尚未使用的国际化文案</p>
<blockquote>
<p>值得注意,是按着每个文件路径作为key来判断当前文件中的 sortKey 是否使用,因此必须保证每个文件中使用的 key 为fileKey + sortKey,否则会导致当前脚本失效</p>
</blockquote>
<pre><code class="language-bash">npx i18n-extract-cli extract:clear
</code></pre>
<h3 id="最后">最后</h3>
<p>欢迎关注【袋鼠云数栈UED团队】~<br>
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star</p>
<ul>
<li><strong>大数据分布式任务调度系统——Taier</strong></li>
<li><strong>轻量级的 Web IDE UI 框架——Molecule</strong></li>
<li><strong>针对大数据领域的 SQL Parser 项目——dt-sql-parser</strong></li>
<li><strong>袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices</strong></li>
<li><strong>一个速度更快、配置更灵活、使用更简单的模块打包器——ko</strong></li>
<li><strong>一个针对 antd 的组件测试工具库——ant-design-testing</strong></li>
</ul><br><br>
来源:https://www.cnblogs.com/dtux/p/18812402
頁: [1]
查看完整版本: 基于AST实现国际化文本提取