「前端基建」帶你在Babel的世界中暢遊

語言: CN / TW / HK

highlight: agate

引言

Babel在目前前端領域類似一座山一樣的存在,任何專案或多或少都有它的身影在浮現。

也許對於Babel絕大多數前端開發者都是處於一知半解的狀態,但是無論是在實際業務開發中還是對於我們個人提升來說熟練掌握Babel一定是晉升高階前端工程師的必備之路。

文章中我們只講“乾貨”,從原理出髮結合深層次實踐帶你領略Babel之美。

我們會從Babel基礎內容從而漸進到Babel外掛開發者的世界,從此讓你對於Babel得心應手。

文中大致目錄如下:

  • Babel日常用法指南

    • 從基礎內容出發,帶你掌握常見的PluginPreset

    • 前端基建專案中的Babel配置講解。

    • Babel相關polyfill內容。

  • Babel外掛開發指南

    • 帶你走進Babel的編譯世界,領略Babel背後的原理知識。

    • 手把手帶你開發一款屬於自己的Babel外掛。

🚀 廢話不多講,讓我們開始真正進入Babel的世界。

Babel日常用法

首先我們會從基礎的配置Babel及相關內容開始講解。

常見pluginPreset

首先我們來說說PluginPreset的區別和聯絡。

所謂Preset就是一些Plugin組成的合集,你可以將Preset理解稱為就是一些的Plugin整合稱為的一個包。

常見Preset

文章中列舉了三個最常用的Preset,更多的Prest你可以在這裡查閱

babel-preset-env

@babel/preset-env是一個智慧預設,它可以將我們的高版本JavaScript程式碼進行轉譯根據內建的規則轉譯成為低版本的javascript程式碼。

preset-env內部集成了絕大多數pluginState > 3)的轉譯外掛,它會根據對應的引數進行程式碼轉譯。

具體的引數配置你可以在這裡看到

@babel/preset-env不會包含任何低於 Stage 3 的 JavaScript 語法提案。如果需要相容低於Stage 3階段的語法則需要額外引入對應的Plugin進行相容。

需要額外注意的是babel-preset-env僅僅針對語法階段的轉譯,比如轉譯箭頭函式,const/let語法。針對一些Api或者Es 6內建模組的polyfillpreset-env是無法進行轉譯的。這塊內容我們會在之後的polyfill中為大家進行詳細講解。

babel-preset-react

通常我們在使用React中的jsx時,相信大家都明白實質上jsx最終會被編譯稱為React.createElement()方法。

babel-preset-react這個預設起到的就是將jsx進行轉譯的作用。

babel-preset-typescript

對於TypeScript程式碼,我們有兩種方式去編譯TypeScript程式碼成為JavaScript程式碼。

  1. 使用tsc命令,結合cli命令列引數方式或者tsconfig配置檔案進行編譯ts程式碼。

  2. 使用babel,通過babel-preset-typescript程式碼進行編譯ts程式碼。

常見Plugin

Babel官網列舉出了一份非常詳盡的Plugin List

關於常見的Plugin其實大多數都整合在了babel-preset-env中,當你發現你的專案中並不能支援最新的js語法時,此時我們可以查閱對應的Babel Plugin List找到對應的語法外掛新增進入babel配置。

同時還有一些不常用的packages,比如@babel/register:它會改寫require命令,為它加上一個鉤子。此後,每當使用require載入.js.jsx.es.es6字尾名的檔案,就會先用Babel進行轉碼。

這些包日常中不是特別常用,如果有同學有相關編譯相關需求完全可以去babel官網查閱。如果官網不存在現成的plugin/package,別擔心!我們同時也會在之後手把手教大家babel外掛的開發。

其中最常見的@babel/plugin-transform-runtime我們會在下面的Polyfill進行詳細的講解。

前端基建中的Babel配置詳解

接下里我們聊聊前端專案構建中相關的babel相關配置。

關於前端構建工具,無路你使用的是webapack還是rollup又或是任何構建打包工具,內部都離不開Babel相關配置。

這裡我們使用業務中最常用的webpack舉例,其他構建工具在使用方面只是引入的包不同,Babel配置原理是相通的。

關於WebPack中我們日常使用的babel相關配置主要涉及以下三個相關外掛:

  • babel-loader

  • babel-core

  • babel-preset-env

也許你經常在專案搭建過程中見到他們,這裡我們將逐步使用一段虛擬碼來講解他們之間的區別和聯絡。

首先我們需要清楚在 webpackloader的本質就是一個函式,接受我們的原始碼作為入參同時返回新的內容。

babel-loader

所以babel-loader的本質就是一個函式,我們匹配到對應的jsx?/tsx?的檔案交給babel-loader:

js /** * * @param sourceCode 原始碼內容 * @param options babel-loader相關引數 * @returns 處理後的程式碼 */ function babelLoader (sourceCode,options) { // .. return targetCode }

關於optionsbabel-loader支援直接通過loader的引數形式注入,同時也在loader函式內部通過讀取.babelrc/babel.config.js/babel.config.json``等檔案注入配置。

關於babel在各種基建專案的初始化方式你在可以在這裡查閱

babel-core

我們講到了babel-loader僅僅是識別匹配檔案和接受對應引數的函式,那麼babel在編譯程式碼過程中核心的庫就是@babel/core這個庫。

babel-corebabel最核心的一個編譯庫,他可以將我們的程式碼進行詞法分析--語法分析--語義分析過程從而生成AST抽象語法樹,從而對於“這棵樹”的操作之後再通過編譯稱為新的程式碼。

babel-core其實相當於@babel/parse@babel/generator這兩個包的合體,接觸過js編譯的同學可能有了解esprimaescodegen這兩個庫,你可以將babel-core的作用理解稱為這兩個庫的合體。

babel-core通過transform方法將我們的程式碼進行編譯。

關於babel-core中的編譯方法其實有很多種,比如直接接受字串形式的transform方法或者接受js檔案路徑的transformFile方法進行檔案整體編譯。

同時它還支援同步以及非同步的方法,具體方法你可以在這裡看到

關於babel-core內部的編譯使用規則,我們會在之後的外掛章節中詳細講到。

接下來讓我們完善對應的babel-loader函式:

```js const core = require('@babel/core')

/* * * @param sourceCode 原始碼內容 * @param options babel-loader相關引數 * @returns 處理後的程式碼 / function babelLoader (sourceCode,options) { // 通過transform方法編譯傳入的原始碼 core.transform(sourceCode) return targetCode } ```

這裡我們在babel-loader中呼叫了babel-core這個庫進行了程式碼的編譯作用。

babel-preset-env

上邊我們說到babel-loader本質是一個函式,它在內部通過babel/core這個核心包進行JavaScript程式碼的轉譯。

但是針對程式碼的轉譯我們需要告訴babel以什麼樣的規則進行轉化,比如我需要告訴babel:“嘿,babel。將我的這段程式碼轉化稱為EcmaScript 5版本的內容!”。

此時babel-preset-env在這裡充當的就是這個作用:告訴babel我需要以為什麼樣的規則進行程式碼轉移

```js const core = require('@babel/core');

/* * * @param sourceCode 原始碼內容 * @param options babel-loader相關引數 * @returns 處理後的程式碼 / function babelLoader(sourceCode, options) { // 通過transform方法編譯傳入的原始碼 core.transform(sourceCode, { presets: ['babel-preset-env'], plugins: [...] }); return targetCode; } ```

這裡pluginprest其實是同一個東西,所以我將plugin直接放在程式碼中了。同理一些其他的preset或者plugin也是發揮這樣的作用。

關於babel的基礎基建配置我相信講到這裡大家已經明白了他們對應的職責和基礎原理,如果還有其他配置方面的問題可以查閱babel文件或者檢視我的這篇文章React-Webpack5-TypeScript打造工程化多頁面應用

Babel相關polyfill內容

何謂polyfill

關於polyfill,我們先來解釋下何謂polyfill

首先我們來理清楚這三個概念:

  • 最新ES語法,比如:箭頭函式,let/const
  • 最新ES Api,比如Promise
  • 最新ES例項/靜態方法,比如String.prototype.include

babel-prest-env僅僅只會轉化最新的es語法,並不會轉化對應的Api和例項方法,比如說ES 6中的Array.from靜態方法。babel是不會轉譯這個方法的,如果想在低版本瀏覽器中識別並且執行Array.from方法達到我們的預期就需要額外引入polyfill進行在Array上新增實現這個方法。

其實可以稍微簡單總結一下,語法層面的轉化preset-env完全可以勝任。但是一些內建方法模組,僅僅通過preset-env的語法轉化是無法進行識別轉化的,所以就需要一系列類似”墊片“的工具進行補充實現這部分內容的低版本程式碼實現。這就是所謂的polyfill的作用,

針對於polyfill方法的內容,babel中涉及兩個方面來解決:

  • @babel/polyfill

  • @babel/runtime

  • @babel/plugin-transform-runtime

我們理清了何謂polyfill以及polyfill的作用和含義後,讓我們來逐個擊破這兩個babel包對應的使用方式和區別吧。

@babel/polyfill

首先我們來看看第一種實現polyfill的方式:

@babel/polyfill介紹

通過babelPolyfill通過往全域性物件上新增屬性以及直接修改內建物件的Prototype上新增方法實現polyfill

比如說我們需要支援String.prototype.include,在引入babelPolyfill這個包之後,它會在全域性String的原型物件上新增include方法從而支援我們的Js Api

我們說到這種方式本質上是往全域性物件/內建物件上掛載屬性,所以這種方式難免會造成全域性汙染。

應用@babel/polyfill

babel-preset-env中存在一個useBuiltIns引數,這個引數決定了如何在preset-env中使用@babel/polyfill

json { "presets": [ ["@babel/preset-env", { "useBuiltIns": false }] ] }

  • useBuiltIns--"usage""entry"false
false

當我們使用preset-env傳入useBuiltIns引數時候,預設為false。它表示僅僅會轉化最新的ES語法,並不會轉化任何Api和方法。

entry

當傳入entry時,需要我們在專案入口檔案中手動引入一次core-js,它會根據我們配置的瀏覽器相容性列表(browserList)然後全量引入不相容的polyfill

Tips: 在Babel7.4。0之後,@babel/polyfill被廢棄它變成另外兩個包的整合。"core-js/stable"; "regenerator-runtime/runtime";。你可以在這裡看到變化,但是他們的使用方式是一致的,只是在入口檔案中引入的包不同了。

瀏覽器相容性列表配置方式簡介你可以在這裡看到

```javascript // 專案入口檔案中需要額外引入polyfill // core-js 2.0中是使用"@babel/polyfill" core-js3.0版本中變化成為了上邊兩個包 import "@babel/polyfill"

// babel { "presets": [ ["@babel/preset-env", { "useBuiltIns": "entry" }] ] } ```

同時需要注意的是,在我們使用useBuiltIns:entry/usage時,需要額外指定core-js這個引數。預設為使用core-js 2.0,所謂的core-js就是我們上文講到的“墊片”的實現。它會實現一系列內建方法或者PromiseApi

core-js 2.0版本是跟隨preset-env一起安裝的,不需要單獨安裝哦~

usage

上邊我們說到配置為entry時,perset-env會基於我們的瀏覽器相容列表進行全量引入polyfill。所謂的全量引入比如說我們程式碼中僅僅使用了Array.from這個方法。但是polyfill並不僅僅會引入Array.from,同時也會引入PromiseArray.prototype.include等其他並未使用到的方法。這就會造成包中引入的體積太大了。

此時就引入出了我們的useBuintIns:usage配置。

當我們配置useBuintIns:usage時,會根據配置的瀏覽器相容,以及程式碼中 使用到的Api 進行引入polyfill按需新增。

當使用usage時,我們不需要額外在專案入口中引入polyfill了,它會根據我們專案中使用到的進行按需引入。

json { "presets": [ ["@babel/preset-env", { "useBuiltIns": "usage", "core-js": 3 }] ] }

關於usageentry存在一個需要注意的本質上的區別。

我們以專案中引入Promise為例。

當我們配置useBuintInts:entry時,僅僅會在入口檔案全量引入一次polyfill。你可以這樣理解:

```js // 當使用entry配置時 ... // 一系列實現polyfill的方法 global.Promise = promise

// 其他檔案使用時 const a = new Promise() ```

而當我們使用useBuintIns:usage時,preset-env只能基於各個模組去分析它們使用到的polyfill從而進入引入。

preset-env會幫助我們智慧化的在需要的地方引入,比如:

```js // a. js 中 import "core-js/modules/es.promise";

... js // b.js中

import "core-js/modules/es.promise"; ... ```

  • usage情況下,如果我們存在很多個模組,那麼無疑會多出很多冗餘程式碼(import語法)。

  • 同樣在使用usage時因為是模組內部區域性引入polyfill所以並不會汙染全域性變數,而entry是掛載在全域性中所以會汙染全域性變數。

usageBuintIns不同引數分別有不同場景的適應度,具體引數使用場景還需要大家結合自己的專案實際情況找到最佳方式。

@babel/runtime

上邊我們講到@babel/polyfill是存在汙染全域性變數的副作用,在實現polyfillBabel還提供了另外一種方式去讓我們實現這功能,那就是@babel/runtime

簡單來講,@babel/runtime更像是一種按需載入的解決方案,比如哪裡需要使用到Promise@babel/runtime就會在他的檔案頂部新增import promise from 'babel-runtime/core-js/promise'

同時上邊我們講到對於preset-envuseBuintIns配置項,我們的polyfillpreset-env幫我們智慧引入。

babel-runtime則會將引入方式由智慧完全交由我們自己,我們需要什麼自己引入什麼。

它的用法很簡單,只要我們去安裝npm install --save @babel/runtime後,在需要使用對應的polyfill的地方去單獨引入就可以了。比如:

```js // a.js 中需要使用Promise 我們需要手動引入對應的執行時polyfill import Promise from 'babel-runtime/core-js/promise'

const promsies = new Promise() ```

總而言之,babel/runtime你可以理解稱為就是一個執行時“哪裡需要引哪裡”的工具庫。

針對babel/runtime絕大多數情況下我們都會配合@babel/plugin-transfrom-runtime進行使用達到智慧化runtimepolyfill引入。

@babel/plugin-transform-runtime

babel-runtime存在的問題

babel-runtime在我們手動引入一些polyfill的時候,它會給我們的程式碼中注入一些類似_extend(), classCallCheck()之類的工具函式,這些工具函式的程式碼會包含在編譯後的每個檔案中,比如:

js class Circle {} // babel-runtime 編譯Class需要藉助_classCallCheck這個工具函式 function _classCallCheck(instance, Constructor) { //... } var Circle = function Circle() { _classCallCheck(this, Circle); };

如果我們專案中存在多個檔案使用了class,那麼無疑在每個檔案中注入這樣一段冗餘重複的工具函式將是一種災難。

所以針對上述提到的兩個問題:

  • babel-runtime無法做到智慧化分析,需要我們手動引入。
  • babel-runtime編譯過程中會重複生成冗餘程式碼。

我們就要引入我們的主角@babel/plugin-transform-runtime

@babel/plugin-transform-runtime作用

@babel/plugin-transform-runtime外掛的作用恰恰就是為了解決上述我們提到的run-time存在的問題而提出的外掛。

  • babel-runtime無法做到智慧化分析,需要我們手動引入。

@babel/plugin-transform-runtime外掛會智慧化的分析我們的專案中所使用到需要轉譯的js程式碼,從而實現模組化從babel-runtime中引入所需的polyfill實現。

  • babel-runtime編譯過程中會重複生成冗餘程式碼。

@babel/plugin-transform-runtime外掛提供了一個helpers引數。具體你可以在這裡查閱它的所有配置引數

這個helpers引數開啟後可以將上邊提到編譯階段重複的工具函式,比如classCallCheck, extends等程式碼轉化稱為require語句。此時,這些工具函式就不會重複的出現在使用中的模組中了。比如這樣:

js // @babel/plugin-transform-runtime會將工具函式轉化為require語句進行引入 // 而非runtime那樣直接將工具模組程式碼注入到模組中 var _classCallCheck = require("@babel/runtime/helpers/classCallCheck"); var Circle = function Circle() { _classCallCheck(this, Circle); };

配置@babel/plugin-transform-runtime

其實用法原理部分已經在上邊分析的比較透徹了,配置這裡還有疑問的同學可以評論區給我留言或者移步babel官網檢視

這裡為列一份目前它的預設配置:

json { "plugins": [ [ "@babel/plugin-transform-runtime", { "absoluteRuntime": false, "corejs": false, "helpers": true, "regenerator": true, "version": "7.0.0-beta.0" } ] ] }

總結polyfill

我們可以看到針對polyfill其實我耗費了不少去將它們之間的區別和聯絡,讓我們來稍微總結一下吧。

babel中實現polyfill主要有兩種方式:

  • 一種是通過@babel/polyfill配合preset-env去使用,這種方式可能會存在汙染全域性作用域。
  • 一種是通過@babel/runtime配合@babel/plugin-transform-runtime去使用,這種方式並不會汙染作用域。

  • 全域性引入會汙染全域性作用域,但是相對於區域性引入來說。它會增加很多額外的引入語句,增加包體積。

useBuintIns:usage情況下其實和@babel/plugin-transform-runtime情況下是類似的作用,

通常我個人選擇是會在開發類庫時遵守不汙染全域性為首先使用@babel/plugin-transform-runtime而在業務開發中使用@babel/polyfill

``` babel-runtime 是為了減少重複程式碼而生的。 babel生成的程式碼,可能會用到一些_extend(), classCallCheck() 之類的工具函式,預設情況下,這些工具函式的程式碼會包含在編譯後的檔案中。如果存在多個檔案,那每個檔案都有可能含有一份重複的程式碼。

babel-runtime外掛能夠將這些工具函式的程式碼轉換成require語句,指向為對babel-runtime的引用,如 require('babel-runtime/helpers/classCallCheck'). 這樣, classCallCheck的程式碼就不需要在每個檔案中都存在了。 ```

Babel外掛開發

上邊我們講到了日常業務中babel的使用方式原理,接下來我們來講講babel外掛相關開發的內容。

也許你會疑惑Babel外掛能有什麼樣的作用?簡單來說,通過babel外掛可以帶你在原理層面更加深入前端編譯原理的知識內容。

當然如果不僅僅是對於個人能力的提升,假使你在開發一款屬於自己的元件庫,你想實現類似element-plus中的按需引入方式,又或許對於lint你存在自己的特殊的規則。再不然對於一些js中特殊的寫法的支援。

總而言之,懂編譯原理真的是可以無所欲為!

帶你走進babel的編譯世界

針對於編譯方面的知識,文章中的重點並不是這個。但是大家請放心,我會用最通俗的方式帶大家入門babel外掛的開發。

webpacklintbabel等等很多工具和庫的核心都是通過抽象語法樹(Abstract Syntax Tree,AST)這個概念來實現對程式碼的處理。

AST

所謂抽象語法樹就是通過JavaScript Parser將程式碼轉化成為一顆抽象語法樹,這棵樹定義了程式碼的結構。然後通過操縱這棵樹的增刪改查實現對程式碼的分析,變更,優化。

針對將程式碼轉化為不同的AST你可以在這裡astexplorer目前主流任何解析器的AST轉化。

這裡我們首先列舉一些涉及的參考網站給大家:

  • astexplorer:這是一個線上的程式碼轉譯器,他可以按照目前業界主流的方式將任何程式碼轉為AST

  • babel-handbookbabel外掛開發中文手冊文件。

  • the-super-tiny-compiler-cn:一個github上的開源小型listp風格轉化js編譯器,強烈推薦對編譯原理感興趣的同學可以去看一看它的程式碼。

babel外掛開發基礎指南

當我們需要開發一款屬於自己的babel外掛時,通常我們會藉助babel的一些庫去進行程式碼的parser以及transform astgenerator code,並不需要我們去手動對程式碼進行詞法/語法分析過程。

外掛開發通常會涉及這些庫:

  • @babel/core:上邊我們說過babel/corebabel的核心庫,核心的api都在這裡。比如上邊我們講到的transformparse方法。

  • @babel/parser:babel解析器。

  • @babel/types: 這個模組包含手動構建 AST 和檢查 AST 節點型別的方法(比如通過對應的api生成對應的節點)。

  • @babel/traverse: 這個模組用於對Ast的遍歷,它維護了整棵樹的狀態(需要注意的是traverse對於ast是一種深度遍歷)。

  • @babel/generator: 這個模組用於程式碼的生成,通過AST生成新的程式碼返回。

babel的工作流程

在日常前端專案中,絕大多數時候我們使用babel進行js程式碼的轉化。

它的工作流程大概可以概括稱為以下三個方面:

  • Parse(解析)階段:這個階段將我們的js程式碼(字串)進行詞法分析生成一系列tokens,之後再進行語法分析將tokens組合稱為一顆AST抽象語法樹。(比如babel-parser它的作用就是這一步)

  • Transform(轉化)階段:這個階段babel通過對於這棵樹的遍歷,從而對於舊的AST進行增刪改查,將新的js語法節點轉化稱為瀏覽器相容的語法節點。(babel/traverse就是在這一步進行遍歷這棵樹)

  • Generator(生成)階段:這個階段babel會將新的AST轉化同樣進行深度遍歷從而生成新的程式碼。(@babel/generator)

我們用一張圖來描述一下這個過程:

image.png

babelAST的遍歷過程

  • AST是所謂的深度優先遍歷,關於何謂深度優先不瞭解的同學可以自行查閱相關資料~

  • babelAST節點的遍歷是基於一種訪問者模式(Visitor),不同的訪問者會執行不同的操作從而得到不同的結果。

  • visitor上掛載了以每個節點命名的方法,當進行AST遍歷時就會觸發匹配的方法名從而執行對應方法進行操作。

手把手帶你開發babel外掛

這裡我們以一個簡單的ES6中的箭頭函式轉化為ES5方式入手,來帶大家入門真正的babel外掛開發。

我相信有的同學可能有疑惑,babel中已經存在對應的@babel/plugin-transform-arrow-functions進行箭頭函式的轉化,為什麼我們還要去實現它呢。

沒錯,babel中的確已經存在這個外掛而且已經非常完美了。這裡我想強調的是,之所以選擇這個例子是想帶大家真正入門babel外掛的開發流程,一個簡單的例子其實可以更好的帶給大家外掛開發背後的思想體會,從而可以讓大家舉一反三。

讓我們開始吧~

首先讓我們來看看我們需要實現的結果:

目標

```js // input const arrowFunc = () => { console.log(this) }

// output var _this = this funciton arrowFunc() { console.log(_this) } ```

babel原版轉化方式

```js /* * babel外掛 * 主要還是@babel/core中的transform、parse 對於ast的處理 * 以及babel/types 中各種轉化規則 * * Ast是一種深度優先遍歷 * 內部使用訪問者(visitor)模式 * * babel主要也是做的AST的轉化 * * 1. 詞法分析 tokens : var a = 1 ["var","a","=","1"] * 2. 語法分析 將tokens按照固定規則生成AST語法樹 * 3. 語法樹轉化 在舊的語法樹基礎上進行增刪改查 生成新的語法書 * 4. 生成程式碼 根據新的Tree生成新的程式碼 /

// babel核心轉化庫 包含core -》 AST -》 code的轉化實現 / babel/core 其實就可以相當於 esprima+Estraverse+Escodegen 它會將原本的sourceCode轉化為AST語法樹 遍歷老的語法樹 遍歷老的語法樹時候 會檢查傳入的外掛/或者第三個引數中傳入的visitor 修改對應匹配的節點 生成新的語法樹 之後生成新的程式碼地址 / const babel = require('@babel/core');

// babel/types 工具庫 該模組包含手動構建TS的方法,並檢查AST節點的型別。(根據不同節點型別進行轉化實現) const babelTypes = require('@babel/types');

// 轉化箭頭函式的外掛 const arrowFunction = require('@babel/plugin-transform-arrow-functions');

const sourceCode = const arrowFunc = () => { console.log(this) };

const targetCode = babel.transform(sourceCode, { plugins: [arrowFunction], });

console.log(targetCode.code) ```

這裡我們使用了babel/core,它的transform方法會將我們的程式碼轉化稱為AST同時進入plugins的處理成為新的AST,最終生成對應的程式碼。

不太清楚外掛工作原理的同學可以根據程式碼註釋自己動手寫一下,這裡僅僅是短短十幾行程式碼。

自己實現@babel/plugin-transform-arrow-functions外掛

這裡我們嘗試一下自己來實現一個這樣的功能。

首先,讓我們先來寫好基礎的結構:

```js const babel = require('@babel/core');

// babel/types 工具庫 該模組包含手動構建TS的方法,並檢查AST節點的型別。(根據不同節點型別進行轉化實現) const babelTypes = require('@babel/types');

// 我們自己實現的轉化外掛 const { arrowFunctionPlugin } = require('./plugin-transform-arrow-functions');

const sourceCode = const arrowFunc = () => { console.log(this) };

const targetCode = babel.transform(sourceCode, { plugins: [arrowFunctionPlugin], }); // 列印編譯後代碼 console.log(targetCode.code) js // plugin-transform-arrow-functions.js const arrowFunctionPlugin = () => { // ... } module.exports = { arrowFunctionPlugin } ```

這裡,我們新建了一個plugin-transform-arrow-functions檔案來實現我們自己的外掛:

上邊我們講過babel外掛實質上就是一個物件,裡邊會有一個屬性visitor。這個visitor物件上會有很多方法,每個方法都是基於節點的名稱去命名的。

babel/core中的transform方法進行AST的遍歷時會進入visitor物件中匹配,如果對應節點的型別匹配到了visitor上的屬性那麼就會從而執行相應的方法。

比如這樣一段程式碼:

js const arrowFunctionPlugin = { visitor: { ArrowFunctionExpression(nodePath) { // do something } }, } 當進行AST遍歷時,如果碰到節點型別為ArrowFunctionExpression時就會進入visitor物件中的ArrowFunctionExpression方法從而執行對應邏輯從而進行操作當前樹。

這裡有兩個tip需要和大家稍微解釋一下。

  • 我如何知道每個節點的型別呢?比如ArrowFunctionExpression就是箭頭函式的型別。

首先,babel/types中涵蓋了所有的節點型別。我們可以通過查閱babel/types查閱對應的節點型別。

當然還存在另一個更加方便的方式,上邊我們提到的astexplorer,你可以在這裡查閱對應程式碼生成的AST從而獲得對應的節點。

  • 什麼是nodePath引數,它有什麼作用?

這裡每一個方法都存在一個nodePath引數,所謂的nodePath引數你可以將它理解成為一個節點路徑。它包含了這個樹上這個節點分叉的所有資訊和對應的api注意這裡可以強調是路徑,你可以在這裡查閱它的含義以及對應的所有API

在我們寫好基礎的結構之後,讓我們來開始動手實現外掛的內部邏輯吧。

我們清楚想要講程式碼進行編譯,難免要進行AST節點的修改。本質上我們還是通過對於AST節點的操作修改AST從而生成我們想要的程式碼結果。

  • 首先,我們可以通過astexplorer分別輸入我們的原始碼和期望的編譯後代碼得到對應的AST結構。

  • 之後,我們在對比這兩棵樹的結構從而在原有的AST基礎上進行修改得到我們最終的AST

  • 剩下,應該就沒有什麼剩下的步驟了。babel transform方法會根據我們修改後的AST生成對應的原始碼。

強烈建議同學們自己進入astexplorer輸入需要轉譯的程式碼和轉譯後的程式碼進行對比一下。

需要編譯的箭頭函式部分節點截圖:

image.png

編譯後代碼的部分節點截圖:

image.png

這裡,我們發現對比inputoutput:

  • output中將箭頭函式的節點ArrowFunctionExpression替換成為了FunctionDeclaration
  • output中針對箭頭函式的body,呼叫表示式宣告ExpressionStatement時,傳入的argumentsThisExpression更換成了Identifier
  • 同時output在箭頭函式同作用域內額外添加了一個變數宣告,const _this = this

很簡單吧,我們只需要在我們的arrowFunctionPlugin中實現這三個功能就可以滿足要求了,讓我們一起來動手試一試吧。

```js const babelTypes = require('@babel/types');

function ArrowFunctionExpression(path) { const node = path.node; hoistFunctionEnvironment(path); node.type = 'FunctionDeclaration'; }

/ * * * @param {} nodePath 當前節點路徑 / function hoistFunctionEnvironment(nodePath) { // 往上查詢 直到找到最近頂部非箭頭函式的this p.isFunction() && !p.isArrowFunctionExpression() // 或者找到跟節點 p.isProgram() const thisEnvFn = nodePath.findParent((p) => { return (p.isFunction() && !p.isArrowFunctionExpression()) || p.isProgram(); }); // 接下來查詢當前作用域中那些地方用到了this的節點路徑 const thisPaths = getScopeInfoInformation(thisEnvFn); const thisBindingsName = generateBindName(thisEnvFn); // thisEnvFn中新增一個變數 變數名為 thisBindingsName 變數值為 this // 相當於 const _this = this thisEnvFn.scope.push({ // 呼叫babelTypes中生成對應節點 // 詳細你可以在這裡查閱到 https://babeljs.io/docs/en/babel-types id: babelTypes.Identifier(thisBindingsName), init: babelTypes.thisExpression(), }); thisPaths.forEach((thisPath) => { // 將this替換稱為_this const replaceNode = babelTypes.Identifier(thisBindingsName); thisPath.replaceWith(replaceNode); }); }

/ * * 查詢當前作用域內this使用的地方 * @param {} nodePath 節點路徑 / function getScopeInfoInformation(nodePath) { const thisPaths = []; // 呼叫nodePath中的traverse方法進行便利 // 你可以在這裡查閱到 https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md nodePath.traverse({ // 深度遍歷節點路徑 找到內部this語句 ThisExpression(thisPath) { thisPaths.push(thisPath); }, }); return thisPaths; }

/ * 判斷之前是否存在 _this 這裡簡單處理下 * 直接返回固定的值 * @param {} path 節點路徑 * @returns / function generateBindName(path, name = '_this', n = '') { if (path.scope.hasBinding(name)) { generateBindName(path, '_this' + n, parseInt(n) + 1); } return name; }

module.exports = { hoistFunctionEnvironment, arrowFunctionPlugin: { visitor: { ArrowFunctionExpression, }, }, };

```

接下來讓我們在程式碼中使用我們寫好的外掛來run一下吧。

image.png

總結外掛開發流程

上邊雖然是一個簡單的外掛Demo例子,但是麻雀雖小五臟俱全。一個完整的babel外掛流程大概就是如此,這裡讓我們稍微總結一下關於babel外掛的開發過程。

  • 通過原始碼和轉譯後代碼進行AST節點對比,找出對應的區別節點,儘量複用之前的節點。

  • 存在修改/增加/刪除的節點,通過nodePath中的Api呼叫對應的方法進行AST的處理。

巨集觀上來講外掛的開發流程主要就分為這兩個步驟,剩下的就是各位對於ast中轉化部分的“業務邏輯”啦。

babel外掛開發部分可能會涉及一些大家之前並沒有接觸過的API,這裡我選擇直接用程式碼的方式去講解外掛的開發並沒有去深入講解這些API。如果對某些部分不太理解的話可以在評論區留言給我,對應的API我個人建議大家多動手去babel-handbook外掛開發手冊查詢,這裡理解起來會更加深刻。

文中外掛僅僅是一個小Demo級別的,目的是為了將大家帶入babel外掛的開發的大門。文章中的程式碼你可以在這裡檢視。這個repo中不僅僅包含文章中的demo,還涉及了一些難度更高的外掛學習模仿,以及文章開始提到的實現元件庫的按需載入外掛 (~~按需載入外掛我還在寫,原諒我的懶惰...~~)。

寫在結尾

至此,感謝每一位看到這裡的小夥伴。

文章中的babel講解也不過是冰山一角,希望成為這篇文章會成為大家探索Babel的起點。