前端科普系列(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 會依賴瀏覽器的版本,後面會講到。這裡可以先參考
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 轉化了哪些語法。
- presets 主要是配置用來編譯的預置,plugins 主要是配置完成編譯的外掛,具體的含義後面會講
- 推薦用 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();
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);
而 @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。
五、參考文獻
更多內容敬請關注vivo 網際網路技術微信公眾號
注:轉載文章請先與微訊號:Labs2020聯絡。