前端需要的一些编译知识
之前通过 the-super-tiny-compiler 学习了一下一个简单编译器的通用执行步骤:
- Parse 词法分析得到 Tokens,语法分析生成 AST
- Transformation 操作 AST,做一些优化工作
- 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 类型的节点,设置了进入和退出时执行的操作。
对于上面的例子,总共有 4 个 n
,也就会各执行 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 插件在提升开发者体验的同时,也带来了一些问题,例如:
- 需要使用
.babelrc
或者 Webpack 全局配置 - 所有的插件在同一次对于 AST 遍历的过程中进行,可能存在互相影响
- 由于代码会经过插件转译,阅读原始代码时反而会被某些特殊语法困扰
- 除开语法层面的插件,有些很简单的可定制的转译需求也需要发布插件才能使用,无法使用本地插件
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))
)
);