作者:劉華
前言
隨著前端構架工具的不斷髮展,提供了很多提高我們的開發體驗和開發效率的能力,同時構建已經成為前端技術棧中常見的技術。
webpack 也是眾多構建工具中嶄露頭角一員,早期的 webpack 配置複雜難懂,隨著其發展,相關配置也不斷簡化,效能也不斷提高,但是對於深入使用的開發人員,通常它的預設配置並不適用於業務開發,需要針對自己業務調整適配。
你對 webpack 瞭解多少?如何針對業務整合最佳配置?如何優化開發體驗?如何開足馬力,實現極速的 webpack 的構建效能 ??又會有哪些坑 ??本文帶你解答這些問題 ?。
本文涉及到的所有程式碼片段的完整程式碼請參考a8k倉庫
一、webpack 關鍵配置項
對構建有所瞭解的,可直接略過本節
此處不會深入介紹相關配置,更多的詳細說明與配置參見官方文件,稍作介紹關鍵配置項鋪墊後面內容。
entry
webpack 查詢依賴的入口檔案配置,入口檔案可以有多個。
單頁面應用入口配置 通常做法配置:vendor.js 第三方依賴庫,polyfill.js 特性填充庫,index.js 單頁面應用入口檔案
// 匯出配置
module.exports = {
entry: {
vendor: './src/vendor.js',
polyfill: './src/polyfill.js',
index: './src/index.js',
},
};
複製程式碼
多頁面應用入口配置 和單頁面應用類似,但不同頁面會不同有入口檔案,這種情況高效的做法就不是直接寫死在 entry 裡面了,而是通過生成 webpack.config 時,掃描指定目錄確定每個頁面的入口檔案以及所有的頁面。
下面舉個例子
假定你的頁面都放置在 src/pages 目錄下面,並且你的每個頁面單獨一個目錄,並且其中有 index.html 和 index.jsx
const path = require('path');
const fs = require('fs');
// 處理公共entry
const commonEntry = ['./src/vendor.js', './src/polyfill.js'];
// 頁面目錄
const PAGES_DIR = './src/pages/';
const entry = {};
// 遍歷頁面目錄
const getPages = () => {
return fs.readdirSync(PAGES_DIR).filter(item => {
let filepath = path.join(PAGES_DIR, item, 'index.js');
if (!fs.existsSync(filepath)) {
filepath = `${filepath}x`; // jsx
}
if (!fs.existsSync(filepath)) {
return false;
}
return true;
});
};
getPages(options).forEach(file => {
const name = path.basename(file);
// 加入頁面需要的公共入口
entry[name] = [...commonEntry, `${PAGES_DIR}/${file}/index`];
});
// 匯出配置
module.exports = {
entry,
};
複製程式碼
入口 boundle 如何插入對應的 html 中?
我們通常需要這個外掛HtmlWebpackPlugin
自動處理,具體程式碼如下:
const plugins = [];
if (mode === 'single') {
// 單頁面只需要一次HtmlWebpackPlugin
plugins.push(
new HtmlWebpackPlugin({
minify: false,
filename: 'index.html',
template: './src/index.html',
})
);
}
if (mode === 'multi') {
// 多頁面遍歷目錄,使用目錄下面的html檔案
// 不同頁面的配置不同,每個頁面都單獨配置一個html
// 所有頁面的公共部分可以抽離後,通過模版引擎編譯處理
// 具體的方式後面部分loader中提到
const files = getPages(options);
files.forEach(file => {
const name = path.basename(file);
file = `${PAGES_DIR}/${file}/index.html`;
// 新增runtime指令碼,和頁面入口指令碼
const chunks = [`runtime~${name}`, name];
plugins.push(
new HtmlWebpackPlugin({
minify: false,
filename: `${name}.html`,
template: file,
chunks,
})
);
});
}
// 匯出配置
module.exports = {
plugins,
};
複製程式碼
output
該項配置輸出的 bundle 的相關資訊,比較常用的配置如下:
{
output:{
// name是你配置的entry中key名稱,或者優化後chunk的名稱
// hash是表示bundle檔名新增檔案內容hash值,以便於實現瀏覽器持久化快取支援
filename: '[name].[hash].js',
// 在script標籤上新增crossOrigin,以便於支援跨域指令碼的錯誤堆疊捕獲
crossOriginLoading:'anonymous',
//靜態資源路徑,指的是輸出到html中的資源路徑字首
publicPath:'https://7.ur.cn/fudao/pc/',
path: './dist/',//檔案輸出路徑
}
}
複製程式碼
resolve
該項配置主要用於解析模組依賴的自定義項, 比較常規的配置項如下,modules用於加速絕對路徑查詢效率,alias可以使用者自定義模組查詢路徑。
resolve: {
modules: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname,'node_modules'),
],
alias: {
components: path.resolve(__dirname, '/src/components'),
},
}
複製程式碼
擴充套件
如果你使用了絕對路徑後,可能就發現vscode智慧程式碼導航就失效了,別慌!請在想目錄下面配置jsconfig.json
檔案解決這個問題,配置和上面對應:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"components/*": ["./src/components/*"],
"assets/*": ["./src/assets/*"],
"pages/*": ["./src/pages/*"]
}
},
"include": ["./src/**/*"]
}
複製程式碼
這樣,你就可以愉快的使用vscode的智慧程式碼提示和導航了!
module
該項主要配置就是rules了,rules中配置對於不同資源的處理器,是其核心之一,這裡簡單新增一個示例程式碼
module: {
// 這些庫都是不依賴其它庫的庫 不需要解析他們可以加快編譯速度
// 通常可以將那些大型的庫且已經編譯好的庫排除,減少webpack對其解析耗時
noParse: /node_modules\/(moment|chart\.js)/,
rules: [
{
test: /\.jsx?$/,
use: resolve('babel-loader'),
// 需要被這個loader處理的資源
include: [
path.resolve(projectDir, 'src'),
path.resolve(projectDir, 'node_modules/@tencent'),
].filter(Boolean),
// 忽略哪些壓縮的檔案
exclude: [/(.|_)min\.js$/],
}
]
複製程式碼
optimization
該頂配項中最重要最常用的是:splitChunks
,minimizer
minimizer
可以自己配置輸出的檔案壓縮外掛,js壓縮我們可以使用webpack整合的uglifyjs,也可以使用Terser,Terser支援es6程式碼的壓縮,同時支援多程式壓縮;css壓縮我們可以使用optimize-css-assets-webpack-plugin
壓縮,它使用cssnano作為處理引擎,幫助我們去除重複樣式.
splitChunks
是webpack4.x推出的重磅功能,優化的公共chunk提取策略,更高效的提取公共模組,在後面效能優化中會詳細說明其使用方法。
plugin
plugin 可以介入整個構建過程任何階段。例如:報告構建耗時、修改輸出程式碼支援主域重試、新增構建進度報告、程式碼壓縮、資源替換等很多能力都在這裡實現。
plugin不展開討論,因為外掛太多了。對於專案需要自己實現外掛的,需要注意一點,當你使用外掛對輸出結果處理時,應當在檔案輸出到磁碟之前處理,我們以前的構建中主域重試外掛就踩了這個坈,導致最終構建的程式碼出現錯誤,原因是該外掛直接修改磁碟上面的檔案,兩次構建同時啟動,結束時兩次構建的外掛都修改了磁碟上同一個檔案,最終導致bug,並且導致我們需要強行清理髮布環境程式碼才恢復正常釋出。
二、開發體驗優化
舒適的開發體驗,有助於提高我們的開發效率,優化開發體驗也至關重要
元件熱重新整理、CSS熱重新整理
自從webpack推出熱重新整理後,前端開發者在開環境下體驗大幅提高。 沒有熱重新整理能力,我們修改一個元件後
加入熱構建後:
主要看一下我們業務基於React技術棧,如何在構建中接入熱重新整理。
-
無論什麼技術棧,都需要在dev模式下加上
webpack.HotModuleReplacementPlugin
外掛 -
在所有entry中插入
require.resolve('../utils/webpackHotDevClient')
,webpackHotDevClient
這份程式碼是由react官方的create-react-app提供的 -
在
webpack-dev-server
模組的啟動引數中新增hot:true
-
在你需要熱載入的js檔案中新增以下程式碼(這段程式碼在構建生產包會自動刪除):
if (process.env.NODE_ENV==='development' && module.hot) { module.hot.accept() } 複製程式碼
注:也可以使用react-hot-loader來實現,具體參考官方文件
SSR熱除錯
輔導的H5/PC專案都有部分頁面支援直出,以前直出除錯方式是如下流程所示:
這種除錯流程太長,每一次修改都需要重新構建靜態資源,並重啟node服務,非常耗時,其次直出模式下,非直出的頁面將無法正常訪問,整個流程無法走通。
因此, 提出了新的解決方案, 採用 webpack watch+nodemon
結合的模式實現對SSR熱除錯的支援。node 服務需要的html/js通過webpack外掛動態輸出,當nodemon檢測到變化後將自動重啟,html檔案中的靜態資源全部替換為dev模式下的資源,並保持socket連線自動更新頁面。
實現熱除錯後,除錯流程大幅縮短,和普通非直出模式除錯體驗保持一致。在a8k
中通過k dev -s
命令即可開啟ssr除錯模式。下面是SSR熱除錯的流程圖:
style除錯體驗
問題:
給style-loader
開啟sourceMap後, sourceMap是內聯在style檔案中的,需要通過link匯入,這種方式是通過JavaScript生成blob後丟個link標籤解析。之後我們可以在dev工具中直接看到每個樣式所在的原始檔位置,方便快速的除錯樣式。但也同樣引起一個問題FOUC(頁面載入後閃爍),可參見這個ssue
解決方法:
新增singleton: true
引數可解決這個問題,但是sourceMap就不能定位到原始檔了,而是合併後的檔案中的位置,二者不可兼得。所以在a8k
工具中提供了可選項,預設開啟singleton:true
,通過k dev -c
可開啟cssSourceMap對映
三、效能優化
node_modules快取
輔導大多數專案node_modules依賴數量都非常驚人,輔導PC專案剔除構建相關依賴後,依賴包都1883個,依賴包的安裝耗時也就大幅增加,因此減少依賴包安裝耗時,對構建整體提升非常重要,方法那就是快取。
JB系統編譯 每次編譯都會啟動一個新的目錄,這導致專案依賴的眾多node_modules無法快取,每次編譯重新安裝耗時非常長,針對JB的編譯,我開發了@tencent/im-build模組自動快取專案依賴的node_modules,大幅提升了編譯效能。
OCI編譯系統 OCI中不需要額外的外掛支援,該系統本身已經可以通過配置實現部分目錄快取,二次利用的能力,使用方法如下:
-
在專案根目錄新增
.orange-cache.cache
檔案,並新增你需要快取的目錄/node_modules /fudao_qq_com_pc_imt 複製程式碼
-
修改
.orange-ci.yml
配置,新增快取配置檔案路徑push: - cacheFrom: .orange-ci.cache #其它配置省略 複製程式碼
優化效果
優化前
優化後
構建中間結果快取
中間結果快取優化同樣能大幅提升構建效能,對模組的編譯本身就是CPU密集型任務。通常來說每次構建並非所有模組都需要被重新處理,可以只考慮處理那些檔案內容有變化的模組,那麼檔案內容沒有變化的模組就可以從快取中獲取,通常通過檔案內容hash值作為快取檔案的名稱,這就是“熱構建”。
在webpack中,能夠被快取的內容有:loader處理結果、plugin處理結果、輸出檔案結果。下面詳細說明不同資源不同階段的快取方式。
1. babel-loader快取,通過cacheDirectory開啟快取
test: /\.jsx?$/,
use: [
{
loader: resolve('babel-loader'),
options: {
babelrc: false,
// cacheDirectory 快取babel編譯結果加快重新編譯速度
cacheDirectory: path.resolve(options.cache, 'babel-loader'),
presets: [[require('babel-preset-imt'), { isSSR }]],
},
},
],
複製程式碼
2. eslint-loader快取,通過cache選項指定快取路徑
test: /\.(js|mjs|jsx)$/,
enforce: 'pre',
use: [
{
options: {
cache: path.resolve(options.cache, 'eslint-loader'),
},
loader: require.resolve('eslint-loader'),
},
],
複製程式碼
eslint-loader通常只需要在開發模式下開啟,方便及時的提醒開發者,存在eslint錯誤,及時修復
3. css/scss快取
css-loader/sass-loader/postcss-loader本身並沒有提供快取機制,這裡需要用到cache-loader輔助我們實現對css/scss的構建結果快取,具體使用方式如下:
{
loader: resolve('cache-loader'),
options: { cacheDirectory: path.join(cache, 'cache-loader-css') },
},
{
loader: resolve('css-loader'),
options: {
importLoaders: 2,
sourceMap,
},
},
...由於篇幅原因,這裡不展示其它更多loader
複製程式碼
只需要將該loader新增到這個loader的最頭部即可,該loader不僅可以對於css快取
4. 輸出程式碼壓縮快取,JS壓縮引擎多程式處理
JS程式碼壓縮我們採用了TerserPlugin
外掛,具體配置如下:
{
// 設定快取目錄
cache: path.resolve(cache, 'terser-webpack-plugin'),
parallel: true,// 開啟多程式壓縮
sourceMap,
terserOptions: {
compress: {
// 刪除所有的 `console` 語句
drop_console: true,
},
},
}
複製程式碼
5. CI系統固定快取目錄
上面在不同的plugin和loader上面配置了cache目錄,對於CI系統來說你需要將cache目錄路徑固定,以便於重複使用快取內容,使用方式:JB就配置/tmp/xxx
目錄,OCI系統可配置在專案目錄。
⚠️注意:由於使用了快取,當你修改你的編譯配置後,需要立即清理快取結果,最好的做法是在構建工具中自動檢測相關配置是否有變化,自動清理快取
其它優化手段
1. 指定絕對路徑模組查詢路徑,加速模組查詢
resolve: {
//加快搜尋速度
modules: [
'node_modules',
path.resolve(projectDir, 'src'),
path.resolve(projectDir, 'node_modules')
],
},
複製程式碼
2. 過濾不需要做任何處理的庫
module: {
// 這些庫都是不依賴其它庫的庫 不需要解析他們可以加快編譯速度
noParse: /node_modules\/(moment|chart\.js)/,
}
複製程式碼
3. 縮小babel處理範圍,避免處理已經壓縮的程式碼
// 指處理指定目錄的檔案
include: [
path.resolve(projectDir, 'src'),
path.resolve(projectDir, 'node_modules/@tencent'),
].filter(Boolean),
// 忽略哪些壓縮的檔案
exclude: [/(.|_)min\.js$/],
複製程式碼
4. lodash庫按需倒入優化,減少無用程式碼
我們在使用lodash庫是,通常只會用到其中非常少的function,但是像下面這段程式碼,將會導致lodash全部被打入最終的bundle中。
import _ from 'lodash'
_.difference(1, 2)
複製程式碼
這種情況幸好有外掛可以幫我們優化,通過lodashPlugin即可自動處理lodash的按需引用
使用方法如下:
const LodashPlugin = require('lodash-webpack-plugin');
plugins:[
// 支援lodash包 按需引用
new LodashPlugin(),
]
複製程式碼
加入這個plugin後,上面的程式碼自動處理為如下程式碼:
import difference from 'lodash/difference';
difference([1, 2], [1, 3]);
複製程式碼
注意:匯入程式碼方式必須使用import,不能使用require
5. 針對服務端渲染程式碼,我們可以剔除node_modules,從而大幅減少服務端程式碼生成耗時
通過webpack-node-externals
外掛實現這一點,具體使用方法如下:
const nodeExternals = require('webpack-node-externals');
module.export={
// 省略其它配置
externals: [
nodeExternals({
// 注意如果存在src下面其他目錄的絕對引用,都需要新增到這裡
whitelist: [
/^components/, /^assets/, /^pages/, /^@tencent/, /\.(scss|css)$/
],
}),
],
// 省略其它配置
}
複製程式碼
6. webpack4.x的鼎力之作之splitChunks
在webpack4之前,我們處理公共模組的方式都是使用CommonsChunkPlugin
,然後該外掛的讓開發這配置繁瑣,並且公共程式碼的抽離,不夠徹底和細緻,因此新的splitChunks
改進了這些能力。使用的正確姿勢如下:
splitChunks: {
chunks: 'all',
minSize: 10000, // 提高快取利用率,這需要在http2/spdy
maxSize: 0,//沒有限制
minChunks: 3,// 共享最少的chunk數,使用次數超過這個值才會被提取
maxAsyncRequests: 5,//最多的非同步chunk數
maxInitialRequests: 5,// 最多的同步chunks數
automaticNameDelimiter: '~',// 多頁面共用chunk命名分隔符
name: true,
cacheGroups: {// 宣告的公共chunk
vendor: {
// 過濾需要打入的模組
test: module => {
if (module.resource) {
const include = [/[\\/]node_modules[\\/]/].every(reg => {
return reg.test(module.resource);
});
const exclude = [/[\\/]node_modules[\\/](react|redux|antd)/].some(reg => {
return reg.test(module.resource);
});
return include && !exclude;
}
return false;
},
name: 'vendor',
priority: 50,// 確定模組打入的優先順序
reuseExistingChunk: true,// 使用複用已經存在的模組
},
react: {
test({ resource }) {
return /[\\/]node_modules[\\/](react|redux)/.test(resource);
},
name: 'react',
priority: 20,
reuseExistingChunk: true,
},
antd: {
test: /[\\/]node_modules[\\/]antd/,
name: 'antd',
priority: 15,
reuseExistingChunk: true,
},
},
},
複製程式碼
簡要解釋上面這段配置
- 將node_modules共用部分打入vendor.js bundle中;
- 將react全家桶打入react.js bundle中;
- 如果專案依賴了antd,那麼將antd打入單獨的bundle中;
- 最後剩下的業務模組超過3次引用的公共模組,將自動提取公共塊
優化效果
做了這麼多優化,下面是基於模組超過2.5k的輔導h5專案,構建耗時對比,感受一下效果
優化前:熱構建需要40s
優化後:只需要20s
四、收斂配置整合最佳實踐
構建的配置和優化的工作並不小,將最佳實踐收斂和整合為獨立的模組,在不同專案中複用,可以大幅減少構建維護工作,以及後續升級優化工作難度。
IMWeb團隊的專案目前也獨立維護一套基於React技術棧的構建最佳實踐工具a8k
,在所有的專案中不會在看到複雜多樣的webpack配置,以及各種花樣的前置、後置指令碼。各專案僅需要簡單的關鍵配置即可快速接入該構建工具,享受其帶來的開發體驗提升,和構建效能提升。
五、其他經驗
關於node-sass
用過node-sass的童鞋應該遇到過,安裝node-sass遇到各種編譯錯誤、二進位制檔案下載錯誤、甚至檔案寫入許可權錯誤等等?。也有各種騷操作解決這個問題,但終歸不能一勞永逸。
於是就出現想通過postcss外掛去相容sass語法,雖然通過外掛能夠相容部分語法,但是想要在已經有一定量的業務程式碼中,替換node-sass的風險是非常高的,本人親自測試各種坑?
當然也有其他途徑解決這個問題,不僅讓你使用完整的sass語法,同時也免去各種安裝node-sass的問題,官方的sass-loader其實已經提供了dart-sass解析模組的支援具體參見文件,可能有人擔心dart-sass的js模組效能不高,本人親測在我們專案中2000+的模組中,dart-sass的編譯效能並沒有明顯下降的感覺,同時我們使用使用了快取能力,通常只變異哪些變化的資源。
具體的配置入下:
{
loader: resolve('sass-loader'),
options: {
// 安裝dart-sass模組:npm i -D sass
implementation: require('sass'),
includePaths: [
// 支援絕對路徑查詢
path.resolve(projectDir, 'src'),
],
sourceMap,
},
},
複製程式碼
node-sass 變數使用問題 我在H5中發現很多這種語法的程式碼,但是實際上沒有生效,構建後,並沒有替換為變數的值。
編譯後:
解決方法如下:
關於 postcss
個人覺得postcss是css前處理器的未來,現在的postcss對於css就像babel對於JavaScript。postcss通過外掛支援未來的css特性,於此同時你還可以自定義外掛實現想要的特性。但其他的less、sass這種前處理器,就難以介入它的處理過程,只能按照它既定的規則處理。因此對於全新的專案建議直接使用postcss+postcss-preset-env
使用最新的css語法特性,同時以便於在未來瀏覽器全面支援相關特性後,快速接入支援。
?如果你使用了css-loader
的import能力,同時有使用了post-css-import
外掛的import能力,兩個外掛會存在衝突,不建議同時使用!
如果使用了postcss-custom-properties
,需要注意在8.x版本中存在一個bug,無法解析如下語法:
:root{
--green: var(--customGreen, #08cb6a);
// 8.x無法正確處理該語法
--primary: var(--customPrimary, var(--green));
}
.test {
background: color(var(--primary) shade(5%));
// 上面面這句將會被轉換為如下程式碼,最終導致瀏覽器無法解析該語法
background: var(--green);
background: var(--primary);
// 我們期望轉換為
background: #08cb6a;
}
複製程式碼
解決方法:禁用 postcss-preset-env 中的custom-properties,安裝6.x版本的custom-properties,單獨新增該外掛。
關於快取
如果在開發模式下面啟用了eslint-loader
對jsx?
檔案校驗,並且啟動了其快取能力,當修改eslint校驗規則,你需要清理快取檔案並且重新啟動構建,否則規則修改不會生效!如果使用a8k
工具構建,可以使用k clean
命令自動處理處理。
主域重試
篇幅太長不詳細介紹了,有興趣的可以在這裡看到相關原始碼webpack-retry-load-plugin, 後續輸入相關文章介紹如何實現CSS/JS同步非同步程式碼重試。