谈谈 JavaScript 代码的编译转换

0

当我们谈代码编译转换时我们在谈论什么?

通过工具对原代码进行一定的加工处理,最后生成目标代码。一些典型场景:ES6 -> ES5,不大典型,但实用的场景:Vue 组件转换成 React 组件。

编译转换有哪些流程

js-compiler

本文会用到的常见工具

  • @babel/types 生成 AST,并提供各类 AST 类型。
  • @babel/parser 将代码解析为 AST。
  • @babel/traverse AST 树遍历。
  • @babel/generator 代码生成器。
  • @babel/template 代码模板转 AST。
  • vue-template-compiler Vue 2 模板预编译。

我们日常使用的 @babel/core 实际上就是基于上述 @babel 开头的模块的封装。

以一个例子入手

转换前

javascript
const foo = (arg) => {
  console.log(arg);
  return 'foo' + arg;
}

预期转换后:常量定义的箭头函数。

function foo(arg) {
  console.log(arg);
  return 'foo' + arg;
}

变更点为将常量定义转换成函数定义,将箭头函数转换成普通函数。

解析

使用的工具:@babel/parser

解析的两个重要处理阶段:

1. 词法分析(Lexical Analysis)的主要目的是将源代码分割成一个个的词汇单元(如标识符、关键字、常量、运算符等)token,并记录它们的相关信息,如所在的行号、列号等。词法分析的输出通常是一个个的“词法单元”,每个词法单元都表示源代码中的一个基本元素。

2. 语法分析(Syntax Analysis)的主要目的是根据编程语言的语法规则,对词法分析产生的词法单元 token 进行组合和构建,形成语法结构(如表达式、语句、函数等),并检查源代码是否符合语法规范。语法分析的输出通常是一个“语法树”,它表示了源代码的结构和上下文关系。

来看一段简单的 JavaScript 代码片段:

var a = 10;
var b = 20;
var sum = a + b;
console.log(sum);

词法分析结果:

  • var:关键字
  • a、b、sum:标识符(变量名)
  • =:赋值运算符
  • 10、20:数字
  • +:加法运算符
  • ;:语句结束符
  • console:对象名
  • log:方法名
  • (、):括号
  • sum:变量名

语法分析结果:

Program
  - DeclarationStatement
    - Identifier: a
    - Literal: 10
  - DeclarationStatement
    - Identifier: b
    - Literal: 20
  - DeclarationStatement
    - Identifier: sum
    - BinaryExpression
      - Identifier: a
      - Operator: +
      - Identifier: b
  - ExpressionStatement
    - CallExpression
      - MemberExpression
        - Identifier: console
        - Identifier: log
      - Arguments
        - Identifier: sum

在应用范畴,我们接触到的解析主要是语法分析层面,解析阶段最重要的产物是标准的 AST,AST 解析的在线演示:https://astexplorer.net,AST 的规范:ESTree

ESTree 中的 JS 解析工具是 acorn,@babel/parser 就是基于 acorn 来实现的,标准上能对齐,所以有时解析工具的选型可考虑使用 acorn。

★ 例子解析

输入:代码

import { parse } from "@babel/parser";

const code = `
const foo = (arg) => {
  console.log(arg);
  return 'foo' + arg;
}
`;

const ast = parser.parse(code);

输出:AST

Node {
  type: 'Program',
  start: 0,
  end: 69,
  loc: SourceLocation {
    start: Position { line: 1, column: 0, index: 0 },
    end: Position { line: 6, column: 0, index: 69 },
    filename: undefined,
    identifierName: undefined
  },
  sourceType: 'module',
  interpreter: null,
  body: [
    Node {
      type: 'VariableDeclaration',
      start: 1,
      end: 68,
      loc: [SourceLocation],
      declarations: [Array],
      kind: 'const'
    }
  ],
  directives: []
}

@babel/parser 还支持哪些重要的语法?

  • JSX
  • TypeScript
  • Flow

AST 中的 node 和 path

node 代表了 AST 树中的一个具体的语法结构,而 path 则是用来操作和访问这些节点的工具。node 是 AST 树的节点,而 path 是访问和操作这些节点的接口。在 AST 的遍历和操作过程中,我们通过 path 对象来获取和修改节点的信息,利用 node 对象来表示和描述源代码的语法结构。

遍历

使用的工具:@babel/traverse

两种遍历方式

path(路径)

  • 优势:基于路径的遍历方式更加灵活和方便,可以对节点进行修改、移除、替换等操作,而不需要显式地创建并构建新的节点。通过操作路径,我们可以动态地修改 AST(抽象语法树)中的节点,而不会破坏整个 AST 的结构。
  • 适用场景:基于路径的遍历适用于需要对节点进行灵活修改的情况,例如对节点进行转换、添加额外的信息、进行条件判断等。它提供了对 AST 的精细控制,使得编译器插件能够以更细粒度的方式进行转换和处理。

基于 path 的遍历(一把梭)

const traverse = require("@babel/traverse").default;

traverse(ast, {
  enter(path) {
    if (path.isIdentifier({ name: "n" })) {
      path.node.name = "x";
    }
  },
});

node(节点)

  • 优势:基于节点的遍历方式更加直观和简单,可以直接访问和处理 AST 中的节点对象。它不需要通过路径来访问和操作节点,简化了代码编写和处理的过程。
  • 适用场景:基于节点的遍历适用于简单的遍历和处理场景,例如对节点进行查找、判断节点类型、遍历子节点等。当只需要对 AST 进行简单的遍历和访问节点,并不需要进行复杂的修改时,基于节点的遍历方式更加方便和高效。

基于 node 的遍历(限定类型)

const traverse = require("@babel/traverse").default;

traverse(ast, {
  FunctionDeclaration: function(path) {
    path.node.id.name = "x";
  },
});

例子解析

const traverse = require("@babel/traverse").default;

traverse(ast, {
  enter(path) {
    if (path.node.type === 'VariableDeclaration') {
      path.node.declarations.forEach(node => {
        if (node.id.type === 'Identifier' && node.init.type === 'ArrowFunctionExpression') {
          // ...
        }
      });
    }
  },
});

Visitor pattern | 访问者模式 traverse(ast, visitor)

From 维基百科:
访问者模式是一种将算法与对象结构分离的~软件设计模式~。 这个模式的基本想法如下:首先我们拥有一个由许多~对象~构成的对象结构,这些对象的~~都拥有一个accept~方法~用来接受访问者对象;访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施accept方法,在每一个元素的accept方法中~回调~访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的实在的访问者类来完成不同的操作。

关于遍历时机

遍历节点时有两种访问节点的时机,分为 enter(进入) 和 exit(离开),默认一般都是 enter 模式,两种模式在应用的区别如下。

  • enter: 语法转换,属性修改,上下文记录等。
  • exit: 结果处理,结果收集,最终检查等。

Identifier() { … } 是 Identifier: { enter() { … } } 的简写形式。

加工&处理

使用的工具:@babel/types

例子解析

const t = require('@babel/types');

traverse(ast, {
  enter(path) {
    if (path.node.type === 'VariableDeclaration') {
      path.node.declarations.forEach(node => {
        if (node.id.type === 'Identifier' && node.init.type === 'ArrowFunctionExpression') {
          // 函数名
          const fnName = node.id;
          // 参数
          const fnParams = node.init.params;
          // 函数体
          const fnBody = node.init.body;
          // 构造一个新函数节点
          const newFn = t.functionDeclaration(fnName, fnParams, fnBody);
          // 替换原节点
          path.replaceWith(newFn);
        }
      });
    }
  },
});

节点创建

@babel/types 可以创建各种类型的节点,也可以对现有节点做类型校验,上例就创建了一个普通函数的声明。

节点操作

path 有各类节点的操作方法。

  • 替换:replaceWith, replaceWithMultiple, replaceWithSourceString
  • 插入:insertBefore, insertAfter
  • 删除:remove

上例基于 node 遍历。

traverse(ast, {
  VariableDeclaration(path) {
    path.node.declarations.forEach(node => {
      // ...
    });
  }
});

生成代码

使用的工具:@babel/generator

例子解析 输入:AST

const generate = require('@babel/generator').default;
const newCode = generate(ast).code;

输出:代码

javascript
function foo(arg) {
  console.log(arg);
  return 'foo' + arg;
}

babel 插件的开发

有了上面的原理介绍,开发一个 babel 插件非常简单。

export default function({ types: t }) {
  return {
    visitor: {
      // visitor contents
    }
  };
};

如何基于编译转换工具将一个 Vue 组件转换成 React 组件

这里以一个 Vue 组件转换成 React 组件的需求作为案例来介绍上面提到的相关工具包的应用,不过本文不详细描述完整实现这么一个转换工具的具体实现细节,仅仅介绍大体流程。

一 解析 .vue 模板

使用的工具:vue-template-compiler

Vue 源码

<template>
    <div v-on:click="handleClick">{{ stateA }}</div>
</template>

<script>
export default {
    name: 'Test',

    data() {
        return { stateA: 'stateA' }
    }
    
    methods: {
        handleClick() {
            console.log(this.stateA);
            this.stateA = 'got new state';
        }
    }
}
</script>

<style>
    div: {
        color: red;
    }
</style>

解析 Vue 源码。

const compiler = require('vue-template-compiler');
        
const { script, styles, template } = compiler.parseComponent(vueCode, {
    pad: 'line'
});

vue-template-compiler 在这一步主要是把 <script> <style> <template> 标签中的内容提取出来,然后对 <template> 的内容进行编译。

  • script <script< 标签的中的 JS 源代码。
  • styles <style< 标签中的 CSS 源代码。
  • template <template< 标签中的编译结果,编译后的产物是 ASTElement 树。

二 收集 <script> 中的结构化数据

对 <script> 的源码使用 @babel/parser 进行解析,获得 AST 树。 所有 Vue 结构化的数据能使用 React 平替实现的都需要收集起来,便于后续组装生成源码。 大部分 Vue 功能能平替,但仍有一小部分平替实现的成本高,甚至不能平替如 ref。

以上面的 Vue 源码为例,收集到的结构化的数据大概包含如下内容:

  • 组件名:为 Test
  • 状态:为 data() 函数的返回内容
  • 生命周期函数:created()
  • 普通函数:handleClick()

三 将 template AST 转换成 JSX AST

转换工具:vue-template-compiler

编译输入:模板代码

<div v-on:click="handleClick">{{ stateA }}</div>

编译输出:模板 ASTElement

{
  type: 1,
  tag: 'div',
  attrsList: [ { name: 'v-on:click', value: 'handleClick' } ],
  attrsMap: { 'v-on:click': 'handleClick' },
  rawAttrsMap: {},
  parent: undefined,
  children: [
    {
      type: 2,
      expression: '_s(stateA)',
      tokens: [Array],
      text: '{{ stateA }}',
      static: false
    }
  ],
  plain: false,
  hasBindings: true,
  events: { click: { value: 'handleClick', dynamic: false } },
  static: false,
  staticRoot: false
}

上面的 AST 包含了标签名、事件名、事件处理函数和子节点的插值,有了这些内容,使用 @babel/types 创建出下面这样的 JSX 结构并不难。

<div onClick={handleClick}>{ stateA }</div>

主要是 {{ stateA }} 插值转换成 { stateA } 需要额外想点办法,由于插值是字符串,那么使用正则将 {{}} 替换成 {} 就行(在创建 JSX 的 AST 节点时插值也是字符串)。

vue-template-compiler 的文档很简单,但是好在有 d.ts,基本能预判出用法。

从 ASTElement 到 JSX,需递归遍历 ASTElement 树,然后一个个基于 @babel/types 构造出 JSXElement。 构造 JSXElement 时也需要从先前收集的结构化数据中读取部分数据。

四 组装生成 React 代码

有了结构化数据和 JSX 的 AST 树,组装生成 React 代码就简单了,先创建出完整的 React 的 AST 树,最终使用 @babel/generator 生成代码。

初始的时候可以使用 @babel/template 先创建出一个 React 组件的模板 AST 节点,如下面这样。


import template from '@babel/template';

const componentTemplate = `
export default function NAME(props) {

}
`;

const buildFC = template(componentTemplate);
const componentName = 'Test';

const node = buildFC({
    NAME: t.identifier(componentName)
});

有了函数组件的 AST 节点,就可以依次将结构化的数据作为函数体进行组装,在组装的过程中需要对 Vue 的结构化数据做一些处理转换,熟悉了 @babel/types 的用法,实现起来并不难,这里说几个简单的转换案例。

  • 初始 state:转换前的 Vue 源码为 { stateA: 'stateA' },转换为 React 就变成了const [stateA, setStateA] = useState('stateA')
  • state 读取:在 handleClick 中的 this.stateA 需要去掉 this
  • state 更新:this.stateA = 'got new state' 需要转换成 setStateA('got new state')。 ⠀ 这里就不一一列举 Vue 组件转换 React 组件相关的语法,超出了本文的范畴,不做深入讨论了。

还有没有其他可选择的工具?

swc

swc 基于 Rust。日常使用比 Babel 快,而且快得多。

SWC is 20x faster than Babel on a single thread and 70x faster on four cores.

用于开发工具,需了解 Rust 和 WASM,且解析出的 AST 是自己的规范。

参考资料

  • Babel Plugin Handbook: https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#toc-being-aware-of-nested-structures
  • Vue Template Compiler: https://github.com/vuejs/vue/tree/dev/packages/vue-template-compiler#readme