babel - 從入門到上手

本文將引導你一步一步的學會 babel,在學習的過程將着重介紹以下幾點:

1.babel 的作用

官方定義:Babel 是一個工具鏈,主要用於將 ECMAScript 2015+ 版本的代碼轉換爲向後兼容的 JavaScript 語法,以便能夠運行在當前和舊版本的瀏覽器或其他環境中。

2.babel 轉譯的三個階段

  1. 源碼 parse 生成 AST(parse)

  2. 遍歷 AST 並進行各種增刪改 (核心)(transform)

  3. 轉換完 AST 之後再打印成目標代碼字符串(generate)

3. AST 如何生成

在學習 AST 和 babel 轉換時,可以藉助下面兩個網站輔助查看代碼轉換成 AST 之後的結果。

https://esprima.org/demo/parse.html

https://astexplorer.net/

整個解析過程主要分爲以下兩個步驟:詞法分析語法分析

3.1 詞法分析

詞法分析,這一步主要是將字符流 (char stream) 轉換爲令牌流(token stream),又稱爲分詞。其中拆分出來的各個部分又被稱爲 詞法單元 (Token)

可以這麼理解,詞法分析就是把你的代碼從 string 類型轉換成了數組,數組的元素就是代碼裏的單詞 (詞法單元), 並且標記了每個單詞的類型。

比如:

const a = 1

生成的 tokenList

[
    { type: 'Keyword', value: 'const' },
    { type: 'Identifier', value: 'a' },
    { type: 'Punctuator',value: '=' },
    { type: 'Numeric', value: '1' },
    { type: 'Punctuator', value: ';' },
];

詞法分析結果,缺少一些比較關鍵的信息:需要進一步進行 語法分析 。

3.2 語法分析

語法分析會將詞法分析出來的 詞法單元 轉化成有語法含義的 **抽象語法樹結構 (AST),**同時,驗證語法,語法如果有錯,會拋出語法錯誤。

這裏截取 AST 樹上的 program 的 body 部分 (採用 @babel/parser 進行轉化)

"body"[
    {
       "type""VariableDeclaration",
       "start": 0,
       "end": 11,
       "loc"{},
       "declarations"[
        {
           "type""VariableDeclarator",
           "start": 6,
           "end": 11,
           "loc"{},
           "id"{
             "type""Identifier",
             "start": 6,
             "end": 7,
             "loc"{},
             "name""a"
          },
           "init"{
             "type""NumericLiteral",
             "start": 10,
             "end": 11,
             "loc"{},
             "extra"{},
             "value"1
          }
        }
      ],
       "kind""const"
    }
  ]

可以看到,經過語法分析階段轉換後生成的 AST,通過樹形的層屬關係建立了語法單元之間的聯繫。

4. AST 節點

轉換後的 AST 是由很多 AST 節點組成,主要有以下幾種類型:字面量(Literal)、標誌符(Identifer)、語句(statement)、聲明(Declaration)、表達式(Expression)、註釋(Comment)、程序(Program)、文件(File)

每種 AST 節點都有自己的屬性,但是它們也有一些公共屬性:

5. babel 常用的 api

babel 中有五個常用的 api:

  1. 針對 parse 階段有 @babel/parser,功能是把源碼轉成 AST

  2. 針對 transform 階段有 @babel/traverse,用於增刪改查 AST

  3. 針對 generate 階段有 @babel/generate,會把 AST 打印爲目標代碼字符串,同時生成 sourcemap

  4. 在 transform 階段,當需要判斷和生成結點時,需要 @babel/types,

  5. 當需要批量創建 AST 的時候可以使用 @babel/template 來簡化 AST 創建邏輯。

我們可以通過這些常用的 api 來自己實現一個 plugin,對代碼進行轉換。接下來就介紹一下幾個常見的 api。

5.1 @babel/parser

babelParser.parse(code, [options])--- 返回的 AST 根節點是 File 節點 babelParser.parseExpression(code, [options])--- 返回的 AST 根結點是 Expression

第一個參數是源代碼,第二個參數是 options,其中最常用的就是 plugins、sourceType 這兩個:

5.2 @babel/traverse(核心)

function traverse(ast, opts)

ast:經過 parse 之後的生成的 ast

opts :指定 visitor 函數 -- 用於遍歷節點時調用(核心)

方法的第二參數中的 visitor 是我們自定義插件時經常用到的地方,你可以通過兩種方式來定義這個參數

第一種是以方法的形式聲明 visitor

traverse(ast, {
   BlockStatement(path, state) {
       console.log('BlockStatement>>>>>>')
  }
});

第二種是以對象的形式聲明 visitor

traverse(ast, {
   BlockStatement: {
       enter(path, state) {
           console.log('enter>>>', path, state)
      },
       exit(path, state) {
           console.log('exit>>>', path, state)
      }
  }
});

每一個 visitor 函數會接收兩個參數 pathstate,path 用來操作節點、遍歷節點和判斷節點,而 state 則是遍歷過程中在不同節點之間傳遞數據的機制, 我們也可以通過 state 存儲一些遍歷過程中的共享數據。

5.3. @babel/generator

轉換完 AST 之後,就要打印目標代碼字符串,這裏通過 @babel/generator 來實現,其方法常用的參數有兩個:

6. babel 的內置功能

上面我們介紹了幾個用於實現插件的 api,而 babel 本身爲了實現對語法特性的轉換以及對 api 的支持 (polyfill),也內置了很多的插件 (plugin)預設 (preset)

其插件主要分爲三類:

那麼預設是**什麼呢?**預設其實就是對於插件的一層封裝,通過配置預設,使用者可以不用關心具體引用了什麼插件,從而減輕使用者的負擔。

而根據上面不同類型的插件又產生了如下幾種預設:

我們目前最常使用的便是 @babel/preset-env 這個預設,下文將會通過一個例子來介紹它的使用。

7. 案例 1-- 自定義插件

需求

如果有一行代碼

  const a = 1

我需要通過 babel 自定義插件來給標識符增加類型定義,讓它成爲符合 ts 規範的語句,結果:const a: number = 1。

實現

通過 babel 處理代碼,其實就是在對 AST 節點進行處理。

我們先搭起一個架子

// 源代碼
const sourceCode = `
 const a = 1
`;
// 調用parse,生成ast
const ast = parser.parse(sourceCode, {})

// 調用traverse執行自定義的邏輯,處理ast節點
traverse(ast, {})

// 生成目標代碼
const { code } = generate(ast, {});

console.log('result after deal with》〉》〉》', code)

在引入對應的包後,我們的架子主要分爲三部分,我們首先需要知道這句話轉換完之後的 AST 節點類型

    "sourceType""module",
    "interpreter": null,
    "body"[
      {
        "type""VariableDeclaration",
        "start": 0,
        "end": 11,
        "loc"{...},
        "declarations"[
          {
            "type""VariableDeclarator",
            "start": 6,
            "end": 11,
            "loc"{...},
            "id"{...},
            "init"{...}
          }
        ],
        "kind""const"
      }
    ]

上圖可以看出這句話的類型是 VariableDeclaration,所以我們要寫一個可以遍歷 VariableDeclaration 節點的 visitor。

// 調用traverse執行自定義的邏輯,處理ast節點
traverse(ast, {
     VariableDeclaration(path, state) {
       console.log('VariableDeclaration>>>>>>', path.node.type)
    }
})

繼續觀察結構,該節點下面有 declarations 屬性,其包括所有的聲明,declarations[0] 就是我們想要的節點。

traverse(ast, {
    VariableDeclaration(path, state) {
       console.log('VariableDeclaration>>>>>>', path.node.type)
       const tarNode = path.node.declarations[0]
       console.log('tarNode>>>>>>', tarNode)
    }
})

每一個聲明節點類型爲 VariableDeclarator,該節點下有兩個重要的節點,id(變量名的標識符)和 init(變量的值)。這裏我們需要找到變量名爲 a 的標識符,且他的值類型爲 number(對應的節點類型爲 NumericLiteral)。

    "declarations"[
        {
          "type""VariableDeclarator",
          "start": 6,
          "end": 11,
          "loc"{...},
          "id"{
            "type""Identifier",
            "start": 6,
            "end": 7,
            "loc"{...},
            "name""a"
          },
          "init"{
            "type""NumericLiteral",
            "start": 10,
            "end": 11,
            "loc"{...},
            "extra"{...},
            "value"1
          }
        }
      ]

這時候就需要我們使用一個新的包 **@babel/types **來判斷類型。判斷類型時只需調用該包中對應的判斷方法即可,方法名都是以 isXxx 或者 assertXxx 來命名的 (Xxx 代表節點類型),需要傳入對應的節點才能判斷該節點的類型。

traverse(ast, {
  VariableDeclaration(path, state) {
    const tarNode = path.node.declarations[0]
    if(types.isIdentifier(tarNode.id) && types.isNumericLiteral(tarNode.init))       
     {
       console.log('inside>>>>>>')
     }
  }
})

鎖定了節點後,我們需要更改 id 節點的 name 內容, 就可以實現需求了。

traverse(ast, {
  VariableDeclaration(path, state) {
  const tarNode = path.node.declarations[0]
    if(types.isIdentifier(tarNode.id)&&types.isNumericLiteral(tarNode.init))       
    {
       console.log('inside>>>>>>')
       tarNode.id.name = `${tarNode.id.name}: number`
    }
  }
})

8. 案例 2-- 工程化使用

8.1 準備工作

@babel/core 是 babel 的核心庫,@babel/cli 是 babel 的命令行工具。如果要使用 babel,首先要安裝 @babel/core@babel/cli

源代碼爲:

const fn = () => 1 ;

位置放在 src 下的 test.js 文件。

8.2 通過配置文件使用

根據官方文檔的說法,目前有兩類配置文件:項目範圍配置 和 文件相對配置。

1. 項目範圍配置(全局配置) -- babel.config.json
2. 文件相對配置(局部配置) -- .babelrc.json、package.json

區別:第一種配置作用整個項目,如果 babel 決定應用這個配置文件,則一定會應用到所有文件的轉換。而第二種配置文件只能應用到 “當前目錄” 下的文件中。

babel 在決定一個 js 文件應用哪些配置文件時, 會執行如下策略: 如果這個 js 文件在當前項目內,則會遞歸向上搜索最近的一個 .babelrc 文件 (直到遇到 package.json),將其與全局配置合併。

這裏我們只需使用 babel.config.json 的形式進行配置

配置文件:

{
     "presets"[
           [
                 "@babel/preset-env"
           ]
      ]
}

再在 package.json 裏配置一下執行的腳本

"dev""./node_modules/.bin/babel src --out-dir lib"

8.4 常用的包

我們在工程裏常用包主要有兩個:

8.4.1 @babel/preset-env

@babel/preset-env 是一個智能的預設,它允許你使用最新的 JavaScript,而不需要微管理你的目標環境需要哪些語法轉換,根據 babel 官網上的描述,它是通過 browsersList、compat-table 相結合來實現智能的引入語法轉換工具。

compat-data 形如如下,其註明了什麼特性,在什麼環境下支持,再結合通過 browsersList 查詢出的環境版本號,就可以確定需要引入哪些 plugin 或者 preset。

"es6.array.fill"{
   "chrome""45",
   "opera""32",
   "edge""12",
   "firefox""31",
   "safari""7.1",
   "node""4",
   "ios""8",
   "samsung""5",
   "rhino""1.7.13",
   "electron""0.31"
},

@babel/preset-env 有三個常用的關鍵可選項:

targets

描述項目支持的環境 / 目標環境,支持 browserslist 查詢寫法

{  "targets""> 0.25%, not dead" }  // 全球使用人數大於0.25%且還沒有廢棄的版本

支持最小環境版本構成的對象

{  "targets"{ "chrome""58",  "ie""11" } }

如果沒配置 targets, Babel 會假設你的目標是最老的瀏覽器 @babel/preset-env 將轉換所有 ES2015-ES2020 代碼爲 ES5 兼容

useBuiltIns

可以使用三個值:"usage" 、"entry" 、 false,默認使用 false

false

當使用 false 時:在不主動 import 的情況下不使用 preset-env 來處理 polyfills

entry

babel 將會根據瀏覽器目標環境 (targets) 的配置,引入全部瀏覽器暫未支持的 polyfill 模塊,只要我們在打包配置入口 或者 文件入口寫入 import "core-js" 這樣的引入, babel 就會根據當前所配置的目標瀏覽器 (browserslist) 來引入所需要的 polyfill 。

usage

設置 useBuiltIns 的值爲 usage 時,babel 將會根據我們的代碼使用情況自動注入 polyfill。

8.4.2 entry 與 usage 的區別

在上文所示例子的基礎上,我們修改一下源代碼

function test() {
    new Promise()
}
test()
const arr = [ 1 , 2 , 3 , 4 ].map(item => item * item)
console.log(arr)

我們沒有配置 useBuiltIns 時,preset-env 只對代碼的語法進行了處理,對於新增的 api 並沒有引入對應的 polyfill。下圖是轉換結果:

"use strict";

function test() {
    new Promise();
}

test();
var arr = [1, 2, 3, 4].map(function (item) {
    return item * item;
});
console.log(arr);

當我們使用 useBuiltIns:“entry” 時 (入口文件需要引入 core-js),由於我們沒有指定 targets,結果當然是引入了一堆包。

加入 "targets": "> 0.25%, not dead" 後,很明顯少了很多的引入(如下圖所示),這也印證了上面所說的, 當 useBuiltIns 的值爲 entry 時,@babel/preset-env 會按照你所設置的 targets 來引入所需的 polyfill。

當我們使用 useBuiltIns:“usage” 時,這時就無須在入口文件引入 core-js 了。

可以看出引入的包非常精準,需要哪些就引入哪些 polyfill。當然你也可以配置 targets,這樣的話 targets 會輔助 preset-env 引入,從而進一步控制引入包數量。

corejs

corejs 是 JavaScript 的模塊化標準庫,其中包括各種 ECMAScript 特性的 polyfill。上面我們轉換後的代碼中引入的 polyfill 都是來源於 corejs。它現有 2 和 3 兩個版本,目前 2 版本已經進入功能凍結階段了,新的功能會添加到 3 版本中。

具體的變化可以查看 corejs 的 github 的說明文檔:core-js@3, babel and a look into the future

這個選項只有在和 useBuiltIns: "usage" 或 useBuiltIns:"entry" 一起使用時纔有效果,該屬性默認爲 "2.0"。其作用是進一步約束引入的 polyfill 的數量。

8.4.3 @babel/plugin-transform-runtime

雖然經過了 preset-env 的轉換,代碼已經可以實現不同版本的特性兼容了。但是會產生兩個問題:

1.preset-env 轉換後引入的 polyfill,是通過 require 進行引入的,這就意味着,對於 Array.from 等靜態方法,以及 includes 等實例方法,會直接在 global 上添加。這就導致引入的 polyfill 方法可能和其他庫發生衝突。

2.babel 轉換代碼的時候,可能會使用一些幫助函數來協助轉換,比如 class

class a {}

轉換之後:

這裏就使用了_classCallCheck 這樣的輔助函數,如果有多個文件聲明 class 的話,就會重複創建這樣的方法。

@babel/plugin-transform-runtime 這個插件的作用就是爲了處理這樣的問題。該插件也有一個 corejs 的配置,這裏配置的是 runtime-corejs 的版本,目前有 2、3 兩個版本。

{
 "presets"[
  [
     "@babel/preset-env"
  ]
],
 "plugins"[
  [
     "@babel/plugin-transform-runtime",
    {
       "corejs""3.0"
    }
  ]
]
}

轉換結果:

這裏由於 babel 是先執行 plugins 後執行 presets 的內容,@babel/plugin-transform-runtime 插件先於 preset-env 將 polyfill 引入了,並且做了一層包裝,所以就無須再通過 @babel/preset-env 來引入 polyfill 了。

可以看到,轉換之後的_classCallCheck 的方法定義全部改爲了從 runtime-corejs 中引入,對於新特性的 polyfill 也不再掛載在全局了。這樣的方法適合定義類庫時使用,可以防止變量污染全局。

結束語

相信通過這麼一篇文章,大家基本都瞭解了 babel 的基礎原理,以及它是如何實現對代碼的轉換的,並可以自己實現簡單的一個 babel 的插件。當然,本文中所述之內容只是 babel 全部內容的十之一二,只是作一個學習 babel 的引路石,如果有較強烈的需求,還是要常翻閱官方文檔。最後希望大家可以將 bebel 學的更透徹。

參考文章:

  1. https://juejin.cn/post/6844903797571977223

  2. https://juejin.cn/post/6844904013033373704

  3. https://www.cnblogs.com/zhishaofei/p/13896056.html

  4. https://zhuanlan.zhihu.com/p/367724302

  5. https://www.babeljs.cn/

  6. https://juejin.cn/book/6946117847848321055

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LlQRx5SPmFgnTDO8VunGnw