背景
最近發現專案(基於Vue2)構建比較慢, 一次上線釋出需要 15
分鐘, 效率低下。
如今這個時代,時間就是金錢,效率就是生命
。
於是這兩天抽空對專案做了一次構建優化,線上(多國家)構建時間, 從 10分鐘
優化到 4分鐘
, 本地單次構建時間, 從 300秒
優化到 90秒
, 效果還不錯。
整個過程,改造成本不大, 但是收益很可觀。
今天把 詳細的改造過程
和 相關 技術原理
整理出來分享給大家, 希望對大家有所幫助。
正文
首先看一下襬在面前的問題:
可以明顯看出: 整體構建環節耗時過長, 效率低下,影響業務的釋出和回滾
。
線上構建流程:
其中, Build base
和 Build Region
階段存在優化空間。
Build base
階段的優化, 和運維團隊溝通過, 後續會增加快取處理。
本次主要關注 Build Region
階段。
初步優化後,達到效果如下:
下面介紹這次優化的細節。
專案優化實戰
面對耗時大這個問題,首先要做耗時資料分析。
這裡引入 SpeedMeasurePlugin
, 示例程式碼如下:
# vue.config.js
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
configureWebpack: (config) => {
config.plugins.push(new SpeedMeasurePlugin());
}
得到結果如下:
得到:
SMP ⏱ Loaders
cache-loader, and
vue-loader, and
eslint-loader took 3 mins, 39.75 secs
module count = 1894
cache-loader, and
thread-loader, and
babel-loader, and
ts-loader, and
eslint-loader took 3 mins, 35.23 secs
module count = 482
cache-loader, and
thread-loader, and
babel-loader, and
ts-loader, and
cache-loader, and
vue-loader took 3 mins, 16.98 secs
module count = 941
cache-loader, and
vue-loader, and
cache-loader, and
vue-loader took 3 mins, 9.005 secs
module count = 947
mini-css-extract-plugin, and
css-loader, and
vue-loader, and
postcss-loader, and
sass-loader, and
cache-loader, and
vue-loader took 3 mins, 5.29 secs
module count = 834
modules with no loaders took 1 min, 52.53 secs
module count = 3258
mini-css-extract-plugin, and
css-loader, and
vue-loader, and
postcss-loader, and
cache-loader, and
vue-loader took 27.29 secs
module count = 25
css-loader, and
vue-loader, and
postcss-loader, and
cache-loader, and
vue-loader took 27.13 secs
module count = 25
file-loader took 12.049 secs
module count = 30
cache-loader, and
thread-loader, and
babel-loader took 11.62 secs
module count = 30
url-loader took 11.51 secs
module count = 70
mini-css-extract-plugin, and
css-loader, and
postcss-loader took 9.66 secs
module count = 8
cache-loader, and
thread-loader, and
babel-loader, and
ts-loader took 7.56 secs
module count = 3
css-loader, and
// ...
Build complete.
fetch translations
en has been saved!
id has been saved!
sp-MX has been saved!
vi has been saved!
zh-TW has been saved!
zh-CN has been saved!
th has been saved!
$ node ./script/copy-static-asset.js
✨ Done in 289.96s.
統計出耗時比較大的幾個loader:
Vue-loader
eslint-loader
babel-loader
Ts-loader,
Thread-loader,
cache-loader
一般而言, 程式碼編譯時間和程式碼規模
正相關。
根據以往優化經驗,程式碼靜態檢查
可能會佔據比較多時間,目光鎖定在 eslint-loader
上。
在生產構建階段, eslint 提示資訊價值不大, 考慮在 build 階段去除,步驟前置
。
比如在 commit
的時候做檢查, 或者在 merge
的時候加一條流水線,專門做靜態檢查。
給出部分示例程式碼:
image: harbor.shopeemobile.com/shopee/nodejs-base:16
stages:
- ci
ci_job:
stage: ci
allow_failure: false
only:
- merge_requests
script:
- npm i -g pnpm
- pnpm pre-build && pnpm lint && pnpm test
cache:
paths:
- node_modules
key: project
於此,初步確定兩個優化方向:
優化構建流程
, 在生產構建階段去除不必要的檢查。整合 esbuild
, 加快底層構建速度。
1. 優化構建流程
檢查專案的配置發現:
# vue.config.js
lintOnSave: true,
修改為:
# vue.config.js
lintOnSave: process.env.NODE_ENV !== 'production',
即: 生產環境的構建不做 lint 檢查。
Vue 官網對此也有相關描述:https://cli.vuejs.org/zh/conf...
再次構建, 得到如下資料:
SMP ⏱ Loaders
cache-loader, and
vue-loader took 1 min, 34.33 secs
module count = 2841
cache-loader, and
thread-loader, and
babel-loader, and
ts-loader took 1 min, 33.56 secs
module count = 485
vue-loader, and
cache-loader, and
thread-loader, and
babel-loader, and
ts-loader, and
cache-loader, and
vue-loader took 1 min, 31.41 secs
module count = 1882
vue-loader, and
mini-css-extract-plugin, and
css-loader, and
postcss-loader, and
sass-loader, and
cache-loader, and
vue-loader took 1 min, 29.55 secs
module count = 1668
css-loader, and
vue-loader, and
postcss-loader, and
sass-loader, and
cache-loader, and
vue-loader took 1 min, 27.75 secs
module count = 834
modules with no loaders took 59.89 secs
module count = 3258
...
Build complete.
fetch translations
vi has been saved!
zh-TW has been saved!
en has been saved!
th has been saved!
sp-MX has been saved!
zh-CN has been saved!
id has been saved!
$ node ./script/copy-static-asset.js
✨ Done in 160.67s.
有一定提升,其他 loader 耗時資料無明顯異常。
下面開始整合 esbuid。
整合 esbuild
這部分的工作,主要是:整合 esbuild 外掛到腳手架中
。
具體程式碼的修改,要看具體情況,大體分為兩類:
- 自己用 webpack 實現了打包邏輯。
- 用的是 cli 自帶的打包配置, 比如 vue-cli。
這兩種方式我都會介紹,雖然形式上有所差異
, 但是原理都是一樣的
。
核心思路如下:
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
loader: 'esbuild-loader',
options: {
charset: 'utf8',
loader: 'tsx',
target: 'es2015',
tsconfigRaw: require('../../tsconfig.json'),
},
exclude: /node_modules/,
},
...
]
const { ESBuildMinifyPlugin } = require('esbuild-loader');
optimization: {
minimizer: [
new ESBuildMinifyPlugin({
target: 'es2015',
css: true,
}),
],
...
}
具體實現上,簡單區分為兩類, 詳細配置如下:
一、webpack.config.js
npm i -D esbuild-loader
1. Javascript & JSX transpilation (eg. Babel)
In webpack.config.js:
module.exports = {
module: {
rules: [
- {
- test: /\.js$/,
- use: 'babel-loader',
- },
+ {
+ test: /\.js$/,
+ loader: 'esbuild-loader',
+ options: {
+ loader: 'jsx', // Remove this if you're not using JSX
+ target: 'es2015' // Syntax to compile to (see options below for possible values)
+ }
+ },
...
],
},
}
2. TypeScript & TSX
In webpack.config.js:
module.exports = {
module: {
rules: [
- {
- test: /\.tsx?$/,
- use: 'ts-loader'
- },
+ {
+ test: /\.tsx?$/,
+ loader: 'esbuild-loader',
+ options: {
+ loader: 'tsx', // Or 'ts' if you don't need tsx
+ target: 'es2015',
+ tsconfigRaw: require('./tsconfig.json'), // If you have a tsconfig.json file, esbuild-loader will automatically detect it.
+ }
+ },
...
]
},
}
3. JS Minification (eg. Terser)
esbuild 在程式碼壓縮上,也有不錯的表現:
詳細對比資料見:https://github.com/privatenum...
In webpack.config.js:
+ const { ESBuildMinifyPlugin } = require('esbuild-loader')
module.exports = {
...,
+ optimization: {
+ minimizer: [
+ new ESBuildMinifyPlugin({
+ target: 'es2015' // Syntax to compile to (see options below for possible values)
+ css: true // Apply minification to CSS assets
+ })
+ ]
+ },
}
4. CSS in JS
如果你的 css 樣式不匯出為 css 檔案, 而是通過比如'style-loader'載入的,也可以通過esbuild來優化。
In webpack.config.js:
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader',
+ {
+ loader: 'esbuild-loader',
+ options: {
+ loader: 'css',
+ minify: true
+ }
+ }
]
}
]
}
}
更多 esbuild 案例, 可以參考: https://github.com/privatenum...
二、vue.config.js
配置比較簡單,直接貼程式碼了:
// vue.config.js
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = {
// ...
chainWebpack: (config) => {
// 使用 esbuild 編譯 js 檔案
const rule = config.module.rule('js');
// 清理自帶的 babel-loader
rule.uses.clear();
// 新增 esbuild-loader
rule
.use('esbuild-loader')
.loader('esbuild-loader')
.options({
loader: 'ts', // 如果使用了 ts, 或者 vue 的 class 裝飾器,則需要加上這個 option 配置, 否則會報錯: ERROR: Unexpected "@"
target: 'es2015',
tsconfigRaw: require('./tsconfig.json')
})
// 刪除底層 terser, 換用 esbuild-minimize-plugin
config.optimization.minimizers.delete('terser');
// 使用 esbuild 優化 css 壓縮
config.optimization
.minimizer('esbuild')
.use(ESBuildMinifyPlugin, [{ minify: true, css: true }]);
}
}
這一番組合拳打完,本地單次構建:
效果還是比較明顯的。
一次線上構建, 整體時間從 10 分鐘縮短為 4 分鐘。
然而,開心不到兩分鐘,發現隔壁專案竟然可以做到 2 分鐘...
這我就不服氣了,同樣是 esbuild , 為何你的就這麼秀?
去研究了一下, 找到了原因。
- 他們的專案是 React + TSX, 我這次優化的專案是 Vue, 在檔案的處理上就需要多過一層
vue-loader
。 - 他們的專案採用了微前端, 對專案對了拆分,主專案只需要載入基座相關的程式碼, 子應用各自構建。 需要構建的主應用程式碼量大大減少, 這是主要原因。
這種微前端的拆分方式在我之前的文章中提到過, 看興趣的可以去看看。
你需要了解的 esbuild
第一部分主要介紹了一些實踐中的細節, 基本都是配置, 沒有太多有深度的內容, 這部分將介紹 更多 esbuild 原理性的內容作為補充。
去年也寫過兩篇相關的內容, 感興趣的可以去看看。
本部分將從 4 個方面為大家介紹。
- 前端遇到了什麼瓶頸 & esbuild 能解決什麼問題
- 效能優先的設計哲學 & 與其它工具合作共贏
- esbuild 官方的定位
- 暢想 esbuild 的未來
1. 前端遇到了什麼瓶頸 & esbuild 能解決什麼問題
前端工程化的瓶頸
JS 之外的構建工具
esbuild 解決的問題
2. 效能優先的設計哲學 & 與其它工具合作共贏
為何 esbuild 速度如此之快?
- 使用了 Golang 編寫,執行效率與 JS 有數量級的差距
- 幾乎所有的設計都以效能優先
效能優先的設計哲學
esbuild 整體架構
詳見: https://github.com/evanw/esbu...
如果未配置 GOMAXPROCS,在執行了大量 goroutine 的情況下,Golang 會佔滿全部 CPU 核數。
上圖表明,除了與依賴圖和 IO 相關的操作之外,所有的操作都是並行的,且不需要昂貴的序列化和拷貝成本。
可以簡單理解為:由於有並行,八核 CPU 可以將編譯和壓縮速度提升接近八倍(不考慮其它程式開銷)。
一般來說,直接用命令列呼叫 esbuild 是最快的,但作為前端,我們暫時還無法避免用 Node.js 來寫打包的配置。
當通過 Node.js 呼叫 esbuild 二進位制程式時,會先 spawn 一個子程式,然後將 Node.js 的標準輸入輸出通過管道連線至子程式。將資料寫入子程式 stdin 表示傳送資料,監聽 stdout 表示接收子程式的輸出資料。
在 Golang 側,如果發現了 --service 啟動引數則會執行 runService,這會生成一個 channel 叫 outgoingPackets,寫入到這裡的資料最終會被寫入到 stdout(表示傳送資料),在 main loop 中從 stdin 讀資料表示接收資料。
其實 esbuild 的專案結構並不複雜,去除掉文件等一些與程式碼無關的東西后是這樣的,遵循 Golang 標準專案結構,大概的呼叫鏈路就是 cmd -> pkg -> internal。
由於 esbuild 的功能更多一些,因此 internal 目錄裡面的包比 Babel 要複雜。此外 Babel 大部分的轉換是基於 preset 和 plugin 做的,但 esbuild 是程式本身自帶,所以擴充套件性差了一些。
最下面的 pkg 包是一些可以被其它 Golang 專案呼叫的包,開發者可以在 Golang 專案裡輕鬆呼叫 esbuild API 來構建(就好比寫了一個 Webpack 來呼叫 Babel)。
golang內部實現一覽:
https://dreampuf.github.io/Gr...
godepgraph -s -novendor ./cmd/esbuild
與其它工具合作共贏
使用 Golang 與 Node.js 呼叫 esbuild 的示例(esbuild 作為其它工具流程的一部分):
3. esbuild 官方的定位
雖然 esbuild 已經很優秀、功能比較齊全了,但作者的意思是“探尋前端構建的另一種可能”,而不是要替代掉 Webpack 等工具。
目前看來,對於大部分專案來說,最好的做法可能還是用 esbuild-loader,將 esbuild 只作為轉換器和程式碼壓縮工具,成為流程的一部分。
esbuild 最近半年的 changelog 都是非常邊緣的問題修復,加上有 Vite 背書,因此可以認為基本穩定了。
esbuild 接入方式
- 通過 esbuild-loader 接入
- 直接呼叫 esbuild 二進位制
- Umi 自帶啟用 esbuild 功能
兩點結論:
- 需要根據自己專案的情況來決定使用哪種方式來接入。
- 優化效果因專案而異,因為構建速度不完全取決於 esbuild。
4. 暢想 esbuild 的未來
結語
esbuild 是一個強大的工具,希望大家能充分使用起來, 為業務帶來更大價值。
好了,今天的內容就這麼多,希望對大家有所啟發。
才疏學淺,文章若有錯誤,歡迎留言指出。
參考資料
https://cli.vuejs.org/zh/conf...
https://esbuild.github.io/get...
https://morioh.com/p/cfd2609d...
https://battlehawk233.cn/post...
https://esbuild.github.io/api...
https://webpack.docschina.org...
https://github.com/privatenum...