likes
comments
collection
share

React国际化实践指南

作者站长头像
站长
· 阅读数 3

示例项目是由vite创建的react + ts模板

输入以下命令后输入项目名->React->Typescript即可创建

npm create vite@latest

安装所需工具包

npm i i18next react-i18next --save

src目录下新建locales目录

新建语言文件,目录结构如下

src
├── locales
|   ├── en-US
    │   └── resources.json
|   └── zh-CN
        └── resources.json

zh-CN/translation.json

{
  "title": "标题",
  "common": { 
     "confirm": "确认", 
     "cancel": "取消" 
  }
}

en-US/translation.json

{
  "title": "Title",
  "common": {
    "confirm": "Confirm",
    "cancel": "Cancel"
  }
}

src新建i18n.ts入口文件

import i18n from 'i18next'
import { initReactI18next } from "react-i18next";
import zhCNTrans from "@/locales/zh-CN/resources.json";
import enUSTrans from "@/locales/en-US/resources.json";

i18n.use(initReactI18next).init({
  resources: {
    // 后面切换需要使用此处定义的key
    "zh-CN": {
      translation: zhCNTrans,
    },
    "en-US": {
      translation: enUSTrans,
    },
  },
  lng: "zh-CN",
  fallbackLng: "zh-CN",
  debug: process.env.NODE_ENV === "development",
  interpolation: {
    escapeValue: false, // not needed for react as it escapes by default
  },
});

export default i18n;

在项目文件中使用

App.tsx

import { useTranslation } from "react-i18next";
import i18n from "@/i18n";

function App() {
  const { t } = useTranslation();

  return (
    <div>
      <div>{t("title")}</div>
      <p>{t("common.confirm")}</p>
      <p>{t("common.cancel")}</p>

      <div>
        <button onClick={() => i18n.changeLanguage("zh-CN")}>切换中文</button>

        <button onClick={() => i18n.changeLanguage("en-US")}>切换英文</button>
      </div>
    </div>
  );
}

export default App;

效果

React国际化实践指南

🤔思考

  1. 在t方法中的文字,在查找问题和理解上是不是会存在一定难度?
  2. 每次都需要在json文件中先写好中文,英文翻译,是不是可以有更好的方式省略这一步?
  3. 在文件中需要翻译的文字都要t方法包裹一下,忘记了是不是很头疼?

推论一波

首先能否在t方法中直接写中文呢?如果写中文,那是不是json中的key就得变成中文呢?如果json中的key写成中文,按照代码规范来说是大忌!而且会很low(拿不出手啊)~,好,既然这样,那就重新包装一下react-i18next中的t方法,先写个hooks,如下:

import { useTranslation } from "react-i18next";
import md5 from "md5";

export const useLang = () => {
  const { t } = useTranslation();
  return (text: string, variable?: { [key: string]: unknown }) => {
    const words = md5(text);
    return t(words, variable);
  };
};

这样一来,是不是就能直接将对应文字转为md5值了,转换之后json中的所有key也要变成文字对应的md5,刚好i18next-scanner可以自动提取文字,并支持自定义transform,那就可以直接key转化为md5生成json了!

开始实践

  • 安装依赖
npm i md5 --save
npm i i18next-scanner --save-dev
  • i18n.ts新增useLang(react hook)
import i18n from 'i18next'
import { initReactI18next } from "react-i18next";
import zhCNTrans from "@/locales/zh-CN/translation.json";
import enUSTrans from "@/locales/en-US/translation.json";

i18n.use(initReactI18next).init({
  resources: {
    // 后面切换需要使用此处定义的key
    "zh-CN": {
      translation: zhCNTrans,
    },
    "en-US": {
      translation: enUSTrans,
    },
  },
  lng: "zh-CN",
  fallbackLng: "zh-CN",
  debug: process.env.NODE_ENV === "development",
  interpolation: {
    escapeValue: false, // not needed for react as it escapes by default
  },
});


export const useLang = () => {
  const { t } = useTranslation();
  return (text: string, variable?: { [key: string]: unknown }) => {
    const words = md5(text);
    return t(words, variable);
  };
};

export default i18n;
  • 修改App.tsx
import i18n, { useLang } from "@/i18n";

function App() {
  const lang = useLang();

  return (
    <div>
      <div>{lang("标题")}</div>
      <p>{lang("确认")}</p>
      <p>{lang("取消")}</p>

      <div>
        <button onClick={() => i18n.changeLanguage("zh-CN")}>切换中文</button>

        <button onClick={() => i18n.changeLanguage("en-US")}>切换英文</button>
      </div>
    </div>
  );
}

export default App;

  • 根目录新建i18next-scanner.config.cjs(如eslint报错,请在.eslintrc.cjs中添加忽略文件)
const fs = require("fs");
const path = require("path");
const md5 = require("md5");

let dirs = [];
const files = fs.readdirSync("./src/locales");
files.forEach(function (item, index) {
  let stat = fs.lstatSync("./src/locales/" + item);
  if (stat.isDirectory() === true) {
    dirs.push(item);
  }
});

module.exports = {
  input: [
    "src/**/*.{ts,tsx}",
    "!src/locales/**",
    "!src/styles/**",
    "!**/node_modules/**",
  ],
  output: "./", // 输出目录
  options: {
    debug: false,
    sort: true,
    func: false,
    trans: false,

    defaultLng: "zh",
    defaultNs: "resources",
    lngs: dirs,
    ns: ["resources"],
    resource: {
      loadPath: "src/locales/{{lng}}/{{ns}}.json",
      savePath: "src/locales/{{lng}}/{{ns}}.json",
      jsonIndent: 2,
    },
    removeUnusedKeys: true, // 移除未使用的 key
    nsSeparator: false, // namespace separator
    keySeparator: false, // key separator
    // pluralFallback: false,
    interpolation: {
      prefix: "{{",
      suffix: "}}",
    },
    trans: false,
    metadata: {},
    allowDynamicKeys: false,
  },
  transform: function customTransform(file, enc, done) {
    ("use strict");

    const parser = this.parser;
    const content = fs.readFileSync(file.path, enc);

    //指定扫描的标识
    parser.parseFuncFromString(content, { list: ["lang", "t"] }, (key) => {
      let hashKey = md5(key); // 此处将key转化md5
      parser.set(hashKey, {
        defaultValue: key
      });
    });

    done();
  },
};
  • package.jsonscripts下添加scan指令
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "scan": "i18next-scanner --config i18next-scanner.config.cjs"
  },
  • 运行scan指令

在每次提交代码前可运行一次,以便提取翻译

npm run scan

结果如下:

zh-CN/translation.json

{
  "32c65d8d7431e76029678ec7bb73a5ab": "标题",
  "625fb26b4b3340f7872b411f401e754c": "取消",
  "e83a256e4f5bb4ff8b3d804b5473217a": "确认"
}

en-US/translation.json

{
  "32c65d8d7431e76029678ec7bb73a5ab": "标题",
  "625fb26b4b3340f7872b411f401e754c": "取消",
  "e83a256e4f5bb4ff8b3d804b5473217a": "确认"
}

en-US/translation.json为何也变中文了?这个是第一次提取会重写的问题,后面修改过再执行提取如果翻译之后的字段是不会被覆盖的

可以看到locales中的json被重写,lang方法包裹的文字被自动提取出来了,这岂不是太妙了,只要lang方法包裹的文字没有发生变化,key就不会发生变化~

  • 至此可以说已经解决了大部分我的疑问(如何自动将中文包裹起来呢?现有的库貌似都不太能支持我的想法,要不就自己写一个?)

说干就干

使用AST语法树修改代码,检索中文自动用lang方法包裹起来

  • 安装AST修改代码所需的库
npm i @babel/parser @babel/types @babel/traverse @babel/generator --save-dev
  • 核心代码
export default (ast, args) => {
  const wrapped = args["wd"];
  const importLibrary = args["impLib"];
  const importFunctions =
    typeof args["impFuncs"] == "string" ? [args["impFuncs"]] : args["impFuncs"];

  traverse(ast, {
    JSXText(path) {
      const value = path.node.value
        ?.replace(/^[\n ]+/, "")
        ?.replace(/[\n ]+$/, "");
      if (hasChineseCharacters(value)) {
        path.replaceWith(
          t.jsxExpressionContainer(
            t.callExpression(t.identifier(wrapped), [t.stringLiteral(value)])
          )
        );
      }
    },
    JSXAttribute(path) {
      if (t.isJSXAttribute(path.node)) {
        if (
          t.isJSXIdentifier(path.node.name) &&
          t.isStringLiteral(path.node.value)
        ) {
          if (hasChineseCharacters(path.node.value.value)) {
            path.replaceWith(
              t.jsxAttribute(
                t.jsxIdentifier(path.node.name.name),
                t.jsxExpressionContainer(
                  t.callExpression(t.identifier(wrapped), [path.node.value])
                )
              )
            );
          }
        }
      }
    },
  });

  traverse(ast, {
    TSEnumMember(path) {
      path.skip();
    },
    StringLiteral(path) {
      if (path.isImportDeclaration()) return;

      if (hasChineseCharacters(path.node.value)) {
        if (
          t.isCallExpression(path.parent) &&
          t.isIdentifier(path.parent.callee)
        ) {
          if (path.parent.callee.name !== wrapped) {
            path.replaceWith(
              t.callExpression(t.identifier(wrapped), [path.node])
            );
          }
        } else {
          path.replaceWith(
            t.callExpression(t.identifier(wrapped), [path.node])
          );
        }
      }
    },
    // TemplateLiteral
    TemplateLiteral(path) {
      const originalString = path.toString();
      if (!hasChineseCharacters(originalString)) return path.skip();
      if (
        t.isCallExpression(path.parent) &&
        t.isIdentifier(path.parent.callee)
      ) {
        if (path.parent.callee.name === wrapped) return path.skip();
      }

      const { expressions, quasis } = path.node;
      const stringsList = [];
      for (let i = 0; i < quasis.length; i++) {
        const quasi = quasis[i];
        stringsList.push(quasi.value.raw);
        if (i < expressions.length) {
          stringsList.push(`{{${expressions[i]?.loc?.identifierName ?? i}}}`);
        }
      }
      const codes = t.callExpression(
        t.identifier(wrapped),
        [
          t.stringLiteral(stringsList.join("")),
          expressions.length > 0
            ? removeDuplicateKeysFromObjectExpression(
                t.objectExpression(
                  expressions.map((item, index) =>
                    t.objectProperty(
                      t.identifier(`${item.loc.identifierName ?? index}`),
                      item as any,
                      false,
                      true
                    )
                  )
                )
              )
            : null,
        ].filter(Boolean)
      );
      path.replaceWith(codes);
    },
  });

  traverse(ast, {
    Program(path) {
      let importedNode;
      let hasLangVariable = false;
      let importFunc = [];
      const { node } = path;
      const { body } = node;
      const importedIdentifiers = new Map();

      path.traverse({
        VariableDeclarator(path) {
          if (t.isIdentifier(path.node.id)) {
            if (path.node.id.name === wrapped) {
              hasLangVariable = true;
            }
          }
        },
        ImportDeclaration(path) {
          const { node } = path;
          const source = node.source.value;

          if (importedIdentifiers.has(source)) {
            path.remove();
            const specifiers = importedIdentifiers.get(source);

            importedIdentifiers.set(source, [
              ...new Set(specifiers.concat(node.specifiers)),
            ]);

            const findImportSource = ast.program.body.find(
              (node) =>
                t.isImportDeclaration(node) && node.source.value === source
            );

            if (t.isImportDeclaration(findImportSource)) {
              findImportSource.specifiers = importedIdentifiers.get(source);
            }
          } else {
            importedIdentifiers.set(source, node.specifiers);
          }

          if (source === importLibrary) {
            importedNode = node;
          }
        },

        CallExpression(path) {
          const { node } = path;

          const flag = path.findParent((p) => {
            if (t.isVariableDeclarator(p.node)) {
              if (t.isIdentifier(p.node.id)) {
                if (importFunctions.includes(p.node.id.name)) {
                  return true;
                }
              }
            }
          });

          if (t.isIdentifier(node.callee)) {
            if (importFunctions.includes(node.callee.name) && !flag) {
              importFunc = [...new Set(importFunc.concat(node.callee.name))];
            }
          }
        },

        Identifier(path) {
          const { node } = path;
          const flag = path.findParent((p) => {
            if (t.isVariableDeclarator(p.node)) {
              if (t.isIdentifier(p.node.id)) {
                if (importFunctions.includes(p.node.id.name)) {
                  return true;
                }
              }
            }
          });

          if (importFunctions.includes(node.name) && !flag) {
            importFunc = [...new Set(importFunc.concat(node.name))];
          }
        },
      });

      if (importedNode) {
        importFunc.forEach((func) => {
          const has = importedNode.specifiers.find((specifier) => {
            if (t.isImportSpecifier(specifier)) {
              if (t.isIdentifier(specifier.imported)) {
                return specifier.imported.name === func;
              }
            }
          });
          if (!has) {
            importedNode.specifiers.push(t.identifier(func));
          }
        });
      } else {
        importFunc.length > 0 &&
          !hasLangVariable &&
          body.unshift(
            t.importDeclaration(
              importFunc.map((func) =>
                t.importSpecifier(t.identifier(func), t.identifier(func))
              ),
              t.stringLiteral(importLibrary)
            )
          );
      }
    },
  });

  return ast;
};

自动引入项目中的国际化方法

import { t } from "@/i18n"; //@i18n 是一个自定义的路径

jsx代码转换

<!-- 转换前 -->
<SelfComp title="测试标题">测试文本</SelfComp>
<!-- 转换后 -->
<SelfComp title={t('测试标题')}>{t('测试文本')}</SelfComp>

字符串转换

// 转换前
const user = "张三";
// 转换后
const user = t("张三");

模板字符串转换

// 转换前
const user = "张三";
const age = 18;
const words = `现在时间${Date.now()}, ${user}已经${age}岁了`;
// 转换后
const user = t("张三");
const age = 18;
const words = t("现在时间{{0}}, {{user}}已经{{age}}岁了", {
  0: Date.now(),
  user,
  age,
});

至此完毕

欢迎指正