什么是 babel?
babel 是一个 Javascript 编译器,是目前前端开发最常用的工具之一,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境。
a ?? 1;
a?.b;
//babel转换后
("use strict");
var _a;
(_a = a) !== null && _a !== void 0 ? _a : 1;
(_a2 = a) === null || _a2 === void 0 ? void 0 : _a2.b;
可以在babel 官网自己测试。
babel 的用途
-
转译 esnext、typescript、flow 等到目标环境支持的 js。
-
一些特定用途的代码转换。 babel 暴露了很多 api,用这些 api 可以完成代码到 AST 的 parse,AST 的转换,以及目标代码的生成。 开发者可以用它来来完成一些特定用途的转换,比如函数插桩(函数中自动插入一些代码,例如埋点代码)、自动国际化、default import 转 named import 等。
-
代码的静态分析。 对代码进行 parse 之后,能够进行转换,是因为通过 AST 的结构能够理解代码。理解了代码之后,除了进行转换然后生成目标代码之外,也同样可以用于分析代码的信息,进行一些检查。
-
linter 工具就是分析 AST 的结构,对代码规范进行检查。
-
api 文档自动生成工具,可以提取源码中的注释,然后生成文档。
-
type checker 会根据从 AST 中提取的或者推导的类型信息,对 AST 进行类型是否一致的检查,从而减少运行时因类型导致的错误。
-
压缩混淆工具,这个也是分析代码结构,进行删除死代码、变量名混淆、常量折叠等各种编译优化,生成体积更小、性能更优的代码。
babel 的编译流程
babel 整体编译流程分为三步:
- parse:通过 parser 把源码转成抽象语法树(AST)
- transform:遍历 AST,调用各种 transform 插件对 AST 进行增删改
- generate:把转换后的 AST 打印成目标代码,并生成 sourcemap
parse
parse 阶段的目的是把源码字符串转换成机器能够理解的 AST,这个过程分为词法分析、语法分析。
比如 let name = ‘guang’; 这样一段源码,我们要先把它分成一个个不能细分的单词(token),也就是 let, name, =, ‘guang’,这个过程是词法分析,按照单词的构成规则来拆分字符串成单词。
transform
transform 阶段是对 parse 生成的 AST 的处理,会进行 AST 的遍历,遍历的过程中处理到不同的 AST 节点会调用注册的相应的 visitor 函数,visitor 函数里可以对 AST 节点进行增删改。
generate
generate 阶段会把 AST 打印成目标代码字符串,并且会生成 sourcemap。
while (node condition) {
// node contet
}
AST 节点
字面量、标识符、表达式、语句、模块语法、class 语法都有各自的 AST。
比如 字面 Literal:
‘a’ 就是 StringLiteral,123 就是 NumberLiteral,变量名,属性名等标识符都是 Identifier。
我们可以去 AST 可视化工具了解各个节点。
也可以去 @babel/types 查看所有 AST 类型。
AST 的公共属性
- type: AST 节点的类型。
- start、end、loc:start 和 end 代表该节点对应的源码字符串的开始和结束下标,不区分行列。而 loc 属性是一个对象,有 line 和 column 属性分别记录开始和结束行列号。
- leadingComments、innerComments、trailingComments: 表示开始的注释、中间的注释、结尾的注释,因为每个 AST 节点中都可能存在注释,而且可能在开始、中间、结束这三种位置,通过这三个属性来记录和 Comment 的关联。
- extra:记录一些额外的信息,用于处理一些特殊情况。
babel api
- @babel/parser
它提供了有两个 api:parse 和 parseExpression。两者都是把源码转成 AST,不过 parse 返回的 AST 根节点是 File(整个 AST),parseExpression 返回的 AST 根节点是是 Expression(表达式的 AST)。可以指定 parse 的内容以及 parse 的方式。最常用的 option 就是 plugins、sourceType 这两个。
plugins: 指定 jsx、typescript、flow 等插件来解析对应的语法。
sourceType: 指定是否支持解析模块语法,有 module、script、unambiguous 3 个取值,module 是解析 es module 语法,script 则不解析 es module 语法,当作脚本执行,unambiguous 则是根据内容是否有 import 和 export 来确定是否解析 es module 语法。
require("@babel/parser").parse("code", {
sourceType: "module",
plugins: ["jsx", "typescript"],
});
- @babel/traverse
parse 出的 AST 由 @babel/traverse 来遍历和修改,babel traverse 包提供了 traverse 方法:
function traverse(parent, opts)
parent 指定要遍历的 AST 节点,opts 指定 visitor 函数。babel 会在遍历 parent 对应的 AST 时调用相应的 visitor 函数。enter 是在遍历当前节点的子节点前调用,exit 是遍历完当前节点的子节点后调用。
visitor: {
Identifier (path, state) {},
StringLiteral: {
enter (path, state) {},
exit (path, state) {}
}
}
参数 path 是遍历过程中的路径,会保留上下文信息。通过 path 可以获取节点信息:
path.scope 获取当前节点的作用域信息
path.isXxx 判断当前节点是不是 xx 类型
path.assertXxx 判断当前节点是不是 xx 类型,不是则抛出异常
isXxx、assertXxx 系列方法可以用于判断 AST 类型
path.insertBefore、path.insertAfter 插入节点
path.replaceWith、path.replaceWithMultiple、replaceWithSourceString 替换节点
path.remove 删除节点
这些方法可以对 AST 进行增删改
参数 state 可以在不同节点之间传递数据。
- @babel/types
创建 AST 和判断 AST 的类型。
如果要创建 IfStatement 就可以调用
t.ifStatement(test, consequent, alternate);
而判断节点是否是 IfStatement 就可以调用 isIfStatement 或者 assertIfStatement
t.isIfStatement(node, opts);t.assertIfStatement(node, opts);
- @babel/template
批量创建节点
const ast = template(code, [opts])(args);
const ast = template.ast(code, [opts]);
const ast = template.program(code, [opts]);
- @babel/generate
打印成目标代码字符串
const { code, map } = generate(ast, { sourceMaps: true })
function (ast: Object, opts: Object, code: string): {code, map}
- @babel/core
基于上面的包来完成 babel 的编译流程,可以从源码字符串、源码文件、AST 开始。
使用方式
- 下载
- 安装
npm i @babel/core @babel-cli
- package.json 配置
{
"scripts": {
"build": "babel src -d dist"
}
}
- webpack 中需要配置 babel-loader
// webpack.config.js
const path = require("path");
module.exports = {
entry: "./src/app.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
},
module: {
rules: [{ test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }],
},
};
- 配置
我们需要告诉 babel 要怎么去编译,编译哪些内容。 配置文件的方式有以下几种:
- 在 package.json 中设置 babel 字段。
//package.json
{
"name":"babel-test",
"version":"1.0.0",
"devDependencies": {
"@babel/core":"^7.4.5",
"@babel/cli":"^7.4.4",
"@babel/preset-env":"^7.4.5"
}
"babel": {
"presets": ["@babel/preset-env"]
}
}
- .babelrc 文件或 .babelrc.js
.babelrc
{
"presets": ["@babel/preset-env"]
}
.babelrc.js
//webpack的配置文件也是这种写法
module.exports = {
presets: ["@babel/preset-env"],
};
- babel.config.js 文件
同.babelrc.js,但是 babel.config.js 是针对整个项目。
- 使用
通过配置文件告诉 babel 编译哪些内容,然后还要引入对应的编译插件(Plugins),比如箭头函数的转换需要的是 @babel/plugin-transform-arrow-functions 这个插件。
npm i @babel/plugin-transform-arrow-functions
// babel.config.js
module.exports = {
plugins: ["@babel/plugin-transform-arrow-functions"],
};
现在我们代码中的箭头函数就会被编译成普通函数。
预设(Presets)
当 plugin 比较多或者 plugin 的 options 比较多的时候就会导致使用成本升高。这时候可以封装成一个 preset,用户可以通过 preset 来批量引入 plugin 并进行一些配置。preset 就是对 babel 配置的一层封装。
只要安装这一个 preset,就会根据你设置的目标浏览器,自动将代码中的新特性转换成目标浏览器支持的代码。
npm i @babel/preset-env
// babel.config.js
module.exports = {
presets: [
[
"@babel/preset-env",
{
targets: {
chrome: "58",
},
},
],
],
};
plugin-transform-runtime 和 runtime 插件
当用 babel 编译 class 的时候,需要一些工具函数来辅助实现。
class People {}
// babel编译后
("use strict");
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
var People = function People() {
_classCallCheck(this, People);
};
每个 class 都会生成 _classCallCheck,最后就会产生大量重复代码。plugin-transform-runtime
就是为了解决这个问题。
npm i @babel/plugin-transform-runtime
//生产依赖
npm i @babel/runtime
module.exports = {
presets: ["@babel/preset-env"],
plugins: ["@babel/plugin-transform-runtime"],
};
"use strict";
// babel 编译后
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
var People = function People() {
(0, _classCallCheck2["default"])(this, People);
};
babel-polyfill
babel 可以转化一些新的特性,但是对于新的内置函数(Promise,Set,Map),静态方法(Array.from,Object.assign),实例方法(Array.prototype.includes)这些就需要 babel-polyfill 来解决,babel-polyfill 会完整模拟一个 ES2015+环境。
因为 @babel/polyfill 体积比较大,整体引入既增加项目体积,又污染了过多的变量,所以更推荐使用 preset-env 来按需引入 polyfill。
// corejs 是一个给低版本的浏览器提供接口的库
npm i core-js@2
// babel.config.js
module.exports = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "usage", // usage-按需引入 entry-入口引入(整体引入) false-不引入polyfill
corejs: 2, // 2-corejs@2 3-corejs@3
},
],
],
};
const a = Array.from([1]);
//babel编译后
("use strict");
require("core-js/modules/es6.string.iterator");
require("core-js/modules/es6.array.from");
var a = Array.from([1]);
一个简单案例
函数插桩
希望通过 babel 能够自动在 console.log 等 api 中插入文件名和行列号的参数,方便定位到代码,这段代码不影响其他逻辑,这种函数插入不影响逻辑的代码的手段叫做函数插桩。
思路: 在遍历 AST 的时候对 console.log 等 api 自动插入一些参数,也就是要通过 visitor 指定对函数调用表达式 CallExpression 做一些处理。CallExrpession 节点有两个属性,callee 和 arguments,分别对应调用的函数名和参数, 所以我们要判断当 callee 是 console.xx 时,在 arguments 的数组中插入一个 AST 节点,创建 AST 节点需要用到 @babel/types 包。
主要代码:
const ast = parser.parse(sourceCode, {
sourceType: "unambiguous",
plugins: ["jsx"],
});
const targetCalleeName = ["log", "info", "error", "debug"].map((item) => `console.${item}`);
traverse(ast, {
CallExpression(path, state) {
const calleeName = generate(path.node.callee).code;
if (targetCalleeName.includes(calleeName)) {
const { line, column } = path.node.loc.start;
path.node.arguments.unshift(types.stringLiteral(`filename: (${line}, ${column})`));
}
},
});
// babel编译前
const sourceCode = `
console.log(1);
function func() {
console.info(2);
}
export default class Clazz {
say() {
console.debug(3);
}
}
`;
// babel编译后
console.log("filename: (2, 4)", 1);
function func() {
console.info("filename: (5, 8)", 2);
}
export default class Clazz {
say() {
console.debug("filename: (10, 12)", 3);
}
}