「Babel」為你的元件庫定製化一款Tree-Shaking外掛吧

語言: CN / TW / HK

「這是我參與11月更文挑戰的第2天,活動詳情檢視:2021最後一次更文挑戰」。

引言

如果對Babel基礎知識和外掛開發不是很瞭解的同學,可以檢視這篇文章「前端基建」帶你在Babel的世界中暢遊補充下Babel的基礎知識哦~

作為前端開發者,無論是作為業務還是學習我相信大家都有一個屬於自己的元件庫。

這裡,我們就從Tree Shaking的角度出發來談談如何為我們自己的元件庫提供按需載入方式。

何謂Tree Shaking

簡單聊聊什麼是Tree Shaking

其實Tree Shaking的概念已經耳熟能詳了,所謂的“搖樹”就是說將我們程式碼中的沒有用到的程式碼進行搖晃掉,從而減少包的體積。

我們來看一個簡單的例子: ```js // index.js 入口檔案 import { funcHao } from './math'

funcHao() js // math.js const funcWang = () => { const obj = {}; return obj; };

const funcHao = () => { console.log('hao'); };

export { funcWang, funcHao };

export { funcWang, funcHao } js // webpack.config.js const { resolve } = require('path');

module.exports = { mode: 'production', entry: resolve(__dirname, './src/index.js'), module: { rules: [ { test: /.js$/, use: [ { loader: 'babel-loader', options: { presets: ['@babel/preset-env'], }, }, ], }, ], }, output: { filename: '[name].js', }, };

```

這裡我們使用webpack新建了一個只有兩個檔案的專案。

  • src/index.js: 入口檔案,匯入math.js中的funcHao方法。
  • src/math.js: 匯出兩個方法funcHaofuncWang兩個方法。

tip: 這裡我們配置babel的原因不單單是為了轉譯箭頭函式,稍微我在後邊會講述為什麼這裡為配置了一個babel-preset-env

讓我們執行webpack命令打包我們的程式碼:

js // dist/main.js (()=>{"use strict";console.log("hao")})();

我們會發現打包後的程式碼僅存在console.log("hao"),而funcWang這個函式的內容並沒有被打包進入。

其實簡單來說這就是所謂的Tree Shaking: 基於 ES Module 規範的 Dead Code Elimination 技術,它會在執行過程中靜態分析模組之間的匯入匯出,確定 ESM 模組中哪些匯出值未曾其它模組使用,並將其刪除,以此實現打包產物的優化。

簡單來說就是刪除專案中沒有使用到的程式碼從而達到優化程式碼的效果

Tree Shaking工作原理

需要額外注意的是:

  • Tree Shaking是基於ESM模組基礎進行處理的。

至於為什麼Tree Shaking需要ESM模組才能使用呢? 讓我們來一起看一看這個問題。

簡單來說一段js程式碼的執行過程,需要經歷以下三個步驟:

  • V8通過原始碼進行詞法分析,語法分析生成AST和執行上下文。

  • 根據AST生成計算機可執行的位元組碼。

  • 執行生成的位元組碼。

JS的執行過程中,ES Module在第一步時就可以確認對應的依賴關係(編譯階段),並不需要執行就可以確認模組的匯入、匯出。

ES Modulejs編譯階段就可以確定模組之間的依賴關係(import)以及模組的匯出(export),所以我們並不需要程式碼執行就可以根據ESM確定模組之間的規則從而實現Tree Shaking,我們稱之為靜態分析特性

同理,對比commonjs模組,它依賴於程式碼的執行,需要在第三階段執行完成程式碼之後才能確認模組的依賴關係。

自然也就不支援Tree Shaking

關於ES Module中的動態引入dynamic import,因為它同樣是動態需要js執行後才能確認的模組關係。自然也就無法支援Tree Shaking

為什麼我要配置babel-preset-env

上文講到過我刻意配置了@babel/preset-env處理我們的程式碼,瞭解過它的同學可能會清楚。

@babel/preset-env是存在一個modules的配置引數,它的預設值是auto

modules配置的含義是,在preset-env轉譯時中啟用 ES 模組語法到另一種模組型別的轉換。

也許你會在很多教程或者網站上看到,由於Tree Shaking必須基於Es Module模組。

所以如果我們專案中使用到babel-preset-env時需要將它的modules配置為false:相當於告訴babel,"嘿,Babel請保留我程式碼中的ESM模組規範"。

沒錯,你配置為false的確沒有任何問題,可是上邊我們的配置沒有進行任何配置,預設值為auto的時候同樣進行了Tree Shaking

你有想過這是為什麼嗎? 日常工作中我相信大部分同學使用preset-env結合業務時也沒有刻意配置modules:false吧。

其實根本原因就出現在它的預設引數auto中。

配置為auto,預設情況下,@babel/preset-env使用caller資料來確定是否import()應轉換ES 模組和模組功能(例如)。

關於如何理解這段話,比如: 如果我們使用Babel-Loader呼叫Babel,那麼modules將設定為False,因為WebPack支援es模組。

關於auto引數更加詳細的資訊,你可以在這次commit中看到。

其實這裡我配置preset-env的原因就是想和大家講述一下關於modules:auto的含義,我相信還是有不少朋友對於modules:auto之前仍然是一知半解的狀態。

實現元件庫Tree Shaking思路

在講述了何謂Tree Shaking之後,讓我們真正來動手基於Babel來實現一個Tree Shaking外掛吧!

元件庫Tree Shaking歷程

首先,在老版本的webpack中是不支援將程式碼編譯成為Es module模組的,所有就會導致一些元件庫編譯後的程式碼無法使用Tree Shaking進行處理。(因為它編譯出來的程式碼壓根就不是ES Module呀!)

所以老版本元件庫中,比如element-ui中借用babel-plugin-component,老版本ant-design使用babel-plugin-import進行分析程式碼從而實現Tree Shaking的效果。

關於這兩個外掛其實是類似的效果,我們會在之後重點講述這部分內容。

細心的朋友可能也發現了,在目前的antdelement-plus中官網中提到已經能夠完全的支援Tree Shaking功能了。

沒錯!這正是因為它的的程式碼現在打包後會額外打出一份ES Module的模組規範程式碼,在結合package.json中的module欄位,可以不借助於任何外掛在ES Module模組下完美的進行Tree-Shaking

既生瑜,何講亮?

也許有的同學會問到了,既然現在很多構建工具都支援打包ES Module規範,如此我們將元件庫直接打包為ES Module規範不就可以了嗎?為什麼費事費力寫這麼一個babel外掛去使用。

首先,之所以選擇寫這樣一個Tree Shaking外掛更多的原因是想讓大家通過這樣一個外掛"管中窺豹"。在瞭解了Tree Shaking和元件庫的發展歷程之後,在結合之前業內的實踐去學習Babel外掛的開發流程。我個人看來這個外掛是最適合入門且思路清晰的。

其次,我們的元件庫的確可以打包成為Es Module形式直接支援Tree Shaking,但是難免有一些我們在業務中使用到的庫打包生成的檔案並不是基於ES Module規範的。

此時,如果我們使用到的這些庫。不同的方式存放於獨立的檔案之中的話,我們完全可以基於我們自己開發的Tree Shaking外掛在引入時候進行語句分析從而實現Tree Shaking的功能。

比如我們以為lodash為例子:

```js import { cloneDeep } from 'lodash'

// ... 業務程式碼 `` 當你這樣使用lodash時,由於打包出來的lodash並不是基於esm模組規範的。所以我們無法達到Tree Shaking`的效果。

```js import cloneDeep from 'lodash/cloneDeep'

// ... 業務程式碼 `` 此時,由於lodash中的cloneDeep方法存在的位置是一個獨立的檔案--lodash/cloneDeep`檔案。

當我們這樣引入時,相當於僅僅引入了一個js檔案而已。就可以顯著的減少引入的體積從而刪除無用的程式碼。

當然現階段lodash已經提供了es標準的庫,這裡我們只是用它來舉例從而讓大家更好的理解而已。

Babel外掛實現Tree Shaking的原理

其實上邊針對於lodash的例子已經非常接近於外掛所要實現的功能了。

在使用import引入特定的庫方法時(非預設),我們分析對應的import語句從而改寫對應的import語句:引入對應的方法指向對應的獨立檔案就可以Tree Shaking的效果了。

當然也許有同學會好奇,我直接這樣可以嗎: ```js import cloneDeep from 'lodash/cloneDeep' import join from 'lodash/join' import findLast from 'lodash/findLast' // ....

```

Emm...這樣的確可以實現效果,不過嘛。作為一個合格的前端工程師怎麼能寫出這樣冗餘的程式碼呢。

js import { cloneDeep,join,findLast } from 'lodash'

相比之下,這樣豈不是更清爽嘛😂。

實現Babel外掛

需要使用到的Babel

文章中頂部連結已經貼出了一份詳盡的Babel配置指南和基礎外掛開發者指南了。這裡我就簡單提一下關於外掛開發中使用到的babel相關的tool package:

  • @babel/core: 核心babel轉譯包,這裡主要使用到它的transform方法。
  • babel/types: babel工具包,這裡使用它來生成對應的AST節點和呼叫對應檢查節點API
  • babel/handbook: babel外掛開發者手冊,這裡涵蓋了babel外掛對應的流程和API

開發外掛

講了那麼多原理,讓我們在真正來到Coding階段吧!

```js // index.js const core = require('@babel/core'); const babelPluginImport = require('./babel-plugin-import');

const sourceCode = import { Button, Alert } from 'hy-store';;

const parseCode = core.transform(sourceCode, { plugins: [ babelPluginImport({ libraryName: 'hy-store', }), ], });

console.log(sourceCode, '輸入的Code'); console.log(parseCode.code, '輸出的結果'); ```

index.js中我們首先建立一個測試檔案。用來測試我們的外掛。這裡呼叫了我們寫好的外掛,並且輸入了原始碼import { Button, Alert } from 'hy-store';

我們希望的列印結果是:

code.png

同時我們的外掛需要接受一個引數為libraryName的引數。這個引數用來告訴我們的外掛:僅針對於這個libraryName的匯入語句進行處理。

在搭建好基礎的測試外掛程式碼後,讓我們來進入外掛內部的邏輯:

Babel外掛本質上就是一個物件中包含一個visitor屬性,從而針對visitor屬性上的key進行深度遍歷生成的AST,匹配到對應visitor上的key時觸發對應的方法從而進行對AST節點的增刪改查實現生成新的AST->生成新的code

當然你也可以匯出一個函式,函式返回這個物件。

讓我們先來進行基礎的外掛結構開發: ```js const core = require('@babel/core'); const t = require('@babel/types');

function babelPluginImport(options) { const { libraryName = 'hy-store' } = options; return { // babel外掛就是基於visitor觀察者模式 visitor: { // do something }, }; }

module.exports = babelPluginImport;

```

接下下讓我們開啟--Astexplorer,輸入我們的輸入程式碼和輸出程式碼:

我們首先來看看輸入(需要分析)程式碼:

image.png

  • 注意1位置我們選擇編譯器為@babel/parser
  • 同時我們在左側輸入我們的source Code
  • 右側就會動態生成對應的AST

同樣的操作讓我們再來輸入targetCode(分析後生成的程式碼)來看一看吧:

image.png

看到這裡的同學,我希望你停一停往下看,自己稍微對比下這兩棵樹的差距。在腦海中思考如果將source中的Tree轉化為target中的Tree,需要怎麼處理。


讓我們稍微來捋一捋:

  • 首先當我們碰到ImportDeclaration語句時,需要判斷它的source是否來自於我們的libararyName這個庫。

  • 當匹配引入我們的對應庫時,我們還需要遍歷當前ImportDeclaration節點中的specifiers中是否包含預設匯出ImportDefaultSpecifier

  • 當上述兩個條件都滿足時,進入我們之後的邏輯處理。

    1. 引入(import)的是我們傳入匹配的libraryName
    2. 引入語句中不包含import xx from libraryName(預設匯出語句)。
  • 我們需要遍歷左側ImportDeclaration中的specifiers,將specifiers中每一個匯出語句修改成為對應獨立檔案路徑的預設匯出語句。

簡單來說,一個Tree Shaking Babel Pluign需要經歷的就是上述四個步驟。

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

function babelPluginImport(options) { const { libraryName = 'hy-store' } = options; return { visitor: { // 匹配ImportDeclaration時進入 ImportDeclaration(nodePath) { // checked Validity if (checkedDefaultImport(nodePath) || checkedLibraryName(nodePath)) { return; } const node = nodePath.node; // 獲取宣告說明符 const { specifiers } = node; // 遍歷對應的宣告符 const importDeclarations = specifiers.map((specifier, index) => { // 獲得原本匯入的模組 const moduleName = specifier.imported.name; // 獲得匯入時的重新命名 const localIdentifier = specifier.local; return generateImportStatement(moduleName, localIdentifier); }); if (importDeclarations.length === 1) { // 如果僅僅只有一個語句時 nodePath.replaceWith(importDeclarations[0]); } else { // 多個宣告替換 nodePath.replaceWithMultiple(importDeclarations); } }, }, };

// 檢查匯入是否是固定匹配庫 function checkedLibraryName(nodePath) { const { node } = nodePath; return node.source.value !== libraryName; }

// 檢查語句是否存在預設匯入 function checkedDefaultImport(nodePath) { const { node } = nodePath; const { specifiers } = node; return specifiers.some((specifier) => t.isImportDefaultSpecifier(specifier) ); }

// 生成匯出語句 將每一個引入更換為一個新的單獨路徑預設匯出的語句 function generateImportStatement(moduleName, localIdentifier) { return t.importDeclaration( [t.ImportDefaultSpecifier(localIdentifier)], t.StringLiteral(${libraryName}/${moduleName}) ); } }

module.exports = babelPluginImport; ```

至此,我們一個最基礎的Tree Shaking Babel plugin已經實現了。

你可以發現它僅僅只有60行程式碼,但是麻雀雖小五臟俱全。針對於一個Babel外掛的開發流程以及核心思路我相信大家在熟練掌握了這個外掛的開發思想後,針對於其他類似需求完全可以做到遊刃有餘。

接下來讓我們執行一下我們最開始的程式碼:

image.png

大功告成!!

針對於這個Babel Plugin其實還很很多可以優化的feature

比如

  • 元件庫中的css/scss/less等樣式支援Tree Shaking引入。

  • 元件庫中路徑支援動態引數傳入。

  • ...

這些細節我會在之後的commit中進行依次完善,有興趣的同學到可以在[這裡看到它的程式碼地址]。(http://github.com/19Qingfeng/QPACK/tree/master/doc/babel/tree-shaking)

寫在結尾

至此,我們針對於Tree Shaking結合Babel Plguin的講述到這裡就完成了。

文章中的Plugin的例子只是我個人覺得比較實用的一個易用簡單講解的🌰,更多的我還是希望的是大家在業務/工具中碰到一些棘手的問題時,不要忘記我們還可以從定製Babel Plugin的角度去嘗試思考解決問題的不同方式。

在之後的程式碼倉庫中我會擴充套件更多的Babel Learn Feature去總結分享給每一個奮鬥在前端路上的同學。

如果大家有什麼疑問也可以在評論區互相交流,我會第一時間進行回覆😂~