Skip to content
On this page

对.vue文件实现自动化函数插桩

vue 文件编码实现自动化函数插桩 实现自动国际化方案。

第一步初始化项目

下面是一个标准的babel转成ats再到生成code代码 就不过多解释了,代码如下:

js
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';
import template from '@babel/template';
import * as types from '@babel/types';

const code = `
<template>
 <div>
   <span>{{ a ? '测试' : 'qq'}}</span>
   <el-button>按钮</el-button>

   <test-component
     v-aaa="我是title"
     :size="CARD_SIZE_MAP.BIG"
     title="默认值"
   />
 </div>
</template>

<script>

import TestComponent, { CARD_SIZE_MAP } from '../components/card/index.vue';


const TYPE_MAP = {
  a: '哈哈哈',
};
export default {
  name: 'APP', 
  
  components: {
    TestComponent,
  },

  props: {
    BBBB: {
      type: [String, Number],
      require: true,
      default: '哈哈',
    },

    permission: {
      type: Boolean,
      require: true,
      default: true,
    },
  },

  data() {
    return {
      myChart: null,
      title: '我是哈哈哈',
    };
  },

  methods: {
    initResize() {
      const a = '哈哈哈';
      this.myChart?.resize();
    },
  },
};
</script>

<style lang="less" scoped>

</style>
`;
const ast = parser.parse(code, {
  sourceType: 'unambiguous',
});

console.log(generate(ast).code);

目标呢就是把上面的code转成下面这个样

vue
<template>
 <div>
   <span>{{ a ? $tx('测试') : 'qq'; }}</span>
   <el-button>{{ $tx('按钮') }}</el-button>

   <test-component
     v-aaa="$tx('我是title')"
     :size="CARD_SIZE_MAP.BIG"
     :title="$tx('默认值')"
   />
 </div>
</template>

<script>
import TestComponent, { CARD_SIZE_MAP } from '../components/card/index.vue';
const TYPE_MAP = {
  a: window.$tx('哈哈哈')
};
export default {
  name: 'APP',
  components: {
    TestComponent
  },
  props: {
    BBBB: {
      type: [String, Number],
      require: true,
      default: this.$tx('哈哈')
    },
    permission: {
      type: Boolean,
      require: true,
      default: true
    }
  },
  data() {
    return {
      myChart: null,
      title: this.$tx('我是哈哈哈')
    };
  },
  methods: {
    initResize() {
      const a = this.$tx('哈哈哈');
      this.myChart?.resize();
    }
  }
};
</script>

<style lang="less" scoped>

</style>

第二步 使用@vue/compiler-dom解析.vuecode

使用@vue/compiler-domparse 解析vue源码

js
import { parse } from '@vue/compiler-dom';

const vueParseAst = parse(code);
console.log(vueParseAst,'vueParseAst...');

会得到如下的一个对象:

bash
{
  type: 0,
  children: [
    {
      type: 1,
      ns: 0,
      tag: 'template',
      tagType: 0,
      props: [],
      isSelfClosing: false,
      children: [Array],
      loc: [Object],
      codegenNode: undefined
    },
    {
      type: 1,
      ns: 0,
      tag: 'script',
      tagType: 0,
      props: [],
      isSelfClosing: false,
      children: [Array],
      loc: [Object],
      codegenNode: undefined
    },
    {
      type: 1,
      ns: 0,
      tag: 'style',
      tagType: 0,
      props: [Array],
      isSelfClosing: false,
      children: [Array],
      loc: [Object],
      codegenNode: undefined
    }
  ],
  helpers: Set(0) {},
  components: [],
  directives: [],
  hoists: [],
  imports: [],
  cached: 0,
  temps: 0,
  codegenNode: undefined,
  loc: {
    start: { column: 1, line: 1, offset: 0 },
    end: { column: 1, line: 63, offset: 826 },
    source: '\n' +
      '<template>\n' +
      ' <div>\n' +
      "   <span>{{ a ? '测试' : 'qq'}}</span>\n" +
      '   <el-button>按钮</el-button>\n' +
      '\n' +
      '   <test-component\n' +
      '     v-aaa="我是title"\n' +
      '     :size="CARD_SIZE_MAP.BIG"\n' +
      '     title="默认值"\n' +
      '   />\n' +
      ' </div>\n' +
      '</template>\n' +
      '\n' +
      '<script>\n' +
      '\n' +
      "import TestComponent, { CARD_SIZE_MAP } from '../components/card/index.vue';\n" +
      '\n' +
      '\n' +
      'const TYPE_MAP = {\n' +
      "  a: '哈哈哈',\n" +
      '};\n' +
      'export default {\n' +
      "  name: 'APP', \n" +
      '  \n' +
      '  components: {\n' +
      '    TestComponent,\n' +
      '  },\n' +
      '\n' +
      '  props: {\n' +
      '    BBBB: {\n' +
      '      type: [String, Number],\n' +
      '      require: true,\n' +
      "      default: '哈哈',\n" +
      '    },\n' +
      '\n' +
      '    permission: {\n' +
      '      type: Boolean,\n' +
      '      require: true,\n' +
      '      default: true,\n' +
      '    },\n' +
      '  },\n' +
      '\n' +
      '  data() {\n' +
      '    return {\n' +
      '      myChart: null,\n' +
      "      title: '我是哈哈哈',\n" +
      '    };\n' +
      '  },\n' +
      '\n' +
      '  methods: {\n' +
      '    initResize() {\n' +
      "      const a = '哈哈哈';\n" +
      '      this.myChart?.resize();\n' +
      '    },\n' +
      '  },\n' +
      '};\n' +
      '</script>\n' +
      '\n' +
      '<style lang="less" scoped>\n' +
      '\n' +
      '</style>\n'
  }
}

第三步 解析 vue template

经过@vue/compiler-domparse解析后我们可以得到一个templateVue AST的一段编程,children里面的tag分别对应 templatestylescript三部分。

开始解析template如下:

js
/**
 * 这是里是解析出来NodeTypes的类型对应
 * import { NodeTypes } from '@vue/compiler-dom';
 * export declare const enum NodeTypes {
    ROOT = 0,
    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,
   
 */

export const NODE_TYPE_MAP = {
  ELEMENT: 1, // NodeTypes.ELEMENT
  TEXT: 2, // NodeTypes.TEXT
  SIMPLE_EXPRESSION: 4, // NodeTypes.SIMPLE_EXPRESSION
  ATTRIBUTE: 6, // NodeTypes.ATTRIBUTE
  INTERPOLATION: 5, // NodeTypes.INTERPOLATION
  DIRECTIVE: 7, // NodeTypes.DIRECTIVE
};


for (const child of parse(code).children) {
  if (child.tag === 'template') {
    handleVueTemplate(child); // 先重点关注这里
  }
}

const handleVueTemplate = (child) => {
  if (node.type === NODE_TYPE_MAP.ELEMENT) { // NodeTypes.ELEMENT 代表的是每一层元素
    handleNodeProps(node); // props 上有该元素身上绑定的值 例如 `title="默认值"`
    for (const child of node.children) {
      handleVueTemplate(child); // dom是一层一层的所以需要递归
    }
  }

  if (node.type === NODE_TYPE_MAP.INTERPOLATION) { // NodeTypes.INTERPOLATION 动态插入的 例如 `{{a ? '测试' : 'qq'}}`
    handleNodeInterpolation(node);
  }

  if (node.type === NODE_TYPE_MAP.TEXT) { // NodeTypes.TEXT 文本 就是标签里面的文本
    handleNodeText(node);
  }
}

下面咱们针对每个方法对不同的vue语法进行处理。

解析文本 handleNodeText

先从handleNodeText开始也最好理解,就是把我是文本变成了 $tx('我是文本'), 代码如下:

ts
/**
 * 数据结构可以是这个样的:
 * 
 * loc 是索引
 * content 是替换的新代码就是携带 $tx 包裹的。
 * 
 * Array<{
 *  loc: { start: number, end: number},
 *  content: string,
 * }>
 * 
 */

interface ReplaceCodeProps {
  loc: {
    start: number;
    end: number;
  },
  content: string;
}

const replaceCodes: Array<ReplaceCodeProps> = []; // 存放需要替换的代码的信息

工具函数:

js
/*
  HTML 中的前后无效空字符串
*/
function invalidSide(val = '') {
  const matchLeft = /^([\n\r\t\s]*)/.exec(val);
  const matchRight = /([\n\r\t\s]*)$/.exec(val);
  return [matchLeft[0].length, matchRight[0].length];
}

function isCN(value) {
  return /[\u4E00-\u9FFF]+/g.test(value);
}
js
const replaceCodes = [];


const handleNodeText = (node) => {
    /**
     * console.log(node);
     * node 的数据日志如下 
     * 可以看出 loc 里面存放着咱们需要的信息 start.offset 是开始的下标  end.offset 是结尾的下标
     * {
        type: 2,
        content: '按钮',
        loc: {
            start: { column: 15, line: 5, offset: 70 },
            end: { column: 17, line: 5, offset: 72 },
            source: '按钮'
        }
        }
     */
  const { loc } = node;
  if (!isCN(loc.source)) {
    return;
  }

  const { source = '', start, end } = loc || {};
  const { length } = source;
  const [left, right] = invalidSide(source);
  const value = source.slice(left, length - right);

  replaceCodes.push({
    loc: {
      start: start.offset + left, // 减去左侧的无效字符之后的索引下标
      end: end.offset - right, // 减去右侧的无效字符之后的索引下标
    },
    content: `{{ $tx('${value}') }}`,
  });
};

可以看出我们把需要的信息都收集在了replaceCodes里面, 里面有原始字符串的下标结束下标,还有需要替换的新代码。

解析动态插入的语法程序 handleNodeInterpolation

把动态插入语法的经过handleNodeInterpolation解析输入到replaceCodes里面。

下面是使用babel解析js的一套代码,再上一篇文章中有说明,下面把它封成一个函数供咱们使用如下:

js
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';
import template from '@babel/template';
import * as types from '@babel/types';
import { isCN } from './utils';

export function scriptTranslated(code, options = {
  callee: 'window.$tx',
}) {
  const ast = parser.parse(code, {
    sourceType: 'unambiguous',
  });
  const txTemp = template(`${options.callee}(NODE_TEMPLATE)`);

  traverse(ast, {
    CallExpression(path) {
      const calleeCode = generate(path.node.callee).code;
      if (calleeCode.includes('$tx')) {
        path.skip();
      }
    },

    StringLiteral(path) {
      if (isCN(path.node.value)) {
        const tem = txTemp({
          NODE_TEMPLATE: path.node,
        });
        path.replaceWith(tem);
        path.skip();

        // const txCallExpression = types.callExpression(
        //   types.identifier('window.$tx'),  // 被调用的函数名
        //   [types.stringLiteral(path.node.value)] // 入参数  这个是需要插桩的语言文本
        // ) 
        // path.replaceWith(txCallExpression);
        // path.skip();
      }
    },
  });

  return generate(ast, {
    jsescOption: {
      minimal: true, // issues https://github.com/babel/babel/issues/4909#issuecomment-397715926
    }
  }).code;
}

handleNodeInterpolation方法如下:

js
const handleNodeInterpolation = (node) => {
    /**
     * node 输出的数据日志
     * {
        type: 5,
        content: {
            type: 4,
            isStatic: false,
            constType: 0,
            content: "a ? '测试' : 'qq'",
            loc: { start: [Object], end: [Object], source: "a ? '测试' : 'qq'" }
        },
        loc: {
            start: { column: 10, line: 4, offset: 28 },
            end: { column: 30, line: 4, offset: 48 },
            source: "{{ a ? '测试' : 'qq'}}"
        }
        }
     */
  const { loc, content: { content } } = node;
  const { start, end } = loc;
  const targetCode = scriptTranslated(content, { // 把 "a ? '测试' : 'qq'" 交给babel js去转成了 a ? $tx('测试') : 'qq';
    callee: '$tx',
  });
  replaceCodes.push({  // 记录信息
    loc: {
      start: start.offset,
      end: end.offset,
    },
    content: `{{ ${targetCode} }}`,
  });
};

咱们是用babel帮咱们处理了动态插入的 js。

解析绑定再元素上的信息 由handleNodeProps 处理

使用 handleNodeProps 去解析绑定再元素上的vue语法。例如: v-aaa="我是title" 、title="默认值"

  • title="默认值 这个样的再vue中属于NodeTypes.ATTRIBUTE属性;
  • v-aaa="我是title" 这个样的再vue中属于NodeTypes.DIRECTIVE指令;
js
const handleNodeProps = (node) => {
  if (node.type === 1) { // NodeTypes.ELEMENT
    handleNodeAttrs(node); // 属性处理
    handleNodeDirectives(node); // 指令处理
  }
};

handleNodeAttrs属性处理

处理元素上的属性,title="默认值" 转成 :title="$tx('默认值")如下:

js
const handleNodeAttrs = (node) => {
  const { props = [] } = node;

 // NodeTypes.ATTRIBUTE
  const attributes = props.filter((prop) => prop.type === NODE_TYPE_MAP.ATTRIBUTE); 
  /**
   * attribute 日志信息输出如下:
   *  {
        type: 6,
        name: 'title',
        value: { type: 2, content: '默认值', loc: [Object] },
        loc: { start: [Object], end: [Object], source: 'title="默认值"' }
      }
   */

  for (const attribute of attributes) {
    const { name, value, loc } = attribute;
    const { start, end } = loc || {};
    const { content = '' } = value || {};
    if (isCN(content)) {
      replaceCodes.push({ //  replaceCodes 记录
        loc: {
          start: start.offset,
          end: end.offset,
        },
        content: `:${name}="$tx('${content}')"`,
      });
    }
  }
};

handleNodeDirectives 处理自定义指令

handleNodeDirectives 处理自定义指令, v-aaa="我是title" 改成 v-aaa="$tx('我是title')", 如下:

js
const handleNodeDirectives = (node) => {
  const { props } = node;
  // NodeTypes.DIRECTIVE
  const directives = props.filter((prop) => prop.type === NODE_TYPE_MAP.DIRECTIVE);

/**
 * directive 日志信息输出如下:
 * {
    type: 7,
    name: 'aaa',
    exp: {
      type: 4,
      content: '我是title',
      isStatic: false,
      constType: 0,
      loc: [Object]
    },
    arg: undefined,
    modifiers: [],
    loc: { start: [Object], end: [Object], source: 'v-aaa="我是title"' }
  }
 */
  for (const directive of directives) {
    const { exp, name } = directive;
    if (!exp || name === 'for') {
      continue;
    }
    const { type: expType, loc: { source, start, end } } = exp;
    if (expType === NODE_TYPE_MAP.SIMPLE_EXPRESSION) { // v-aaa、v-title
      if (isCN(source)) {
        replaceCodes.push({ // 记录 
          loc: {
            start: start.offset,
            end: end.offset,
          },
          content: `$tx('${source}')`,
        });
      }
    }
  }
};

到这里咱们的vue template算是解析完了 都放在了replaceCodes里面。

第四步 解析 vue script

vue script进行处理,如下:

js
for (const child of parse(code).children) {
  if (child.tag === 'template') {
    handleVueTemplate(child);
  }

  if (child.tag === 'script') {
    handleVueScript(child);
  }
}

const handleVueScript = (node) => {
  if (node.children.length === 0) {
    return;
  }

  const { loc, content } = node.children[0];
  const { start, end } = loc || {};

  // 查找 export default 开始索引 
  const exportDefaultStartIndex = content.search(/export\s*default/);

  if(exportDefaultStartIndex !== -1) {
    const exportDefaultbeforeCode = content.slice(0, exportDefaultStartIndex);
    // scriptTranslated babel 对js转换 再上面 
    const targetExportDefaultBeforeCode = scriptTranslated(exportDefaultbeforeCode, {
      callee: 'window.$tx',  // 对export default之外的 用 window.$tx
    });

    const targetExportDefaultAfterCode = scriptTranslated(content.slice(exportDefaultStartIndex), {
      callee: 'this.$tx', // 对export default之内的 用 this.$tx
    });
    replaceCodes.push({ //收集
        loc: {
          start: start.offset,
          end: end.offset,
        },
        content: `\n${targetExportDefaultBeforeCode}\n${targetExportDefaultAfterCode}\n`,
    });
  }
};

需要注意一点就是要区分export default之前, 以及之内是做区分的。

第五步 替换

把收集到的replaceCodes数据对原数据进行替换,代码如下:

js
const handleReplaceCode = () => {
  const resultsCode = [];
  let cursor = 0;
  // 排序
  const sortReplaceCodes = replaceCodes.sort((pre, next) => pre.loc.start - next.loc.start);
  sortReplaceCodes.forEach(({ loc, content }) => {
    const { start, end } = loc;
    resultsCode.push(code.slice(cursor, start));
    resultsCode.push(content); // 分段截取替换
    cursor = end;
  });

  resultsCode.push(code.slice(cursor)); // 最后一段
  return resultsCode.join('');
};

const afterReplaceCode = handleReplaceCode();
console.log(afterReplaceCode, 'parse.code....');

console.log(afterReplaceCode, 'parse.code....');输出日志:

bash
<template>
 <div>
   <span>{{ a ? $tx('测试') : 'qq'; }}</span>
   <el-button>{{ $tx('按钮') }}</el-button>

   <test-component
     v-aaa="$tx('我是title')"
     :size="CARD_SIZE_MAP.BIG"
     :title="$tx('默认值')"
   />
 </div>
</template>

<script>
import TestComponent, { CARD_SIZE_MAP } from '../components/card/index.vue';
const TYPE_MAP = {
  a: window.$tx('哈哈哈')
};
export default {
  name: 'APP',
  components: {
    TestComponent
  },
  props: {
    BBBB: {
      type: [String, Number],
      require: true,
      default: this.$tx('哈哈')
    },
    permission: {
      type: Boolean,
      require: true,
      default: true
    }
  },
  data() {
    return {
      myChart: null,
      title: this.$tx('我是哈哈哈')
    };
  },
  methods: {
    initResize() {
      const a = this.$tx('哈哈哈');
      this.myChart?.resize();
    }
  }
};
</script>

<style lang="less" scoped>

</style>
 parse.code

总结

babel 使用后的感受就是还挺有意思,不错不错好极了!!!

Released under the MIT License.