项目地址
wood3n/tvt: Extract and transform chinese charaters automatically. (github.com)
前言
传统的 VUE 国际化解决方案一般基于vue-i18n
这样的基础库来做key
和多国语文案之间的映射转换,一般来说开发过程中需要维护key
和文案之间的映射关系,这种方式虽然可以保证一定程度的准确率,但是开发效率低下,当项目需要维护的多国语文案越来越多时,手动配置也变得越来越麻烦;开发者还需要考虑重复key
、重复文案等导致的各种问题。
在掌握了 AST 大法以后,考虑利用解析和转换 AST 节点的方式来完成代码中的中文替换和多国语映射文件生成,提高开发效率同时降低多国语文案的维护难度。
在介绍 AST 大法之前,先一起简单了解一下 AST 相关的基础知识。
什么是 AST
在编译原理中,编译过程一般从词法分析(lexical analysis)开始,由编译器的scanner
或者tokenizer
按照语言的特殊标记符号,例如定义的关键词等,对源代码的字符串的每个字符进行分析并组合,最后形成一个个单词token
,token
就是 AST 的节点,它们之间相互关联形成 AST。
JS-AST
关于 AST 的知识这里简单描述一下,目前主流 JS 编译器例如@babel/parser
定义的 AST 节点都是根据estree/estree: The ESTree Spec (github.com)规范来的,可以在AST explorer在线演示。
babel 提供的工具库
babel
提供了一系列操作 JS AST 的工具库,诸如以下几个
@babel/parser
@babel/parser
是 babel 的核心工具之一,原来叫 Babylon,babel6 以后迁移到了 babel 的 menorepo 架构里,作为单独的一个 package 维护。
@babel/parser
提供两种解析代码的方法:
babelParser.parse(code, [options])
:解析生成的代码含有完整的 AST 节点,包含File
和Program
层级。babelParser.parseExpression(code, [options])
:解析单个 js 语句,这里需要注意的是parseExpression
生成的 AST 不完整,所以使用@babel/traverse
必须提供scope
属性,限定 AST 节点遍历的范围。
import parser from '@babel/parser';
const code = `function square(n) {
return n * n;
}`;
parser.parse(code);
@babel/types
@babel/types
用于判断节点类型,生成 AST 节点等操作,例如生成一个函数调用表达式:
import * as t from '@babel/types';
t.callExpression(t.identifier('xxx'), ...arguments);
@babel/traverse
@babel/traverse
提供遍历 JS AST 节点的方法,用它来遍历指定类型的节点非常方便,主要是其提供的Visitor
模式大大简化了递归的过程。
所谓Visitor
模式就是 babel 提供了一个visitor
对象来访问指定类型的 AST 节点,例如下面的例子,定义一个visitor
,其内部有一个StringLiteral
的方法,那么意思就是visit StringLiteral
,也就是访问所有字符串类型节点的时候都会触发调用这个定义的StringLiteral
方法。
import babelTraverse, { Visitor } from '@babel/traverse';
const MyVisitor: Visitor = {
StringLiteral() {
console.log('这是一个字符串');
},
};
babelTraverse(ast, MyVisitor);
并且由于 babel 采用的深度优先遍历的算法,所以在每个类型的 AST 节点内部还具有两种访问方向 —— enter
和exit
,即进入和离开。
const MyVisitor: Visitor = {
Identifier: {
enter() {
console.log('Entered!');
},
exit() {
console.log('Exited!');
},
},
};
@babel/generator
@babel/generator
就负责将 AST 转换生成代码,同时支持定义生成的部分代码的风格,例如分号结尾、双引号和单引号的使用等。
template-AST
对于 VUE SFC 内部模板语法解析得到的 AST 和 JS 的 AST 区别很大,主要是没有像babel/traverse
等处理工具,有的只是@vue/compiler-sfc
或者@vue/compiler-dom
这样的解析工具。
在 VUE SFC 内部主要存在以下几张类型的 AST 节点:
template
template
也就是解析<template>
内部得到的 AST,其内部的 AST 节点主要分为两种类型props
和children
。children
也就是各种 AST 节点,而props
就是元素内部传递的各种指令或者 html 元素的属性。
export enum NodeTypes {
ROOT = 0,
// 元素节点,包括template元素
ELEMENT = 1,
// 文本类型,包括代码里的一切空白字符,例如换行,空格等
TEXT = 2,
// 注释
COMMENT = 3,
// 表达式,包括模板字符串等
SIMPLE_EXPRESSION = 4,
// 插值
INTERPOLATION = 5,
// 普通属性
ATTRIBUTE = 6,
// 指令的值
DIRECTIVE = 7,
COMPOUND_EXPRESSION = 8,
IF = 9,
IF_BRANCH = 10,
FOR = 11,
TEXT_CALL = 12,
VNODE_CALL = 13,
JS_CALL_EXPRESSION = 14,
JS_OBJECT_EXPRESSION = 15,
JS_PROPERTY = 16,
JS_ARRAY_EXPRESSION = 17,
JS_FUNCTION_EXPRESSION = 18,
JS_CONDITIONAL_EXPRESSION = 19,
JS_CACHE_EXPRESSION = 20,
JS_BLOCK_STATEMENT = 21,
JS_TEMPLATE_LITERAL = 22,
JS_IF_STATEMENT = 23,
JS_ASSIGNMENT_EXPRESSION = 24,
JS_SEQUENCE_EXPRESSION = 25,
JS_RETURN_STATEMENT = 26,
}
script
script
就是解析<script>
标签内部 JS 得到的内容,只不过@vue/compiler-dom
或者@vue/compiler-sfc
没有将这部分直接解析成 AST,也就是我们还需要利用@babel/parser
去解析。
scriptSetup
scriptSetup
是 VUE3 以后组件的写法,会在<script>
标签内部添加setup
属性,这样解析得到的代码就会包含在scriptSetup
中而不是在script
中。
styles
styles
也就是多个<style>
标签内部的 CSS 代码。其余的还有customBlocks
和cssVars
这些都包含的一样的属性,也就是content
和attrs
,attrs
就是标签内部的属性,content
就是标签内部的所有格式化代码。
方案细节
整体流程
中文 unicode 码点范围
利用 unicode 码点值来检测代码中是否包含中文字符:
4E00
~9FA5
是基本汉字9FA6
~9FFF
是补充汉字- 其他乱七八糟奇形怪状的汉字暂不考虑
/[\u{4E00}-\u{9FEF}]/gu;
编译 SFC
在 VUE SFC 里只需要处理template
和script
部分的 AST 来提取中文字符。可以使用@vue/compiler-sfc
编译 VUE SFC 文件,可以同时提取出template
和script
部分的代码。
template
需要处理的文本分为两种情况:
-
属性字符串
- 指令内部的 JS 表达式,需要按照 JS 代码解析
- 普通属性内部的文本字符串,如果包含中文字符,则全部提取
-
文本子元素
- 普通的文本子元素,如果包含中文字符,则全部提取
- 双大括号的文本插值,也要按照 JS 代码解析
script
里的代码全部按照 JS 代码解析生成 AST,这样无论是template
还是script
最终复杂的部分就是针对 JS 代码生成的 AST 的解析。
遍历 JS AST
通过遍历StringLiteral
或者TemplateLiteral
两种类型的节点就能覆盖所有中文字符的情况。
const visitor: Visitor = {
StringLiteral: {
exit: (path) => {
if (hasChineseCharacter(path.node.extra?.rawValue as string)) {
const locale = (path.node.extra?.rawValue as string).trim();
const key = generateHash(locale);
this.locales[key] = locale;
// 如果是在template内部的JS表达式,使用插值语法
if (!script) {
path.replaceWith(
t.callExpression(t.identifier("$t"), [t.stringLiteral(key)])
);
} else {
path.replaceWith(
t.callExpression(
t.memberExpression(
t.identifier(this.importVar),
t.identifier("t")
),
[t.stringLiteral(key)]
)
);
}
}
},
},
TemplateLiteral: {
exit: (path) => {
// 检测模板字符串内部是否含有中文字符
if (
path.node.quasis.some((q) => hasChineseCharacter(q.value.cooked))
) {
// 生成替换字符串,注意这里不需要过滤quasis里的空字符串
const replaceStr = path.node.quasis
.map((q) => q.value.cooked)
.join("%s");
const key = generateHash(replaceStr);
this.locales[key] = replaceStr;
let importVar = this.importVar;
// 模板语法使用vue-i18n注入的对象
if (!script) {
importVar = "$i18n";
}
if (path.node.expressions?.length) {
path.replaceWith(
t.callExpression(
t.memberExpression(
t.identifier(importVar),
t.identifier("tExtend")
),
[
t.stringLiteral(key),
t.arrayExpression(path.node.expressions as t.Expression[]),
]
)
);
} else {
// 如果没有内插JS表达式,则使用vue-i18n的简单函数,只填充文案的key
if (script) {
path.replaceWith(
t.callExpression(
t.memberExpression(
t.identifier(importVar),
t.identifier("t")
),
[t.stringLiteral(key)]
)
);
} else {
path.replaceWith(
t.callExpression(t.identifier("$t"), [t.stringLiteral(key)])
);
}
}
}
},
},
};
生成 key 值
目前的解决方案是利用 Nodejs 内部的 hash 函数根据提取的中文字符生成 hash 值,来保证不重复保存相同的中文文案。(预计后续会继续支持以目录为层级的key
生成策略,或者提供自定义key
生成方法)
'use strict';
const { createHash } = require('crypto');
export function generateHash(char) {
const hash = createHash('md5');
hash.update(char);
return hash.digest('hex');
}
代码转换
一般来说,vue-i18n
的使用在<template>
内部主要通过$t
这样注入的方法,同时每个 VUE 组件中也都会包含一个$i18n
对象,那么为了能够对在模板字符串内部的中文字符进行转换。那么我们对$i18n
拓展出一个tExtend
方法,用于处理在模板字符串内部中文字符的转换情况。
import Vue from "vue";
import VueI18n from "vue-i18n";
import cn from "./cn.json";
Vue.use(VueI18n);
// 通过选项创建 VueI18n 实例
const i18n = new VueI18n({
locale: "cn", // 设置地区
fallbackLocale: "cn",
messages: {
cn,
},
});
/**
* 转换模板字符串内部%s字符的方法
*/
i18n.tExtend = (key, values) => {
let result = i18n.t(key);
if (Array.isArray(values) && values.length) {
values.forEach((v) => {
result = result.replace(/%s/, v);
});
}
return result;
};
export default i18n;
例如如下 SFC 内部的插值语法中包含一个 JS 模板字符串如下:
<template>
<div>
{{ `你的钱包余额:${money}` }}
</div>
</template>
<script>
export default {
data() {
return {
money: 10,
};
},
};
</script>
经tvt
提取的中文为:
{
"9ef86bfdc5f84d52634c2732a454e3f8": "你的钱包余额:%s"
}
自动转换的结果为:
<div>
{{ $i18n.tExtend('9ef86bfdc5f84d52634c2732a454e3f8', [money]) }}
</div>
结语
目前项目已开源在 GitHub 平台,期望各位能给出宝贵意见,也欢迎各位朋友提 PR 😁😁😁。