「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中進行依次完善,有興趣的同學到可以在[這裏看到它的代碼地址]。(https://github.com/19Qingfeng/QPACK/tree/master/doc/babel/tree-shaking)

寫在結尾

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

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

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

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