前端工程師的自我修養-關於 Babel 那些事兒

🙏向這次肺炎疫情中逝世的同胞表示哀悼。前端

本文首發於政採雲前端團隊博客: 前端工程師的自我修養-關於 Babel 那些事兒

前言

隨着 Nodejs 的崛起,編譯這個昔日在 Java、C++ 等語言中流行的詞,在前端也逐漸火了起來,如今一個前端項目在開發過程當中沒有編譯環節,總感受這個項目是沒有靈魂的。提及前端編譯就不得不提早端編譯界的扛把子 Babel ,大部分前端攻城獅對 Babel 並不陌生,可是在這個 Ctrl+C 和 Ctrl+V 的年代,大多數人對它也只是知道、瞭解或者聽過,少數可能配置過 Babel,但也僅此而已。做爲一個有想法和靈魂的前端攻城獅僅僅知道這些是不夠的,你須要對 Babel 有一個系統的瞭解,今天就來聊聊 Babel 那些事兒。node

什麼是 Babel?

官方的解釋 Babel 是一個 JavaScript 編譯器,用於將 ECMAScript 2015+ 版本的代碼轉換爲向後兼容的 JavaScript 語法,以便可以運行在當前版本和舊版本的瀏覽器或其餘環境中。簡單來講 Babel 的工做就是:react

  • 語法轉換
  • 經過 Polyfill 的方式在目標環境中添加缺失的特性
  • JS 源碼轉換

Babel 的基本原理

原理很簡單,核心就是 AST (抽象語法樹)。首先將源碼轉成抽象語法樹,而後對語法樹進行處理生成新的語法樹,最後將新語法樹生成新的 JS 代碼,整個編譯過程能夠分爲 3 個階段 parsing (解析)、transforming (轉換)、generating (生成),都是在圍繞着 AST 去作文章,話很少說上圖:npm

圖片

整個過程很清晰,可是,好多東西都是看着簡單,可是實現起來賊複雜,好比這裏說到的 AST,要是你以爲你對 AST 已經信手拈來了,老哥麻煩在下面留下聯繫方式,我要來找你要簡歷。言歸正傳,這裏提一下,Babel 只負責編譯新標準引入的新語法,好比 Arrow function、Class、ES Modul 等,它不會編譯原生對象新引入的方法和 API,好比 Array.includes,Map,Set 等,這些須要經過 Polyfill 來解決,文章後面會提到。json

Babel 的使用

運行 babel 所需的基本環境segmentfault

  1. babel/cli

    npm install i -S @babel/cliapi

    @babel/cli 是 Babel 提供的內建命令行工具。提到 @babel/cli 這裏就不得不提一下 @babel/node ,這哥倆雖然都是命令行工具,可是使用場景不一樣,babel/cli 是安裝在項目中,而 @babel/node 是全局安裝。數組

  2. @babel/corepromise

    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 的配置文件有四種形式

  1. 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 配置

  2. .babelrc

    在你的項目中建立名爲 .babelrc 的文件

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

    .babelrc文檔

  3. .babelrc.js

    .babelrc 的配置相同,你可使用 JavaScript 語法編寫。

    const presets = [ ... ];
    const plugins = [ ... ];
    module.exports = { presets, plugins };
  4. 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 你值得擁有。

轉換前:

圖片

轉換後:

圖片

找到差異,而後就到了用代碼來解決問題的時候了

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 提供 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 ,它就是下面幾種插件的組合。

固然咱們也能夠手動的在 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 的人幫咱們配置好了。

那些她認識你而你不認識她的預設

  1. @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):將添加到下一個年度版本發佈中
  1. @babel/preset-es2015

    preset-es2015 是僅包含 ES6 功能的 Babel 預設。

    實際上在 Babel7 出來後上面提到的這些預設 stage-x,preset-es2015 均可以廢棄了,由於 @babel/preset-env 出來一統江湖了。

  2. @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 中去加載,這樣就能夠作到代碼複用了。

<img src="https://zcy-cdn.oss-cn-shanghai.aliyuncs.com/f2e-assets/ca10147e-1fca-4431-8197-c942fac395e0.png?x-oss-process=image/quality,Q_75/format,jpg" />

結語

在這個拿來主義的社會,有時候知其然的同時也須要知其因此然。但願這篇關於 Babel 知識的梳理對你有幫助。

招賢納士

政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 50 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在平常的業務對接以外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推進並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

若是你想改變一直被事折騰,但願開始能折騰事;若是你想改變一直被告誡須要多些想法,卻無從破局;若是你想改變你有能力去作成那個結果,卻不須要你;若是你想改變你想作成的事須要一個團隊去支撐,但沒你帶人的位置;若是你想改變既定的節奏,將會是「5 年工做時間 3 年工做經驗」;若是你想改變原本悟性不錯,但老是有那一層窗戶紙的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但願參與到隨着業務騰飛的過程,親手推進一個有着深刻的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我以爲咱們該聊聊。任什麼時候間,等着你寫點什麼,發給 ZooTeam@cai-inc.com

相關文章
相關標籤/搜索