带你在Babel的世界中畅游

前言
Babel在目前前端领域类似一座山一样的存在,任何项目或多或少都有它的身影在浮现。

也许对于Babel绝大多数前端开发者都是处于一知半解的状态,但是无论是在实际业务开发中还是对于我们个人提升来说熟练掌握Babel一定是晋升高级前端工程师的必备之路。

文章中我们只讲“干货”,从原理出发结合深层次实践带你领略Babel之美。

我们会从Babel基础内容从而渐进到Babel插件开发者的世界,从此让你对于Babel得心应手。

Babel日常用法
首先我们会从基础的配置Babel及相关内容开始讲解。

常见plugin和Preset
首先我们来说说Plugin和Preset的区别和联系。

所谓Preset就是一些Plugin组成的合集,你可以将Preset理解称为就是一些的Plugin整合称为的一个包。

常见Preset
文章中列举了三个最常用的Preset,更多的Prest你可以在这里查阅。

babel-preset-env

@babel/preset-env是一个智能预设,它可以将我们的高版本JavaScript代码进行转译根据内置的规则转译成为低版本的javascript代码。

preset-env内部集成了绝大多数plugin(State > 3)的转译插件,它会根据对应的参数进行代码转译。

@babel/preset-env不会包含任何低于 Stage 3 的 JavaScript 语法提案。如果需要兼容低于Stage 3阶段的语法则需要额外引入对应的Plugin进行兼容。

需要额外注意的是babel-preset-env仅仅针对语法阶段的转译,比如转译箭头函数,const/let语法。针对一些Api或者Es 6内置模块的polyfill,preset-env是无法进行转译的。这块内容我们会在之后的polyfill中为大家进行详细讲解。

babel-preset-react

通常我们在使用React中的jsx时,相信大家都明白实质上jsx最终会被编译称为React.createElement()方法。

babel-preset-react这个预设起到的就是将jsx进行转译的作用。

babel-preset-typescript

对于TypeScript代码,我们有两种方式去编译TypeScript代码成为JavaScript代码。

使用tsc命令,结合cli命令行参数方式或者tsconfig配置文件进行编译ts代码。

使用babel,通过babel-preset-typescript代码进行编译ts代码。

常见Plugin
Babel官网列举出了一份非常详尽的Plugin List。

关于常见的Plugin其实大多数都集成在了babel-preset-env中,当你发现你的项目中并不能支持最新的js语法时,此时我们可以查阅对应的Babel Plugin List找到对应的语法插件添加进入babel配置。

同时还有一些不常用的packages,比如@babel/register:它会改写require命令,为它加上一个钩子。此后,每当使用require加载.js、.jsx、.es和.es6后缀名的文件,就会先用Babel进行转码。

这些包日常中不是特别常用,如果有同学有相关编译相关需求完全可以去babel官网查阅。如果官网不存在现成的plugin/package,别担心!我们同时也会在之后手把手教大家babel插件的开发。

其中最常见的@babel/plugin-transform-runtime我们会在下面的Polyfill进行详细的讲解。

前端基建中的Babel配置详解
接下里我们聊聊前端项目构建中相关的babel相关配置。

关于前端构建工具,无路你使用的是webapack还是rollup又或是任何构建打包工具,内部都离不开Babel相关配置。

这里我们使用业务中最常用的webpack举例,其他构建工具在使用方面只是引入的包不同,Babel配置原理是相通的。

关于WebPack中我们日常使用的babel相关配置主要涉及以下三个相关插件:

babel-loader

babel-core

babel-preset-env
也许你经常在项目搭建过程中见到他们,这里我们将逐步使用一段伪代码来讲解他们之间的区别和联系。

首先我们需要清楚在 webpack中loader的本质就是一个函数,接受我们的源代码作为入参同时返回新的内容。

babel-loader

所以babel-loader的本质就是一个函数,我们匹配到对应的jsx?/tsx?的文件交给babel-loader:

/**
 *
 * @param sourceCode 源代码内容
 * @param options babel-loader相关参数
 * @returns 处理后的代码
 */
function babelLoader (sourceCode,options) {
  // ..
  return targetCode
}
关于options,babel-loader支持直接通过loader的参数形式注入,同时也在loader函数内部通过读取.babelrc/babel.config.js/babel.config.json等文件注入配置。

babel-core

我们讲到了babel-loader仅仅是识别匹配文件和接受对应参数的函数,那么babel在编译代码过程中核心的库就是@babel/core这个库。

babel-core是babel最核心的一个编译库,他可以将我们的代码进行词法分析--语法分析--语义分析过程从而生成AST抽象语法树,从而对于“这棵树”的操作之后再通过编译称为新的代码。

babel-core其实相当于@babel/parse和@babel/generator这两个包的合体,接触过js编译的同学可能有了解esprima和escodegen这两个库,你可以将babel-core的作用理解称为这两个库的合体。

babel-core通过transform方法将我们的代码进行编译。

关于babel-core中的编译方法其实有很多种,比如直接接受字符串形式的transform方法或者接受js文件路径的transformFile方法进行文件整体编译。

关于babel-core内部的编译使用规则,我们会在之后的插件章节中详细讲到。

接下来让我们完善对应的babel-loader函数:

const core = require('@babel/core')

/**
 *
 * @param sourceCode 源代码内容
 * @param options babel-loader相关参数
 * @returns 处理后的代码
 */
function babelLoader (sourceCode,options) {
  // 通过transform方法编译传入的源代码
  core.transform(sourceCode)
  return targetCode
}
这里我们在babel-loader中调用了babel-core这个库进行了代码的编译作用。

babel-preset-env

上边我们说到babel-loader本质是一个函数,它在内部通过babel/core这个核心包进行JavaScript代码的转译。






但是针对代码的转译我们需要告诉babel以什么样的规则进行转化,比如我需要告诉babel:“嘿,babel。将我的这段代码转化称为EcmaScript 5版本的内容!”。

此时babel-preset-env在这里充当的就是这个作用:告诉babel我需要以为什么样的规则进行代码转移。

const core = require('@babel/core');

/**
 *
 * @param sourceCode 源代码内容
 * @param options babel-loader相关参数
 * @returns 处理后的代码
 */
function babelLoader(sourceCode, options) {
  // 通过transform方法编译传入的源代码
  core.transform(sourceCode, {
    presets: ['babel-preset-env'],
    plugins: [...]
  });
  return targetCode;
}
这里plugin和prest其实是同一个东西,所以我将plugin直接放在代码中了。同理一些其他的preset或者plugin也是发挥这样的作用。

关于babel的基础基建配置我相信讲到这里大家已经明白了他们对应的职责和基础原理.


Babel相关polyfill内容
何谓polyfill
关于polyfill,我们先来解释下何谓polyfill。

首先我们来理清楚这三个概念:

最新ES语法,比如:箭头函数,let/const。
最新ES Api,比如Promise
最新ES实例/静态方法,比如String.prototype.include
babel-prest-env仅仅只会转化最新的es语法,并不会转化对应的Api和实例方法,比如说ES 6中的Array.from静态方法。babel是不会转译这个方法的,如果想在低版本浏览器中识别并且运行Array.from方法达到我们的预期就需要额外引入polyfill进行在Array上添加实现这个方法。

其实可以稍微简单总结一下,语法层面的转化preset-env完全可以胜任。但是一些内置方法模块,仅仅通过preset-env的语法转化是无法进行识别转化的,所以就需要一系列类似”垫片“的工具进行补充实现这部分内容的低版本代码实现。这就是所谓的polyfill的作用,

针对于polyfill方法的内容,babel中涉及两个方面来解决:

@babel/polyfill

@babel/runtime

@babel/plugin-transform-runtime

我们理清了何谓polyfill以及polyfill的作用和含义后,让我们来逐个击破这两个babel包对应的使用方式和区别吧。

@babel/polyfill

首先我们来看看第一种实现polyfill的方式:

@babel/polyfill介绍

通过babelPolyfill通过往全局对象上添加属性以及直接修改内置对象的Prototype上添加方法实现polyfill。

比如说我们需要支持String.prototype.include,在引入babelPolyfill这个包之后,它会在全局String的原型对象上添加include方法从而支持我们的Js Api。

我们说到这种方式本质上是往全局对象/内置对象上挂载属性,所以这种方式难免会造成全局污染。

应用@babel/polyfill

在babel-preset-env中存在一个useBuiltIns参数,这个参数决定了如何在preset-env中使用@babel/polyfill。

{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": false
        }]
    ]
}
useBuiltIns--"usage"| "entry"| false
false

当我们使用preset-env传入useBuiltIns参数时候,默认为false。它表示仅仅会转化最新的ES语法,并不会转化任何Api和方法。

entry

当传入entry时,需要我们在项目入口文件中手动引入一次core-js,它会根据我们配置的浏览器兼容性列表(browserList)然后全量引入不兼容的polyfill。

Tips:  在Babel7.4。0之后,@babel/polyfill被废弃它变成另外两个包的集成。"core-js/stable"; "regenerator-runtime/runtime";。你可以在这里看到变化,但是他们的使用方式是一致的,只是在入口文件中引入的包不同了。

// 项目入口文件中需要额外引入polyfill
// core-js 2.0中是使用"@babel/polyfill" core-js3.0版本中变化成为了上边两个包
import "@babel/polyfill"

// babel
{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "entry"
        }]
    ]
}
同时需要注意的是,在我们使用useBuiltIns:entry/usage时,需要额外指定core-js这个参数。默认为使用core-js 2.0,所谓的core-js就是我们上文讲到的“垫片”的实现。它会实现一系列内置方法或者Promise等Api。

core-js 2.0版本是跟随preset-env一起安装的,不需要单独安装哦~

usage

上边我们说到配置为entry时,perset-env会基于我们的浏览器兼容列表进行全量引入polyfill。所谓的全量引入比如说我们代码中仅仅使用了Array.from这个方法。但是polyfill并不仅仅会引入Array.from,同时也会引入Promise、Array.prototype.include等其他并未使用到的方法。这就会造成包中引入的体积太大了。

此时就引入出了我们的useBuintIns:usage配置。

当我们配置useBuintIns:usage时,会根据配置的浏览器兼容,以及代码中 使用到的Api 进行引入polyfill按需添加。

当使用usage时,我们不需要额外在项目入口中引入polyfill了,它会根据我们项目中使用到的进行按需引入。






{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "usage",
            "core-js": 3
        }]
    ]
}
关于usage和entry存在一个需要注意的本质上的区别。

我们以项目中引入Promise为例。

当我们配置useBuintInts:entry时,仅仅会在入口文件全量引入一次polyfill。你可以这样理解:

// 当使用entry配置时
...
// 一系列实现polyfill的方法
global.Promise = promise

// 其他文件使用时
const a = new Promise()
而当我们使用useBuintIns:usage时,preset-env只能基于各个模块去分析它们使用到的polyfill从而进入引入。

preset-env会帮助我们智能化的在需要的地方引入,比如:

// a. js 中
import "core-js/modules/es.promise";

...
// b.js中

import "core-js/modules/es.promise";
...
在usage情况下,如果我们存在很多个模块,那么无疑会多出很多冗余代码(import语法)。

同样在使用usage时因为是模块内部局部引入polyfill所以按需在模块内进行引入,而entry则会在代码入口中一次性引入。

usageBuintIns不同参数分别有不同场景的适应度,具体参数使用场景还需要大家结合自己的项目实际情况找到最佳方式。

@babel/runtime

上边我们讲到@babel/polyfill是存在污染全局变量的副作用,在实现polyfill时Babel还提供了另外一种方式去让我们实现这功能,那就是@babel/runtime。

简单来讲,@babel/runtime更像是一种按需加载的解决方案,比如哪里需要使用到Promise,@babel/runtime就会在他的文件顶部添加import promise from 'babel-runtime/core-js/promise'。

同时上边我们讲到对于preset-env的useBuintIns配置项,我们的polyfill是preset-env帮我们智能引入。

而babel-runtime则会将引入方式由智能完全交由我们自己,我们需要什么自己引入什么。

它的用法很简单,只要我们去安装npm install --save @babel/runtime后,在需要使用对应的polyfill的地方去单独引入就可以了。比如:

// a.js 中需要使用Promise 我们需要手动引入对应的运行时polyfill

import Promise from 'babel-runtime/core-js/promise'

const promsies = new Promise()
总而言之,babel/runtime你可以理解称为就是一个运行时“哪里需要引哪里”的工具库。

针对babel/runtime绝大多数情况下我们都会配合@babel/plugin-transfrom-runtime进行使用达到智能化runtime的polyfill引入。

@babel/plugin-transform-runtime

babel-runtime存在的问题

babel-runtime在我们手动引入一些polyfill的时候,它会给我们的代码中注入一些类似_extend(), classCallCheck()之类的工具函数,这些工具函数的代码会包含在编译后的每个文件中,比如:

class Circle {}
// babel-runtime 编译Class需要借助_classCallCheck这个工具函数
function _classCallCheck(instance, Constructor) { //... }
var Circle = function Circle() { _classCallCheck(this, Circle); };
如果我们项目中存在多个文件使用了class,那么无疑在每个文件中注入这样一段冗余重复的工具函数将是一种灾难。

所以针对上述提到的两个问题:

babel-runtime无法做到智能化分析,需要我们手动引入。
babel-runtime编译过程中会重复生成冗余代码。
我们就要引入我们的主角@babel/plugin-transform-runtime。

@babel/plugin-transform-runtime作用
@babel/plugin-transform-runtime插件的作用恰恰就是为了解决上述我们提到的run-time存在的问题而提出的插件。

babel-runtime无法做到智能化分析,需要我们手动引入。

@babel/plugin-transform-runtime插件会智能化的分析我们的项目中所使用到需要转译的js代码,从而实现模块化从babel-runtime中引入所需的polyfill实现。

babel-runtime编译过程中会重复生成冗余代码。

@babel/plugin-transform-runtime插件提供了一个helpers参数。具体你可以在这里查阅它的所有配置参数。

这个helpers参数开启后可以将上边提到编译阶段重复的工具函数,比如classCallCheck, extends等代码转化称为require语句。此时,这些工具函数就不会重复的出现在使用中的模块中了。比如这样:

// @babel/plugin-transform-runtime会将工具函数转化为require语句进行引入

// 而非runtime那样直接将工具模块代码注入到模块中
var _classCallCheck = require("@babel/runtime/helpers/classCallCheck");
var Circle = function Circle() { _classCallCheck(this, Circle); };
配置@babel/plugin-transform-runtime
其实用法原理部分已经在上边分析的比较透彻了,配置这里还有疑问的同学可以评论区给我留言或者移步babel官网查看。

这里为列一份目前它的默认配置:

{
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": false,
        "helpers": true,
        "regenerator": true,
        "version": "7.0.0-beta.0"
      }
    ]
  ]
}
总结polyfill
我们可以看到针对polyfill其实我耗费了不少去将它们之间的区别和联系,让我们来稍微总结一下吧。

在babel中实现polyfill主要有两种方式:

一种是通过@babel/polyfill配合preset-env去使用,这种方式可能会存在污染全局作用域。

一种是通过@babel/runtime配合@babel/plugin-transform-runtime去使用,这种方式并不会污染作用域。

全局引入会污染全局作用域,但是相对于局部引入来说。它会增加很多额外的引入语句,增加包体积。

在useBuintIns:usage情况下其实和@babel/plugin-transform-runtime情况下是类似的作用,

通常我个人选择是会在开发类库时遵守不污染全局为首先使用@babel/plugin-transform-runtime而在业务开发中使用@babel/polyfill。

babel-runtime 是为了减少重复代码而生的。babel生成的代码,可能会用到一些_extend(), classCallCheck() 之类的工具函数,默认情况下,这些工具函数的代码会包含在编译后的文件中。如果存在多个文件,那每个文件都有可能含有一份重复的代码。

babel-runtime插件能够将这些工具函数的代码转换成require语句,指向为对babel-runtime的引用,如 require('babel-runtime/helpers/classCallCheck'). 这样, classCallCheck的代码就不需要在每个文件中都存在了。

作者:19组清风 链接:https://juejin.cn/post/7025237833543581732

作者:19组清风


欢迎关注微信公众号 :前端阳光