compile&transpile——JS ToolChain

最后修改于

相比于其他语言完善的工具链,JS 不那么成熟的工具链使得上层用户也需要知道很多底层细节,同时也给了用户施加魔法的机会。编译器就是其中一步。本文旨在对编译转换部分的使用做一些介绍。
如果你曾经是一名计科相关专业的学生,不出意外你已经遭受过编译原理的毒打了。对词法分析、语法分析、IR、优化等理论方面的知识有了一定的了解,此处便不再 "复制粘贴" 了。

AST 操作(以 Babel 为例)#

Note

该部分参考 babel-handlebook

编译相关的话题中 AST 是核心,通过 AST 可以做到很多优化和转换,实现不一样的魔法。ESTree 就是社区维护的对应于 ES 的 AST 标准。
AST 的基本单位是 Node,一个 AST 可以由许多节点共同构成。 它们组合在一起可以描述用于静态分析的程序语法。

interface Node {
  type: string;
}

在使用时,根据节点类型的不同,会附带一些额外信息,此外 Parser 也可以根据需要添加一些属性。
例如对于这样一个函数

function square(n) {
  return n * n;
}

将其转换为 AST 之后,可以表示为如下所示的树状结构。

相比于文本字符串,树状结构的更易于分析和修改。我们可以对 AST 进行分析、修改从而实现很fancy 的特性。

遍历#

得到 AST 之后,要进行更好的分析,除了当前节点信息还需要一些上下文,进行辅助判断,因此需要进行遍历。
比方说我们有一个 FunctionDeclaration 类型。它有几个属性:idparams,和 body,每一个都有一些内嵌节点。

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  params: [{
    type: "Identifier",
    name: "n"
  }],
  body: {
    type: "BlockStatement",
    body: [{
      type: "ReturnStatement",
      argument: {
        type: "BinaryExpression",
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}

于是我们从根 FunctionDeclaration 开始遍历,我们依次访问每一个属性及它们的子节点(根据 type 和对应的 AST 规范,可以确定节点中具有的其他属性)。
id-->params[0]-->body(BlockStatement)-->body[0](ReturnStatement) --> argument(BinaryExpression) --> operator(*) --> left(n) --> right(n)
这就是对 AST 对一次遍历。

Visitor#

访问者是一个用于 AST 遍历的跨语言的模式。 简单来说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。这么说有些抽象所以让我们来看一个例子。

- FunctionDeclaration
  - Identifier (id)
  - Identifier (params[0])
  - BlockStatement (body)
    - ReturnStatement (body)
      - BinaryExpression (argument)
        - Identifier (left)
        - Identifier (right)

对于这样一个 AST,我们会这样遍历。

  • 进入 FunctionDeclaration
    • 进入 Identifier (id)
    • 走到尽头
    • 退出 Identifier (id)
    • 进入 Identifier (params[0])
    • 走到尽头
    • 退出 Identifier (params[0])
    • 进入 BlockStatement (body)
    • 进入 ReturnStatement (body)
      • 进入 BinaryExpression (argument)
      • 进入 Identifier (left)
        • 走到尽头
      • 退出 Identifier (left)
      • 进入 Identifier (right)
        • 走到尽头
      • 退出 Identifier (right)
      • 退出 BinaryExpression (argument)
    • 退出 ReturnStatement (body)
    • 退出 BlockStatement (body)
  • 退出 FunctionDeclaration
    访问者模式定义了一种通用的访问方式。将对 AST 的访问交给库。
    我们只需要定义访问节点事件的回调即可,回调触发时根据当前持有的路径信息,对 AST 进行修改。
const MyVisitor = {
  Identifier: {
    enter(path) {
      console.log("Entered!");
    },
    exit(path) {
      console.log("Exited!");
    }
  }
};
路径#

当我们谈及遍历操作时,我们会对一个节点进行访问,对 AST 进行分析时,节点之间的关系也是重要的信息,因此使用 Visitor 时除了节点本身,还包括一些上下文信息,称为 Path

{
  "parent": {...},
  "node": {...},
  "hub": {...},
  "contexts": [],
  "data": {},
  "shouldSkip": false,
  "shouldStop": false,
  "removed": false,
  "state": null,
  "opts": null,
  "skipKeys": null,
  "parentPath": null,
  "context": null,
  "container": null,
  "listKey": null,
  "inList": false,
  "parentKey": null,
  "key": null,
  "scope": null,
  "type": null,
  "typeAnnotation": null
}
状态#

状态是抽象语法树 AST 转换的敌人,状态管理会不断牵扯你的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。

考虑下列代码:

function square(n) {
  return n * n;
}

让我们写一个把 n 重命名为 x 的访问者的快速实现。

let paramName;

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = "x";
  },
  Identifier(path) {
    if (path.node.name === paramName) {
      path.node.name = "x";
    }
  }
};

对上面的例子代码这段访问者代码也许能工作,但它很容易被打破:

function square(n) {
  return n * n;
}
n;

更好的处理方式是使用递归,把一个访问者放进另外一个访问者里面。

const updateParamNameVisitor = {
  Identifier(path) {
    if (path.node.name === this.paramName) {
      path.node.name = "x";
    }
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    const paramName = param.name;
    param.name = "x";

    path.traverse(updateParamNameVisitor, { paramName });
  }
};

path.traverse(MyVisitor);

当然,这只是一个刻意编写的例子,不过它演示了如何从访问者中消除全局状态。

Scopes(作用域)#

作用域的概念编程语言中的重要概念,JS 支持词法作用域。也遵循一些常见的约束,每当创建了一个引用,不管是通过变量(variable)、函数(function)、类型(class)、参数(params)、模块导入(import)还是标签(label)等,它都属于当前作用域;深层作用域可以使用浅层作用域中的引用;内层作用域也可以创建和外层作用域同名的引用。

// global scope
function scopeOne() {
  // scope 1
  var one = "I am in the scope created by `scopeOne()`";
  var two = "I am in the scope created by `scopeOne()`";
  function scopeTwo() {
  // scope 2
    one = "I am updating the reference in `scopeOne` inside `scopeTwo`";
    var two = "I am creating a new `two` but leaving reference in `scopeOne()` alone.";
  }
}

当编写一个 AST 转换插件时,必须小心作用域。我们得确保在改变代码的各个部分时不会破坏已经存在的代码。在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突。 或者我们仅仅想找出使用一个变量的所有引用, 我们只想在给定的作用域(Scope)中找出这些引用。
作用域可以被表示为如下形式:

{
  path: path,
  block: path.node,
  parentBlock: path.parent,
  parent: parentScope,
  bindings: [...]
}

当你创建一个新的作用域时,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内收集所有的引用 (“绑定”)。
一旦引用收集完毕,你就可以在作用域(Scopes)上使用各种方法,稍后我们会了解这些方法。

Bindings(绑定)#

所有引用属于特定的作用域,引用和作用域的这种关系被称作:绑定(binding)

function scopeOnce() {
  var ref = "This is a binding";
  ref; // This is a reference to a binding
  function scopeTwo() {
    ref; // This is a reference to a binding from a lower scope
  }
}

单个绑定看起来像这样︰

{
  identifier: node,
  scope: scope,
  path: path,
  kind: 'var',
  referenced: true,
  references: 3,
  referencePaths: [path, path, path],
  constant: false,
  constantViolations: [path]
}

有了这些信息之后,可以查找一个绑定的所有引用,并且知道这是什么类型的绑定 (参数,定义等等),查找它所属的作用域,或者拷贝它的标识符。 甚至可以知道它是不是常量,如果不是,那么是哪个路径修改了它。
在很多情况下,知道一个绑定是否是常量非常有用,最有用的一种情形就是代码压缩时。

function scopeOne() {
  var ref1 = "This is a constant binding";
  becauseNothingEverChangesTheValueOf(ref1);
  function scopeTwo() {
    var ref2 = "This is *not* a constant binding";
    ref2 = "Because this changes the value";
  }
}

Babel#

Babel 最初主要用于将 ES 6+/TS/JSX 等其他 JS 方言的代码转换为兼容性更好的 JavaScript 版本,使得一些新特性可以在较旧的浏览器或环境正常执行。
随着发展,逐渐变为提供一个转义层,有合适的插件系统,通过插件去转化不一样的语法 / 方言 (如 jsx、tsx)。
这其中又分为两类

  • 一种是类语法糖性质的,新代码中有类似的 / 可替代概念(如 const -> let),可以无痛转换为旧代码
  • 另一种需要底层支持 (如 promise),需要用一个旧代码实现来模拟新特性,提供相同功能(称为 polyfill)
    Babel 处理时有三个主要步骤: 解析(parse)转换(transform)生成(generate)
插件#

Babel 使用基于 ESTree 修改的 AST,为插件提供 Visitor pattern 的接口。插件通过 Vistor Pattern 对 AST 进行修改。

ESBuild#

esbuild 是 JavaScript 工具生态系统中一个相对较新的成员。它于 2020 年 1 月首次出现在 GitHub 上的 JavaScript 编译器,底层使用 Go 以实现高度并发。同时提供了简单的打包功能,内置对 css,js,ts,jsx,tsx 的支持。
esbuild 的构建模型很简单,从一个 entrypoint 开始,resolve 解析依赖的模块,load 加载被解析的模块作为新的 input,最终得到 output。
对外提供buildtransform两种 api,build对文件进行处理,transform作为特例直接对字符串内容处理 。

插件#

其插件模型也是基于此,因此插件的更改粒度是文件级的,更细粒度的更改则由插件自行实现(比如搭配 babel)。也正因如此,不同于 babel 直接提供对 ast 更改的能力。esbuild 插件重点放在对各种 import 语句的处理上,通过插件改写 import 导入的内容。这部分内容是可以被插件改写的,插件也可以通过 Babel实现对单个模块的转化。与 Rollup 类似。从这个角度来看,除了编译能力,esbuild 还有着较强的打包能力。

插件对外提供onresolveonbuildonStartonEndonDispose五个 hook API,统一在 setup 中进行注册。
其中 onStartonEndonDispose 与构建生命周期有关。
onResolve/onBuild是插件的核心。

esbuild 在处理一个模块中的类 import 语句时,触发插件的 onresolve,返回一个path(可为空),然后进行 Load,触发 onloadonload部分根据参数去解析内容,最终返回string/buffer,附带一个 loader字段告知esbuild如何理解这些内容,如js

每个模块都有一个关联的命名空间。默认情况下,位于 file 命名空间中,对应文件系统中的文件。
通过命名空间机制,可以从文件系统之外加载模块,比如创建一个 remote命名空间,插件实现从远程加载模块,如import React from "https://esm.sh/react@^18"

onresolve 触发时,参数中的 namespace 指名 import 语句所在模块的 namespace,返回结果时,返回 import 语句导入模块的 namespace。
onload触发时,参数中的 namespace 表示当前正在加载模块的 namespace。

以官方的 http 示例插件为例。

import * as esbuild from 'esbuild'
import https from 'node:https'
import http from 'node:http'

let httpPlugin = {
  name: 'http',
  setup(build) {
    // 拦截以"http:"、"https:"开头的 import path
    // 将通过这样 import 的模块视为 namespace:http-url
    build.onResolve({ filter: /^https?:\/\// }, args => ({
      path: args.path,
      namespace: 'http-url',
    }))
	// 拦截以 http 方式导入的所有模块及其内容中的涉及到的其他导入
	// 所有这些模块都会视为在 'http-url' namespace 中
    build.onResolve({ filter: /.*/, namespace: 'http-url' }, args => ({
      path: new URL(args.path, args.importer).toString(),
      namespace: 'http-url',
    }))
	// 加载模块,返回内容
    build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => {
      let contents = await new Promise((resolve, reject) => {
        function fetch(url) {
          console.log(`Downloading: ${url}`)
          let lib = url.startsWith('https') ? https : http
          let req = lib.get(url, res => {
            if ([301, 302, 307].includes(res.statusCode)) {
              fetch(new URL(res.headers.location, url).toString())
              req.abort()
            } else if (res.statusCode === 200) {
              let chunks = []
              res.on('data', chunk => chunks.push(chunk))
              res.on('end', () => resolve(Buffer.concat(chunks)))
            } else {
              reject(new Error(`GET ${url} failed: status ${res.statusCode}`))
            }
          }).on('error', reject)
        }
        fetch(args.path)
      })
      return { contents }
    })
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [httpPlugin],
})

TSC#

tsc 是 typescript 官方集成的 ts compiler,相比于其他编译器,没有那么多 fancy 的特性或是丰富的插件生态,并且速度也没那么优秀。但因为是官方出品,有着最好的第一方支持,也不需要额外的东西。

SWC#

用 Rust 编写的 ts/js 编译器,速度极快,附带 bundling/formatter 等功能。支持插件,但是需要使用 Rust 编译,编译为 WASM,API 层面类似于 Babel 提供 Vistor 接口,但并非与 Babel 相同的 ESTree AST,而是自行定义的 AST 类型。生态方面不够完善,但在编译方面的优秀性能使得在许多新的上层工具中被采用。