12

上一节我们提到了ES6语法转换插件 babel-loader, 然而babel-loader只是webpack调用 babel的一个桥梁。 实际上,babel是一个具有强大语言转换功能的独立程序。它的主要功能是把ES6或者更新的语言语法转换为浏览器可识别的ES5语法。

了解ES6

ES6甚至包括后来出现的ES7都是下一代的JavaScript的语法版本名称。目前chrome已经支持了大部分的ES6语法,而其他一些浏览器还是大多数支持ES5为主。如果要在纯前端兼容低端浏览器,则需要 es6-shim 之类的前端js库解决polyfill的问题,用babel-standalone.js 解决新的es语法转换的问题。

在这里可以看到各个平台对ES6,ES7等的支持情况:http://kangax.github.io/compa...
这个网址非常全,其平台涵盖了所有浏览器、server端平台(包括node),以及各种polyfill对ES语法的支持情况(其实babel-preset-env 这个智能预设就是利用这个对照表进行自动化的插件加载的)。如果要详细看Nodejs所有版本对ES特性的支持情况,在这里可以看到: http://node.green/

至于ES6的语法学习,请参考中文的阮一峰的:http://es6.ruanyifeng.com/#do...
或者我写的 es6语法精要

babel

为了提前使用更新的JavaScript语法,牛人们就发明了babel。通过babel,可以把我们写的ES6语法的代码,转换为ES5语法。这样我们就可以写ES6最终却可以在ES5的浏览器上跑了,岂不快哉。

Babel 把用最新标准编写的 JavaScript 代码向下编译成可以在今天随处可用的版本。 这一过程叫做“源码到源码”编译, 也被称为转换编译(transpiling,是一个自造合成词,即转换+编译。以下也简称为转译)

不过 Babel 的用途并不止于此,它支持语法扩展,能支持像 React 所用的 JSX 语法,同时还支持用于静态类型检查的流式语法(Flow Syntax)。更重要的是,Babel 的一切都是简单的插件,谁都可以创建自己的插件,利用 Babel 的全部威力去做任何事情。

再进一步,Babel 自身被分解成了数个核心模块(babel-core, babel-cli, babel-node),任何人都可以利用它们来创建下一代的 JavaScript 工具。

我对shim、polyfill和transpile的理解

上面我们讲了,babel是一种语言转换的技术。那么其实要想在浏览器里运行更新的语言语法,需要解决2个问题。

  • 一个是新的语法如箭头函数怎么解析
  • 一个是以前没有的类或方法该怎么hack(伪造),如Promise。

对于这种新增的类、方法,我们很容易想到可以在JavaScript运行之前去hack一个自己实现的类,如自己造一个Promise. 这种方法在业界叫做shim或者polyfill技术。比如,如果你想让你的ES6代码支持低端浏览器,这里是一个shim库: https://github.com/paulmillr/...

shim、polyfill所谓的垫片技术,是通过提前加载一个脚本,给宿主环境添加一些额外的功能。从而让宿主拥有更多的能力。例如可以基于JavaScript的原型能力,给Array.prototype增加额外的方法,就可以一定程度上让宿主环境拥有ES6的能力。除了对ES6+之外,我们还得根据项目情况,添加一些额外的shim或者polyfill。比如fetch、requestAnimationFrame 这种浏览器API,如果我们需要兼容IE8,还需要添加 ES5 shim来兼容更早的JS语法

然而,有些功能,是通过shim/polyfill技术难以实现的,比如箭头函数 =>,因为JavaScript自身无法提供这样的能力进行扩展。所以这种能力要想实现,就必然需要进行 语言转换transpile (我在本文也叫做transform,实际上不太严谨),即将代码中的 => 箭头预先转换为ES5的 function 函数。

如果要在浏览器里进行transpile,babel为浏览器提供了一个运行时的转换器babel-standlone. 这个版本内置了大量的babel插件,所以可以直接在浏览器中运行并编译特定标签内的代码(而不需要安装额外的预设或插件),用户的ES6脚本放在script标签之中,但是要注明type="text/babel" (具体使用方法可参考其文档); 因为放在这种script标签内的脚本不会被浏览器执行,所以standalone版本的babel可以读取script标签内容并解析转换和执行它。 这种standalone版本主要用在那些非webpack打包的场景,比如说在线的try-out网站,jsfiddler这样的网站,或者一些APP上内嵌一个V8引擎让你REPL执行ES6语法的场景。

独立版本的babel使用方法类似下面这样:

standalone

由于babel要在浏览器的运行时对你的js代码重新编译一遍执行,性能必然有所降低,因此不适合线上运行的生产环境站点。不过我们后面会讲到如何使用babel对代码进行预编译,这样最终运行在浏览器中的代码就是ES5了,就不存在性能问题了。所以正因为有了前端编译的过程,现在babel这种transform才流行起来。

babel编译代码的几种方式

在前端项目,我们的目的往往是利用babel提前把ES6代码转成ES5代码然后放到浏览器执行,而不是为了立即执行他,这里就要对用babel对代码进行编译成es5的源码,然后再交给浏览器或node平台去执行。下面我们看下几种不同的babel使用方式。

直接执行es6代码

在后端node项目中,可能你会需要直接执行ES6编写的代码来进行测试(一般也只用在mocha等测试场景,生产环节还是建议预编译后再执行)。 基于babel-cli,你是可以实现的直接执行node代码的,因为babel-cli自带了一个babel-node的命令,可以直接执行node.js脚本。

首先安装babel-cli

npm i -g babel-cli

现在babel7之后,babel内置的模块和插件都放在了一个babel的命名空间下。而且一般建议局部安装:

npm install --save-dev @babel/core @babel/cli // babel7的cli安装方式

然后直接执行node脚本:

babel-node es6.js

如果是局部安装的,则可以使用npm scripts

{
  "scripts": {
    "start": "babel-node script.js"
  }
}

或者使用这两种方式:

./node_modules/.bin/babel-node ./index.js   // 所有node版本都支持
npx babel-node ./index.js // npm@5.2.0之后支持

注意,在执行 babel-node 时,你需要配置自己的 .babelrc 文件 (babel7 里面采用 babel.config.js ),开启babel相关的转换插件。否则你代码中的ES6特性等都可能无法使用。比如你不能使用export和import来定义模块。可以说,凡是使用babel的地方,你都必须对babel进行配置,否则babel什么都不会做。

题外话: 为什么学习 webpack 的时候,在没有使用babel的时候,webpack就能转译 esmodule 的模块化语法呢? 答: webpack从版本2之后 确实内置了对 esmodule 的默认转译支持。但不代表它能转换其他语法(如箭头函数)。 所以在webpack中要使用 ES 新特性,还是要安装并配置babel.

现在我们来尝试开启下 node 的ES6语法转换,最简单的办法是使用官方的 env 预设(这已经是babel7默认的建议预设)。先安装这个预设 babel-preset-env:

npm i @babel/preset-env --save-dev // babel7
npm install --save-dev babel-preset-env // 老的babel版本

然后在babelrc 或 babel.config.js(babel7的配置文件) 里配置babel:

// babel.config.js
const presets = [
  ["@babel/env"]
];
module.exports = { presets };
// .babelrc
{
  "presets": ["env"]
}

其实跟 babel.config.js 跟 babelrc 的原理是一样的,只是JavaScript文件更灵活,所以babel7建议使用 babel.config.js。

另外,babel-node 默认是加载了 babel-polyfill 的,所以各种新的API 都能用。 (关于babel-polyfill 和 语法转换之间的情感纠葛,我们后文再讲)

babel-register

Node中另一种直接执行ES6代码的方式是使用 babel-register,该库引入后会重写你的require加载器,让你的Node代码中require模块时自动进行ES6转码。例如在你的 index.js 中使用 babel-register:

// index.js
require('babel-register')
...
require('./abc.js') // abc.js可以用ES6语法编写,require时会自动使用babel编译

另外,需要注意的是,babel-register只会对require命令加载的文件转码,而不会对当前文件转码。所以最好你要设计一个什么都不做的入口让它只做一件事情:就是加载其他模块。另外,由于它是实时转码,所以只适合在开发环境使用。

babel命令

一般外网要上线的代码,都不会用 babel-node 或 babel-standalone 直接去运行时运行的。因此,上线前必须提前编译为目标平台可支持的语法的代码。

如果你已经安装了 @babel/cli, 那么就有了babel和babel-node命令可以用。babel命令就是对源码进行转译的命令。使用方法如下:

# 编译 example.js 输出到 compiled.js
babel example.js -o compiled.js

# 或 整个目录转码
# --out-dir 或 -d 参数指定输出目录
$ babel src --out-dir lib
# 或者
$ babel src -d lib
# -s 参数生成source map文件
$ babel src -d lib -s

看一个例子:

// index.js
// Babel Input: ES2015 arrow function
[1, 2, 3].map((n) => n + 1);

// Babel Output: ES5 equivalent
[1, 2, 3].map(function(n) {
  return n + 1;
});

然后安装babel相关模块,并执行编译:

npm i @babel/cli @babel/core --save-dev // 安装babel
npx babel ./index.js -d dist // 编译index.js 生成到dist目录下

生成结果如下:

// Babel Input: ES2015 arrow function
[1, 2, 3].map(n => n + 1); // Babel Output: ES5 equivalent

[1, 2, 3].map(function (n) {
  return n + 1;
});

跟源码一样,为什么没有编译呢? 因为我们并没有对babel进行配置,在没有任何配置的情况下,babel什么都不会做。我们像上文中babel-node那个例子一样简单安装并配置下env这个预设,就得到了编译结果:

"use strict";

// Babel Input: ES2015 arrow function
[1, 2, 3].map(function (n) {
  return n + 1;
}); // Babel Output: ES5 equivalent

[1, 2, 3].map(function (n) {
  return n + 1;
});

箭头函数已经被编译成了普通函数。

API调用babel实现源码编译

如果想在代码中调用babel API进行转码。则依赖的是babel-core,这时就不用babel-cli了。(理论上,babel-cli也是调用的babel-core而已啦)。我们先来安装babel-core

npm install babel-core --save-dev // 老版本的babel
npm install @babel/core --save-dev // babel7

安装后可以调用babel这个模块的函数进行编译:

var babel = require("@babel/core");
import { transform } from "@babel/core";
import * as babel from "@babel/core";

babel.transform("code();", options, function(err, result) {
  result.code;
  result.map;
  result.ast;
});

一般情况下,我们并不会用API的方式调用babel。这里就不多做讲述了。总之 本质上跟我们通过其他方式调用babel都是一样的,配置方式也是一样的,只是API方式调用时我们的babel配置是通过函数传给babel。具体转码API的使用,可以参考官方文档: https://babeljs.io/docs/core-....

通过babel-loader调用babel

我们大部分情况下,做前端项目是有一套自己的构建、打包过程的,这个过程会对js进行压缩等处理。而这种情况下,我们要用ES6,就可以顺便把babel加入到这个构建过程当中(岂不是更加灵活咯)。而babel也为webpack这类的工具提供了对应的loader。(loader是webpack里的概念哦,有了babel-loader,webpack就能在打包过程中加入babel的强大编译功能了)

其实babel除了能支持webpack,也能支持JavaScript社区所有的主流构建工具,可以访问这里寻找各种构建工具的集成帮助:
http://babeljs.io/docs/setup

babel-loader的使用方法实际上跟你使用命令CLI或者API的方式都是一模一样的。只是这个调用者变成了webpack,webpack执行时其实类似于你通过babel API来转译你的源码。所以他们之间的关系是: webpack依赖babel-loader, babel-loader依赖babel编译相关的包(如babel-core), 而babel编译又依赖自身或社区一些插件(如preset-env等)。

babel-loader 是无法独立存在运行的。在babel-loader的package.json里你会发现有个 peerDependencies,其中就定义了一个依赖项叫做webpack。peerDependencies依赖表示了一个模块所依赖的宿主运行环境(一般各种插件的包内会使用 peerDependencies 来表明自己的宿主依赖)。

看下使用babel-loader时,webpack的配置文件:

{
  module: {
    loaders: [
          {
            test: /\.js$/,
            exclude: /node_modules/,
            use: [
                {
                    loader: 'babel-loader',
                    options: {
                        presets: ['es2015']
                    }
                }
            ]
          }
    ]
  }
}

由于babel有自己的配置文件,所以上面代码中babel-loader中的options配置可以不写,而是放到独立配置文件当中。

babel配置详解

OK,上面呢我们已经学习了调用babel的N种方式。可以说,无论哪种方式调用吗,都离不开babel的配置文件的配置(否则babel什么都不做)。现在我们学习如何配置babel。Babel的配置文件是.babelrc或者babel.config.js(babel7推荐的),存放在项目的根目录下, rc结尾的文件通常代表运行时自动加载的文件、配置。使用Babel的第一步,就是配置这个文件。

因为所有babel命令的执行,都会去读这个文件来作为配置,如果没有配置的话,相当于没有预设转码规则,他是什么都不会做的。

你可以通过配置 插件(plugins)预设(presets,也就是一组插件) 来指示 Babel 去做什么事情。

其格式如下:

{
  "presets": [],
  "plugins": []
}

除了放到 .babelrc 中,该配置还可放到package.json中也可以生效, 如:

插件配置

babel6以后,babel自身只能完成基本的核心功能。并不去做转换任何语法特性的事情。比如 transform-es2015-classes 这个插件就可以让babel转译你代码中的class定义的语法。比如如果在babel6里想用箭头函数,得装上插件:npm install babel-plugin-transform-es2015-arrow-functions。然后设置babelrc配置文件:

{
  "plugins": ["transform-es2015-arrow-functions"]
}

如果要编译react jsx 语法,则可以安装react的插件:

npm install --save-dev @babel/preset-react

babel官方内置插件都在babel的官方仓库package目录下(babel-cli代码也在这): https://github.com/babel/babe...

关于babel6的变化可查看http://jamesknelson.com/the-s...

preset预设

但是这么多插件,写起来非常麻烦。总不能让开发者记住所有插件的功能并且去配置上项目所需要的插件吧。这显然不行,所以有了preset预设。 一个预设就包含了很多插件咯。preset预设是一系列plugin插件的集合,配置了该预设,就不需要配置n个插件了,减少了配置的繁琐。比如使用 preset-es2015 的预设为什么就可以转换class定义这种语法呢,其实就因为 es2015的预设中已经包含了 transform-es2015-classes 这个插件。官方的预设还是在babel的这个仓库里.

babel内置的预设如下:

  • env
  • es2015
  • es2016
  • es2017
  • latest (已经废弃,请用preset-env代替)
  • react
  • flow

还有其他一些非官方的预设,可以在npm上进行搜索: https://www.npmjs.com/search?...

其中,es2015, es2016, es2017分别代表不同ES标准。react、flow是另一个领域的,暂且不表。另外还有 stage-0, stage-1 等预设代表最新标准的提案四个阶段. (stage解释)

$ npm install --save-dev babel-preset-stage-0
$ npm install --save-dev babel-preset-stage-1
$ npm install --save-dev babel-preset-stage-2
$ npm install --save-dev babel-preset-stage-3

例子:

{
    "presets": [
      "es2015",
      "react",
      "stage-2"
    ],
    "plugins": []
  }

如果要使用某个预设,就先安装它。例如 npm i babel-preset-es2015。然后.babelrc中加入如下配置, 把包名的最后那个名字加进去即可:

  {
    "presets": [
      "es2015"
    ],
    "plugins": []
  }

但安装该预设时,需要使用完整的预设名称。

着重介绍preset-env预设

有一个预设叫做 babel-preset-env, 他是一个高级的预设,能编译 ES2015+ 到 ES5,但它是根据你提供的目标浏览器版本和运行时环境来决定使用哪些插件和polyfills。 这个预设是 babel7 里面唯一推荐使用的预设, babel7建议废弃掉其他所有的预设。preset-env的目标是 make your bundles smaller and your life easier

对于preset-env预设来说,如果不做任何配置:

{
  "presets": ["env"]
}

那么preset-env就相当于 babel-preset-latest 这个预设。它包含所有的 babel-preset-es2015, babel-preset-es2016, and babel-preset-es2017 预设。

如果你了解你的目标用户所使用的平台(比如大部分用户都使用了较新的浏览器),那么你大可不必转译所有的特性。你只需要告诉babel让他转译你目标平台现在不支持的语法即可。

此时你需要配置一个数组写法, 且第二个元素是个对象用来配置preset-env的options:

{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["last 2 versions", "safari >= 7"]
      }
    }]
  ]
}

其中 targets字段可以用来指明目标平台和版本等信息。如果是面向node环境,可以指明node环境版本:

"targets": {
  "node": "6.10"
}

可以看到preset-env的options中,最重要的就是这个targets配置。targets中有2个选项,一个叫 node, 一个叫 browsers。node这个key后面可以写一个字符串类型的版本号或者"current", 如果想直接面向其babel运行环境的node版本,则可以改写为这样: "node": "current",此时babel会直接取 process.versions.node 中的版本号。browsers这个字段后面是一个Array类型的字符串数组或者是一个字符串。比如可以是一个字符串:

"targets": {
  "browsers": "> 5%"
}

也可以是个字符串数组:

"targets": {
  "browsers": ["last 2 versions", "ie >= 7"]
}

targets.browsers浏览器版本配置采用了browerslist写法,因此具体写法就去参考这个文档吧。而browserslist的配置是可以配置在多个地方的,其官方建议是配置在package.json中,这也是可以被babel识别的。browserlist的源除了可以配置在package.json中,还可以单独配置在一个叫做.browserslistrc文件中,甚至可以配置在BROWSERSLIST的环境变量中。不过,在babel的 .babelrc 中配置了targets选项时,babel就会忽略其他文件中的browserlist配置. 我个人觉得,在使用babel时就配置在babel的配置文件里就好了。

preset-env还有其他一些配置,如:

  • modules 设置为true可以让babel把你的模块编译为 "amd", ”"umd"或者"commonjs". 在配合webpack使用的时候,一般由webpack打包,因此一般将babel的这个配置设置为false
  • include, exclude, 可以让babel加载或者去除指定名称的插件。适用于我们要自定义改动preset-env的情况。
  • useBuiltIns. 这个配置用来给preset-env这个智能预设添加polyfill的。因为babel只转换语法,不转换API(下文会讲),所以代码中很多API需要根据你设置的targets环境进行polyfill处理,而在preset-env中能根据配置的环境进行智能添加polyfill的过程,就需要useBuiltIns的支持。 这也是在开发web应用(非类库时)使用preset-env时的polyfill最佳实践,下文会讲。

问题:复杂语法转换和babel-polyfill

babel只转换语法,不转换API。babel在语言转换方面,只转换各种ES新语法以及jsx等,但不转换ES6提供的新的API功能,例如Promise、Array的新增的原型、静态方法等。这时就需要polyfill垫片。

我们可以分析下,对于ES6转换为ES5这件事情来说。有几种需要做不同实现的转换类型呢?

大概是这样的:

  1. 一种是仅仅是语法糖的区别,比如箭头函数能直接转为ES5的function;
  2. 一种是API方法或类的。比如Array.from是ES6新加的API,ES5没有这方法。babel要想提供只能提前给实现这个方法。
  3. 一种是既是新语法,但ES5也没有能直接对应的语法. babel要想实现这个,就既要做语法变换,又要提前提供一些辅助函数。比如 class类声明以及async这些,你不能简单的转换成一个 ES5 的映射,你需要一些辅助函数配合。

babel是怎么处理这些情况的呢?

  1. 对于第一种,babel是通过上文讲到的插件直接进行代码翻译即可,很容易理解,也很简单; 这是上文讲到的babel+presetEnv预设所完成的。
  2. 对于第二种情况,为了解决这个问题,babel使用一种叫做 Polyfill(代码填充,也可译作兼容性补丁) 的技术。 简单地说,polyfill 即是在当前运行环境中用来复制(意指模拟性的复制,而不是拷贝)尚不存在的原生 api 的代码。 能让你提前使用还不可用的 APIs,Array.from 就是一个例子。Babel 用了优秀的 core-js 用作 polyfill。
  3. 对于第三种情况,babel采用的方法是:编译你代码的过程中如果发现了这种语法,就会把你的语法包装成另一种ES5实现的语法,但是由于实现比较复杂,所以除了对语法进行转换之外,还需要辅助函数的配合,因此你会发现有运行时函数插入到代码的最上方。

我们来看一段代码:

// 原型方法
[1, 2, 3].map((n) => n + 1);

// 新类型
var a = new Promise(function (resolve, reject) {
    resolve('123')
})
a.then(d => console.log(d))

// 新的class语法
class Foo {
    method() {}
}

// 新的async语法
async function testAsyncFn() {
    var a = await Promise.resolve('ok')
    return a
}
testAsyncFn().then(data=>{console.log(data)})

这段代码中包含了上面我提到的3种情形: 新箭头语法、原型/静态方法/新类型、新的复杂语法class/async。 我们使用 preset-env的默认设置对它进行编译(preset-env预设的默认设置意味着对最新的所有ES特性都进行转换)。 转换结果如下:

"use strict";

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }

function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }

function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }

// 原型方法
[1, 2, 3].map(function (n) {
  return n + 1;
}); // 新类型

var a = new Promise(function (resolve, reject) {
  resolve('123');
});
a.then(function (d) {
  return console.log(d);
}); // 新的class语法

var Foo =
/*#__PURE__*/
function () {
  function Foo() {
    _classCallCheck(this, Foo);
  }

  _createClass(Foo, [{
    key: "method",
    value: function method() {}
  }]);

  return Foo;
}(); // 新的async语法


function testAsyncFn() {
  return _testAsyncFn.apply(this, arguments);
}

function _testAsyncFn() {
  _testAsyncFn = _asyncToGenerator(
  /*#__PURE__*/
  regeneratorRuntime.mark(function _callee() {
    var a;
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return Promise.resolve('ok');

          case 2:
            a = _context.sent;
            return _context.abrupt("return", a);

          case 4:
          case "end":
            return _context.stop();
        }
      }
    }, _callee, this);
  }));
  return _testAsyncFn.apply(this, arguments);
}

testAsyncFn().then(function (data) {
  console.log(data);
});

分析转换结果,我们可以看到:

  1. 对于普通的ES语法如箭头函数,babel会通过你preset-env指定的插件完成语法转换。把箭头函数转为ES5的函数写法
  2. 对于ES5中没有的原型方法和静态方法,babel自身在进行语法转换时,并不关注这一点。这个需要交给polyfill垫片js库来完成。
  3. 对于复杂的语法如class/async,babel的preset-env里面包含了对这类语法的转换。但是这类语法由于其比较复杂,所以会产生辅助函数,而且这些辅助函数的实现代码会注入到转译后的文件里。 如class的实现需要_createClass和classCallback函数,这两个函数就注入到了编译结果代码里。

由此可见,由于默认的preset-env配置是转换所有的ES6语法,所以我们的箭头函数、async、class都被启用了相应的插件进行转换,并且转换成功了。 现在伤脑筋的问题有两个:

  1. 原型方法、静态方法等都无法转换,包括但不限于Array.from Object.assign Array.prototype.includes. 那么,我们上文也说了,这种活应该交给polyfill(比如在页面里引入一个shim.js),那么babel有没有提供相应的polyfill办法呢? 答案是有的,它就是 babel-polyfill.
  2. 复杂的ES语法,经过转换后会生成一坨函数实现代码在文件里。如果只有一个文件/模块还好,如果有 a.js, b.js, c.js 等多个模块文件,babel编译后每个js文件里都有一堆重复的 _createClass函数实现;如果再未来用webpack对他们打包上线,则会导致打包里面每个模块里也包含重复的_createClass函数实现(因为这个函数在每个js文件里相当于是个私有函数)

怎么办呢? 下面我们来分别分析一下这俩伤脑筋的问题如何解决。

如何解决语法API无法被转换的问题

【备注:此小节是3级标题】

babel自身只转换语法,不负责hack语法的API。这个一般用polyfill代码实现。其实用一个polyfill垫片库最简单的方式就是全量引入了。如果你是希望在执行代码的页面里进行垫片,则在页面中引入babel-polyfill的页面版本即可:

使用 babel-polyfill/dist/polyfill.js

如果希望在预编译阶段引入到业务代码中,你可以 require 到业务代码的开头;未来打包到bundle.js的时候就能加载polyfill的代码了。步骤如下

  • 首先用 npm 安装它:
$ npm install --save babel-polyfill // 要作为运行依赖哦,因为polyfill要最终交给浏览器执行
$ npm install @babel/polyfill // babel7 版本的安装方式
  • 然后只需要在入口文件最顶部导入 polyfill 就可以了:import "babel-polyfill" . babel7需要使用 import @babel/polyfill. 如果是webpack可以作为entry数组的第一项。具体官方文档

示例代码:

// polyfill.js
import 'babel-polyfill'
console.log([1, 2, 3].includes(2))
console.log(Object.assign({}, {a: 1}))
console.log(Array.from([1,2,3]))

// babel.config.js
const presets = [
    ["@babel/env"]
];
module.exports = { presets };

用这个 preset-env 的默认配置进行 npx babel ./polyfill.js -d dist 编译,得到:

"use strict";

require("babel-polyfill");

console.log([1, 2, 3].includes(2));
console.log(Object.assign({}, {
  a: 1
}));
console.log(Array.from([1, 2, 3]));

可以发现,babel编译的过程,除了对js模块代码进行了上文讲述的必要的语法转译外,并没有做任何事情。对于此案例,仅仅就是把esmodule语法转译为commonjs语法(因为你源码中写了import这样的es模块引用的代码)。 但实际上,我们这段代码在经过webpack等工具打包放入页面后,是可以polyfill的,因为打包后 require('babel-polyfill') 这一句会把babel-polyfill的代码打包进来。

所以,可以看出来,垫片这个事情跟babel的转译其实无关。是因为我们在页面或代码开头引入了一些babel-polyfill的垫片代码,所以才让我们的业务代码可以使用一些新的API特性。babel-polyfill 可以垫片的API包括这些:

垫片能力

仔细研究babel-polyfill的话就会发现,这个包其实就是依靠 core-jsregenerator-runtime 实现了所有的shim/polyfill。所以在babel-polyfill这个npm包里面,只有一个index.js文件,里面直接引用了这两个npm库而已。

会看到babel-polyfill引用了core-js/shim.js, 其实shim.js这个文件就是把core-js包里的所有polyfill的API暴漏出来。

虽然polyfill的使用很简单,甚至跟babel都没有多少关系。可是现在问题来了:

  1. 如果你的代码是要支持chrome的某个较新版本即可,由于chrome已经支持了大部分的ES6能力,可能你只需polyfill该版本chrome尚不支持的少量API即可;结果却引入了一个庞然大物babel-polyfill。能不能根据目标平台的支持情况来精简polyfill呢?
  2. 你的业务代码中可能仅仅使用了一个Object.assign和Promise,结果却要引入一个庞然大物 babel-polyfill。能不能根据代码中用到的API来精简polyfill引入呢?
  3. 尽管polyfill的目的就是能全局hack API,但是有些时候比如你开发的是一个类库。你可能仅仅希望局部去hack一下你用到的这个API就好了,不要影响外部环境。能不能只在局部hack我的Array.from呢?

优化是无止境的,让我们看看怎么解决上面问题呢?

1.根据目标平台的支持情况引入需要的polyfill

【备注,此小节已经是4级标题】

恭喜,这个能力已经被 preset-env 这个预设所支持了。只要你打开preset-env预设的这个特性,那么preset-env就能自动根据你配置的env targets,按照目标平台的支持情况引入对应平台所需的polyfill模块。来个例子:

// babel.config.js 配置
const presets = [
    ["@babel/env", {
        targets: {
            node: '0.10.42',
            // node: 'current'
        },
        useBuiltIns: 'usage' // 这里是关键,要配置为 usage
    }]
];

module.exports = { presets };

编译如下源码:

import 'babel-polyfill'
console.log([1, 2, 3].includes(2))
console.log(Object.assign({}, {a: 1}))
console.log(Array.from([1,2,3]))
console.log(new Promise())
console.log(Object.defineProperties())
console.log([1,2,3].flat())

由于目标平台是node的0.10版本,这个版本是不支持Object.assign, Array.from 这些API的。因此编译结果中就引入了该平台所需要的polyfill模块:

"use strict";

require("core-js/modules/es6.promise");

require("core-js/modules/es6.array.from");

require("core-js/modules/es6.object.assign");

require("core-js/modules/es7.array.includes");

require("core-js/modules/es6.string.includes");

require("babel-polyfill");

console.log([1, 2, 3].includes(2));
console.log(Object.assign({}, {
  a: 1
}));
console.log(Array.from([1, 2, 3]));
console.log(new Promise());
console.log(Object.defineProperties());
console.log([1, 2, 3].flat());

注意到我们上面除了preset-env帮我们按需引入的polyfill之外,还有个 require('babel-polyfill') 的代码。这一行是多余的,因此,当我们开启了 preset-env 的useBuiltIns能力后,源码中就不要再import babel-polyfill 了。

另外就是发现:这里除了只加载了目标平台支持的,还跟进一步只加载了我代码中用到的。这是因为我们把UserBuiltIns设置为usage。如果设置为 etnry,则只加载目标平台不支持的那些特性的polyfill,而不会根据代码使用情况来加载(这在性能上要快一些)。不过,useBuiltIns: 'entry' 是替换import "@babel/polyfill" / require("@babel/polyfill") 语句为独立的(根据环境)需要引入的polyfill特性的import语句,因此你必须在源码中显式声明 imoprt 'babel-polyfill'

2. 根据代码中用到的API来加载polyfill

通过上面的 useBuiltIns 案例我们已经发行,preset-env 开启了useage的 useBuiltIns之后,它既能够根据目标平台来选择性的引入polyfill,而且它引入的polyfill是你业务代码中用到的,并不会把所有平台不支持的polyfill都引入。

这一点在 @babel/preset-env@7.0 版本我验证是 OK 的, 在之前的版本中我曾经测试发现preset-env不能实现按需引入。 应该是在7.0版本修复了这个问题。

3.局部hack

babel-polyfill有个缺点,就是污染了宿主全局环境。此时有个babel-runtime的包可以解决局部使用的问题,babel-runtime更像是分散的 polyfill 模块,我们可以在自己的模块里单独引入,比如 var innerPromise = require(‘babel-runtime/core-js/promise’) ,它们不会在全局环境添加未实现的方法. 这样你在使用Promise的时候就要这样了:

var innerPromise = require(‘babel-runtime/core-js/promise’)
var a = new innerPromise(...)

可是,自己去发现并改写业务代码里的API调用未免有点麻烦了. 这里就有个插件来帮忙做这个事情了: babel-transform-runtime 插件。 首先安装它:

npm install --save-dev @babel/plugin-transform-runtime // babel7的安装方式
npm install --save @babel/runtime // 这个要作为运行依赖

然后我们配置下transform-runtime插件:

// babel.config.js
const presets = [
    ["@babel/env", {
        targets: {
            node: '0.10.42',
            // node: 'current'
        },
        useBuiltIns: 'usage'
    }]
];

const plugins = [
    ["@babel/plugin-transform-runtime", {
        "corejs": 2, // 只能设置为 undefined,false,2
        "helpers": true,
        "regenerator": true,
        "useESModules": false
    }]
]


module.exports = { presets, plugins };

我们执行编译看下结果:

"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs2/helpers/interopRequireDefault");

var _defineProperties = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/object/define-properties"));

var _promise = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/promise"));

var _from = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/array/from"));

var _assign = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/object/assign"));

require("core-js/modules/es7.array.includes");

require("core-js/modules/es6.string.includes");

console.log([1, 2, 3].includes(2));
console.log((0, _assign.default)({}, {
  a: 1
}));
console.log((0, _from.default)([1, 2, 3]));
console.log(new _promise.default());
console.log((0, _defineProperties.default)());
console.log([1, 2, 3].flat());

仿佛很完美的样子, 所有的ES6特性,都被 transform-runtime 编译成了对 corejs2的函数调用,而且是按照实际的使用情况按需引用和改写的。 不过这里有个疑惑点:就是 [1,2,3].includes 这种我们在网上经常看到资料说 transform-runtime 无法做到的这里也做到了,这是为什么呢?

实际上之所以上面编译后出现:

require("core-js/modules/es7.array.includes");
require("core-js/modules/es6.string.includes");

是因为 preset-env 的 useBuiltIns 设置导致的。 我们知道preset-env的useBuiltIns可以按需在全局进行polyfill,所以才出现了这个垫片。 因此可以说,transform-runtime开启corejs的方案和babel-runtime的方案是互斥的,最好不要同时polyfill。transform-runtime的确无法解决实例的原型方法调用的hack问题。(当然由于transform-runtime常建议用在类库项目里,所以这种实例写法问题不大,只需类库开发者自己文档提醒开发者要在全局做includes的polyfill)

另外要注意的一点是:transform-runtime使用core-js:2的配置进行polyfill时,无法感知你目标平台环境(即不能像preset-env一样感知目标平台)。因此局部polyfill时务必要知道这一点,也就说只要你局部polyfill,你设置的preset-env环境跟你polyfill的效果无关(事实上,preset-env跟transform-runtime本来就是两个东西)

如何解决复杂语法转换后重复问题

实际上babel-runtime里不止包含了所有ES6的API(即core-js),也包含了ES6语法转换时需要的那些辅助函数helpers, 也包含了async和生成器的实现(即regenerator-runtime)。仔细观察babel-runtime的包依赖也可以证实这一点. 所以 transform-runtime 的方案也不止用来局部hack polyfill,也会用在上文中提到的另外一个疑难问题: “复杂语法编译后多文件重复” 的问题。

上文的例子中,我们看到,代码中使用了ES7的async,babel会使用了定制化的 regenerator 来让 generators(生成器)和 async functions(异步函数)正常工作。 但这个regenerator函数会插入到编译后代码的最上方。如果源码中使用了ES6的class,也会出现类似的 _createClass 等函数的实现代码放在代码模块文件的上方。

此时,如果有多个js模块文件,每个文件编译后都会有自己文件内的辅助函数插入,非常影响将来的打包合并。(会导致打包后每个js factory工厂函数模块里都有重复代码)

要解决这个问题,我们其实可以想到办法:

如果是用自己写代码的思路来看,根据DRY原则,如果每个js文件里都使用同一个函数如_createClass, 那么我们最好把他们放到一个单独的文件/模块里,然后需要的时候require它。 这样写的话,最终webpack等工具打包的时候会以模块为粒度打包,大家都依赖的这个模块只会存在一份,不会存在重复。

所以上文讲到的 _classCallback 这些辅助函数其实可以改为 var _classCallCheck2 = require('babel-runtime/helpers/classCallCheck'); 这样从babel-runtime包种引用。 是不是跟上面局部hack polyfill很像啊?

是的,跟局部polyfill的原理一样,我们可以让代码中的复杂ES6语法如class、async,自动引入对应的babel-runtime辅助函数。解决办法:一样是借助 transform-runtime 插件来自动化处理这一切。

步骤:插件安装方式跟上文一样

npm install --save-dev @babel/plugin-ransform-runtime
npm install --save @babel/runtime // runtime是运行时依赖

然后修改 babel.config.js 的配置为:

// babel.config.js
const presets = [
    ["@babel/env", {
        targets: {
            node: '0.10.42',
            // node: 'current'
        },
        useBuiltIns: 'usage'
    }]
];
const plugins = [
    ["@babel/plugin-transform-runtime", {
        "corejs": 2,
        "helpers": true,
        "regenerator": true,
        "useESModules": false
    }]
]
module.exports = { presets, plugins };

这样再运行babel编译时,这个插件会把这种generator或者class的运行时的定义移到单独的文件里。 我们看下编译示例:

// 源码
console.log(Object.assign({}, {a: 1}))
console.log(new Promise())

// 新的class语法
class Foo {
    method() {}
}

编译结果如下:

// 编译结果
"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs2/helpers/interopRequireDefault");

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs2/helpers/classCallCheck"));

var _createClass2 = _interopRequireDefault(require("@babel/runtime-corejs2/helpers/createClass"));

var _promise = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/promise"));

var _assign = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/object/assign"));

console.log((0, _assign.default)({}, {
  a: 1
}));
console.log(new _promise.default()); // 新的class语法

var Foo =
/*#__PURE__*/
function () {
  function Foo() {
    (0, _classCallCheck2.default)(this, Foo);
  }

  (0, _createClass2.default)(Foo, [{
    key: "method",
    value: function method() {}
  }]);
  return Foo;
}();

但是 假如我们这是一个Web应用,我们发现上面的编译结果是有问题的。由于transform-runtime的存在,导致我们本该全局polyfill的静态方法变成了局部polyfill。 这个原因就是transform-runtime导致的。不过幸好,tranform-runtime是可配置的,我们可以配置他是否局部hack polyfill, 是否局部hack helpers, 是否局部x修正regenertor。

对于web应用 我们一般是希望:

  1. ES6复杂语法转换可以不重复打包。(用transform-runtime配合babel-runtime来实现)
  2. polyfill能够按需引入并全局polyfill(用preset-env配合开启useBuiltIns实现)

因此这种场景下正确的babel配置应该是这样的:

// babel.config.js
const presets = [
    ["@babel/env", {
        targets: {
            node: '0.10.42',
            // node: 'current'
        },
        useBuiltIns: 'usage'
    }]
];

const plugins = [
    ["@babel/plugin-transform-runtime", {
        "corejs": false,
        "helpers": true,
        "regenerator": true,
        "useESModules": false
    }]
]
module.exports = { presets, plugins };

最佳实践和取舍

小总结

首先,上面讲了那么多polyfill和语法转换的使用和优化方式。我们可以看到要想正确配置babel需要看我们的需要和场景。而且,作为babel的使用者,我们需要理解几个个概念:helper, 垫片函数,一个是垫片库,一个是regenerator-runtime。

  • helper是为了帮助你构造ES6的ES5 class实现和generator实现的辅助函数
  • 而垫片是为了实现ES6的ES5版本的API,如Array.from. core-js这个垫片函数库是对某个API的具体实现。regenerator-runtime也是一些垫片函数,只是它特定地用来实现ES6里面的generaotor语法。
  • 垫片库是指的一个调用了各个垫片函数再对页面全局进行污染的垫片库,如babel-polyfill

如此,我们就能明白babel-polyfill只是为实现API垫片为目的的一个库,可以全局污染来垫片。它包含了core-js和regeneraor-runtime两个垫片库的实现,core-js垫片用于普通的API垫片实现,regenerator-runtime垫片用于实现generator生成器。

babel-runtime是什么?它不是一个可以直接用的库(它的package.json里都没有main),可以认为它是core-js、regenerator-runtime、helpers函数的集合。它的corejs和regeneratorRuntime可以帮助你局部不污染全局的按需加载polyfill,它的helpers可以帮助你改变babel编译async等语法带来的辅助函数重复问题。当然,在局部利用babel-runtime里的使用某个垫片函数或helpers函数时,一般都不是手工操作,而是通过transform-runtime插件来完成。

类库项目

对于类库项目来说,你可以使用最新的语法特性,然后用babel+presetEnv进行语法编译后释出一个ES5的dist.js。但你代码中使用的API你不能直接全局给他polyfill掉,哪怕你按需polyfill也不好,因为这会污染全局环境。你在未知你的调用者环境的情况下,你不能污染全局。所以,类库中最好的polyfill方式是局部polyfill(利用transform-runtime或手工引入core-js的module)。在babel官方polyfill文档里有提到这个小细节

If you are looking for something that won't modify globals to be used in a tool/library, checkout the transform-runtime plugin. This means you won't be able to use the instance methods mentioned above like Array.prototype.includes.

Depending on what ES2015 methods you actually use, you may not need to use @babel/polyfill or the runtime plugin. You may want to only load the specific polyfills you are using (like Object.assign) or just document that the environment the library is being loaded in should include certain polyfills.

也就是说,如果你是开发一个类库项目,那么你一般是不要污染全局的。如果你不想污染全局,你可以用transform-runtime配合 babel-runtime的方案,但是这个方案 无法解决实例的原型方法的polyfill问题 这个缺点你必须要注意。 而如果你很明显地知道这个类库调用了哪些较新的API(你的客户环境可能会不支持的API),那么你就不要使用 @babel/polyfill 或 babel-runtime方案了,你可以直接手工走core-js来加载它,或者你在你的类库文档里告诉你的开发者说你这个类库需要依赖什么polyfill。

Web应用项目

这种项目由于不怕全局polyfill污染,因此一般采用全局polyfill的方式。不过为了提高页面性能,一般也通过 preset-env 配合 useBuiltIns配置的方式实现按需加载polyfill。注意,现在版本的preset-env如果开启了useBuildIns,你就不要自己在代码的开头出引用babel-polyfill了。

至于复杂语法转换带来的辅助函数问题,就靠 transform-runtime来解决了。注意不要开启 core-js选项,从而避免局部polyfill(因为你已经preset-env+useBuiltIns使用了全局polyfill的方式)。

关于async语法

async generator转换成新的辅助函数后,到底需要依赖哪些东西才能正常运行?

经过我的测试发现,它需要依赖两个polyfill:

  • regenerator-runtime这个polyfill(因为helpers辅助函数是不够的)。
  • promise的polyfill(因为转译后的代码中用到了promise)

我们进行preset-env+useBultIns的全局转换,可以看到结果里面自动引入了需要的polyfill:

require("regenerator-runtime/runtime");

require("core-js/modules/es6.promise");


function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }

function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

参考issue:
5085
112

我们再试试用transform-runtime来局部polyfill,可以看到结论是一样的:

var _interopRequireDefault = require("@babel/runtime-corejs2/helpers/interopRequireDefault");

var _regenerator = _interopRequireDefault(require("@babel/runtime-corejs2/regenerator"));

var _promise = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/promise"));

var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime-corejs2/helpers/asyncToGenerator"));

只是这个promise和regenerator-runtime的polyfill换成了babel-runtime(或runtime-corejs2)里面的。

babel与mocha和lint结合使用

用ES6写代码之后,测试有时也希望使用ES6来编写。而且eslint进行代码检查时也要利用babel进行转换。关于结合mocha的使用将在后面的文章讲解。eslint的使用请参看博文[[实践]-使用ESLINT检查代码规范]()

总之,测试这些环节执行ES6的测试用例代码时就不需走编译步骤了。由于不在乎性能,因此可以直接走实时编译执行的模式。

mocha --compilers js:babel-core/register --require babel-polyfill

babel不止于ES

现在流行框架,都在使用babel进行框架特有的语法转换。例如除了react,还有Vue2.0的jsx

我们也可以写自己的babel插件,详情可参考手册: https://github.com/thejamesky...
官方脚手架:https://github.com/babel/gene...

下一节,就用这些知识点真正搭建一个类库开发项目了。

Refer

babel-handbook中文
babel 7 教程
babel-preset-env
https://babeljs.io/docs/plugi...
https://github.com/brunoyang/...
你真的会用 Babel 吗?
21 分钟精通前端 Polyfill 方案
https://leanpub.com/setting-u...
babel笔记
测试external-helper
creeperyang的博客
Babel 入门教程(三):babel-plugin- 插件及与 babel-preset- 预设插件的关系
Babel 入门教程(六):babel-polyfill 与 相关插件和包


sheldon
947 声望1.6k 粉丝

echo sheldoncui


引用和评论

0 条评论