理解babel的基本原理和使用方法
babel是一个编译器,用于将ECMA2015+代码转换为向后兼容的javascript语法,其原因在于目前浏览器并不能及时的兼容js的新语法,而开发过程中我们往往会选择es6、jsx、typescript进行开发,而浏览器并不能识别并执行这些代码,因此就必须将这些代码编译并转换成浏览器识别的代码,所以我们才会发现所有的项目构建工具都是使用babel,这就显示出来babel的重要性。虽然经常使用,但是每次使用都是使用固定的配置代码,却没有了解其执行原理。知其然就需要知其所以然,所以记下自己对于babel的理解。
一、babel的执行过程
想要了解一个东西就需要先从宏观上分析它,babel也不例外。我们知道对于一个计算机语言,我们敲出来的代码都是字符串,想要执行就必须经过编译。比如react、vue等框架也需要对其类html代码进行编译才能生成viertual dom。既然babel需要将高级语法转换,那么babel也势必需要进行编译,而babel的执行过程就是一个编译转换的工程。如下图是babel的执行过程:
由此我们可以看到babel的核心就是parse、transform和generator三个部分。
接下来我们采用babel中的插件演示一个最简单的例子。
var {parse} = require('@babel/parser'); var {default: generate} = require('@babel/generator'); var code = `const name = "jyy";`; // 原始代码 var ast = parse(code); // 源代码生成的ast var targetCode = generate(ast); // 将ast转成目标代码 console.log(targetCode); //{ code: 'const name = "jyy";', map: null, rawMappings: undefined }
parse和generate顾名思义就是编译器和生成器,你可能会发现缺少转换过程,而且生成的目标代码和原始代码都是一样的,只是多了属性而已,这相当于什么都没有做啊。那是因为转换过程的具体操作需要插件来实现,如果没有使用插件,最后生成的目标代码是和原始代码一样的。
1.parse
在babel中编译器插件是@babel/parser,其作用就是将源码转换为AST,使用前需要npm install @babel/parser,使用方法如下:
const babelParser = require("@babel/parser"); const code = "const name= 'jyy';"; const ast = babelParser.parse(code); console.log(ast);
执行后输出的结果如下:
1 Node { 2 type: 'File', 3 start: 0, 4 end: 12, 5 loc: 6 SourceLocation { 7 start: Position { line: 1, column: 0 }, 8 end: Position { line: 1, column: 12 } }, 9 errors: [], 10 program: 11 Node { 12 type: 'Program', 13 start: 0, 14 end: 12, 15 loc: SourceLocation { start: [Position], end: [Position] }, 16 sourceType: 'script', 17 interpreter: null, 18 body: [ [Node] ], 19 directives: [] }, 20 comments: [] }
这就是babel生成的ast结构,你可能会发现这个ast和平常我们看到的ast好像结构不同,因为没有发现“name"、“=”等词法单元。是的,我一开始看到的时候也在怀疑这个输出到底是不是ast。后来发现babel的parser是根据babel的AST结构生成的,他基于ESTree规范。于是深入输出发现了code的词法单元的位置,就在上面代码的标红处,可以使用ast.program.body[0],得到如下的输出:
从结果中我们看到了declarations(声明)关键字和我们源码中的const,接着继续输出declarations,结果如下:
可以看到这个结果确实将我们的源码构造成了一个AST。
2.transform
这个过程虽然在本文中名字叫transfom,但是事实上babel官网中并没有这个词,更没有称为转换器的结构。想要知道为什么没有,我们需要知道bable是一个工具链,所谓工具链就是babel是依赖于它的插件的,只有有了插件babel才能发挥出真正的作用,没有插件的babel只是会将源码生成AST,然后在通过生成器生成和原来的源码一摸一样的代码,这样的过程是没有任何作用的。插件发挥作用的地方基本都是在tranfrom这个过程,当源码通过parse生成了ast后,我们可以通过转换插件,对ast进行操作。比如@babel/plugin-transform-react-jsx是将react中的jsx转换为react的节点对象。这样这些插件都涉及到对ast的操作,babel提供了一些工具插件,让我们可以方便的操作ast节点,也就更方便我们开发适合自己项目的插件。比如在babel官网中设计到的插件,点这里。下面介绍两个比较重要的插件,同时用这两个实现一个比较简单的操作ast过程。
@babel/types
这个插件的api非常多,见这里,我也没有实际用过,在这里只是简单的介绍下了。它的作用是创建、修改、删除、查找ast节点,因为ast也是一个树状结构,我们可以像js操作dom节点一样,使用types对ast进行操作。
另外我们知道ast的节点也是分为多种类型,比如ExpressionStatement是表达式、ClassDeclaration是类声明、VariableDeclaration是变量声明等等,同样的这些类型都对应了其创建方法:t.expressionStatement、t.classDeclaration、t.variableDeclaration,也对应了判断方法:t.isExpressionStatement、t.isClassDeclaration、t.isVariableDeclaration。这个插件往往和traverse遍历插件一起使用,因为types只能对单一节点进行操作,一般是在对节点的迭代中使用,所以这个插件的例子会放在traverse的实例中。
@babel/traverse
这个插件的作用是对ast进行遍历parse,在迭代的过程中可以定义回调函数,回调函数的参数提供了丰富的增、删、改、查以及类型断言的方法,比如replaceWith/remove/find/isMemberExpression。
下面以一个例子结合types和traverse进行演示。
假设我们在开发过程中使用一个函数findEleById来代替document.getElementById,如果我们直接使用findEle而不对其进行处理,js代码执行过程中是会报错,因为window下是没有这个函数的。但是我们可以使用babel修改其ast,将findEleById改为document.getElementById,这样babel的生成器生成的最新代码就是document.getElementById,然后js引擎就可以编译通过了。当然这个过程对于开发者是隐藏的,开发者只需要关注于使用findEleById便捷的开发就可以了,后续的操作交给babel。见如下代码:
var t = require('@babel/types'); var {parse} = require('@babel/parser'); var {default: traverse} = require('@babel/traverse'); var {default: generate} = require('@babel/generator'); var orginCode = `findEleById("jyy")`; // 原始代码 // 生成原始AST var originAST = parse(orginCode, { sourceType: "module" }); // 对AST进行遍历并操作 traverse(originAST,{ Identifier(path){ var {node} = path; // 找到findEleById,将其替换成为目标节点 if(node && node.name === "findEleById"){ var newNode = t.memberExpression(t.identifier("document"), t.identifier("getElementById")); // 创建目标节点 path.replaceWith(newNode); // 替换原始节点 path.stop(); } } }); const targetCode = generate(originAST, { /* options */ }, orginCode); // 将转换后的AST生成目标代码 console.log(targetCode); // { code: 'document.getElementById("jyy");',map: null,rawMappings: undefined }
从上面代码可以看到基本的转换过程,生成的最终代码可以直接交付给浏览器引擎编译执行了。
在babel的工具插件还有一些,因为本文不是为了讲解如何开发babel插件,所以这里仅介绍以上两个插件只为介绍babel在transform阶段的基本的工作原理。如果你真的需要开发自己的babel插件,那么需要了解babel提供的插件们,并了解其api的使用。
3.generator
这个过程已经在上面的实例中有所展现,使用的插件是@babel/generator,其作用就是将转换好的ast重新生成代码。这样的代码就就可以安全的在浏览器运行。
4.babel-core——整合基本插件
我们发现基本的babel插件如@babel/parse、@babel/generator都是提供了代码转换的基本功能,而另外的一些工具类型的插件比如@babel/types、@babel/traverser起作用是提供操作ast节点的功能。然而在开发插件的过程中如果每个都需要去引入实在太麻烦,所以就有了@babel/core插件,顾名思义就是核心插件,他将底层的插件进行封装,并另外加入了其他功能,比如读取、分析配置文件,这个后面会在配置中讲到。而这个插件将复杂的过程进行简化,如下代码所示:
1 var babel = require("@babel/core"); 2 var code = "<div class='c'>jyy</div>"; // 代码 3 babel.transform(code,{plugins: ["@babel/plugin-transform-react-jsx"],},function(err, result){ 4 console.log(result.code); 5 // React.createElement("div", { 6 // class: "c" 7 // }, "jyy"); 8 });
可以发现,我们可以使用transform就可以完成整个步骤。另外我们查看core的依赖可以发现,它依赖于底层的插件并基于次进行进行封装:
core的api很多,可以查看babel官网,另外我们看transform函数的参数第一个为原始代码,第二个为用于在转换过程中对ast进行操作的插件,例子中我们使用的是转换jsx的插件,第三个参数是一个回调函数。
二、插件
我们一般不会自己开发babel的转换插件,实际项目中往往都是直接使用现成的插件。而配置插件却时常让人很烦闷,因为有各种插件,什么stage-1、env、es2015等等,各种插件的各种配合设置给人摸不到头脑的感觉,会想问为什么不能出一个统一的插件,里面包含所有的转换功能,这样在配置的时候只需要在plugins里面放一个插件名就好了呢?这个问题主要是有以下几点原因:
一是因为js发展太快了,我们知道这几年js新的语法和函数不断地出现,如果把所有对最新语法的转换放在一个插件中,那么每次出现新的语法就需要不断的修改代码
二是babel不仅仅包含对ecma2015+的转换,还包括ts、flow和上面我们提到的jsx的转换,全都揉在一起的话实在是太大太乱了,不易于维护
三是这样做可以让用户更自由地去选择,就像菜市场去买菜,我只会去买我想要买的菜。这无疑减少了项目打包时的负担,进而影响到所占用的网络的带宽。事实上这种方式广泛的存在于产品中。比如echarts,我们可以选择其中的某些图表,然后下载对应的代码,而这样做的前提就是降低模块之间的耦合。
以上的分析都是我在瞎扯淡,仅供参考,谁知道babel的开发者是怎么想的。但至少是本人的一个见解。
一般情况下,项目不需要我们去开发babel插件,因为这些插件都写好了,看官网。里面的插件非常多,而且有些插件貌似很小,仅仅具体到一个语法,比如arrow-function,这是ES2015中的箭头函数语法,下面使用这个插件进行演示:
var babel = require("@babel/core"); var code = `num => { return num ** 2; }`; // 代码 babel.transform(code,{plugins: ["@babel/plugin-transform-arrow-functions"]},function(err, result){ console.log(result.code); // (function (num) { // return num ** 2; // }); });
结果如我们预期,使用arrow-function插件后,确实将箭头函数转换为了es5的语法。但是我们还会发现代码中的**,幂等运算符是ES2016的新特性。这样的代码还是不能被es5识别,于是我们加入转换幂等运算符的插件:
var babel = require("@babel/core"); var code = `num => { return num ** 2; }`; // 代码 babel.transform(code,{plugins: ["@babel/plugin-transform-arrow-functions", "@babel/plugin-transform-exponentiation-operator"]},function(err, result){ console.log(result.code); // (function (num) { // return Math.pow(num, 2); // }); });
发现结果已经将幂等运算符进行了转换。对于插件的配置需要记住以下几点:
1.plugin的段名称
配置plugin的时候,可以设置插件的短名称,可以将省略babel-plugin,例如:
["@babel/babel-plugin-name"]和["@babel/name"]是等价的
2.排列顺序
多个插件的执行顺序是按照从前到后的顺序执行,例如["@babel/name1","@babel/name2"]两个插件的执行顺序是先执行name1,然后执行name2。
三、预设(preset)——babel的插件套装
那么问题来了新语法新特性那么多,难道我们要挨个去加吗?当然不是,babel已经预设了几套插件,将最新的语法进行转换,可以使用在不同的环境中,如下:
@babel/preset-env
@babel/preset-flow
@babel/preset-react
@babel/preset-typescript
从名字上就能看出他们使用的环境了,需要注意的是env,他的作用是将最新js转换为es6代码。预设是babel插件的组合,我们可以看下package.json(截取一部分):
由此看到他组合了很多的插件,是一个官方提供的,这样我们只需要使用一个插件就可以了。那么有了这个插件,我们使用上一个例子,来测试一下:
var babel = require("@babel/core"); var code = `num => { const offset = 23; return num ** 2 + offset; }`; // 代码 babel.transform(code,{presets: ["@babel/preset-env"]},function(err, result){ console.log(result.code); // "use strict"; // (function (num) { // var offset = 23; // return Math.pow(num, 2) + offset; // }); });
可以看到,代码中额外将const转为var,还加上了use strict
需要注意的是因为@babel/preset-env是预设的包含多个插件,所以不同于单一的插件,需要使用presets参数,如代码红色标记所示。
对于env插件,我们还需要知道他是以前es2015、es2016和es2017的集合,另外他默认不支持stage-x插件。
stage-x(babel7已废弃)
那么什么是stage-x呢?state-x里面包含了当年最新规范的草案,每年更新。因为有可能项目所使用的是最新的语法,那么官方的预设插件还没有将其纳入,这时候就需要使用state-x。如下是state-x的阶段:
- Stage 0 - 稻草人: 只是一个想法,经过 TC39 成员提出即可。
- Stage 1 - 提案: 初步尝试。
- Stage 2 - 初稿: 完成初步规范。
- Stage 3 - 候选: 完成规范和浏览器初步实现。
- Stage 4 - 完成: 将被添加到下一年度发布。
所以我们经常会在代码中看到这样的preset配置:[es2015, react, stage-0]。好在在babel7,stage-x以被废弃,详情点这里。
额外注意的
1.preset可以设置短名称
和插件一样preset也可以设置段名称。可以省略preset,例如:
{ presets: ["@babel/preset-env", "@babel/preset-react"]
}
可以省略为:
{ presets: ["@babel/env", "@babel/react"]
}
2.排列顺序
预设的执行顺序也同样重要,preset在plugin之前执行,而且和plugin不同的是,preset是从后往前执行,比如我们使用react,那么应该这么写:
{ presets: ["@babel/env", "@babel/react"] }
因为我们需要将react中的jsx转为js,然后将js在转换为es5,所以需要将react的插件放在后面,让他先执行。
四、配置
实际项目中我们不会亲自动手去调用babel的api去转换代码,而且如果我们整个项目很可能都是用es6编写,不可能手动调用babel的api去一个一个转换,我们希望使用命令行,通过传递文件夹的名称去交给babel转换。这个时候babel-cli就出现了,比如,我们想要转换某个文件夹下的文件,那么我们可以在控制台输入这样的命令:
babel src --out-dir lib --presets=@babel/preset-env,@babel/react
这段命令的意思是,将src文件夹下的所有文件使用env和react预设进行转换,并且将转换后的文件存放在lib文件夹下。这样就节省了我们很多的时间。需要注意的是:babel-cli只是一套命令,想要执行babel的转换工作,仍然需要引入babel-core。
此时仍然有个问题预设也是有参数的,另外还有plugins等等,当然可以将这些参数加在命令中,但是这样还是会很复杂。解决这个问题的通用方法就是在项目中创建一个配置文件,在里面配置相应的插件和预设。
1. .babelrc
只是项目中经常用到的方式,在项目根目录创建名为.babelrc文件,内部包括两个方面:plugins和p'resets
{ "presets": [...], "plugins": [...] }
2. babel.config.js
这种方式是使用js代码编写,并导出一个和上面方式相同的对象
1 module.exports = function () { 2 const presets = [ ... ]; 3 const plugins = [ ... ]; 4 return { 5 presets, 6 plugins 7 }; 8 }
3. package.json
这种该方式是在项目配置文件package.json中进行配置,如下:
1 { 2 "name": "my-package-babel", 3 "version": "1.0.0", 4 "babel": { 5 "presets": [ ... ], 6 "plugins": [ ... ], 7 } 8 }
这三种都是等效的,当配置完成后,可以在babel-cli的命令行中配置,比如:
babel --config-file /path/to/my/babel.config.json --out-dir dist ./src
事实上,真正收取并处理分析配置文件的还是@babel/core,在core源码里面对于支持的配置文件名如下:
五、具体使用实例
下面以真实项目的搭建为例,简单介绍babel的具体使用方式。
项目的框架使用react,构建工具是webpack。webpack有babel-loader的插件,加入这个loader之后表示所有打包的文件都会由babel-loader来处理,同时使用到的babel插件有@babel/core、@babel/preset-env、@babel/preset-react。此时webpack中的配置文件关于babel的配置如下:
webpack.config.js:
1 { 2 test: "\.js$", 3 loader: "babel-loader", 4 exclude: "/node_modules" 5 }
.babelrc
{ "presets": ["@babel/env", "@babel/react"], "plugins": ["transform-runtime"] }