引言
前端高度模块化、工程化的今天,我们只需要几行指令就可以搭建起一个前端工程,稍微改改配置(甚至都不用改),加个路由就可以开始写业务代码了。但最近在写一个自己封装的npm包的时候,需要自己从头开始配置工程,包括webpack。其中就涉及到了babel,似懂非懂地折腾了半天以后决定闲下来的时候好好梳理一下babel的相关知识。本文不是一篇babel从入门到出门的长篇教程,内容的深度仅限于babel的基本原理和基本配置。希望能够对babel的初学者有所帮助,也方便自己随时能够温习自己曾经花费时间学习过的内容,防止遗忘。
什么是babel
先上个官网首页的图
因为我开始入门前端的时候正是ES6开始盛行但各浏览器对ES6的支持却参差不齐的时候,那时候的面试都喜欢问”你了解ES6吗?“、”说说ES6的XXX吧“。所以那时候对babel有个错误的认识:babel是一个将ES6转换成ES5的工具,让我们写的ES6代码能够在浏览器上运行。
这样的说法在当时来看并没有太大的问题,但现在再看,或许有人会问:现在大部分的浏览器都支持ES6了,那Babel是不是就可以退出江湖了呢?
答案当然是否定的。
准确来说,JavaScript的发展是先于浏览器的支持速度的,当大家开始写ES6代码的时候,浏览器们还在疯狂的更新以支持ES6,且支持的速度也并不一致。而当主流浏览器都已经支持ES6的时候,ES7又出来了。所以babel做的事情就是将高版本的JavaScript代码转换成能够在指定浏览器版本(配置Babel的时候可以指定需要支持的浏览器版本)下运行的代码,即advanced->current。所以只要JavaScript还在发展,那babel就还会出现在你的项目里,除非有更好的替代品出现。
Babel做了什么
- 转换语法,比如将ES6的箭头函数转换成ES5的函数。
- polyfill实例方法和类方法等,比如数组的includes方法。polyfill在很多文章中会翻译成垫片,但总感觉这个中文翻译是个很难理解的东西,所以这里就保留了英文表达。通俗来说,polyfill就是本来浏览器不支持这个实例方法,polyfill一下这个实例方法就可以用了。实现方式上,既可以在
Array.prototype上加上这个方法,也可以通过其他不污染prototype的方式在代码中加上方法的定义。 - 去除类型注释。babel也可以将ts代码转换成js代码,但babel不会进行类型检查,只是单纯地把类型注释去掉。
以上是babel的主要作用,更多其他的作用可以参考官方文档,在此就不搞那么深入了
babel的原理
babel通过插件对代码进行转换,即下图
babel官网上分类罗列了所有的官方插件,比如ES6的插件就有以下这些
一个插件处理一个语法,开发者可以根据实际需要在项目中引入插件,形成一个插件链对源码进行处理,得到最终的可运行代码。
但实际应用中一个插件一个插件地配置,工作量是巨大的,而且要求开发者对babel插件的生态比较了解。为了方便插件使用,babel引入了预设(preset)的概念。这并不是一个新鲜的名词,在PS中我们会将经常要重复使用的一系列操作保存为一个预设,以此来提升工作效率。同理,babel中的预设就是将一系列的插件组合在一起,我们只需要配置一个预设就相当于配置了这个预设下面包含的插件,从而避免了一个插件一个插件地配置,节约了开发者的时间。常用的预设包括
有了预设之后,babel的处理过程就变成了
当配置文件中同时配置了插件和预设时,处理顺序记住下面三句话
- 先插件后预设
- 多个插件从前往后依次执行
- 多个预设从后往前依次执行
babel怎么用
@babel/core和@babel/cli
@babel/core是babel的核心库,可以理解成babel的整个转换过程都依赖于这个库。@babel/cli让你可以通过命令行的方式执行babel命令,完成对源码的转换。
写个小demo感受一下babel的魔法
先用npm init初始化一个项目,然后在根目录下新建一个src文件夹,里面新建一个index.js文件,里面写一些ES6的代码。如下所示,既包括箭头函数这样的ES6语法,也包括includes这样的实例方法。
const func = () => 1
var arr = [1, 2, 3]
var isInclude = arr.includes(2)
console.log(isInclude)
class Person {
constructor(name) {
this.name = name
}
}
var person = new Person("JavaScript")
var prom=new Promise((resolve,reject)=>{resolve(1)})
const asyncFunc = async function () {
var data = await Promise.resolve(1)
return data
}
然后npm install上面说的两个工具包@babel/core和@babel/cli,这样就可以开始用babel对代码进行转换了。在根目录下执行./node_modules/.bin/babel src --out-dir lib后,去lib文件夹下查看转换后的代码,发现输出的代码和输入的代码是一模一样的。这是为什么呢?因为目前还没有给babel配置任何的插件和预设。babel的配置有以下两种方式
- 全局配置:babel.config.*。该配置文件支持多种拓展名,常用为json。babel开始工作后,会在当前工作目录下搜索该文件(不搜索子文件),作为整个项目的配置。
- 局部配置:.babelrc(.*)或package.json中的babel属性。
配置文件的搜索和合并过程如下:
- babel在编译的时候会从当前文件所在位置向上寻找.babelrc文件
- .babelrc会和babel.config.json全局配置文件合并形成最终的配置
- 仅babelrcRoots下的文件会被转换,默认为当前工作目录
- 向上寻找的过程中遇到package.json就会停止(因为package.json代表一个package,。babelrc只应用于本package内的文件)
OK,那么我们就在项目的根目录下新增一个babel.config.json文件,并在里面配置一个箭头函数的转换插件,重新执行babel命令后看看效果。
{
"plugins": ["@babel/plugin-transform-arrow-functions"]
}
查看转换的结果文件可以看到,其中的箭头函数已经转换成了ES5的普通函数,而其他语法依旧没有进行转换。接下来我们使用@babel/preset-env预设对源码进行处理,发现全部的语法都被转换了,但实例方法还是没有做任何处理。
{
"presets": ["@babel/preset-env"]
}
除了在命令行中每次执行./node_modules/.bin/babel src --out-dir lib,我们也可以在package.json中配置脚本以简化命令,这样以后每次只需要执行npm run babel命令就可以了。
"scripts": {
"babel": "./node_modules/.bin/babel src --out-dir lib"
},
polyfill
上文通过简单的配置已经能够对语法进行转换,至于实例方法和类方法则需要通过polyfill来解决。@babel/preset-env不仅可以实现语法的转换,也可以引入polyfill,这依赖于以下两个参数useBuiltIns和corejs。
core-js
npm官网上关于这个库的英文说明是:Modular standard library for JavaScript. Includes polyfills for ECMAScript up to 2023。翻译过来就是说这是一个polyfill库,引入这个库你就可以在不支持某些方法的浏览器下使用这些方法了。目前分为2和3两个版本,通过corejs参数来指定,一般用3就行了。
useBuiltIns
默认是false,即不转换方法。另外两个取值是"entry"和"usage":
- entry:需要在项目的唯一入口手动引入core-js包,babel会根据targets参数设置的浏览器版本要求,自动往结果代码中加入全量的polyfill的导入代码,不管代码中是否用到。
- usage:不需要手动引入core-js包,babel会根据targets参数设置的浏览器版本要求和代码中实际用到的方法自动往结果代码中加入需要的polyfill的导入代码。
给配置文件加上polyfill参数
那么我们现在给@babel/preset-env加上这两个参数。
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns":"usage",
"corejs":3
}
]
]
}
运行babel命令后查看结果代码,可以看到结果文件的顶部自动插入了一些polyfill的导入语句。
上面的参数我没有写targets,babel会使用targets的默认值。targets参数要怎么写以及@babel/preset-env的其他参数可以参考官网的说明。
至此,我们使用@babel/preset-env插件,既完成了语法的转换,也引入了polyfill让我们可以使用比较新的实例方法。但如果我们再仔细看看结果文件中的代码,会发现还有些优化空间。
一些优化空间
因为我们的源码中使用了class,所以结果代码中插入了下面这样一个辅助函数。
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
那么如果我们有很多个文件都用了class语法,那么每个文件转换后都会有一段这样的代码,造成了代码冗余,增加了代码体积。
然后我们再看看数组的includes语法,转换后的代码中自动引入了polyfill后,该实例方法的使用还是保持原样的,即
var arr = [1, 2, 3];
var isInclude = arr.includes(2);
那我们就不难想象,导入的polyfill可能就是在Array.prototype中加入了includes方法的定义,这样就污染了全局环境。如果我们的项目是一个独立应用那其实是无所谓的,但如果我们的项目是一个公共库,那可能就会造成一些潜在的问题。
鉴于以上的两个问题,接下来看看babel runtime。
runtime
@babel/runtime
代码冗余的问题,只需要将这些辅助函数放到一个单独的库里,转换代码的时候不再往结果文件中注入辅助函数的定义,而是注入辅助函数的引用。这个库就是@babel/runtime。
@babel/plugin-transform-runtime
但@babel/runtime只是一个辅助函数库,它需要配合@babel/plugin-transform-runtime一起使用。这个插件能够抽离结果文件中的辅助函数,并自动插入辅助函数的引用代码。除此之外,这个插件也可以引入polyfill完成方法的处理。具体配置如下,下面的配置不再通过@babel/preset-env引入polyfill,而是通过@babel/plugin-transform-runtime来完成,同时抽离了辅助函数的定义。
{
"presets": [
[
"@babel/preset-env",
{
// "useBuiltIns":"usage",
// "corejs":3
}
]
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": 3,
"helpers": true
}
]
]
}
helpers的默认值是true,corejs的默认值是false。当设置了corejs的值为非false后,应该安装相应的runtime库。
我们看看转换后的效果,首先辅助函数的定义已经抽离,取而代之的是辅助函数的导入语句。其次,引入了一个_includes方法来实现源码中的array.includes,对应的源码也发生了转换。这种方式避免了在原型上添加方法,不会造成全局污染。
var _createClass2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/createClass"));
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/classCallCheck"));
var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/includes"));
/**
...
*/
var arr = [1, 2, 3];
var isInclude = (0, _includes["default"])(arr).call(arr, 2);
方案对比
综上,我们有以下三种方式完成JavaScript的基本转换。
- 仅使用@babel/preset-env完成语法转换和方法polyfill,这样会造成全局污染,也会有代码冗余。
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns":"usage",
"corejs":3
}
]
]
}
- 使用@babel/preset-env完成语法转换,使用@babel/plugin-transform-runtime完成方法polyfill和辅助函数导入。这样不会造成全局污染,也不会有代码冗余。
{
"presets": [
[
"@babel/preset-env",
{
// "useBuiltIns":"usage",
// "corejs":3
}
]
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": 3,
"helpers": true
}
]
]
}
3、使用@babel/preset-env完成语法转换和方法polyfill,使用@babel/plugin-transform-runtime完成辅助函数导入。这样会造成全局污染,不会有代码冗余。
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns":"usage",
"corejs":3
}
]
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"helpers": true
}
]
]
}
具体如何配置需要根据项目的实际应用场景决定。消除代码冗余是利大于弊的,而是否污染全局变量则要考虑项目是一个独立的应用还是一个工具库。虽然不污染全局变量从代码设计上要好一些,但@babel/runtime-corejs3也会增加最终包的体积。
总结
- babel能根据我们指定的浏览器兼容版本要求,将我们写的JavaScript代码转换成能够在指定浏览器中运行的低版本代码。
- babel的转换过程依赖插件和预设,预设是一系列插件的集合。
- babel通过插件对语法进行转换,通过引入polyfill来实现实例方法。
- babel的配置需要根据具体应用环境来决定:是否需要支持jsx、是否需要支持typescript、是否要避免全局污染等等。
本文对babel的基本概念和基本用法做了介绍,但babel的功能是很强大的,更多复杂的概念和用法还是需要查看babel的官方文档深入学习。