Webpack5 實踐 - 構建效率倍速提升!
本文轉載自微信公眾號「五月君」,作者五月君。轉載本文請聯系五月君公眾號。
對于前端構建工具 Webpack、babel、eslint 等的每一次升級,就像剛剛經歷一場地震似得,最不想面對的就是處理各種 API 的不兼容性,有時還會出現一些奇奇怪怪的問題,為什么還要升呢?并不是為了給自己找事,還是要講究投入產出比的,也就是最終的收益是要大于產出比的。
前段在團隊內部對 Webpack v5 帶來的一些新特性做一些 Research,相較于一些項目的構建工具版本(Webpack v3)做了一個對比,在構建效率這塊是有質的飛躍的,同樣相對于 Webpack v4 也是有很大提升的。本文是本次升級過程中的實踐(踩坑)記錄,分享一些值得關注的功能、一些重大的改變、遇到的一些 NPM 組建兼容性問題,希望能給予讀者朋友一些參考和幫助。
先上一張腦圖,涵蓋本文主題!
構建效果對比
基于一些項目做了一些測試,首次構建相較于之前提速將近 2 倍多,二次構建差不多 2s 左右,效果更顯著,修改文件后的增量構建,差不多也在幾秒鐘可完成,整體構建效率提升還是很明顯的,除此之外打包后的文件大小也比之前小了一些,但之間的差距不是特別的大,重點還是構建效率大幅提升。
構建效率上之所以有這么大的性能提升,這與它的基于文件系統的持久化緩存是有很大幫助的,下文會講解。內部的項目數據就不便再這里展示了,文末提供了一些來自社區的實踐,也可以看到一些數據對比。
下面,基于之前 Research 時寫的一些 Demo 可以對比下使用了持久化緩存在初次構建、二次無文件改動構建、改動文件后增量構建三種情況下的效果對比,也可以顯著的看到一些效果。
代碼壓縮(生產環境)
JavaScript 代碼壓縮
Webpack5 在生產環境下默認使用自帶的 TerserPlugin 插件(無需安裝)來做代碼壓縮,這個插件也被認為是在代碼壓縮方面性能是較好的。無需再借助 UglifyjsPlugin、ParallelUglifyPlugin 這些插件了。
如果你使用的是 webpack4 版本需要手動安裝 yarn add terser-webpack-plugin -D 并將插件添加到生產環境的配置文件中。
以下是使用示例,在 Webpack v5 的生產環境默認開啟。
- const TerserPlugin = require("terser-webpack-plugin");
- module.exports = {
- optimization: {
- minimize: true,
- minimizer: [new TerserPlugin()],
- },
- };
支持做一些自定義的配置:文件過濾、并發運行等,詳細參見 Webpack 文檔 TerserWebpackPlugin[1]。
- test:匹配需要壓縮的文件。
- include:匹配包含的目錄。
- exclude:匹配不需要包含的目錄。
- parallel:多進程并發運行,默認 os.cpus().length - 1。
- module.exports = {
- optimization: {
- minimize: true,
- minimizer: [
- new TerserPlugin({
- test: /\.js(\?.*)?$/i,
- include: /\/includes/,
- exclude: /\/excludes/,
- parallel: true
- // more ...
- }),
- ],
- },
- };
CSS 文件分離
CSS 壓縮之前先做的一項工作是 CSS 和 JS 文件分離,如果是從 Webpack v3 升級到 v5 會遇到一些問題,之前使用的是 extract-text-webpack-plugin 在 webpack v5 會收到廢棄提醒,建議使用 **MiniCssExtractPlugin** 這個插件,本插件基于 webpack v4 的新特性(模塊類型)構建。
與 extract-text-webpack-plugin 相比,擁有這些特性:異步加載、沒有重復的編譯(性能提升)、更容易使用、特別針對 CSS 開發。
下面是一個配置,這里還有些優化,生產模式使用 mini-css-extract-plugin 插件分離 JS/CSS 文件實現并行加載,而開發環境選擇 style-loader 它可以使用多個標簽將 CSS 插入到 DOM 中,并且反應會更快。
- const MiniCssExtractPlugin = require('mini-css-extract-plugin');
- module.exports = {
- module: {
- rules: [
- {
- test: /\.css$/i,
- use: [
- devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
- 'css-loader'
- ],
- },
- ],
- },
- plugins: [new MiniCssExtractPlugin()],
- };
關于 CSS 分割插件的更詳細配置 Webpack 文檔 mini-css-extract-plugin[2]。
CSS 打包后加載圖片 404?
生產環境我們使用 mini-css-extract-plugin 插件分離 CSS 文件,如果你在 CSS 里引用了圖片,可能會遇到為什么打包后 CSS 里引用的圖片加載時 404 了?
在 Webpack 的 output 選項中有一個 publicPath 配置,它指定了應用程序中所有資源的基礎路徑。
- module.exports = {
- output: {
- publicPath: 'auto'
- }
- }
Webpack loader 的 options 選項中也有一個 publicPath 配置,為 CSS 內的圖片、文件等外部資源指定一個自定義的公共路徑,默認值為 output.publicPath。如果出現打包后 CSS 內圖片 404 的可以檢查下這里的配置是否有問題。
- const MiniCssExtractPlugin = require('mini-css-extract-plugin');
- module.exports = {
- module: {
- rules: [
- {
- test: /\.css$/i,
- use: [
- devMode ? 'style-loader' : {
- loader: MiniCssExtractPlugin.loader,
- options: {
- publicPath: '../'
- }
- },
- ],
- },
- ],
- },
- plugins: [new MiniCssExtractPlugin()],
- };
CSS 代碼壓縮
CSS 壓縮之前會使用 optimize-css-assets-webpack-plugin 這個插件,在 webpack v5 之后推薦使用 css-minimizer-webpack-plugin 這個插件。
- const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
- module.exports = {
- optimization: {
- minimizer: [
- new CssMinimizerPlugin(),
- ],
- },
- };
性能提升核心緩存優化
之前通過 cache-loader、babel-loader?cacheDirectory 在配置 cacheDirectory:true 實現將編譯結果寫入磁盤或者通過 hard-source-webpack-plugin 插件。
Webpack5 自帶緩存能力,會緩存生成的 webpack module 和 chunk,對于二次構建有了很大的性能提升。通過 cache 屬性配置,分為內存和文件兩種緩存方式,默認在生產環境是禁用的,需自行開啟。
基于內存緩存
當在開發環境默認設置為 memory,基于內存的緩存,除了下面的方式配置外,也可通過 cache: true 配置。
- module.exports = {
- cache: {
- type: 'memory'
- },
- };
基于 FileSystem 的持久化緩存
基于內存的緩存,只有在服務運行中,才有效,每次的單獨構建是利用不了緩存的,webpack5 對于緩存另一個比較好的功能是提供了基于文件系統的持久化緩存。
基于文件系統的持久化緩存無論在單獨構建或連續構建(可以指熱更新操作)中都可應用,首先它會查看內存緩存,如果未命中,則降級到文件系統緩存。
應用很簡單,設置 type:filesystem。默認情況下它位于 node_modules/.cache/webpack/ 目錄,我們還可以通過 name 屬性修改它的名稱,例如,我們通過不同的環境 NODE_ENV 來區別不同環境的緩存。
當 type 設置為 filesystem 后,有很多屬性是可以配置的,參見 Webpack 文檔 cache[3]。
- module.exports = {
- cache: {
- type: 'filesystem',
- buildDependencies: {
- config: [__filename],
- },
- name: `${ process.env.NODE_ENV || 'development'}-cache`
- }
- }
緩存失效
基于內存的緩存每一次重新運行都是一次新的構建。需要注意的是持久化緩存,當你修改了文件或傳遞了一些參數,發現最終展現的效果沒有被更改,通常這與持久化緩存的緩存策略相關。
出于性能考慮,緩存會跳過 node_modules 認為這會極大降低 webpack 執行速度,建議是不要手動編輯 node_modules。通常也不會這么干直接去修改 node_modules。
有些操作也會使緩存失效,例如:當 NPM 升級 loader、plugin、更改配置等。
Webpack 提供了 buildDependencies、name、version 三種方式可以使構建緩存失效。
方法一:cache.buildDependencies
buildDependencies 指定構建過程中受影響的代碼依賴,默認為 webpack/lib,當 node_modules 中的 webpack 或其依賴項發生任何變化,當前的緩存即失效。
還有一個是指定的配置文件 config: [__filename] 或配置文件的依賴項發生變化,也會失效。
- module.exports = {
- cache: {
- type: 'filesystem',
- buildDependencies: {
- defaultWebpack: ["webpack/lib/"],
- config: [__filename],
- },
- name: `${ process.env.NODE_ENV || 'development'}-cache`
- }
- }
方法二:cache.version
如果是把構建工具封裝為一個單獨的工具包,類似于 react-scripts 這種的,理論上每次升級工具包,就需要重新編譯的,之前在一次本地測試時發現工具包升級后緩存沒有失效,如果出現這種情況的可以在 cache 里加上 version 配置指向 package.json 里的 version。
- module.exports = {
- cache: {
- type: 'filesystem',
- version: `${packageJson.version}`
- }
- }
有時配置文件或者代碼沒有修改,但是會依賴于命令行傳遞值想使緩存失效,同樣也可在 version 上加上這些命令行傳遞的值做為版本控制。
- module.exports = {
- cache: {
- type: 'filesystem',
- version: `${process.env.CLI_VALUE}`
- }
- }
當 version 依賴于多個值時,可以將多個值做個 md5 生成一串唯一的字符串做為版本也可。
方法三:cache.name
name 屬性比較好的是可以保存多個緩存目錄,例如通過 process.env.NODE_ENV 區分不同的環境。
- module.exports = {
- cache: {
- type: 'filesystem',
- name: `${ process.env.NODE_ENV || 'development'}-cache`
- }
- }
持久化緩存這塊也有很多的東西可以講,詳情參見 [譯] webpack 5 之持久化緩存[4]。
長期緩存優化
Webpack 5 新增了長期緩存算法,以確定性的方式為模塊和分塊分配短的(3 或 5 位)數字 ID,這是包大小和長期緩存之間的一種權衡,生產環境默認開啟以下配置。在減小文件打包大小同時也利于瀏覽器長期緩存(不會因為刪除或新增一個模塊而導致大量緩存文件失效)。
- // production default
- module.exports = {
- optimization: {
- moduleIds: 'deterministic',
- chunkIds: 'deterministic'
- mangleExports: 'deterministic'
- }
- }
Webpack v5 VS v4 模塊 ID
Webpack v4 及之前的 moduleId 默認是自增的,例如 0.xxx.js、1.xxx.css、2.xxx.js 如果更改模塊數量(即使內容沒有變化),也會導致模塊文件重新發生改變,不利于長期緩存。
不同的版本也提供了不同的解決方案,webpack v4 之前使用 HashedModuleIdsPlugin 插件覆蓋默認的模塊 ID 規則,在 webpack v4 中可以配置 optimization.moduleIds = 'hashed' 解決。這幾種方案都是使用模塊路徑生成的 hash 做為 moduleId。
Webpack v5 生產環境默認 optimization.moduleIds='deterministic' 無需更改。
Webpack v5 VS v4 Chunk ID
webpack v4 及之前的 chunkId 默認也是遞增的,如果在 entry 配置中新增或刪除一個元素,chunkId 也會隨著遞增或遞減。
webpack v4 之前使用 NamedChunksPlugin 插件覆蓋默認的 chunkId 規則,在 webpack v4 中可以配置 optimization.chunkIds = 'named' 解決。
Webpack v5 生產環境默認 optimization.chunkIds='deterministic' 無需更改。
真正的內容哈希
另外,當使用 [contenthash] 時,webpack5 將使用真正的文件內容做為哈希值,這個類似于協商緩存 Etag,不一樣的是還有一些優化,如果你只是刪除了代碼中的一些注釋或重新命名變量,而這種情況代碼邏輯是沒有修改的,這些變化在壓縮后是不可見的,不會導致 [contenthash] 也發生變化。
如果是從 webpack v3 升級到 v5 的,HashedModuleIdsPlugin、NamedChunksPlugin 這些插件是可以去掉的,webpack v5 環境默認開啟新的算法,無需再配置。
參考文檔 Webpack release 日志記錄 — 重大變更:長期緩存[5]。
原生支持資源模塊
Webpack v5 內置了資源模塊(assert),用來處理資源文件(圖片、字體等),在之前是通過配置額外的 loader,例如 raw-loader、file-loader、url-loader 實現的。
Webpack v4 資源文件處理
下面是一段 webpack v4 及之前版本的資源文件處理配置,當匹配的文件大小如果小于 limit 限制,將其處理成 data URI 內聯到 bundle 中,否則生成文件(使用 file-loader)輸出到目錄中,url-loader 內置了 file-loader 對文件的處理。
- {
- test: /\.(jpe?g|png|gif|svg)$/,
- use: [
- {
- loader: 'url-loader',
- options: {
- limit: 1024 * 10
- }
- }
- ]
- },
Webpack v5 新的資源文件處理
Webpack v5 不再需要安裝 url-loader 處理資源文件,內置了資源模塊類型,通過 type 定義,用來替換之前需要額外配置 loader 的方式。
- asset/resource:將文件打包輸出并導出 URL,類似于 file-loader。
- asset/inline:導出一個資源的 data URI,編碼到 bundle 中輸出,類似于 url-loader。
- asset/source:導出資源的源代碼,類似于 raw-loader。
- asset:提供了一種通用的資源類型,根據設置的 Rule.parser.dataUrlCondition.maxSize 自動的在 asset/resource、asset/inline 之間做選擇,小于該大小指定的文件視為 inline 模塊類型,否則視為 resource 模塊類型。
下面是一個示例,大于 4kb 的輸出到目錄 static 中。
- {
- test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2)$/i,
- type: "asset",
- parser: {
- dataUrlCondition: {
- maxSize: 4 * 1024 // 4kb
- }
- },
- generator: {
- filename: 'static/[hash][ext][query]'
- }
- },
參考文檔 Webpack 文檔 Assert module[6]。
強大的 tree-shaking 能力
tree-shaking 是一個術語,翻譯為中文為 “樹搖”,想想一下一顆長滿果子的樹木,其中有些已經熟透了,當搖晃樹木時是不是一部分會被搖掉。
圖片來源:https://cdn.pixabay.com/photo/2019/05/16/23/39/apple-tree-4208594_1280.jpg
對于我們代碼層面來說,那些上下文未引用的 JavaScript 代碼,也可以通過工具移除(“搖掉”),實現打包體積的優化。
嵌套的 tree-shaking
在這種情況下,可以刪除未使用的變量 b,生產環境默認開啟。
- // inner.js
- export const a = 1;
- export const b = 2;
- // module.js
- export * as inner from './inner';
- // user.js
- import * as module from './module';
- console.log(module.inner.a);
內部模塊 tree-shaking
Webpack v5 還增加了模塊導出和引用之間的依賴關系分析,通過配置 optimization.innerGraph 控制,生產環境默認開啟。
以下示例,something 只有在使用 test 導出時才會使用。
- import { something } from './something';
- function usingSomething() {
- return something;
- }
- export function test() {
- return usingSomething();
- }
支持 CommonJS Tree Shaking
新增 CommonJS 模塊的導出和引用之間的依賴分析,下例,可以刪除未使用的變量 b。
- // inner.js
- exports.a = 1;
- exports.b = 2;
- // module.js
- exports.inner = require('./inner');
- // user.js
- const module = require('./module');
- console.log(module.inner.a);
參考 Webpack 文檔 tree-shaking[7]。
Node.js 調用 webpack API
之前在團隊內部,基于 webpack 這些構建工具封裝了適合團隊內部的構建工具模塊,是通過 API 調用的,有些問題還是要注意下。
生產環境
調用 webpack() 創建一個 compiler 實例,之后調用 run() 方法執行,需要注意的是在完成之后記得關閉 compiler,這樣低優先級的工作(比如持久緩存)就有機會完成,否則,有時候會發現每次都是重新構建沒有利用上緩存。
下例中的 stats 參數可以獲取到代碼編譯過程產生的錯誤和警告、計時信息、module 和 chunk 信息,如果想達到 cli 環境下的日志輸出格式,調用 stats.toString() 方法即可。
- const compiler = webpack(config);
- return new Promise((resolve, reject) => {
- compiler.run((err, stats) => {
- if (err) {
- return reject(err);
- }
- console.log(stats.toString({
- chunks: false,
- colors: true
- }));
- compiler.close(closeErr => {
- if (closeErr) {
- console.log(chalk.red(`compiler close failed with message ${closeErr.message}`));
- }
- });
- return resolve(stats);
- });
- });
開發環境
與生產環境 API 調用不同,開發環境我們需要熱更新,在創建一個 compiler 后需要調用 webpack-dev-server 插件。
還有個問題是 devServer 中的配置選項將被忽略,但可以將配置選項作為第二個參數傳入。
- const compiler = webpack(config);
- const devServerOptions = Object.assign({}, config.devServer, {
- port: port,
- host: DEFAULT_HOST,
- open: true,
- });
- const server = new WebpackDevServer(compiler, devServerOptions);
- server.listen(port, DEFAULT_HOST);
參考文檔 Webpack 文檔 Node.js API 接口[8]。
原生 Web Worker 支持
從 webpack 5 開始,使用 Web Workers 代替 worker-loader,這種語法也是為了實現不使用 bundler 就可以運行代碼。
Web Worker 是解決一些密集型的任務,例如一些加解密、圖片處理等一些耗時的計算任務可以放置于工作線程處理,處理完畢在通知到主線程,在處理的過程不會影響用戶在界面上的一些其它操作。
- const worker = new Worker(new URL('./worker-calculate.js', import.meta.url));
- worker.postMessage({
- question:
- 'The Answer to the Ultimate Question of Life, The Universe, and Everything.',
- });
- worker.onmessage = ({ data: { answer } }) => {
- console.log(answer);
- };
- // worker-calculate.js
- self.onmessage = ({ data: { question } }) => {
- self.postMessage({
- answer: 42,
- });
- };
Web Workers 可以在瀏覽器中的原生 ECMAScript 模塊中使用,也可以用于 Node.js 中,如果采用 ESM 模塊規范,Node.js 需要 >= 12.17.0。
- import { Worker } from 'worker_threads';
- new Worker(new URL('./worker.js', import.meta.url));
Node.js 通過 worker_threads 模塊提供支持,在 Node.js 中如果你使用 CommonJS 規范在 v10.0.5 版本就已經支持了。
- const { Worker } = require('worker_threads');
更多詳情參考 webpack 文檔 Web Workers[9]。
其它 NPM 組件兼容性問題
開發環境熱更新
如果是從 webpack v3.x 升級的,會發現之前的熱更新方式會報如下錯誤。
- Error: Cannot find module 'webpack/bin/config-yargs'
Webpack v5 使用 webpack serve 啟動開發環境,解決這個問題就是重新安裝 webpack-cli、還有 webpack-dev-server 也是需要安裝的。
- // For webpack-cli 3.x:
- "scripts": { "start:dev": "webpack-dev-server" }
- // For webpack-cli 4.x:
- "scripts": { "start:dev": "webpack serve" }
問題參考 cannot-find-module-webpack-bin-config-yargs。
compiler.plugin is not a function with InterpolateHtmlPlugin
可能會遇到以下錯誤:
- TypeError: compiler.plugin is not a function
- at InterpolateHtmlPlugin.apply (/Users/qufei/Documents/code/f-designer-tool-webpack5-test/node_modules/react-dev-utils/InterpolateHtmlPlugin.js:25:14)
單獨安裝插件 yarn add -D interpolate-html-plugin 替換 InterpolateHtmlPlugin = require('react-dev-utils/interpolate-html-plugin'); 為 const InterpolateHtmlPlugin = require('interpolate-html-plugin');
最后確保 InterpolateHtmlPlugin 出現在插件列表中的 HtmlWebpackPlugin 之后。
webpack.NamedModulesPlugin is not a constructor with NamedModulesPlugin
當開啟 HMR 的時候使用該插件會顯示模塊的相對路徑,該插件已廢棄,在 Webpack4 中建議設置為 optimization.namedModules,但是在 Webpack5 也被廢棄,如果需要請改為 optimization.moduleIds: 'named',在 Webpack5 中的建議是 “請考慮將 optimization.moduleIds 和 optimization.chunkIds 從你 webpack 配置中移除。使用默認值會更合適,因為它們會在 production 模式 下支持長效緩存且可以在 development 模式下進行調試。” 參見 https://webpack.docschina.org/migrate/5/#clean-up-configuration
移除 Node.js 模塊 Polyfills
Webpack5 移除了 Node.js 模塊的 Polyfills,更專注于前端模塊兼容。認為這會在構建時給 bundle 附加龐大的 Polyfills,大部分情況下也并非必須的,如果你的模塊或你安裝的第三方模塊引用了 cypto、process 這些模塊,就會看到報錯。
- process is not a function
- xxx is not a function
參考 automatic-nodejs-polyfills-removed,對于 webpack v5,可以從 webpack.config.js 的相應插件部分引用 process/browser。
參考 webpack-bundle-js-uncaught-referenceerror-process-is-not-defined。
- const webpack = require('webpack')
- module.exports = {
- plugins: [
- // fix "process is not defined" error:
- // (do "npm install process" before running the build)
- new webpack.ProvidePlugin({
- process: 'process/browser',
- }),
- ]
- }
compiler.plugin is not a function with react-dev-utils/WatchMissingNodeModulesPlugin
- new WatchMissingNodeModulesPlugin(),
- // 運行之后報錯
- // TypeError: compiler.plugin is not a function
- // at WatchMissingNodeModulesPlugin.apply (/Users/xxx/node_modules/react-dev-utils/WatchMissingNodeModulesPlugin.js:20:14)
這個錯誤是在 Webpack 4 upgrade PR,升級 react-dev-utils yarn add react-dev-utils -D。
babel-eslint has been deprecated
之前在使用 eslint 代碼檢查時,如果有用到 eslint 不支持的試驗性特性時會需要用到 babel-eslint,但是這個項目已經廢棄了,到官網會看到這樣一句話:
- babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.
現在推薦使用 @babel/eslint-parser 代替。
更多新功能
- Top Level Await 支持:目前在 Webpack v5 計劃中屬于試驗性支持,可通過開啟 experiments.topLevelAwait 配置支持,這對于文件頭部初始化資源很有用,無需讓 await 必須在 async 里面。參考 Webpack v5 配置#experiments。
- 模塊聯邦(Federated Modules):是 webpack v5 增加的一個新功能,為前端項目打包模式提供了新的方式,對多個不存在依賴關系的多個項目可以獨立構建組成一個應用程序,從開發者的角度看,模塊可以從遠程構建中導入。這通常稱為微前端,也并不僅限于此。更多可參考 Webpack 提供的單獨指南 module-federation,也可看看下面的社區實踐。
- 原生支持 WebAssembly 構建:webpack v5 原生支持了 WebAssembly 的代碼構建,只需開啟 experiments.syncWebAssembly 配置即可,這個功能也屬于試驗性支持。
- ... 更多功能參考 webpack v5 release 日志。
來自社區實踐
- 字節:Webpack5 新特性業務落地實戰
- 騰訊:構建效率大幅提升,webpack5 在企鵝輔導的升級實踐
- 螞蟻:調研 Federated Modules,應用秒開,應用集方案,微前端加載方案改進等
- 百度:Webpack 5 升級實驗
- 飛書:Webpack5 上手測評
因為微信對外鏈的限制,文中有些鏈接不能打開,可以 “閱讀原文” 查看。
參考資料
[1]Webpack 文檔 TerserWebpackPlugin: https://webpack.docschina.org/plugins/terser-webpack-plugin/
[2]Webpack 文檔 mini-css-extract-plugin: https://webpack.docschina.org/plugins/mini-css-extract-plugin
[3]Webpack 文檔 cache: https://webpack.docschina.org/configuration/other-options/#cache
[4][譯] webpack 5 之持久化緩存: https://juejin.cn/post/6844903967793627149
[5]Webpack release 日志記錄 — 重大變更:長期緩存: https://webpack.docschina.org/blog/2020-10-10-webpack-5-release/#major-changes-long-term-caching
[6]Webpack 文檔 Assert module: https://webpack.docschina.org/guides/asset-modules/
[7]Webpack 文檔 tree-shaking: https://www.webpackjs.com/guides/tree-shaking/
[8]Webpack 文檔 Node.js API 接口: https://webpack.docschina.org/api/node/
[9]webpack 文檔 Web Workers: https://webpack.docschina.org/guides/web-workers/