一、检测方案设计
目前项目中采用了i18n多语言库,代码编写中为了方便开发,提高代码可读性采用中文作为翻译key值,该方案针对代码中的中文文案进行识别检测,以避免上线后多语言未翻译问题的出现。
-
检测文件范围:只检测
src/components
和src/pages
目录下的文件。
-
检测内容:组件和 JSX 中以字面量形式出现的中文字符。
-
注释排除:排除单行和多行注释中的中文字符,避免干扰检测。
-
翻译函数标准:检查中文是否被
transStr()
、t()
或getTranslateOptions()
包裹进行翻译,未翻译的中文需要在控制台输出提示。
-
开发过程中的实时提示:
-
在开发过程中,遍历所有页面组件和配置文件,检测出现的中文文案是否通过翻译函数处理(如
transStr()
、t()
等)。 -
如果没有翻译,立即在控制台打印错误,并且弹窗提示,指出文件名和未翻译的文案。
-
打包前的最终检查:
-
在资源打包前,统一进行一次翻译检查。如果发现未被翻译的文案,打包过程会停止,直到所有问题得到解决。
二、初步方案和存在的问题
-
逻辑
-
按行读取文件,通过
\n
分隔行,检查每一行中的中文字符是否已通过transStr()
、t()
或getTranslateOptions()
进行翻译处理。 -
在读取文件时,使用正则表达式移除文件中的注释部分(包括单行和多行注释)。
-
-
问题
-
对于跨行文本和复杂注释的检测较为困难,正则表达式处理可能存在误判,因此考虑放弃此方案。
-
三、优化版方案
(一)核心思路
-
AST解析:使用 Abstract Syntax Tree(AST)解析代码,精确识别每一行代码的结构和中文字符串。通过 AST 解析可以有效识别跨行的文本内容和 JSX 中的中文字符,避免传统正则的复杂性和不准确性。
-
翻译函数检测:通过判断中文字符串是否作为
transStr()
、t()
或getTranslateOptions()
函数的参数来确定是否已经进行了翻译。 -
过滤注释:过滤掉代码中的注释(包括单行注释
//
和多行注释/* */
),避免干扰检测。 -
处理特殊逻辑:针对需要根据
lang
值条件渲染模板的地方(比如在UncheckJsx.js
中),考虑到该部分的特殊逻辑,不对这些部分进行检测。该部分的内容可以通过配置文件进行管理,不必进行翻译函数的自动化检测。
(二)具体步骤
-
使用 AST 解析代码:通过使用
babel-parser
或其他 AST 解析工具,将代码转化为抽象语法树,方便对代码中的节点进行处理。 -
判断中文字符串是否已处理:
-
查找
JSXText
类型的节点,或字符串类型的节点。 -
判断该中文字符串是否作为翻译函数的参数之一(
transStr()
、t()
或getTranslateOptions()
)。 -
如果未找到翻译函数,记录该中文字符串。
-
-
注释过滤:在解析 AST 时,跳过注释节点,确保不会对注释内容进行翻译检测。
-
特殊处理:对于条件渲染和特殊逻辑(如
UncheckJsx.js
中的特殊翻译逻辑),通过配置文件config.js
管理,脚本可以根据配置文件中的特殊处理规则跳过这些部分。 -
全局检测key值是否存在
为了实现全局检测 i18n 的 key 值是否存在,并在缺少某个 key 时抛出错误或进行提示
-
使用 i18n 的
missingKey
方法:检测在国际化文件中是否缺少某些翻译键(key),如果缺少则触发错误提示。 -
控制台输出:在开发环境中,将缺少的 key 打印到控制台,以便开发人员及时发现问题。
-
生产环境提示:在生产环境中,不将错误打印到控制台,而是通过应用内消息提示(如
Message
组件)提醒用户或开发者。
假设你使用的 i18n 库是 i18next
,以下是实现代码:
import i18n from 'i18next';
import { Message } from 'element-ui'; // 假设使用 Element UI 的 Message 组件
import { isProd } from './env'; // 假设有一个判断当前环境是否是生产环境的工具函数
// 初始化 i18n
i18n.init({
lng: 'en', // 默认语言
fallbackLng: 'en', // 回退语言
resources: {
en: {
translation: {
// 你的英文翻译
}
},
zh: {
translation: {
// 你的中文翻译
}
}
},
missingKeyHandler: function(lng, ns, key, fallbackValue) {
handleMissingKey(key); // 处理缺失的 key
}
});
// 处理缺失 key 的方法
function handleMissingKey(key) {
// 在开发环境中,将缺失的 key 输出到控制台
if (process.env.NODE_ENV !== 'production') {
console.error(`缺失的翻译 key: ${key}`);
}
// 在生产环境中,通过 Message 提示用户或开发者
if (isProd()) {
Message.error(`缺失翻译:${key}`);
}
}
// 在应用中需要使用的地方进行i18n翻译
function translate(key) {
return i18n.t(key);
}
export { translate, i18n };
(三)可优化部分
-
按模块处理缺失的 key:可以根据不同的模块或页面,灵活配置不同的
missingKeyHandler
,以便在不同的区域进行更精细的错误管理。 -
支持日志记录:除了控制台输出或消息提示,可以集成日志系统(如 Sentry、LogRocket 等)来记录生产环境中的翻译缺失错误,更好地追踪问题。
PS:当前的监测方案基本覆盖了开发需求,后续有时间可进一步调研
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const fs = require("fs");
const path = require("path");
const t = require("@babel/types");
// 检测文件夹
const dirPaths = [
path.join(__dirname, "src/pages"),
path.join(__dirname, "src/components"),
];
// const dirPath = path.join(__dirname, "src/pages/test/");
// 排除文件名
const excludeFiles = ["AppMenu.js", "UncheckJsx.js"];
function isChinese(str) {
return /[\u4e00-\u9fa5]/.test(str);
}
function isInsideTCall(path) {
let parentPath = path.parentPath;
while (parentPath) {
if (
parentPath.isCallExpression() &&
(t.isIdentifier(parentPath.node.callee, { name: "t" }) ||
t.isIdentifier(parentPath.node.callee, {
name: "transStr",
}) ||
t.isIdentifier(parentPath.node.callee, {
name: "getTranslateOptions",
}))
) {
return true;
}
parentPath = parentPath.parentPath;
}
return false;
}
let isValid = true;
// 递归读取文件夹内所有文件
async function checkFiles(dirPath) {
try {
const files = fs.readdirSync(dirPath, { withFileTypes: true });
files.forEach((file) => {
if (file.isDirectory()) {
checkFiles(path.join(dirPath, file.name));
} else if (file.isFile() && file.name.endsWith(".js")) {
const filePath = path.join(dirPath, file.name);
if (!excludeFiles.includes(file.name)) {
try {
const data = fs.readFileSync(filePath, "utf8");
const ast = parser.parse(data, {
sourceType: "module",
plugins: ["jsx"],
});
traverse(ast, {
enter(path) {
if (
(path.isStringLiteral() || path.node.type === "JSXText") &&
isChinese(path.node.value) &&
!isInsideTCall(path)
) {
isValid = false;
console.error(filePath, path.node.value);
}
},
});
} catch (err) {
throw err;
}
}
}
});
} catch (err) {
throw err;
}
}
dirPaths.forEach((dirPath) => {
checkFiles(dirPath);
!isValid && process.exit(1);
});