前端需要的一些编译知识

之前通过 the-super-tiny-compiler 学习了一下一个简单编译器的通用执行步骤:

  1. Parse 词法分析得到 Tokens,语法分析生成 AST
  2. Transformation 操作 AST,做一些优化工作
  3. Code Generation 生成代码

前文中研究了下 Vue 对于 template 编译到 render 函数的代码。自己也试着写了一个 vue-style-variables-loader,用来转换 Less Sass 和 Stylus 中各自定义变量的语法。

不过说起实际项目中接触最多的还是 Babel 以及各种插件。这里必须要推荐 Babel 插件手册,上面提到的 the-super-tiny-compiler 也出自他手。

除了基础的知识,我们需要引入一些新知识。

Visitor 访问者

来看一个简单的例子:

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

上述代码在 Parse 阶段后生成 AST:

{
    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"
                }
            }
        }]
    }
}

为了遍历 AST,我们定义了访问者 Visitor,针对每一个 Identifier 类型的节点,设置了进入和退出时执行的操作。 对于上面的例子,总共有 4n,也就会各执行 4 次 enter/exit

const MyVisitor = {
    Identifier: {
        enter() {},
        exit() {}
    }
};
path.traverse(MyVisitor);

值得一提的是,Visitor 中的可以使用 | 以表示定义多种节点:

const MyVisitor = {
    "ExportNamedDeclaration|Flow"(path) {}
};

被访问的路径

AST 中节点的父子关系可以通过路径 Path 来表示,当节点发生更新时,路径信息自动更新,开发者无需关心。

之前 Visitor 定义的方法,实际上访问的是路径,而通过路径能够取得当前节点 node 的信息。

const MyVisitor = {
    Identifier(path) {
        console.log("Visiting: " + path.node.name);
    }
};

除了表示当前节点的 node,路径上还有很多属性和方法,比如父节点 parent,以及操作这些节点的方法。

递归访问

在上述的例子中,如果我们想把参数以及 square 函数体中的 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";
        }
    }
};

唯一的问题是需要在 Visitor 各个方法中共享变量 x,这里我们通过作用域中的 paramName 似乎能解决这个问题。 但是要注意,这种共享方式存在一个致命的问题,那就是不在 square 方法中的 n 也会被替换掉:

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

所以我们必须要限制只有在方法中的 Identifier 才进行替换,因此需要在 Visitor 中使用另一个 Visitor:

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);

作用域

路径上还包括作用域信息 scope,而作用域对象又保存了当前路径节点以及父节点的引用。

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

插件 API

babel-types 提供了很多操作 AST 的工具方法。 在编写 babel 插件时十分有用,例如创建一个 a * b 对应的 AST 表达式节点:

t.binaryExpression("*", t.identifier("a"), t.identifier("b"));

在下面实际项目的例子中会看到对于工具方法的大量使用。

CRA 中的 named-asset-import

在上一篇分析 CRA 的文章中,我们知道 CRA 是无配置的,而使用 Webpack 时经常使用到的处理各类资源的 loader 如何配置呢? CRA 的开发人员显然也意识到了这个问题,他们给出的方案是使用 babel-plugin 转译如下 import 语句,相关 ISSUE & PR

import { url as logoUrl } from './logo.png';
import { html as docHtml } from './doc.md';
import { ReactComponent as Icon } from './icon.svg';

转译之后使用 Webpack inlined loader 得到不同类型的资源:

import logoUrl from 'url-loader!./logo.png';
import docHtml from 'html-loader!markdown-loader!./doc.md';
import Icon from 'svg-react-loader!./icon.svg';"

具体转译规则,即资源使用的 loader 由插件参数决定:

loaderMap: {
    svg: {
        ReactComponent: 'svgr/webpack![path]',
    },
}

在插件方法中,可以得到传进来的 babel-types 参数,返回值则是包含了 Visitor 的对象:

export function namedAssetImportPlugin({ types: t }) {
    // 缓存解析过的资源路径
    const visited = new WeakSet();

    return {
        visitor: {...}
    };
}

由于我们的目标是 import 语句,Visitor 中只需要包含 ImportDeclaration 即可。其中第一个参数是 Path 路径,第二个则是 State 状态,这里就可以取得传入插件的参数 loaderMap

ImportDeclaration(path, { opts: { loaderMap } }) {
    // './icon.svg'
    const sourcePath = path.node.source.value;
    // 'svg'
    const ext = extname(sourcePath).substr(1);
}

替换路径是通过 replaceWithMultiple 实现的,我们需要替换成多条语句,相关 API

path.replaceWithMultiple(...);

我们生成的新的 import 语句数目是由紧跟着 import 的对象决定的, 例如 import url, { ReactComponent as Icon } from './icon.svg'; 应该生成两条。 这里又需要分成默认 import 和重命名两种情况分析:

path.replaceWithMultiple(
    path.node.specifiers.map(specifier => {
        // 1. 处理 import default
        // 2. 处理 重命名 import
    })
);

对于第一种默认 import,我们不需要做特别的处理,对于上面 SVG 的例子,会生成 import url from './icon.svg'; 语句:

if (t.isImportDefaultSpecifier(specifier)) {
    const newDefaultImport = t.importDeclaration(
        [
            t.importDefaultSpecifier( // 'import'
                t.identifier(specifier.local.name) // 'url'
            ),
        ], // 'from'
        t.stringLiteral(sourcePath) // './icon.svg'
    );

    visited.add(newDefaultImport);
    return newDefaultImport;
}

而对于第二种,会生成 import Icon from 'svgr/webpack!./icon.svg'; 语句。其中替换掉了 [path] 占位符:

const newImport = t.importDeclaration(
    [
        t.importSpecifier(
            t.identifier(specifier.local.name),
            t.identifier(specifier.imported.name)
        ),
    ],
    t.stringLiteral(
        loaderMap[ext][specifier.imported.name]
        ? loaderMap[ext][specifier.imported.name].replace(
            /\[path\]/,
            sourcePath
            )
        : sourcePath
    )
);

值得一提的是 yyx 也就是 Vue 的作者在 ISSUE 中也提到在 vue-cli 中也会考虑借鉴这种思路。

另一种思路

Babel 插件在提升开发者体验的同时,也带来了一些问题,例如:

babel-plugin-macros 提出了解决以上问题的一种新思路,在 CRA 这种需要保证无配置的场景下十分适用。

值得一提的是它本身是一个 babel 插件,因此也需要通过 .babelrc 配置,但是只需要一次,其他 macro 均不需要。 来看看它和其他 babel 插件有啥不同吧。

当我们使用 babel-plugin-console 这个插件时,需要使用特殊的语法 console.scope(),本身并不是标准 API:

function add100(a) {
    const oneHundred = 100
    console.scope('Add 100 to another number')
    return add(a, oneHundred)
}
function add(a, b) {
    return a + b
}

而使用 macro 时,我们在代码中显式地进行引用,增加了可读性。

import scope from 'babel-plugin-console/scope.macro'
function add100(a) {
    const oneHundred = 100
    scope('Add 100 to another number')
    return add(a, oneHundred)
}
function add(a, b) {
    return a + b
}

另一个优势是可以指定多个 macro 的执行顺序,这在 babel 插件中是很难控制的。

import preval from 'preval.macro'
import idx from 'idx.macro'

还是在 CRA 中,除了现有的 named-asset-import 插件,开发者还提供了使用 macro 的思路

import toReactComponent from 'svgr.macro';
const Logo = toReactComponent('./logo.svg');

      ↓ ↓ ↓ ↓ ↓ ↓

const Logo = props => <svg width={116} height={28} viewBox="0 0 116 28" {...props}>
    <g fill="none" fillRule="evenodd">
      ...

编写 macro

我们以 babel-plugin-console 为例,可见改造成本并不高,已有的 babel 插件完全可以提供额外的 macro 版本。

import { createMacro } from 'babel-plugin-macros';
import generateScopeLog from '../scope';

module.exports = createMacro(({ babel: { template, types }, references }) =>
    references.default.forEach(({ parentPath }) =>
        parentPath.replaceWithMultiple(generateScopeLog(parentPath, template, types))
    )
);

参考资料