VUE 国际化自动提取和转换

4,095 阅读6分钟

项目地址

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按照语言的特殊标记符号,例如定义的关键词等,对源代码的字符串的每个字符进行分析并组合,最后形成一个个单词tokentoken就是 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 节点,包含FileProgram层级。
  • 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 节点内部还具有两种访问方向 —— enterexit,即进入和离开。

 const MyVisitor: Visitor = {
   Identifier: {
     enter() {
       console.log('Entered!');
     },
     exit() {
       console.log('Exited!');
     },
   },
 };

1638285683847.gif

@babel/generator

@babel/generator就负责将 AST 转换生成代码,同时支持定义生成的部分代码的风格,例如分号结尾、双引号和单引号的使用等。

template-AST

对于 VUE SFC 内部模板语法解析得到的 AST 和 JS 的 AST 区别很大,主要是没有像babel/traverse等处理工具,有的只是@vue/compiler-sfc或者@vue/compiler-dom这样的解析工具。 在 VUE SFC 内部主要存在以下几张类型的 AST 节点:

image-20211130224316277.png

template

template也就是解析<template>内部得到的 AST,其内部的 AST 节点主要分为两种类型propschildrenchildren也就是各种 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 代码。其余的还有customBlockscssVars这些都包含的一样的属性,也就是contentattrsattrs就是标签内部的属性,content就是标签内部的所有格式化代码。

方案细节

整体流程

tvt.png

中文 unicode 码点范围

利用 unicode 码点值来检测代码中是否包含中文字符:

  • 4E00~9FA5是基本汉字
  • 9FA6~9FFF是补充汉字
  • 其他乱七八糟奇形怪状的汉字暂不考虑
 /[\u{4E00}-\u{9FEF}]/gu;

编译 SFC

在 VUE SFC 里只需要处理templatescript部分的 AST 来提取中文字符。可以使用@vue/compiler-sfc编译 VUE SFC 文件,可以同时提取出templatescript部分的代码。

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 😁😁😁。