1. 程式人生 > 實用技巧 >前端科普系列(4):Babel —— 把 ES6 送上天的通天塔

前端科普系列(4):Babel —— 把 ES6 送上天的通天塔

本文首發於 vivo網際網路技術 微信公眾號
連結: https://mp.weixin.qq.com/s/plJewhUd0xDXh3Ce4CGpHg
作者:Morrain

一、前言

在上一節 《CommonJS:不是前端卻革命了前端》中,我們聊到了 ES6 Module,它是 ES6 中對模組的規範,ES6 是 ECMAScript 6.0 的簡稱,泛指 JavaScript 語言的下一代標準,它的第一個版本 ES2015 已經在 2015 年 6 月正式釋出,本文中提到的 ES6 包括 ES2015、ES2016、ES2017等等。在第一節的《Web:一路前行一路忘川》中也提到過,ES2015 從制定到釋出歷經了十幾年,引入了很多的新特性以及新的機制,瀏覽器對 ES6 的支援進度遠遠趕不上前端開發小哥哥們使用 ES6 的熱情,於是矛盾就日益顯著……

二、Babel 是什麼

先來看下它在官網上的定義:

Babel is a JavaScript compiler

沒錯就一句話,Babel 是 JavaScript 的編譯器。至於什麼是編譯器,可以參考the-super-tiny-compiler這個專案,可以找到很好的答案。

本文是以 Babel 7.9.0 版本進行演示和講解的,另外建議學習者閱讀英文官網,中文官網會比原版網站慢一個版本,並且很多依然是英文的。

Babel 就是一套解決方案,用來把 ES6 的程式碼轉化為瀏覽器或者其它環境支援的程式碼。注意我的用詞哈,我說的不是轉化為 ES5 ,因為不同型別以及不同版本的瀏覽器對 ES6 新特性的支援程度都不一樣,對於瀏覽器已經支援的部分,Babel 可以不轉化,所以 Babel 會依賴瀏覽器的版本,後面會講到。這裡可以先參考

browerslist專案。

Babel 的歷史

在學習任何一門知識前,我都習慣先了解它的歷史,這樣才能深刻理解它存在意義。

Babel 的作者是 FaceBook 的工程師 Sebastian McKenzie。他在 2014 年釋出了一款 JavaScript 的編譯器 6to5。從名字就能看出來,它主要的作用就是將 ES6 轉化為 ES5。

這裡的 ES6 指 ES2015,因為當時還沒有正式釋出, ES2015 的名字還未被正式確定。

於是很多人評價,6to5 只是 ES6 得到支援前的一個過渡方案,它的作者非常不同意這個觀點,認為 6to5 不光會按照標準逐步完善,依然具備非常大的潛力反過來影響並推進標準的制定。正因為如此 6to5 的團隊覺得 '6to5' 這個名字並沒有準確的傳達這個專案的目標。加上 ES6 正式釋出後,被命名為 ES2015,對於 6to5 來說更偏離了它的初衷。於是 2015 年 2 月 15 號,6to5 正式更名為 Babel。

(圖片來源於網路)

Babel 是巴比倫文化裡的通天塔,用來給 6to5 這個專案命名真得太貼切了!羨慕這些牛逼的人,不光程式碼寫得好,還這麼有文化,不像我們,起個變數名都得憋上半天,吃了沒有文化的虧。這也是為什麼我把這篇文章起名為 《Babel:把 ES6 送上天的通天塔》的原因。

三、Babel 怎麼用

瞭解了 Babel 是什麼後,很明顯我們就要開始考慮怎麼使用 Babel 來轉化 ES6 的程式碼了,除了 Babel 本身提供的 cli 等工具外,它還支援和其它打包工具配合使用,譬如 webpack、rollup 等等,可以參考官網對不同平臺提供的配置說明

本文為了感受 Babel 最原始的用法,不結合其它任何工具,直接使用 Babel 的 cli 來演示。

1、構建 Babel 演示的工程

使用如下命令構建一個 npm 包,並新建 src 目錄 和 一個 index.js 檔案。

npm init -y

package.json 內容如下:

{
  "name": "demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

2、安裝依賴包

npm install --save-dev @babel/core @babel/cli @babel/preset-env

後面會介紹這些包的作用,先看用法

增加 babel 命令來編譯 src 目錄下的檔案到 dist 目錄:

{
  "name": "demo",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.js",
  "scripts": {
    "babel": "babel src --out-dir dist",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/cli": "^7.8.4",
    "@babel/core": "^7.9.0",
    "@babel/preset-env": "^7.9.0"
  }
}

3、增加 Babel 配置檔案

在工程的根目錄新增 babel.config.js 檔案,增加 Babel 編譯的配置,沒有配置是不進行編譯的。

const presets = [
  [
    '@babel/env',
    {
      debug: true
    }
  ]
]
const plugins = []
 
module.exports = { presets, plugins }

上例中 debug 配置是為了打印出 Babel 工作時的日誌,可以方便的看來,Babel 轉化了哪些語法。

  1. presets 主要是配置用來編譯的預置,plugins 主要是配置完成編譯的外掛,具體的含義後面會講
  2. 推薦用 Javascript 檔案來寫配置檔案,而不是 JSON 檔案,這樣可以根據環境來動態配置需要使用的 presets 和 plugins
const presets = [
  [
    '@babel/env',
    {
      debug: true
    }
  ]
]
const plugins = []
 
if (process.env["ENV"] === "prod") {
  plugins.push(...)
}
 
module.exports = { presets, plugins }

4、編譯的結果

配置好後,我們執行 npm run babel 命令,可以看到 dist 資料夾下生成了 index.js 檔案,內容如下所示:

// src/index.js
const add = (a, b) => a + b
 
// dist/index.js
"use strict";
 
var add = function add(a, b) {
  return a + b;
};

可以看到,ES6 的 const 被轉化為 var ,箭頭函式被轉化為普通函式。同時打印出來如下日誌:

> babel src --out-dir dist
 
@babel/preset-env: `DEBUG` option
 
Using targets:
{}
 
Using modules transform: auto
 
Using plugins:
  proposal-nullish-coalescing-operator {}
  proposal-optional-chaining {}
  proposal-json-strings {}
  proposal-optional-catch-binding {}
  transform-parameters {}
  proposal-async-generator-functions {}
  proposal-object-rest-spread {}
  transform-dotall-regex {}
  proposal-unicode-property-regex {}
  transform-named-capturing-groups-regex {}
  transform-async-to-generator {}
  transform-exponentiation-operator {}
  transform-template-literals {}
  transform-literals {}
  transform-function-name {}
  transform-arrow-functions {}
  transform-block-scoped-functions {}
  transform-classes {}
  transform-object-super {}
  transform-shorthand-properties {}
  transform-duplicate-keys {}
  transform-computed-properties {}
  transform-for-of {}
  transform-sticky-regex {}
  transform-unicode-regex {}
  transform-spread {}
  transform-destructuring {}
  transform-block-scoping {}
  transform-typeof-symbol {}
  transform-new-target {}
  transform-regenerator {}
  transform-member-expression-literals {}
  transform-property-literals {}
  transform-reserved-words {}
  transform-modules-commonjs {}
  proposal-dynamic-import {}
 
Using polyfills: No polyfills were added, since the `useBuiltIns` option was not set.
Successfully compiled 1 file with Babel.

四、Babel 工作原理

在瞭解瞭如何使用後,我們一起來探尋一下編譯背後的事情,同時會熟悉 Babel 的組成和進階用法。

1、Babel 工作流程

前面提到 Babel 其實就是一個純粹的 JavaScript 的編譯器,任何一個編譯器工作流程大致都可以分為如下三步:

  • Parser 解析原始檔

  • Transfrom 轉換

  • Generator 生成新檔案

Babel 也不例外,如下圖所示:

(圖片來源於網路)

因為 Babel 使用是acorn這個引擎來做解析,這個庫會先將原始碼轉化為抽象語法樹 (AST),再對 AST 作轉換,最後將轉化後的 AST 輸出,便得到了被 Babel 編譯後的檔案。

那 Babel 是如何知道該怎麼轉化的呢?答案是通過外掛,Babel 為每一個新的語法提供了一個外掛,在 Babel 的配置中配置了哪些外掛,就會把外掛對應的語法給轉化掉。外掛被命名為@babel/plugin-xxx 的格式。

2、Babel 組成

(1)@babel/preset-env

上面提到過 @babel/preset-* 其實是轉換外掛的集合,最常用的就是 @babel/preset-env,它包含了 大部分 ES6 的語法,具體包括哪些外掛,可以在 Babel 的日誌中看到。如果原始碼中使用了不在 @babel/preset-env 中的語法,會報錯,手動在 plugins 中增加即可。

例如 ES6 明確規定,Class 內部只有靜態方法,沒有靜態屬性。但現在有一個提案提供了類的靜態屬性,寫法是在例項屬性的前面,加上 static 關鍵字。

// src/index.js
const add = (a, b) => a + b
 
class Person {
  static a = 'a';
  static b;
  name = 'morrain';
  age = 18
}

編譯時就會報如下錯誤:

根據報錯的提示,新增 @babel/plugin-proposal-class-properties 即可。

npm install --save-dev @babel/plugin-proposal-class-properties
點選並拖拽以移動

  

// babel.config.js
const presets = [
  [
    '@babel/env',
    {
      debug: true
    }
  ]
]
const plugins = ['@babel/plugin-proposal-class-properties']
 
module.exports = { presets, plugins }

@babel/preset-env 中還有一個非常重要的引數 targets,最早的時候我們就提過,Babel 轉譯是按需的,對於環境支援的語法可以不做轉換的。就是通過配置targets屬性,讓 Babel 知道目標環境,從而只轉譯環境不支援的語法。如果沒有配置會預設轉譯所有 ES6 的語法。

// src/index.js
const add = (a, b) => a + b
 
// dist/index.js  沒有配置targets
"use strict";
 
var add = function add(a, b) {
  return a + b;
};

按如下配置targets

// babel.config.js
const presets = [
  [
    '@babel/env',
    {
      debug: true,
      targets: {
        chrome: '58'
      }
    }
  ]
]
const plugins = ['@babel/plugin-proposal-class-properties']
 
module.exports = { presets, plugins }

編譯後的結果如下:

// src/index.js
const add = (a, b) => a + b
 
// dist/index.js  配置targets  chrome 58
"use strict";
 
const add = (a, b) => a + b;

可以看到 const 和箭頭函式都沒有被轉譯,因為這個版本的 chrome 已經支援了這些特性。可以根據需求靈活的配置目標環境。

為後方便後續的講解,把targets的配置去掉,讓 Babel 預設轉譯所有語法。

(2)@babel/polyfill

polyfill 直譯是墊片的意思,又是 Babel 裡一個非常重要的概念。先看下面幾行程式碼:

// src/index.js
const add = (a, b) => a + b
 
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise()

按之前的方法,執行 npm run babel 後,我們驚奇的發現,Array.prototype.includes 和 Promise 竟然沒有被轉譯!

// dist/index.js
"use strict";
 
var add = function add(a, b) {
  return a + b;
};
 
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise();

原來 Babel 把 ES6 的標準分為 syntax 和 built-in 兩種型別。syntax 就是語法,像 const、=>這些預設被 Babel 轉譯的就是 syntax 的型別。而對於那些可以通過改寫覆蓋的語法就認為是 built-in,像 includes 和 Promise 這些都屬於 built-in。而 Babel 預設只轉譯 syntax 型別的,對於 built-in 型別的就需要通過 @babel/polyfill 來完成轉譯。@babel/polyfill 實現的原理也非常簡單,就是覆蓋那些 ES6 新增的 built-in。示意如下:

Object.defineProperty(Array.prototype, 'includes',function(){
  ...
})

由於 Babel 在 7.4.0 版本中宣佈廢棄 @babel/polyfill ,而是通過 core-js 替代,所以本文直接使用 core-js 來講解 polyfill 的用法。

  • 安裝 core-js

npm install --save core-js
  • 注意core-js要使用 --save 方式安裝,因為它是需要被注入到原始碼中的,在執行程式碼前提供執行環境,用來實現 built-in 的注入
  • 配置 useBuiltIns

    在 @babel/preset-env 中通過 useBuiltIns 引數來控制 built-in 的注入。它可以設定為 'entry'、'usage' 和 false 。預設值為 false,不注入墊片。

    設定為 'entry' 時,只需要在整個專案的入口處,匯入 core-js 即可。

// src/index.js
import 'core-js'
 
const add = (a, b) => a + b
 
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise()
 
// dist/index.js
"use strict";
 
require("core-js/modules/es7.array.includes");
require("core-js/modules/es6.promise");
//
// ……  這裡還有很多
//
require("regenerator-runtime/runtime");
var add = function add(a, b) {
  return a + b;
};
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise();
  • 編譯後,Babel 會把目標環境不支援的所有 built-in 都注入進來,不管是不是用到,這有一個問題,對於只用到比較少的專案來說完全沒有必要,白白增加程式碼,浪費包體大小。

設定為 'usage' 時,就不用在專案的入口處,匯入 core-js了,Babel 會在編譯原始碼的過程中根據 built-in 的使用情況來選擇注入相應的實現。

// src/index.js
const add = (a, b) => a + b
 
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise()
 
// dist/index.js
"use strict";
 
require("core-js/modules/es6.promise");
 
require("core-js/modules/es6.object.to-string");
 
require("core-js/modules/es7.array.includes");
 
var add = function add(a, b) {
  return a + b;
};
 
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise();
  • 配置 corejs 的版本

當 useBuiltIns 設定為 'usage' 或者 'entry' 時,還需要設定 @babel/preset-env 的 corejs 引數,用來指定注入 built-in 的實現時,使用 corejs 的版本。否則 Babel 日誌輸出會有一個警告。

最終的 Babel 配置如下:

// babel.config.js
const presets = [
  [
    '@babel/env',
    {
      debug: true,
      useBuiltIns: 'usage',
      corejs: 3,
      targets: {}
    }
  ]
]
const plugins = ['@babel/plugin-proposal-class-properties']
 
module.exports = { presets, plugins }

(3)@babel/plugin-transform-runtime

在介紹 @babel/plugin-transform-runtime 的用途之前,先前一個例子:

// src/index.js
const add = (a, b) => a + b
 
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise(resolve=>resolve(10))
 
class Person {
  static a = 1;
  static b;
  name = 'morrain';
  age = 18
}
 
// dist/index.js
"use strict";
 
require("core-js/modules/es.array.includes");
 
require("core-js/modules/es.object.define-property");
 
require("core-js/modules/es.object.to-string");
 
require("core-js/modules/es.promise");
 
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
 
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
 
var add = function add(a, b) {
  return a + b;
};
 
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise(function (resolve) {
  return resolve(10);
});
 
var Person = function Person() {
  _classCallCheck(this, Person);
 
  _defineProperty(this, "name", 'morrain');
 
  _defineProperty(this, "age", 18);
};
 
_defineProperty(Person, "a", 1);
 
_defineProperty(Person, "b", void 0);

在編譯的過程中,對於 built-in 型別的語法通過 require("core-js/modules/xxxx") polyfill 的方式來相容,對於 syntax 型別的語法在轉譯的過程會在當前模組中注入類似_classCallCheck 和_defineProperty 的 helper 函式來實現相容。對於一個模組而言,可能還好,但對於專案中肯定是很多模組,每個模組模組都注入這些 helper 函式,勢必會造成程式碼量變得很大。

而 @babel/plugin-transform-runtime 就是為了複用這些 helper 函式,縮小程式碼體積而生的。當然除此之外,它還能為編譯後的程式碼提供一個沙箱環境,避免全域性汙染。

使用 @babel/plugin-transform-runtime

  • ①安裝

npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime

其中 @babel/plugin-transform-runtime 是編譯時使用的,安裝為開發依賴,而 @babel/runtime 其實就是 helper 函式的集合,需要被引入到編譯後代碼中,所以安裝為生產依賴

  • ②修改 Babel plugins 配置,增加@babel/plugin-transform-runtime

// babel.config.js
const presets = [
  [
    '@babel/env',
    {
      debug: true,
      useBuiltIns: 'usage',
      corejs: 3,
      targets: {}
    }
  ]
]
const plugins = [
  '@babel/plugin-proposal-class-properties',
  [
    '@babel/plugin-transform-runtime'
  ]
]
 
module.exports = { presets, plugins }
  • 之前的例子,再次編譯後,可以看到,之前的 helper 函式,都變成類似require("@babel/runtime/helpers/classCallCheck")的實現了。
// dist/index.js
"use strict";
 
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
 
require("core-js/modules/es.array.includes");
 
require("core-js/modules/es.object.to-string");
 
require("core-js/modules/es.promise");
 
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
 
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
 
var add = function add(a, b) {
  return a + b;
};
 
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise(function (resolve) {
  return resolve(10);
});
 
var Person = function Person() {
  (0, _classCallCheck2["default"])(this, Person);
  (0, _defineProperty2["default"])(this, "name", 'morrain');
  (0, _defineProperty2["default"])(this, "age", 18);
};
 
(0, _defineProperty2["default"])(Person, "a", 1);
(0, _defineProperty2["default"])(Person, "b", void 0);
  • 配置 @babel/plugin-transform-runtime

到目前為止,對於 built-in 型別的語法還是通過 require("core-js/modules/xxxx") polyfill 的方式來實現的,例如為了支援 Array.prototype.includes 方法,需要 require

("core-js/modules/es.array.includes")在 Array.prototype 中新增 includes 方法來實現的,但這會導致一個問題,它是直接修改原型的,會造成全域性汙染。如果你開發的是獨立的應用問題不大,但如果開發的是工具庫,被其它專案引用,而恰好該專案自身實現了 Array.prototype.includes 方法,這樣就出了大問題!而 @babel/plugin-transform-runtime 可以解決這個問題,只需要配置 @babel/plugin-transform-runtime 的引數 corejs。該引數預設為 false,可以設定為 2 或者 3,分別對應 @babel/runtime-corejs2 和 @babel/runtime-corejs3。

把 @babel/plugin-transform-runtime 的 corejs 的值設定為3,把 @babel/runtime 替換為 @babel/runtime-corejs3。

去掉 @babel/preset-env 的 useBuiltIns 和 corejs 的配置,去掉 core-js。因為使用 @babel/runtime-corejs3 來實現對 built-in 型別語法的相容,不用再使用 useBuiltIns了。

npm uninstall @babel/runtime
npm install --save @babel/runtime-corejs3
npm uninstall core-js
// babel.config.js
const presets = [
  [
    '@babel/env',
    {
      debug: true,
      targets: {}
    }
  ]
]
const plugins = [
  '@babel/plugin-proposal-class-properties',
  [
    '@babel/plugin-transform-runtime',
    {
      corejs: 3
    }
  ]
]
 
module.exports = { presets, plugins }
 
 
// dist/index.js
"use strict";
 
var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault");
 
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/classCallCheck"));
 
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/defineProperty"));
 
var _promise = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/promise"));
 
var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/includes"));
 
var add = function add(a, b) {
  return a + b;
};
 
var arr = [1, 2];
var hasThreee = (0, _includes["default"])(arr).call(arr, 3);
new _promise["default"](function (resolve) {
  return resolve(10);
});
 
var Person = function Person() {
  (0, _classCallCheck2["default"])(this, Person);
  (0, _defineProperty2["default"])(this, "name", 'morrain');
  (0, _defineProperty2["default"])(this, "age", 18);
};
 
(0, _defineProperty2["default"])(Person, "a", 1);
(0, _defineProperty2["default"])(Person, "b", void 0);

可以看到 Promise 和 arr.includes 的實現已經變成區域性變數,並沒有修改全域性上的實現。

3、Babel polyfill 實現方式的區別

截至目前為止,對於 built-in 型別的語法的 polyfill,一共有三種方式:

  • 使用 @babel/preset-env ,useBuiltIns 設定為 'entry'

  • 使用 @babel/preset-env ,useBuiltIns 設定為 'usage'

  • 使用 @babel/plugin-transform-runtime

前兩種方式支援設定 targets ,可以根據目標環境來適配。useBuiltIns 設定為 'entry' 會注入目標環境不支援的所有 built-in 型別語法,useBuiltIns 設定為 'usage' 會注入目標環境不支援的所有被用到的 built-in 型別語法。注入的 built-in 型別的語法會汙染全域性。

第三種方式目前不支援設定 targets,所以不會考慮目標環境是否已經支援,它是通過區域性變數的方式實現了所有被用到的 built-in 型別語法,不會汙染全域性。

針對第三種方式不支援設定 targets 的問題,Babel 正在考慮解決,目前意向的方案是通過Polyfill provider來統一 polyfill 的實現:

  • 廢棄 @babel/preset-env 中 useBuiltIns 和 corejs 兩個引數,不再通過 @babel/preset-env 實現 polyfill。

  • 廢棄 @babel/plugin-transform-runtime 中的 corejs 引數,也不再通過 @babel/plugin-transform-runtime 來實現 polyfill。

  • 增加 polyfills 引數,類似於現在 presets 和 plugins,用來取代現在的 polyfill 方案。

  • 把 @babel/preset-env 中 targets 引數,往上提一層,和 presets、plugins、polyfills 同級別,並由它們共享。

這個方案實現後,Babel 的配置會是下面的樣子:

// babel.config.js
const targets = [
  '>1%'
]
const presets = [
  [
    '@babel/env',
    {
      debug: true
    }
  ]
]
const plugins = [
  '@babel/plugin-proposal-class-properties'
]
const polyfills = [
  [
    'corejs3',
    {
      method: 'usage-pure'
    }
  ]
]
 
module.exports = { targets, presets, plugins, polyfills }

配置中的 method 值有 'entry-global'、'usage-global'、'usage-pure' 三種。

  • 'entry-global' 等價於 @babel/preset-env 中的 useBuiltIns: 'entry'

  • 'usage-global' 等價於 @babel/preset-env 中的 useBuiltIns: 'usage'

  • 'usage-pure' 等價於 @babel/plugin-transform-runtime 中的 corejs

本文為了講解方便,都是用 Babel 原生的 @babel/cli 來編譯檔案,實際使用中,更多的是結合 webpack、rollup 這樣第三方的工具來使用的。

所以下一節,我們聊聊打包工具 webpack。

五、參考文獻

  1. 6to5 JavaScript Transpiler Changes Name to Babel

  2. Babel學習系列2-Babel設計,組成

  3. 初學 Babel 工作原理

  4. RFC: Rethink polyfilling story

更多內容敬請關注vivo 網際網路技術微信公眾號

注:轉載文章請先與微訊號:Labs2020聯絡。