「Babel」為你的元件庫定製化一款Tree-Shaking外掛吧
「這是我參與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
: 匯出兩個方法funcHao
和funcWang
兩個方法。
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 Module
在js
編譯階段就可以確定模組之間的依賴關係(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
的效果。
關於這兩個外掛其實是類似的效果,我們會在之後重點講述這部分內容。
細心的朋友可能也發現了,在目前的antd
和element-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';
。
我們希望的列印結果是:
同時我們的外掛需要接受一個引數為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,輸入我們的輸入程式碼和輸出程式碼:
我們首先來看看輸入(需要分析)程式碼:
- 注意
1
位置我們選擇編譯器為@babel/parser
。 - 同時我們在左側輸入我們的
source Code
。 - 右側就會動態生成對應的
AST
。
同樣的操作讓我們再來輸入targetCode
(分析後生成的程式碼)來看一看吧:
看到這裡的同學,我希望你停一停往下看,自己稍微對比下這兩棵樹的差距。在腦海中思考如果將source
中的Tree轉化為target
中的Tree,需要怎麼處理。
讓我們稍微來捋一捋:
-
首先當我們碰到
ImportDeclaration
語句時,需要判斷它的source
是否來自於我們的libararyName
這個庫。 -
當匹配引入我們的對應庫時,我們還需要遍歷當前
ImportDeclaration
節點中的specifiers
中是否包含預設匯出ImportDefaultSpecifier
。 -
當上述兩個條件都滿足時,進入我們之後的邏輯處理。
- 引入(
import
)的是我們傳入匹配的libraryName
。 - 引入語句中不包含
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
外掛的開發流程以及核心思路我相信大家在熟練掌握了這個外掛的開發思想後,針對於其他類似需求完全可以做到遊刃有餘。
接下來讓我們執行一下我們最開始的程式碼:
大功告成!!
針對於這個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
去總結分享給每一個奮鬥在前端路上的同學。
如果大家有什麼疑問也可以在評論區互相交流,我會第一時間進行回覆😂~
- 新時代的 SSR 框架破局者:qwik
- 巧妙利用TypeScript模組宣告幫助你解決宣告拓展
- 從應用到原始碼-深入淺出Redux
- Blob、File、ArrayBuffer、TypedArray、DataView究竟應該如何應用
- 如何進階TypeScript功底?一文帶你理解TS中各種高階語法
- 為什麼Proxy一定要配合Reflect使用?
- 一年三跳,來聊聊我的想法
- Webapck5核心打包原理全流程解析
- 詳解「react-dom」 API
- 「Babel」為你的元件庫定製化一款Tree-Shaking外掛吧
- 「前端基建」帶你在Babel的世界中暢遊
- 全方位解析瀏覽器渲染原理
- 一次useEffect引發瀏覽器執行機制的思考
- 十分鐘帶你手撕一份"漸進式"JS深拷貝
- React-Webpack5-TypeScript打造工程化多頁面應用
- Webpack中各種環境變數的正確姿勢