前言
我习惯通过阅读官方文档,提出自己的猜想,再实践官方实例验证猜想,最后再结合现代框架及工程化工具的应用来学习babel的使用和运行原理,以此期望能对babel有更全面的认识。本文是对学习过程的整理和记录。
本文以:
- 官方提供的实例为切入点,通过逐步安装
babel相关包并查看实例编译结果,来认识和学习babel。 Vue.js为切入点,逐步配置一个简单的Vue项目,明确babel与Vue.js、webpack在代码编译时的关系。
使用到的包及版本:
- babel:7
- babel-loader: 8
- vue: 2
- vue-cli:4
- vue-loader: 15
- vue-template-compiler: 2
- webpack: 5
- webpack-cli: 4.9
babel官方定义
Babel 是一个 JavaScript 编译器
是一个工具链,主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。
实例探究
在官网使用指南的概述中,提到需要安装的包。下面我们就来实践下。
首先,我们先通过npm init创建一个项目,使该项目可以下载安装npm平台上的包。
npm install --save-dev @babel/core @babel/cli @babel/preset-env
以上是官网给出的安装命令,我们先了解这三个库是什么作用。
@babel/core
@babel/core看名字指的是babel的核心包,可以猜想,这个包应该是处理代码的解析,转译,产生。我们来看看@babel/core的package.json文件来验证我们的猜想。
果不其然,确实是核心的功能。
官网在对其介绍时,首先提到了该包的transform方法,我们猜想,这个方法就是用来把源码转译为能够运行在当前和旧版本的浏览器或其他环境中。
所以至少应该把箭头函数转译为函数声明function吧。
我们来验证下:
npm install --save-dev @babel/core
"devDependencies": {
"@babel/core": "^7.16.5"
}
执行安装命令后,package.json中新增了对应的包。
我们新建文件testBabelCore.js:
var babel = require("@babel/core");
var sourceCode = `[1, 2, 3].map(n => n + 1)`;
var options = {};
babel.transform(sourceCode, options, function(err, result) {
console.log('sourceCode=========', result.code);
console.log('result.code=========', result.code);
console.log(result);
});
执行
执行后发现,代码并没有转换,猜想的不对?
查阅资料后发现,@babel/core负责解析,转译,产生是没错的,只是具体转译为什么版本的js,是要自定义的。需要安装另外的包或插件,在options中添加对应参数处理。
这样的设计确实非常棒,可以根据用户需要自定义,非常灵活了。
@babel/preset-env
官方推荐使用此包来定义转译后的js版本,我们先来实践下看下效果。
安装
npm install --save-dev @babel/preset-env
在options参数中配置上
var babel = require("@babel/core");
var sourceCode = `[1, 2, 3].map(n => n + 1)`;
var options = {
presets: [
'@babel/env'
]
};
babel.transform(sourceCode, options, function (err, result) {
if (err) {
console.error(err)
return
}
console.log('sourceCode=========', sourceCode);
console.log('result.code=========', result.code);
console.log(result);
});
再次执行
结果显示代码已经转换。
@babel/preset-env我认为可以理解为根据宿主环境版本预设编译生成的代码的版本。比如高版本的现代浏览器已经支持了async/await关键词,编译出的文件就不需要生成Polyfill代码了,直接使用async/await关键词就可以了,这样避免冗余的代码增大代码包体积。
指定宿主环境
这里就有疑问了,这个宿主环境的版本是怎么确定的?上面的配置并没有设置宿主环境版本,它又是怎么在编译阶段就知道宿主环境的呢?是有默认配置吗?默认配置又是什么呢?
我们在@babel/preset-env的How Does it Work?里找到答案。
其实babel是运用的browserslist来设置默认宿主环境版本的。
在browserslist的Queries提到了宿主环境版本获取方法,其中第5条是默认版本:
5. If the above methods did not produce a valid result Browserslist will use defaults:
> 0.5%, last 2 versions, Firefox ESR, not dead.
我们可以在项目根目录加一个.browserslistrc文件
> 0.5%, last 2 versions, Firefox ESR, not dead
然后在代码中参数添加debug参数,方便查看信息
var options = {
presets: [
[
'@babel/env',
{ 'debug': true }
]
]
};
再运行
Using targets很清楚的显示,我们.browserslistrc文件里配置的各浏览器宿主环境默认的版本是什么。
而且我们可以看到里面有个plugin是transform-arrow-functions { ie },这里表示如果宿主环境中包含IE浏览器,就需要对箭头函数进行转译。plugin后面我们再做说明。
如果想编译出指定某个宿主环境的代码,除了在可以在.browserslistrc文件里配置,还可以设置参数里的targets配置。
var options = {
presets: [
[
'@babel/env',
{ 'targets': {"chrome": "47"}, debug: true }
]
]
};
执行结果如下:
我们发现,这里显示宿主环境只有chrome:47了,而且用到的转译plugin也少了很多,transform-arrow-functions也没有用到,因为chrome:47是支持箭头函数的,所以无需转译。
具体哪些语句在哪些宿主环境需要转译,可以查看这个表
如果chrome版本改为46,箭头函数就转译为函数声明function了。
更多配置,比如node版本的配置,可以查阅官网了。
如果不想用preset-env预设,也可以自定义预设版本,无非就是装相应包和配置参数,这里不再赘述。
@babel/cli
@babel/core和 @babel/preset-env已经满足我转译代码的需求,但是之前需要转译的源代码是一行就可以写完的,如果我要转译成千上万行的代码呢?
再者,我期望的是写完一个源代码文件,执行一个命令,直接生成一个转译好的目标文件,而不是打印一个长长的字符串。
@babel/cli 很好的满足了以上需求。
我们先安装
npm install --save-dev @babel/cli
注意:@babel/cli是依赖于@babel/core和@babel/preset-env的,所以这两个包也必须安装。
新建testBabelCli.js文件,只需要键入一行代码
然后执行下面命令:
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js --presets=@babel/preset-env
就会在源代码的同级目录下生成转译后的目标文件。
也许你会说,在执行转译的命令里加参数太麻烦,是否可以创建个配置文件?
当然可以。@babel/cli支持创建babel.config.js配置文件,执行babel命令时可以自动读取该文件里的配置信息。
在项目根目录下新增文件babel.config.js
module.exports = {
presets: [
'@babel/preset-env'
]
}
然后执行指令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
转译后生成的文件内容与在命令行里添加参数的方式生成的内容是一致的。
这里需要提一下.babelrc配置文件。从babel7开始babel.config.js相当于全局性配置文件,.babelrc相当于模块的配置文件。
Plugins
babel里面还有Plugins这个概念,可以更灵活的配置js转译规则,从而扩展代码的转译功能。
说到这里,大家可能会疑惑,presets和Plugins的关系是啥?都是转译代码,为啥要整两个?
其实,presets是Plugin的集合。
我们来看下,@babel/presets-env的package.json。
显而易见,@babel/presets-env是由一堆plugin组成的。
不得不说,babel的架构设计的确高明:功能粒度尽可能细,保证了babel的高灵活度,强扩展性。
这里需要格外注意一点,引入presets和plugins的执行顺序是不同的,这里可以看官网的Plugin Ordering
Plugin实现原理
之前已经提到,babel进行代码转换的核心步骤是三个:解析(parser),转译(traverse),产生(generator)。
Plugin就是在 转译(traverse) 这一步对代码做处理的。
首先,我们需要根据源代码,获取到AST,可以在AST explorer这个网站在线生成AST,然后再编辑处理这个AST就可以啦。
对js中新增内置函数的转译
官方实例里,并没有提到这个插件,但是在实践中,这个插件确实也是必不可少的。下面我们就来一步步探究下。
首先,在testBabelCli.js文件中,把代码改为以下
然后我们在babel.config.js中把宿主环境设置为IE8。
这两行代码中,包含const关键词和includes数组方法,这些都是ES6的新增的语法和API,IE8不支持,理论上这两个方法都应该被转译。
执行一下命令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
查看编译后文件testBabelCli-compiled.js
发现const被转译了,但是includes方法并没有转译,这样运行在IE8上肯定会报错。
我们看下控制台输出
transform-block-scoping就是处理const的插件,这个在@babel/preset-env里默认包含的。但是像includes这样的API默认是没有处理它的包的。
@babel/polyfill
对于向includes这样比较新的js内置函数可以通过引入@babel/polyfill进行转译。
@babel/polyfill包含regenerator runtime 和 core-js.
core-js是对ES6+的新特性API提供polyfill的库,以适配低版本浏览器。
而需要regenerator runtime 是因为,babel对async/await、yield这样的生成器代码转译为在执行时需要调用regeneratorRuntime方法,这个方法就定义在regenerator runtime 中,其实就是babel对regenerator runtime 对依赖引用。
我们来验证下,把testBabelCli.js内容改为
执行命令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
查看编译后文件testBabelCli-compiled.js
确实有调用。
配置文件babel.config.js里新增配置项
useBuiltIns是设置polyfill引入的方式,usage是按需引入,entry是全部引入。显然,usage使用更合理一些。
执行命令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
查看编译后文件testBabelCli-compiled.js
显示全局引入了core-js/modules/es7.array.includes.js
然后我们安装@babel/polyfill。
npm install --save @babel/polyfill
注意这里是安装在dependency里的,下面是官方提示原文:
Because this is a polyfill (which will run before your source code), we need it to be a
dependency, not adevDependency
@babel/plugin-transform-runtime
解决全局变量污染问题
不知你发现没有,上面对es7.array.includes.js的引入是全局方式的引入,就像官网上对@babel/polyfill的描述:
This means you can use new built-ins like
PromiseorWeakMap, static methods likeArray.fromorObject.assign, instance methods likeArray.prototype.includes, and generator functions (provided you use the regenerator plugin). The polyfill adds to the global scope as well as native prototypes likeStringin order to do this.
直接在对象构造函数或是原型上添加方法,会出现污染全局变量的风险。因为你无法保证你引用的第三方库里也修改了同一个全局变量,就会出现问题了。
@babel/plugin-transform-runtime可以解决这个问题。
正如官网中的描述
Another purpose of this transformer is to create a sandboxed environment for your code. If you directly import core-js or @babel/polyfill and the built-ins it provides such as
Promise,SetandMap, those will pollute the global scope. While this might be ok for an app or a command line tool, it becomes a problem if your code is a library which you intend to publish for others to use or if you can't exactly control the environment in which your code will run.
下面再来验证下,安装相关包:
npm install --save-dev @babel/plugin-transform-runtime
对babel.config.js添加配置信息
执行命令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
查看编译后文件testBabelCli-compiled.js
这里显示要引入包
@babel/runtime-corejs3,获取到方法_interopRequireDefault
安装
npm install --save @babel/runtime-corejs3
这个包是代码运行时用到的,所以也是要安装在dependency里的。
安装后,我们找到这个方法,看看干了啥
就是判断参数是不是ESM,如果是直接返回;如果不是,把参数放在一个对象的default属性里返回,其实就是为了模块化代码。
解决辅助代码重复声明问题
@babel/plugin-transform-runtime还有一个作用:可以解决编译后的辅助代码重复定义的问题。
我们来看下面这个例子:
先把配置文件的@babel/plugin-transform-runtime插件相关配置注释掉。
然后,定义一个Foo类
执行一下命令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
查看编译后文件testBabelCli-compiled.js
可以发现编译后的代码定义并调用了一个_classCallCheck方法。
下面我们在babel.config.js中加上@babel/plugin-transform-runtime插件配置。
再执行命令
npx babel ./src/testBabelCli.js --out-file ./src/testBabelCli-compiled.js
查看编译后文件testBabelCli-compiled.js
可以发现,_classCallCheck2这个方法是定义在@babel/runtime-corejs3中的。编译后的代码直接引用调用就可以,不需要再定义了。
小结
@babel/core是babel的核心包,负责js代码的解析(parser),转译(traverse),产生(generator)。
但是具体转译为什么版本的js,还需要再引入包进行定义。官方推荐使用@babel/preset-env进行定义,当然你也可以按自己的需要进行定义。
为了项目工程化的需要,官方还提供了@babel/cli,可以直接将源代码文件进行编译,直接生成目标文件。
为了babel有更好的扩展性,也为了更灵活的实现代码编译,babel提供了Plugin功能,可以让用户自己实现符合自己需求的编译规则。babel所有的转译规则都是以plugin为基础的,presets也是Plugin的集合。
对于js中新增的内置API的转译,需要另外安装@babel/polyfill进行转译,这个包包含core-js和regenerator runtime,一个是对低版本浏览器提供ES6+的新特性的实现方法,一个是定义生成器函数函数被babel转译后,要调用的方法,因为都是在代码执行时用到的,所以要安装在dependencies中。
但是使用@babel/polyfill会有两个问题,一个是因为polyfill方法是定义在全局变量上的,会污染全局环境;再一个,编译过后的辅助代码,会出现重复定义的问题。为了解决这些问题,可以使用@babel/plugin-transform-runtime插件解决。
探究babel对Vue代码的转译
目前的项目开发,我们都使用现代框架,我们可以根据框架提供的模板进行快速开发。可是模板代码浏览器并不认识,最终还是要转为浏览器认识的代码,babel正好是做js代码转译工作的,下面就以Vue框架为例,探究下,babel是怎么转译Vue代码的。
尝试babel直接转译Vue代码
安装Vue.js
npm install vue --save
新建index.html文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
新建index.js文件
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
new Vue({
el: '#app',
render: (h) => h(App),
components: { App }
})
新建App.vue文件
<template>
<div id="app">
<h1>My Vue App!</h1>
</div>
</template>
执行babel编译命令:
npx babel ./src/vue-demo/index.js --out-file ./src/vue-demo/dist/index.js
执行命令后生成了编译后的文件/dist/index.js
"use strict";
var _vue = _interopRequireDefault(require("vue"));
var _App = _interopRequireDefault(require("./App"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
_vue["default"].config.productionTip = false;
new _vue["default"]({
el: '#app',
render: function render(h) {
return h(_App["default"]);
},
components: {
App: _App["default"]
}
});
把/dist/index.js文件引入index.html中。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./dist/index.js"></script>
</body>
</html>
然后我们把html文件在浏览器中打开,发现控制台报错
浏览器并不认识引入文件的require方法,babel把import转为require方法这样的CommonJS规范形式就结束了。
为了解决require方法为定义问题,从而实现文件间引入,我们可以使用webpack。
通过webpack解决require未定义问题
安装webpack
npm install --save-dev webpack webpack-cli
根目录下新建文件webpack.config.js
const path = require('path')
module.exports = {
entry: './src/vue-demo/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'build.js'
}
}
在package.json中的script中添加
"scripts": {
"build": "webpack --mode development",
},
执行命令
npm run build
报错。。。。
这里错误提示的很明显,就是webpack不认识.vue类型文件,需要一个loader做转译,转换为js模块。
我们来装一个vue-loader
npm install --save-dev vue-loader
在webpack.config.js中配置引入vue-loader
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
entry: './src/vue-demo/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'build.js'
},
// vue-loader 版本15以上时必须加这个插件
plugins: [
new VueLoaderPlugin()
],
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
},
}
再次执行命令npm run build,又报错了。。。。
这里错误提示也很明确,是要求添加vue-template-compiler模块,这个模块的作用是将 Vue 2.0 模板预编译为渲染函数(template => ast => render) ,以避免运行时编译开销和 CSP 限制。
安装
npm install vue-template-compiler --save-dev
再次执行npm run build,成功!
把打包好的dist文件夹下的build.js文件,写入index.html文件中,再在浏览器中打开index.html文件
完美!
等等,回头看下webpack配置文件,好像并没有配置babel相关的loader呀,只是用vue-loader就把在vue框架里的代码编写好了,那么是不需要babel?还是说我们的例子太简单,没有涉及到相关功能的使用呢?
我们继续做实验
在App.vue里加段js代码
<template>
<div id="app">
<h1>My Vue App!</h1>
</div>
</template>
<script>
export default {
created () {
this.init()
},
methods: {
init () {
[1, 2, 3].map(n => n + 1);
console.log('init method')
}
}
}
</script>
我们执行一下npm run build,查看编译之后的文件。
发现这段代码没有变化,而且这条语句所在的created方法,也都是用ES6语法定义的,之所以也能在浏览器里正常执行文件,因为我的浏览器版本高,可以识别这些语法。
然后我们安装babel-loader
npm install --save-dev babel-loader
再把babel-loader配置到webpack.config.js中。
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
entry: './src/vue-demo/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'build.js'
},
plugins: [
new VueLoaderPlugin()
],
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader'
},
]
},
}
然后再执行npm run build,查看输出
看!之前提到的代码都进行了转译。
vue-cli4是如何转译代码的呢?
我们在vue-cli4的官方文档里,浏览器兼容性这一个章节里,可以了解到vue-cli使用babel的所有信息。这里也指出了使用babel的目的,就是为了解决浏览器兼容性。
我们还是先从vue-cli生成的实际项目中开始探究。
安装步骤这里省略了,我们直接看通过vue-cli生成的实际项目的package.json文件。
我们发现了在devDependencies文件中的@vue/cli-plugin-babel这个插件。我们先看官网对这个插件的介绍:
Uses Babel 7 +
babel-loader+ @vue/babel-preset-app by default, but can be configured viababel.config.jsto use any other Babel presets or plugins.
这里可以看出,这个插件集合了Babel 7、babel-loader和@vue/babel-preset-app(单从名字看),是用来环境预设的,后面我们再细看。
先来看下@vue/cli-plugin-babel的package.json文件,看都依赖了什么。
里面包含babel主要的包和配合webpack使用的babel-loader。
下面我们再来看看@vue/babel-preset-app作用是什么。
This is the default Babel preset used in all Vue CLI projects. Note: this preset is meant to be used exclusively in projects created via Vue CLI and does not consider external use cases.
其实就是vue-cli的默认预设选项。
再看看它的package.json文件。
在里面我们可以发现,有babel的很多关键包和插件,我们之前也有介绍过了。除此之外,还有一些@vue/babel-preset-app引入的默认插件和辅助方法,这里不再一一介绍,可以看下官方文档。
小结
探究如何把Vue项目代码编译为浏览器可识别的代码时发现,babel只能把import语句转为require语句,这个语句浏览器也不识别。所以需要借助webpack对require模块引入问题再次处理。
但是,webpack在处理模块引入时,又回遇到问题:它不认识.vue模板文件,需要再引入vue-loader把模板文件转译为js模块。
但是vue-loader只能把模板专为ES6版本的js模块,所以要添加babel-loader再对js文件中的代码进行向下兼容的转译。
vue-cli中的@vue/cli-plugin-babel插件是对babel功能做了封装,里面包含Babel 7、babel-loader和@vue/babel-preset-app,实现了vue、babel和webpack的集成。
vue-cli还引入了默认插件proposal-dynamic-import、@babel/plugin-proposal-class-properties、@babel/plugin-proposal-decorators等语法转译插件和@babel/plugin-syntax-jsx、 @vue/babel-preset-jsx等vue jsx的语法转译插件。
vue-cli也使用了@babel/plugin-transform-runtime插件来优化编译结果。
总结
经过以上实践得以验证Babel 是一个 JavaScript 编译器的官方定义。是以Plugin为最细粒度的模块,组合成的对js代码进行转译的规则方案。最直接的目的就是为了代码兼容宿主环境。
但是它只是单个文件的编译,不涉及文件间的引入,如果涉及多模块文件的引入,就需要运用像webpack这样的工具了。
在Vue这样的现代框架中,首先是需要webpack这样的工具进行代码模块文件管理的,与之配套的框架工具包(如:vue-loader)的实现,往往都采用ES6及以上来实现的。这时,就需要安装babel-loader对生成的js文件进行二次转译,以适配目标宿主环境。
学习探究的过程,难免有疏漏、有错误。欢迎大家批评指教,感谢!