react自动国际化

228 阅读6分钟

一、背景

工作需求,将一个五岁的项目,支持英文,如果是新项目或者小项目,在开发的时候可以手动的维护对应语言包的json,并用国际化插件的语法包裹中文,我用的是react-intl,那么使用 intl.formatMessage(intlMessages["你好"])。但是这个老项目文件太多了,还是自动化好些。

注意

由于不止在tsx中,也就是组件中出现中文,在非组件中也会存在中文,因此不使用 useIntl hook, 而是在 src/locales/index.ts

import { createIntl, createIntlCache } from 'react-intl';
import zhCN from './zh.json';
import enUS from './en.json';
console.log(enUS);

const cache = createIntlCache();

const _messages: Record<string, any> = {
  en: enUS,
  zh: zhCN
};

// const locale = 'zh';
const locale = 'en';
console.log(_messages[locale]);

const intl = createIntl(
  {
    locale: locale,
    messages: _messages[locale]
  },
  cache
);
export default intl;

目标

  • 每个文件自动引入 intl,并用 intl.formatMessage(intlMessages["你好"]) 包裹中文
  • 每个文件自动提取中文 并定义 message 如:
import { defineMessages } from "react-intl";

const intlMessages = defineMessages({
  测试: { id: "测试" },
  你好: { id: "你好" },
});

二、基于babel实现

1、创建项目

结构如下:

image.png

2、安装依赖

npm i @babel/core @babel/generator @babel/parser @babel/template @babel/types prettier
npm i -D @types/babel__core @types/node typescript
// package.json

{
  "name": "react-auto-intl",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/babel__core": "^7.20.5",
    "@types/node": "^22.9.0",
    "typescript": "^5.6.3"
  },
  "dependencies": {
    "@babel/core": "^7.26.0",
    "@babel/generator": "^7.26.2",
    "@babel/parser": "^7.26.2",
    "@babel/template": "^7.25.9",
    "@babel/types": "^7.26.0",
    "prettier": "^3.3.3"
  }
}

3、ts配置

// tsconfig.json

{
  "compilerOptions": {
    "outDir": "dist",
    "types": [ "node" ],
    "target": "es2016", 
    "module": "NodeNext", 
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
  },
  "exclude": ["demo"]
}

4、demo 里是要处理的代码

function App() {
  const title = '安佛';
  const desc = `蜂蜜`;
  const desc2 = /*i18n-disable*/`而非你`;
  const desc3 = `爱上非农 ${ title + desc} 阿福 ${ desc2 } 暗示法比`;
  const getPwdValidator = (pwdRule) => () => ({
    validator(rule, value) {
      if (value?.length > 0) {
        const chineseReg = /[\u4e00-\u9fa5]/;
        const numberReg = /[0-9]/;
        const upperReg = /[A-Z]/;
        const lowerReg = /[a-z]/;
        const charReg = /[`~'"!@#$%^&*()~,.?/{}<>[\]]/;
        const { number, upper, lower, special_char: char } = pwdRule!.pwd_strength;
        if (chineseReg.test(value)) {
          return Promise.reject('不允许包含中文');
        }
        if (number === 1 && !numberReg.test(value)) {
          return Promise.reject('必须包含数字');
        }
        if (upper === 1 && !upperReg.test(value)) {
          return Promise.reject('必须包含大写字母');
        }
        if (lower === 1 && !lowerReg.test(value)) {
          return Promise.reject('必须包含小写字母');
        }
        if (char === 1 && !charReg.test(value)) {
          return Promise.reject('必须包含'+'`~\'"!@#$%^&*()~,.?/{}<>[]');
        }
        if (value.length < pwdRule!.pwd_shortest_length || value.length > 50) {
          return Promise.reject(`密码长度必须大于等于${pwdRule!.pwd_shortest_length}, 小于50`);
        }
      }

      return Promise.resolve();
    }
  });
  return (
    <div className="撒非农" title={"测试"}>
      <img src={Logo} />
      <h1>你好{title}</h1>
      <p>{desc}</p>  
      <div>
      {
          /*i18n-disable*/'中文'
      }
      </div>
    </div>
  );
}


5、 核心代码 transform.ts

import {
  NodePath,
  PluginObj,
  PluginPass,
  transformFromAstSync,
} from "@babel/core";
import parser from "@babel/parser";
import template from "@babel/template";
import {
  jsxExpressionContainer,
  JSXText,
  StringLiteral,
  TemplateLiteral,
} from "@babel/types";
import prettier from "prettier";
import fs from "fs";
import path from "path";
import generate from "@babel/generator";
import { readFile } from "node:fs/promises";

const INTL_NAME = "intl";
const DEFINE_MESSAGES = "defineMessages";
const INTL_MESSAGES = "intlMessages";
const INTL_FILE_PATH = "@/locales";
const INTL_DISABLE = "i18n-disable";

const OUTPUT_ZH_FILE = path.resolve(process.cwd(), "src/locales/zh.json");
const OUTPUT_EN_FILE = path.resolve(process.cwd(), "src/locales/en.json");

const messagesZh: Record<string, string> = {}; // 存储所有中文消息
const messagesEn: Record<string, string> = {}; // 存储所有英文消息,值为空字符串

function createFormatMessageCall(text: string, expressionParams?: string[]) {
  return template.expression(
    `${INTL_NAME}.formatMessage(${INTL_MESSAGES}["${text.trim()}"]${
      expressionParams
        ? `, {
      ${expressionParams.map((key, index) => `'placeholder${index + 1}': ${key}`).join(",")}
    }`
        : ""
    })`,
    {
      plugins: ["typescript"],
    }
  )();
}

// 判断字符串是否包含中文字符
function isChinese(str) {
  return str && /[\u4e00-\u9fa5]/.test(str); // 匹配中文字符的正则表达式
}

// 标记该文件需要跳过处理
function fileSkip(state: PluginPass, excludeFiles: string[]) {
  const filename = state.filename || "";
  state.skip = excludeFiles.some((file) => filename.includes(file));
  return state.skip;
}

// 标记该文本需要跳过遍历处理
function traverseSkip(path: NodePath) {
  if (path.node.leadingComments) {
    path.node.leadingComments = path.node.leadingComments.filter(
      (comment, index) => {
        if (comment.value.includes(INTL_DISABLE)) {
          path.node.skip = true;
          return false;
        }
        return true;
      }
    );
  }
  if (
    path.findParent((p) => p.isImportDeclaration() || p.isTSTypeAnnotation())
  ) {
    path.node.skip = true;
  }
}

function chineseSkip(path: NodePath, value: string) {
  if (!isChinese(value)) {
    path.node.skip = true;
  }
}

function babelAutoIntlPlugin({
  excludeFiles = [],
}: {
  excludeFiles: string[];
}): PluginObj {
  return {
    visitor: {
      Program(path, state) {
        // 文件跳过处理
        const shouldSkip = fileSkip(state, excludeFiles);
        if (shouldSkip) return;

        let index = 0; // import语句的行数
        let methodName1 = DEFINE_MESSAGES;
        let methodName2 = INTL_NAME;
        function createImportIntl() {
          while (path.node.body[index].type === "ImportDeclaration") {
            index++;
          }

          if (path.scope.getBinding(methodName1)) {
            methodName1 = path.scope.generateUid(methodName1);
          }
          if (path.scope.getBinding(methodName2)) {
            methodName2 = path.scope.generateUid(methodName2);
          }

          const ast = template.statements(`
            import { ${methodName1} } from 'react-intl';
            import ${methodName2} from '${INTL_FILE_PATH}';
          `)();
          path.node.body.splice(index, 0, ...ast);
        }
        // 添加import
        createImportIntl();

        // 获取所有中文消息
        function getAllCnMessages() {
          const messageKeys: string[] = [];
          path.traverse({
            "JSXText|StringLiteral"(path) {
              traverseSkip(path);
              const node = path.node as StringLiteral | JSXText;
              chineseSkip(path, node.value);
              if (node.skip) return;
              // console.log("JSXText|StringLiteral", node.value);

              const trimmedValue = node.value.trim();
              if (!messageKeys.includes(trimmedValue))
                messageKeys.push(trimmedValue);
            },
            TemplateLiteral(path) {
              traverseSkip(path);
              const node = path.node as TemplateLiteral;
              if (node.skip) return;
              const value = path.node.quasis
                .map((item) => item.value.raw)
                .reduce((prev, curr, index) => {
                  if (index !== path.node.quasis.length - 1) {
                    prev = `${prev}${curr}{placeholder${index + 1}}`;
                  } else {
                    prev = `${prev}${curr}`;
                  }
                  return prev;
                }, "");

              chineseSkip(path, value);
              if (node.skip) return;

              const trimmedValue = value.trim();
              if (!messageKeys.includes(trimmedValue))
                messageKeys.push(trimmedValue);
            },
          });
          // console.log(messageKeys);

          if (messageKeys.length > 0) {
            const messagesAst =
              template.statement(`const ${INTL_MESSAGES} = ${methodName1}({
              ${messageKeys
                .map((key) => `'${key}': { id: "${key}" }`)
                .join(",")}
            })`)();
            path.node.body.splice(index + 2, 0, messagesAst);

            // 存储消息到全局的 messagesZh 和 messagesEn 对象中
            messageKeys.forEach((key) => {
              if (!messagesZh[key]) {
                messagesZh[key] = key;
                messagesEn[key] = ""; // en.json 的值为空字符串
              }
            });
          }
        }
        getAllCnMessages();
      },

      JSXText(path, state) {
        if (state.skip || path.node.skip) return;

        path.replaceWith(
          jsxExpressionContainer(createFormatMessageCall(path.node.value))
        );
        path.skip();
      },

      StringLiteral(path, state) {
        if (state.skip || path.node.skip) return;

        if (path.parent.type === "JSXAttribute") {
          path.replaceWith(
            jsxExpressionContainer(createFormatMessageCall(path.node.value))
          );
        } else {
          // 跳过对象属性中的文本
          if (
            path.findParent(
              (p) =>
                p.isVariableDeclarator() && p.node.id.name === INTL_MESSAGES
            )
          ) {
            return;
          }
          chineseSkip(path, path.node.value);
          if (path.node.skip) return;
          path.replaceWith(createFormatMessageCall(path.node.value));
        }
        path.skip();
      },

      TemplateLiteral(path, state) {
        if (state.skip || path.node.skip) return;
        const expressionParams = path.node.expressions.map(
          (item) => generate.default(item).code
        );
        // console.log(expressionParams);

        const value = path.node.quasis
          .map((item) => item.value.raw)
          .reduce((prev, curr, index) => {
            if (index !== path.node.quasis.length - 1) {
              prev = `${prev}${curr}{placeholder${index + 1}}`;
            }
            return prev;
          }, "");
        path.replaceWith(createFormatMessageCall(value, expressionParams));
        path.skip();
      },
    },

    post() {
      if (this.skip) return;
      // 将所有的 messages 写入到 zh.json);

      fs.writeFileSync(
        OUTPUT_ZH_FILE,
        JSON.stringify(messagesZh, null, 2),
        "utf-8"
      );
      fs.writeFileSync(
        OUTPUT_EN_FILE,
        JSON.stringify(messagesZh, null, 2),
        "utf-8"
      );
    },
  };
}

export async function transformFile(filePath: string) {
  const sourceCode = await readFile(filePath, "utf-8");

  const ast = parser.parse(sourceCode, {
    sourceType: "module",
    plugins: ["jsx", "typescript"],
  });

  const res = transformFromAstSync(ast, sourceCode, {
    plugins: [babelAutoIntlPlugin],
    retainLines: true,
  });

  const formatedCode = await prettier.format(res?.code!, {
    filepath: filePath,
  });

  return formatedCode;
}

6、index.ts 调用

import path from "node:path";
import { transformFile } from "./transform.js";

(async function () {
  const filePath = path.join(process.cwd(), "./demo/index.tsx");

  const code = await transformFile(filePath);
  console.log(code);
})();

7、运行

执行 npx tsc -w,dist文件下生成 index.js 和 transform.js 执行 node ./dist/index.js 可以看到控制台输出

import { defineMessages } from "react-intl";
import intl from "@/locales";
const intlMessages = defineMessages({
  安佛: { id: "安佛" },
  蜂蜜: { id: "蜂蜜" },
  "爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比": {
    id: "爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比",
  },
  不允许包含中文: { id: "不允许包含中文" },
  必须包含数字: { id: "必须包含数字" },
  必须包含大写字母: { id: "必须包含大写字母" },
  必须包含小写字母: { id: "必须包含小写字母" },
  必须包含: { id: "必须包含" },
  "密码长度必须大于等于{placeholder1}, 小于50": {
    id: "密码长度必须大于等于{placeholder1}, 小于50",
  },
  撒非农: { id: "撒非农" },
  测试: { id: "测试" },
  你好: { id: "你好" },
});
function App() {
  const title = intl.formatMessage(intlMessages["安佛"]);
  const desc = intl.formatMessage(intlMessages[""], {});
  const desc2 = `而非你`;
  const desc3 = intl.formatMessage(
    intlMessages["爱上非农 {placeholder1} 阿福 {placeholder2}"],
    { placeholder1: title + desc, placeholder2: desc2 },
  );
  const getPwdValidator = (pwdRule) => () => ({
    validator(rule, value) {
      if (value?.length > 0) {
        const chineseReg = /[\u4e00-\u9fa5]/;
        const numberReg = /[0-9]/;
        const upperReg = /[A-Z]/;
        const lowerReg = /[a-z]/;
        const charReg = /[`~'"!@#$%^&*()~,.?/{}<>[\]]/;
        const {
          number,
          upper,
          lower,
          special_char: char,
        } = pwdRule!.pwd_strength;
        if (chineseReg.test(value)) {
          return Promise.reject(
            intl.formatMessage(intlMessages["不允许包含中文"]),
          );
        }
        if (number === 1 && !numberReg.test(value)) {
          return Promise.reject(
            intl.formatMessage(intlMessages["必须包含数字"]),
          );
        }
        if (upper === 1 && !upperReg.test(value)) {
          return Promise.reject(
            intl.formatMessage(intlMessages["必须包含大写字母"]),
          );
        }
        if (lower === 1 && !lowerReg.test(value)) {
          return Promise.reject(
            intl.formatMessage(intlMessages["必须包含小写字母"]),
          );
        }
        if (char === 1 && !charReg.test(value)) {
          return Promise.reject(
            intl.formatMessage(intlMessages["必须包含"]) +
              "`~'\"!@#$%^&*()~,.?/{}<>[]",
          );
        }
        if (value.length < pwdRule!.pwd_shortest_length || value.length > 50) { 
          return Promise.reject(
            intl.formatMessage(
              intlMessages["密码长度必须大于等于{placeholder1}"],
              { placeholder1: pwdRule!.pwd_shortest_length },
            ),
          );
        }
      }

      return Promise.resolve();
    },
  });
  return (
    <div
      className={intl.formatMessage(intlMessages["撒非农"])}
      title={intl.formatMessage(intlMessages["测试"])}
    >
      <img src={Logo} />
      <h1>
        {intl.formatMessage(intlMessages["你好"])}
        {title}
      </h1>
      <p>{desc}</p>
      <div>{"中文"}</div>
    </div>
  );
}

并且zh.json

{
  "安佛": "安佛",
  "蜂蜜": "蜂蜜",
  "爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比": "爱上非农 {placeholder1} 阿福 {placeholder2} 暗示法比",
  "不允许包含中文": "不允许包含中文",
  "必须包含数字": "必须包含数字",
  "必须包含大写字母": "必须包含大写字母",
  "必须包含小写字母": "必须包含小写字母",
  "必须包含": "必须包含",
  "密码长度必须大于等于{placeholder1}, 小于50": "密码长度必须大于等于{placeholder1}, 小于50",
  "撒非农": "撒非农",
  "测试": "测试",
  "你好": "你好"
}

仓库地址 github.com/miniliupeng…

三、babel插件

npm i babel-plugin-lp-react-intl

注意:插件去掉了自动生成中英文json的功能,因为当我vite在项目中使用时,post钩子在babel处理每个文件后都会调用,引起无限vite hmr,使用 cli extract方法单独提取

1、核心代码:

import { NodePath, PluginObj } from "@babel/core";
import template from "@babel/template";
import {
  jsxExpressionContainer,
  JSXText,
  StringLiteral,
  TemplateLiteral,
} from "@babel/types";
import generate from "@babel/generator";

const INTL_NAME = "intl";
const DEFINE_MESSAGES = "defineMessages";
const INTL_MESSAGES = "intlMessages";
const INTL_FILE_PATH = "@/locales";
const INTL_DISABLE = "i18n-disable";

function createFormatMessageCall(text: string, expressionParams?: string[]) {
  return template.expression(
    `${INTL_NAME}.formatMessage(${INTL_MESSAGES}["${text.trim()}"]${
      expressionParams
        ? `, {
      ${expressionParams.map((key, index) => `'placeholder${index + 1}': ${key}`).join(",")}
    }`
        : ""
    })`,
    {
      plugins: ["typescript"],
    }
  )();
}

// 判断字符串是否包含中文字符
function isChinese(str) {
  return str && /[\u4e00-\u9fa5]/.test(str); // 匹配中文字符的正则表达式
}

// 标记该文本需要跳过遍历处理
function traverseSkip(path: NodePath) {
  // 跳过带有 i18n-disable 注释的
  if (path.node.leadingComments) {
    path.node.leadingComments = path.node.leadingComments.filter(
      (comment, index) => {
        if (comment.value.includes(INTL_DISABLE)) {
          path.node.skip = true;
          return false;
        }
        return true;
      }
    );
  }
  // 跳过 import语法 和 ts声明
  if (path.findParent((p) => p.isImportDeclaration() || p.isTSLiteralType())) {
    path.node.skip = true;
  }
}

function chineseSkip(path: NodePath, value: string) {
  if (!isChinese(value)) {
    path.node.skip = true;
  }
}

export default function babelPluginReactIntl({ messageKeys = [] }: { messageKeys?: string[] } = {}): PluginObj {
  return {
    visitor: {
      Program(path, state) {
        let index = 0; // import语句的行数
        while (path.node.body[index].type === "ImportDeclaration") {
          index++;
        }
        let methodName1 = DEFINE_MESSAGES;
        let methodName2 = INTL_NAME;
        if (path.scope.getBinding(methodName1)) {
          methodName1 = path.scope.generateUid(methodName1);
        }
        if (path.scope.getBinding(methodName2)) {
          methodName2 = path.scope.generateUid(methodName2);
        }

        // 获取所有中文消息
        const fileMessagekeys: string[] = []
        path.traverse({
          "JSXText|StringLiteral"(path) {
            traverseSkip(path);
            const node = path.node as StringLiteral | JSXText;
            chineseSkip(path, node.value);
            if (node.skip) return;
            // console.log("JSXText|StringLiteral", node.value);

            const trimmedValue = node.value.trim();
            if (!fileMessagekeys.includes(trimmedValue))
              fileMessagekeys.push(trimmedValue);
          },
          TemplateLiteral(path) {
            traverseSkip(path);
            const node = path.node as TemplateLiteral;
            if (node.skip) return;
            const value = path.node.quasis
              .map((item) => item.value.raw)
              .reduce((prev, curr, index) => {
                if (index !== path.node.quasis.length - 1) {
                  prev = `${prev}${curr}{placeholder${index + 1}}`;
                }
                return prev;
              }, "");

            chineseSkip(path, value);
            if (node.skip) return;
            // console.log('TemplateLiteral', value);

            const trimmedValue = value.trim();
            if (!fileMessagekeys.includes(trimmedValue))
              fileMessagekeys.push(trimmedValue);
          },
        });
        // console.log(messageKeys);

        if (fileMessagekeys.length > 0) {
          // 添加import
          const ast = template.statements(`
          import { ${methodName1} } from 'react-intl';
          import ${methodName2} from '${INTL_FILE_PATH}';
        `)();
          path.node.body.splice(index, 0, ...ast);
          // 添加 defineMessages
          const messagesAst =
            template.statement(`const ${INTL_MESSAGES} = ${methodName1}({
              ${fileMessagekeys.map((key) => `'${key}': { id: "${key}" }`).join(",")}
            })`)();
          path.node.body.splice(index + 2, 0, messagesAst);
          // 合并到全局的 messageKeys 中
          messageKeys.push(...fileMessagekeys);
        }
      },

      JSXText(path, state) {
        if (state.skip || path.node.skip) return;

        path.replaceWith(
          jsxExpressionContainer(createFormatMessageCall(path.node.value))
        );
        path.skip();
      },

      StringLiteral(path, state) {
        if (state.skip || path.node.skip) return;

        if (path.parent.type === "JSXAttribute") {
          path.replaceWith(
            jsxExpressionContainer(createFormatMessageCall(path.node.value))
          );
        } else {
          // 跳过对象属性中的文本
          if (
            path.findParent(
              (p) =>
                p.isVariableDeclarator() && p.node.id.name === INTL_MESSAGES
            )
          ) {
            return;
          }
          chineseSkip(path, path.node.value);
          if (path.node.skip) return;
          path.replaceWith(createFormatMessageCall(path.node.value));
        }
        path.skip();
      },

      TemplateLiteral(path, state) {
        if (state.skip || path.node.skip) return;
        const expressionParams = path.node.expressions.map(
          (item) => generate.default(item).code
        );
        // console.log(expressionParams);

        const value = path.node.quasis
          .map((item) => item.value.raw)
          .reduce((prev, curr, index) => {
            if (index !== path.node.quasis.length - 1) {
              prev = `${prev}${curr}{placeholder${index + 1}}`;
            }
            return prev;
          }, "");
        path.replaceWith(createFormatMessageCall(value, expressionParams));
        path.skip();
      },
    },
  };
}

2、插件地址

www.npmjs.com/package/bab…

四、vite插件

1、核心代码:

import { createFilter, PluginOption } from "vite";
import { transformAsync } from "@babel/core";
import babelPluginReactIntl from "babel-plugin-lp-react-intl"; // 引入自动国际化插件

export default function myVitePlugin(): PluginOption {
  // 只处理 .js, .jsx, .ts, .tsx 文件,排除 excludeFiles 中的文件
  const filter = createFilter(["src/**/*.{js,jsx,ts,tsx}"]); // 只匹配 src 目录下的 .js, .jsx, .ts, .tsx 文件
  return {
    name: "vite-plugin-babel-react-intl",
    enforce: "pre", // 在 vite 的默认插件之前执行,如babel,esbuild,swc 转换之前调用
    async transform(code, id) {
      if (!filter(id)) return null; // 跳过不匹配的文件
      // 使用 Babel 进行代码转换,注入我们的插件
      const result = await transformAsync(code, {
        filename: id,
        plugins: [babelPluginReactIntl()],
        presets: ["@babel/preset-typescript"],
        sourceMaps: true,
      });

      return {
        code: result?.code || code,
        map: result?.map || null,
      };
    },
  };
}

2、结果图

7e797d3a-94d9-42b7-94d2-237a03ea1078.jpeg

92d20ccb-3ccf-4cf2-aae8-e13f1d567b06.jpeg

3、插件地址

www.npmjs.com/package/vit…

五、cli

www.npmjs.com/package/lp-…