基于解析AST方案实现VUE组件中文提取和转换
前言
在介绍 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>
作者:0²
链接:https://juejin.cn/post/7038191513880231973
伪原创工具 SEO网站优化 https://www.237it.com/