前端工程師的自我修養-關於 Babel 那些事兒
- 2020 年 3 月 10 日
- 筆記
前言
隨著 Nodejs 的崛起,編譯這個昔日在 Java、C++ 等語言中流行的詞,在前端也逐漸火了起來,現在一個前端項目在開發過程中沒有編譯環節,總感覺這個項目是沒有靈魂的。說起前端編譯就不得不提前端編譯界的扛把子 Babel ,大部分前端攻城獅對 Babel 並不陌生,但是在這個 Ctrl+C 和 Ctrl+V 的年代,大多數人對它也只是知道、了解或者聽過,少數可能配置過 Babel,但也僅此而已。作為一個有想法和靈魂的前端攻城獅僅僅知道這些是不夠的,你需要對 Babel 有一個系統的了解,今天就來聊聊 Babel 那些事兒。
什麼是 Babel?
官方的解釋 Babel 是一個 JavaScript 編譯器,用於將 ECMAScript 2015+ 版本的程式碼轉換為向後兼容的 JavaScript 語法,以便能夠運行在當前版本和舊版本的瀏覽器或其他環境中。簡單來說 Babel 的工作就是:
- 語法轉換
- 通過 Polyfill 的方式在目標環境中添加缺失的特性
- JS 源碼轉換
Babel 的基本原理
原理很簡單,核心就是 AST (抽象語法樹) (https://segmentfault.com/a/1190000016231512?utm_source=tag-newest)。首先將源碼轉成抽象語法樹,然後對語法樹進行處理生成新的語法樹,最後將新語法樹生成新的 JS 程式碼,整個編譯過程可以分為 3 個階段 parsing (解析)、transforming (轉換)、generating (生成),都是在圍繞著 AST 去做文章,話不多說上圖:

圖片
整個過程很清晰,但是,好多東西都是看著簡單,但是實現起來賊複雜,比如這裡說到的 AST,要是你覺得你對 AST 已經信手拈來了,老哥麻煩在下面留下聯繫方式,我要來找你要簡歷。言歸正傳,這裡提一下,Babel 只負責編譯新標準引入的新語法,比如 Arrow function、Class、ES Modul 等,它不會編譯原生對象新引入的方法和 API,比如 Array.includes,Map,Set 等,這些需要通過 Polyfill 來解決,文章後面會提到。
Babel 的使用
運行 babel 所需的基本環境
- babel/cli
npm install i -S @babel/cli
@babel/cli 是 Babel 提供的內建命令行工具。提到 @babel/cli 這裡就不得不提一下 @babel/node ,這哥倆雖然都是命令行工具,但是使用場景不同,babel/cli 是安裝在項目中,而 @babel/node 是全局安裝。 - @babel/core
npm install i -S @babel/core
安裝完 @babel/cli 後就在項目目錄下執行babel test.js
會報找不到 @babel/core 的錯誤,因為 @babel/cli 在執行的時候會依賴 @babel/core 提供的生成 AST 相關的方法,所以安裝完 @babel/cli 後還需要安裝 @babel/core。 安裝完這兩個插件後,如果在 Mac 環境下執行會出現command not found: babel
,這是因為 @babel/cli是安裝在項目下,而不是全局安裝,所以無法直接使用 Babel 命令,需要在 package.json 文件中加上下面這個配置項: "scripts": { "babel":"babel" } 然後執行npm run babel ./test.js
,順利生成程式碼,此時生成的程式碼並沒有被編譯,因為 Babel 將原來集成一體的各種編譯功能分離出去,獨立成插件,要編譯文件需要安裝對應的插件或者預設,我們經常看見的什麼 @babel/preset-stage-0、@babel/preset-stage-1,@babel/preset-env 等就是干這些活的。那這些插件和預設怎麼用呢?下面就要說到 Babel 的配置文件了,這些插件需要在配置文件中交代清楚,不然 Babel 也不知道你要用哪些插件和預設。
安裝完基本的包後,就是配置 Babel 配置文件,Babel 的配置文件有四種形式:
- babel.config.js 在項目的根目錄(
package.json
文件所在目錄)下創建一個名為 babel.config.js 的文件,並輸入如下內容。 module.exports = function (api) { api.cache(true); const presets = [ … ]; const plugins = [ … ]; return { presets, plugins }; } 具體 babel.config.js 配置 (https://www.babeljs.cn/docs/config-files#project-wide-configuration) - .babelrc 在你的項目中創建名為
.babelrc
的文件 { "presets": […], "plugins": […] } .babelrc 文檔 (https://www.babeljs.cn/docs/config-files#file-relative-configuration) - .babelrc.js 與 .babelrc 的配置相同,你可以使用 JavaScript 語法編寫。 const presets = [ … ]; const plugins = [ … ]; module.exports = { presets, plugins };
- package.json 還可以選擇將 .babelrc 中的配置資訊寫到
package.json
文件中 { … "babel": { "presets": [ … ], "plugins": [ … ], } }
四種配置方式作用都一樣,你就合著自己的口味來,那種看著順眼,你就翻它。
插件(Plugins)
插件是用來定義如何轉換你的程式碼的。在 Babel 的配置項中填寫需要使用的插件名稱,Babel 在編譯的時候就會去載入 node_modules 中對應的 npm 包,然後編譯插件對應的語法。
.babelrc
{ "plugins": ["transform-decorators-legacy", "transform-class-properties"] }
插件執行順序
插件在預設(Presets) 前運行。
插件的執行順序是從左往右執行。也就是說在上面的示例中,Babel 在進行 AST 遍歷的時候會先調用 transform-decorators-legacy 插件中定義的轉換方法,然後再調用 transform-class-properties 中的方法。
插件傳參
參數是由插件名稱和參數對象組成的一個數組。
{ "plugins": [ [ "@babel/plugin-proposal-class-properties", { "loose": true } ] ] }
插件名稱
插件名稱如果為 @babel/plugin-XX
,可以使用短名稱@babel/XX
,如果為 babel-plugin-xx
,可以直接使用 xx
。
自定義插件
大部分時間我們都是在用別人的寫的插件,但是有時候我們總是想秀一下,自己寫一個 Babel 插件,那應該怎麼操作呢?
插件載入
要致富先修路,要用自己寫的插件首先得知道怎麼使用自定義的插件。一種方式是將自己寫的插件發布到 npm 倉庫中去,然後本地安裝,然後在 Babel 配置文件中配置插件名稱就好了:
npm install @babel/plugin-myPlugin
.babelrc
{ "plugins": ["@babel/plugin-myPlugin"] }
另外一種方式就是不發布,直接將寫好的插件放在項目中,然後在 babel 配置文件中通過訪問相對路徑的方式來載入插件:
.babelrc
{ "plugins": ["./plugins/plugin-myPlugin"] }
第一種通過 npm 包的方式一般是插件功能已經完善和穩定後使用,第二種方式一般在開發階段,本地調試時使用。
編寫插件
插件實際上就是在處理 AST 抽象語法樹,所以編寫插件只需要做到下面三點:
- 確認我們要修改的節點類型
- 找到 AST 中需要修改的屬性
- 將 AST 中需要修改的屬性用新生成的屬性對象替換
好像少了生成 AST 對象和生成源碼的步驟,不急,後面會講。說一千道一萬不如一個例子來的實在,下面實現一個預計算(在編譯階段將表達式計算出來)的插件:
const result = 1 + 2;
轉換成:
const result = 3;
在寫插件前你需要明確轉換前後的 AST 長什麼樣子,就好像整容一樣,你總得選個參考吧。AST explorer (https://astexplorer.net/) 你值得擁有。
轉換前:

圖片
轉換後:

圖片
找到差別,然後就到了用程式碼來解決問題的時候了
let babel = require('@babel/core'); let t = require('babel-types'); let preCalculator={ visitor: { BinaryExpression(path) { let node = path.node; let left = node.left; let operator = node.operator; let right = node.right; if (!isNaN(left.value) && !isNaN(right.value)) { let result = eval(left.value + operator + right.value); //生成新節點,然後替換原先的節點 path.replaceWith(t.numericLiteral(result)); //遞歸處理 如果當前節點的父節點配型還是表達式 if (path.parent && path.parent.type == 'BinaryExpression') { preCalculator.visitor.BinaryExpression.call(null,path.parentPath); } } } } } const result = babel.transform('const sum = 1+2+3',{ plugins:[ preCalculator ] });
上面這段程式碼,Babel 在編譯的時候會深度遍歷 AST 對象的每一個節點,採用訪問者的模式,每個節點都會去訪問插件定義的方法,如果類型和方法中定義的類型匹配上了,就進入該方法修改節點中對應屬性。在節點遍歷完成後,新的 AST 對象也就生成了。babel-types (https://www.npmjs.com/package/babel-types) 提供 AST 樹節點類型對象。
上面這樣寫只是為了我們開發測試方便,其實最終的完整體是下面這樣的:
const types = require('babel-types'); const visitor = { BinaryExpression(path) {//需要處理的節點路徑 let node=path.node; let left=node.left; let operator=node.operator; let right=node.right; if (!isNaN(left.value) && !isNaN(right.value)) { let result=eval(left.value+operator+right.value); path.replaceWith(t.numericLiteral(result)); if (path.parent&& path.parent.type == 'BinaryExpression') { preCalculator.visitor.BinaryExpression.call(null,path.parentPath); } } } } module.exports = function(babel){ return { visitor } }
我們在插件中只需要修改匹配上的 AST 屬性,不需要關注源碼到 AST 以及新 AST 到源碼的過程,這些都是 Babel 去乾的事,我們干好自己的活就好了,其他的交給 babel。這也就解釋了我上面的步驟中為嘛沒有 AST 的生成和源碼的生成,那就不是我們在插件中乾的事兒。
預設(Presets)
預設就是一堆插件(Plugin)的組合,從而達到某種轉譯的能力,就比如 react 中使用到的 @babel/preset-react ,它就是下面幾種插件的組合。
- @babel/plugin-syntax-jsx
- @babel/plugin-transform-react-jsx
- @babel/plugin-transform-react-display-name
當然我們也可以手動的在 plugins 中配置一系列的 plugin 來達到目的,就像這樣:
{ "plugins":["@babel/plugin-syntax-jsx","@babel/plugin-transform-react-jsx","@babel/plugin-transform-react-display-name"] }
但是這樣一方面顯得不那麼優雅,另一方面增加了使用者的使用難度。如果直接使用預設就清新脫俗多了~
{ "presets":["@babel/preset-react"] }
預設(Presets)的執行順序
前面提到插件的執行順序是從左往右,而預設的執行順序恰好反其道行之,它是從右往左
{ "presets": [ "a", "b", "c" ] }
它的執行順序是 c、b、a,是不是有點奇怪,這主要是為了確保向後兼容,因為大多數用戶將 "es2015" 放在 "stage-0" 之前。
自定義預設(Presets)
這種場景一般很少,在這個拿來主義的時代,插件我們都很少寫,就更別說自定義預設了。不過前面插件我們都說了怎麼寫了,預設咱也不能冷落她呀。
前面提到預設就是已有插件的組合,主要就是為了避免使用者配置過多的插件,通過預設把插件收斂起來,其實寫起來特別簡單,前提是你已經確定好要用哪些插件了。
import { declare } from "@babel/helper-plugin-utils"; import pluginA from "myPluginA"; import pluginB from "myPluginB" export default declare((api, opts) => { const pragma = opts.pragma; return { plugins: [ [ pluginA, {pragma}//插件傳參 ], pluginB ] }; });
其實就是把 Babel 配置中的 plugins 配置放到 presets 中了,實質上還是在配置 Plugins,只是寫 Presets 的人幫我們配置好了。
那些她認識你而你不認識她的預設
- @babel/preset-stage-xxx @babel/preset-stage-xxx 是 ES 在不同階段語法提案的轉碼規則而產生的預設,隨著被批准為 ES 新版本的組成部分而進行相應的改變(例如 ES6/ES2015)。 提案分為以下幾個階段:
- stage-0 – 設想(Strawman):只是一個想法,可能有 Babel 插件,stage-0 的功能範圍最廣大,包含 stage-1 , stage-2 以及 stage-3 的所有功能
- stage-1 – 建議(Proposal):這是值得跟進的
- stage-2 – 草案(Draft):初始規範
- stage-3 – 候選(Candidate):完成規範並在瀏覽器上初步實現
- stage-4 – 完成(Finished):將添加到下一個年度版本發布中
- @babel/preset-es2015 preset-es2015 是僅包含 ES6 功能的 Babel 預設。 實際上在 Babel7 出來後上面提到的這些預設 stage-x,preset-es2015 都可以廢棄了,因為 @babel/preset-env 出來一統江湖了。
- @babel/preset-env 前面兩個預設是從 ES 標準的維度來確定轉碼規則的,而 @babel/preset-env 是根據瀏覽器的不同版本中缺失的功能確定程式碼轉換規則的,在配置的時候我們只需要配置需要支援的瀏覽器版本就好了,@babel/preset-env 會根據目標瀏覽器生成對應的插件列表然後進行編譯: { "presets": [ ["env", { "targets": { "browsers": ["last 10 versions", "ie >= 9"] } }], ], … } 在默認情況下 @babel/preset-env 支援將 JS 目前最新的語法轉成 ES5,但需要注意的是,如果你程式碼中用到了還沒有成為 JS 標準的語法,該語法暫時還處於 stage 階段,這個時候還是需要安裝對應的 stage 預設,不然編譯會報錯。 { "presets": [ ["env", { "targets": { "browsers": ["last 10 versions", "ie >= 9"] } }], ], "stage-0" } 雖然可以採用默認配置,但如果不需要照顧所有的瀏覽器,還是建議你配置目標瀏覽器和環境,這樣可以保證編譯後的程式碼體積足夠小,因為在有的版本瀏覽器中,新語法本身就能執行,不需要編譯。@babel/preset-env 在默認情況下和 preset-stage-x 一樣只編譯語法,不會對新方法和新的原生對象進行轉譯,例如: const arrFun = ()=>{} const arr = [1,2,3] console.log(arr.includes(1)) 轉換後: "use strict"; var arrFun = function arrFun() {}; var arr = [1, 2, 3]; console.log(arr.includes(1)); 箭頭函數被轉換了,但是 Array.includes 方法,並沒有被處理,這個時候要是程式跑在低版本的瀏覽器上,就會出現
includes is not function
的錯誤。這個時候就需要 polyfill 閃亮登場了。
Polyfill
polyfill
的翻譯過來就是墊片,墊片就是墊平不同瀏覽器環境的差異,讓大家都一樣。
@babel/polyfill
@babel/polyfill
模組可以模擬完整的 ES5 環境。
安裝:
npm install --save @babel/polyfill
注意 @babel/polyfill 不是在 Babel 配置文件中配置,而是在我們的程式碼中引入。
import '@babel/polyfill'; const arrFun = ()=>{} const arr = [1,2,3] console.log(arr.includes(1)) Promise.resolve(true)
編譯後:
require("@babel/polyfill"); var arrFun = function arrFun() {}; var arr = [1, 2, 3]; console.log(arr.includes(1)); Promise.resolve(true);
這樣在低版本的瀏覽器中也能正常運行了。
不知道大家有沒有發現一個問題,這裡是require("@babel/polyfill")
將整個 @babel/polyfill 載入進來了,但是在這裡我們需要處理 Array.includes 和 Promise 就好了,如果這樣就會導致我們最終打出來的包體積變大,顯然不是一個最優解。要是能按需載入就好了。其實 Babel 早就為我們想好了。
useBuiltIns
回過頭來再說 @babel/preset-env,他出現的目的就是實現民族大統一,連 stage-x 都幹掉了,又怎麼會漏掉 Polyfill 這一功能,在 @babel/preset-env 的配置項中提供了 useBuiltIns 這一參數,只要在使用 @babel/preset-env 的時候帶上他,Babel 在編譯的時候就會自動進行 Polyfill ,不再需要手動的在程式碼中引入@babel/polyfill 了,同時還能做到按需載入
{ "presets": [ "@babel/preset-flow", [ "@babel/preset-env", { "targets": { "node": "8.10" }, "corejs": "3", // 聲明 corejs 版本 "useBuiltIns": "usage" } ] ] }
注意,這裡需要配置一下 corejs 的版本號,不配置編譯的時候會報警告。講都講到這裡了就再順便提一嘴 useBuiltIns 的機構參數:
- false:此時不對Polyfill 做操作,如果引入 @babel/polyfill 則不會按需載入,會將所有程式碼引入
- usage:會根據配置的瀏覽器兼容性,以及你程式碼中使用到的 API 來進行 Polyfill ,實現按需載入
- entry:會根據配置的瀏覽器兼容性,以及你程式碼中使用到的 API 來進行 Polyfill ,實現按需載入,不過需要在入口文件中手動加上
import ' @babel/polyfill'
編譯後:
"use strict"; require("core-js/modules/es.array.includes"); require("core-js/modules/es.object.to-string"); require("core-js/modules/es.promise"); var arrFun = function arrFun() {}; var arr = [1, 2, 3]; console.log(arr.includes(1)); Promise.resolve(true);
這個時候我們再藉助 Webpack 編譯後,產出的程式碼體積會大大減小。

圖片
說完了上面這些你以為我就說完了嗎?

圖片
其實 Babel 在編譯中會使用一些輔助函數,比如:
class Person { constructor(){} say(word){ console.log(":::",word) } }
編譯後:
"use strict"; require("core-js/modules/es.object.define-property"); 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; } var Person = /*#__PURE__*/ function () { function Person() { _classCallCheck(this, Person); } _createClass(Person, [{ key: "say", value: function say(word) { console.log(":::", word); } }]); return Person; }();
這些方法會被 inject
到每個文件中,沒法做到復用,這樣也會導致打包體積的增加。
沒事兒,逢山開路遇水搭橋,是時候讓@babel/plugin-transform-runtime
登場了。

圖片
@babel/plugin-transform-runtime
@babel/plugin-transform-runtime 可以讓 Babel 在編譯中復用輔助函數,從而減小打包文件體積,不信你看:
npm install --save-dev @babel/plugin-transform-runtime npm install --save @babel/runtime
順便說一下,這一對 CP 要同時出現,形影不離,所以安裝的時候你就一起裝上吧~
配置 Babel:
{ "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 3 } ] ], "plugins": [ "@babel/plugin-transform-runtime" ] }
結果:
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck")); var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass")); var Person = /*#__PURE__*/ function () { function Person() { (0, _classCallCheck2["default"])(this, Person); } (0, _createClass2["default"])(Person, [{ key: "say", value: function say(word) { console.log(":::", word); } }]); return Person; }();
這些用到的輔助函數都從 @babel/runtime 中去載入,這樣就可以做到程式碼復用了。

結語
在這個拿來主義的社會,有時候知其然的同時也需要知其所以然。希望這篇關於 Babel 知識的梳理對你有幫助。
招賢納士
政采雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政采雲產品研發部,Base 在風景如畫的杭州。團隊現有 50 余個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在日常的業務對接之外,還在物料體系、工程平台、搭建平台、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推動並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。
如果你想改變一直被事折騰,希望開始能折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個結果,卻不需要你;如果你想改變你想做成的事需要一個團隊去支撐,但沒你帶人的位置;如果你想改變既定的節奏,將會是「5 年工作時間 3 年工作經驗」;如果你想改變本來悟性不錯,但總是有那一層窗戶紙的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨著業務騰飛的過程,親手推動一個有著深入的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我覺得我們該聊聊。任何時間,等著你寫點什麼,發給 [email protected]