如何使用 webpack 優化 lodash
lodash提供了很多可用的方法供我們使用,絕對是一個很好用且用起來得心應手的工具庫。但是同時,lodash的體積也不小,我們項目中使用的大概522K,可能只是使用了幾個方法,但是卻把整個lodash庫引入了。為了吃幾條魚,就承包了整個魚塘,代價有點大呀!
lodash庫結構目錄
|-- lodash
|-- fp // lodash Functional Programming
|-- debounce.js // CommonJS
|-- ...
|-- debounce.js // CommonJS
|-- endsWith.js
|-- ...
|-- lodash.js // OOP CommonJS
|-- lodash.min.js
|-- uniqBy.js
|-- ...
lodash/fp
(1)使用lodash.*庫
import debounce from 'lodash.debounce';
lodash在npm上同時也發佈了以lodash.為前綴的包,他們將lodash的每個函數,單獨作為一個包發佈了出去。 顯然這些包只包含他們所用到功能的代碼而非整個lodash。
當你正在寫一個給外部使用的庫,僅需要使用到lodash中的幾個函數,那麼這種方法會讓庫的使用者更快地安裝依賴
(2)手動按需引入lodash/*
lodash的package.json中,對於每個lodash中的函數,都通過exports字段,暴露了對應的入口,因此我們可以改變引入的方式
import debounce from 'lodash/debounce';
如此,在構建的時候僅會引入debounce函數相關的代碼。 這種方法手工的成分過大,對已有項目引入的優化需要變動到較多的代碼,且無法使用全局替換完成。
(3)使用lodash-es
import { debounce, throttle, padStart } from 'lodash-es';
比起lodash,lodash-es使用了ES module組織模塊,構建工具構建時在做體積優化(tree shaking)的時候,通過對模塊的依賴分析,能將lodash包中未使用到的模塊都移除掉。
此種方法不需要像引入手動按需引入一樣改變使用習慣,保留了ES module按名稱引入的寫法。
如果是一個新的項目,或沒有使用babel編譯源代碼(用了swc, esbuild, tsc等等),那麼lodash-es便是最佳選擇。
對於已有項目,它需要修改業務代碼。不過,這種修改只做簡單的全局搜索替換即可完成。
(4)藉助 lodash-webpack-plugin,babel-plugin-lodash插件優化
如果你傾向於 import { head } from 'lodash' 的寫法,而且想繼續使用 lodash,又需要按需加載的能力,可以引入 babel-plugin-lodash 插件,它的作用就是幫你把你的 import 寫法自動轉化成按需加載的形式
babel-plugin-lodash 做了什麼
這個 babel 插件做的事件從結果上説比較簡單,就是做了下面的轉化:
import { head } from 'lodash';
// 或者
import _ from 'lodash';
// +
_.head(...)
// 轉化為↓↓↓
import head from 'lodash/head';
head(...)
似乎還不夠小
然而,現實往往不是很理想,在實際使用中你會發現,只引入了 lodash 的幾個方法,從數量上看還不到總量的零頭,但是好像 lodash 的一大半都被打包進去了。
這並不是錯覺,以大家都熟悉的 map 為例(但是並不推薦使用,請使用語言內置的 map):
import map from 'lodash/map'
map()
壓縮後體積 32K,快一半 lodash 的大小了。簡單分析一下,會發現它一共打包了 121 個 lodash 模塊。
剛看到這個結果的時候肯定會非常驚訝,因為在大家印象中 map 的實現應該很簡單,幾行代碼就能搞一個出來。
進一步查看文檔和源碼,會發現 _.map 的功能遠比我們想象的複雜,這裏舉幾個例子:
_.map({ key1: 1, key2: 2 }, x => x) // [1, 2]
_.map([
{ id: 1, age: 12 },
{ id: 1, age: 13 },
{ id: 1, age: 12 },
], { age: 12 }) // [true, false, true]
_.map([{a: {b: 11}}, {a: {b: 22}}], 'a.b')
121 個模塊,就是為了實現這些奇奇怪怪的需求而引入的。
lodash-webpack-plugin
考慮到大多數人在使用時,並不會用到這些特殊的用法,但是不得不為大量的冗餘代碼買單。lodash 就提供了 lodash-webpack-plugin 這個神奇的插件,在引入插件之後,打包的代碼量從 32K 降低到了 1K,去除 Webpack 的運行時代碼,只剩下不到 200 字節,減少了超過 99%。
而引入 lodash-webpack-plugin 後,map 方法的其他各種奇奇怪怪的用法就失效了,只剩下最基本的類似 Array map 的用法。
另一個例子,是 _.clamp 方法,它的功能是把某個數限制在某個區間。在正常情況下,這個方法能接收字符串並自動轉化成數字,所以下面的代碼會返回 20:
clamp('123', '1', '20') // 20
// 等價於 clamp(123, 1, 20) // 20
而一旦引入 lodash-webpack-plugin 後,它的返回值就變成了字符串 '123'
另一個例子是:
const sortBy = require('lodash/sortBy');
sortBy([{id: 3}, {id: 1}, {id: 2}], x => x.id);
類似的使用前後差異,還有很多。
lodash-webpack-plugin 做了什麼
整體來説這個 Plugin 的代碼量並不多,稍微花一些時間多多少少就能知道它做了哪些事情。
首先,需要簡單瞭解下 Webpack 的基本流程:
Webpack 在打包前,會從入口模塊開始,針對每個模塊使用 loader 處理得到標準 js 文件內容,再對這個文件進行語法樹分析,拿到它的依賴,然後解析(resolve)出依賴的真實路徑,然後對這個依賴進行一樣的處理(廣度優先遍歷),最後得到一個依賴圖(以及每個依賴壓縮前的內容)。
lodash-webpack-plugin 插件做的事,就是在 webpack 的 afterResolve 鈎子中,把某些 lodash 模塊的資源路徑替換掉,犧牲一些不常用的接口用法,達到見效打包體積的目的。
比如 map.js 會被替換成 _arrayMap ,顧名思義,替換之後它只能用來處理數組(或者類數組)。
更多的情況是,某模塊 A 依賴的內部模塊 _B 會被替換,這會導致 A 的核心邏輯可用,但是涉及到和 _B 相關的那一部分特性不被支持。
一個簡單的例子,是 clamp 模塊會依賴 toNumber 進行參數處理,也就是説它支持傳入字符串參數,並在內部先處理成數字。但是使用 Plugin 後, Plugin 會把 toNumber 替換成 identity(即a => a),導致 clamp 不再支持字符串參數。如果傳入的是字符串,返回的結果將發生變化。
Plugin 會默認移除(即替換成假模塊)一大堆特性,同時提供了一個配置項讓用户可以指定保留某些特性。相關的替換規則維護在 lodash/lodash-webpack-plugin/src/mapping.js 中。
也就是説,使用 lodash-webpack-plugin 之後,你的 lodash 就相當於變成了 嚴格模式 + 精簡模式。和標準的 Webpack 並不完全一樣。
你可能會問:「我只使用基礎的 Webpack 功能,而且我使用之後肯定有測過,插件只是約束了我的用法而已,應該不會產生什麼問題吧?」
大部分人可能都會這麼認為(甚至我覺得插件作者也是這麼想的),然而這種想法是錯的。接下來你會知道,lodash-webpack-plugin 存在嚴重的隱患,不建議在任何項目中使用它。
lodash-webpack-plugin 的坑
坑一:影響第三方模塊的行為
如果第三方模塊中也使用了 lodash 模塊,而且用到了某些非常規用法,一旦使用了 Plugin 後,這個第三方模塊使用的 lodash 的執行邏輯就可能發生變化。產生的後果可能是立即報錯,也可能產生更嚴重的後果,即返回了和預期不一致的值,這個錯誤值在一系列流轉之後,在另一個地方產生了 BUG。一旦出現了這種情況,因為這是一個第三方模塊,問題的排查可能會非常困難。
僅憑這一點,就完全有充足的理由拒絕使用 lodash-webpack-plugin 了。畢竟為了區區幾十 K 的代碼大小,給自己的項目埋下一個雷,並不是一個明智的選擇。
然而,lodash-webpack-plugin 的坑不只如此。
試想一下,如果你在 A 頁面引入了一個 lodash 模塊,甚至只是引入了一個第三方庫,(或者是刪除),導致功能邏輯完全不相干的 B 頁面出現了 BUG,你會是怎樣的心情?
對,如果你在使用 lodash-webpack-plugin,就是存在這樣的可能。下面會分析,在文章結尾會給出一些示例代碼。
坑二:自動檢測並配置特性
Plugin 很「貼心」地為用户提供了「自動保留特性」的能力,拿 clamp 的例子來説,如果你的是引入 clamp ,那麼它依賴的 toNumber 模塊會被替換成 identity。而如果你直接引入並使用 toNumber,則 toNumber 不會被替換成 identity。
那麼,如果我們既引入了 clamp,又引入了 toNumber ,結果會怎麼樣?
結論是:一般情況下,toNumber 會被替換成 identify。
這樣會導致一個結果:
原本你使用的 clamp 是不支持處理字符串的,但是在你引入 toNumber 後,它變得支持字符串了。
也許你會説,這看上去貌似不太會產生問題,因為 clamp 的功能是向後兼容的。但是萬一是反過來的,你原本正常使用字符串參數,然後又刪掉了 toNumber 呢?
一句話描述:就是在你引入/刪除某個第三方模塊(或者 lodash 模塊),你的另一個不相干的代碼邏輯(或者第三方模塊)可能發生變化。
坑三:插件內在缺陷,導致常規使用也會產生 BUG
前面提到,Plugin 是在 Webpack 遍歷解析模塊的時候進行路徑替換的,而模塊的遍歷是有先後順序的。那麼遍歷順序會影響到最終的替換結果嗎?
經過簡單的測試,發現還真會。
以在我們代碼中同時引入 toNumber 和 clamp 為例,toNumber 會被 resolve 兩次,一次是來自我們自己的代碼,另一次是來自 clamp. 我們需要構建兩種代碼結構,控制這兩次 resolve 的先後順序。
構造代碼結構的邏輯是很簡單的,因為 webpack 是廣度優先遍歷的,我們需要哪個模版被更靠後 resolve,把這個模塊多套幾層 import 就行。
下面的代碼在不同的代碼結構下會出現兩種完全不同的結果:
// 下面兩部分代碼在不同文件中:
require('lodash/toNumber')('12')
require('lodash/clamp')('123', '1', '20')
可能會分別返回數字 12 和 20, 或者分別返回字符串 '12'和 '123'.
後面這種結構的結果很顯然是錯誤的。這裏列一下文件結構:
// 文件 index.js
require('lodash/clamp')
require('./a')
// 文件 a.js
require('./b')
// 文件 b.js
const toNumber = require('lodash/toNumber')
console.log([
`toNumber('123')`,
toNumber('123'),
]) // 這個 toNumber 是錯的,它返回了字符串 '123'
總結
是否應該繼續使用 lodash-webpack-plugin,結論已經很明顯了。
我想説的是,就算現在沒有發現上面這些問題,只看 lodash-webpack-plugin 那 100 多行的人肉維護的替換配置項,就足夠你嚴肅考慮是否應該在生產環境使用它。
其他建議
- 如果你的項目代碼量足夠大,或者
lodash使用得足夠多,那麼插件帶來的優化可能已經不明顯了。全量引入(打包到vendor dll)可能是更優的選擇,還能獲得打包速度的提升(在使用dll/external的情況下繼續使用babel插件或者手寫lodash/xx甚至會導致負優化。 - You-Dont-Need-Lodash-Underscore 或者 ramda 等替代方案。
- 等待
lodash 5
為什麼你應該立即停止使用 lodash-webpack-plugin