我們的系統(一個 ToB 的 Web 單頁應用)前端單頁應用經過多年的迭代,目前已經累積有大幾十萬行的業務程式碼,30+ 路由模組,整體的程式碼量和複雜度還是比較高的。
專案整體是基於 Vue + TypeScirpt,而構建工具,由於最早專案是經由 vue-cli
初始化而來,所以自然而然使用的是 Webpack。
我們知道,隨著專案體量越來越大,我們在開發階段將專案跑起來,也就是通過 npm run serve
的單次冷啟動時間,以及在專案發布時候的 npm run build
的耗時都會越來越久。
因此,打包構建優化也是伴隨專案的成長需要持續不斷去做的事情。在早期,專案體量比較小的時,構建優化的效果可能還不太明顯,而隨著專案體量的增大,構建耗時逐漸增加,如何儘可能的降低構建時間,則顯得越來越重要:
本文,就將詳細介紹整個 WMS FE 專案,在隨著專案體量不斷增大的過程中,對整體的打包構建效率的優化之路。
再更具體一點,我們的專案最初是基於 vue-cli 4
,當時其基於的是 webpack4 版本。如無特殊說明,下文的一些設定會基於 webpack4 展開。
工欲善其事必先利其器,解決問題前需要分析問題,要優化構建速度,首先得分析出 Webpack 構建編譯我們的專案過程中,耗時所在,側重點分佈。
這裡,我們使用的是 SMP 外掛,統計各模組耗時資料。
speed-measure-webpack-plugin
是一款統計 webpack 打包時間的外掛,不僅可以分析總的打包時間,還能分析各階段loader 的耗時,並且可以輸出一個檔案用於永久化儲存資料。
// 安裝
npm install --save-dev speed-measure-webpack-plugin
// 使用方式
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
config.plugins.push(smp());
對於 npm run serve
,也就是開發階段而言,在沒有任何快取的前提下,單次冷啟動整個專案的時間達到了驚人的 4 min。
上一次 HappyPack 更新已經是 3 年前
對於定址優化,總體而言提升並不是很大。
它的核心即在於,合理設定 loader 的 exclude
和 include
屬性。
這肯定是有用的優化手段,只是對於一些大型專案而言,這類優化對整體構建時間的優化不會特別明顯。
在上述的一些常規優化完成後。整體效果仍舊不是特別明顯,因此,我們開始思考一些其它方向。
我們再來看看 Webpack 構建的整體流程:
上圖是大致的 webpack 構建流程,簡單介紹一下:
隨著專案體量地不斷增大,耗時大頭消耗在第 7 步,遞迴遍歷 AST,解析 require,如此反覆直到遍歷完整個專案。
而有意思的是,對於單次單個開發而言,極大概率只是基於這整個大專案的某一小個模組進行開發即可。
所以,如果我們可以在收集依賴的時候,跳過我們本次不需要的模組,或者可以自行選擇,只構建必要的模組,那麼整體的構建時間就可以大大減少。
這也就是我們要做的 -- 分模組構建。
什麼意思呢?舉個栗子,假設我們的專案一共有 6 個大的路由模組 A、B、C、D、E、F,當新需求只需要在 A 模組範圍內進行優化新增,那麼我們在開發階段啟動整個專案的時候,可以跳過 B、C、D、E、F 這 5 個模組,只構建 A 模組即可:
假設原本每個模組的構建平均耗時 3s,原本 18s 的整體冷啟動構建耗時就能下降到 3s。
Webpack 是靜態編譯打包的,Webpack 在收集依賴時會去分析程式碼中的 require(import 會被 bebel 編譯成 require) 語句,然後遞迴的去收集依賴進行打包構建。
我們要做的,就是通過增加一些設定,簡單改造下我們的現有程式碼,使得 Webpack 在初始化遍歷整個路由模組收集依賴的時候,可以跳過我們不需要的模組。
再說得詳細點,假設我們的路由大致程式碼如下:
import Vue from 'vue';
import VueRouter, { Route } from 'vue-router';
// 1. 定義路由元件.
// 這裡簡化下模型,實際專案中肯定是一個一個的大路由模組,從其他檔案匯入
const moduleA = { template: '<div>AAAA</div>' }
const moduleB = { template: '<div>BBBB</div>' }
const moduleC = { template: '<div>CCCC</div>' }
const moduleD = { template: '<div>DDDD</div>' }
const moduleE = { template: '<div>EEEE</div>' }
const moduleF = { template: '<div>FFFF</div>' }
// 2. 定義一些路由
// 每個路由都需要對映到一個元件。
// 我們後面再討論巢狀路由。
const routesConfig = [
{ path: '/A', component: moduleA },
{ path: '/B', component: moduleB },
{ path: '/C', component: moduleC },
{ path: '/D', component: moduleD },
{ path: '/E', component: moduleE },
{ path: '/F', component: moduleF }
]
const router = new VueRouter({
mode: 'history',
routes: routesConfig,
});
// 讓路由生效 ...
const app = Vue.createApp({})
app.use(router)
我們要做的,就是每次啟動專案時,可以通過一個前置命令列指令碼,收集本次需要啟動的模組,按需生成需要的 routesConfig
即可。
我們嘗試了:
最終選擇了使用 NormalModuleReplacementPlugin
外掛進行檔案替換的方式,原因在於它對整個專案的侵入性非常小,只需要新增前置指令碼及修改 Webpack 設定,無需改變任何路由檔案程式碼。總結而言,該方案的兩點優勢在於:
利用 NormalModuleReplacementPlugin
外掛,可以不修改原來的路由組態檔,在編譯階段根據設定生成一個新的路由組態檔然後去使用它,這樣做的好處在於對整個原始碼沒有侵入性。
NormalModuleReplacementPlugin 外掛的作用在於,將目標原始檔的內容替換為我們自己的內容。
我們簡單修改 Webpack 設定,如果當前是開發環境,利用該外掛,將原本的 config.ts
檔案,替換為另外一份,程式碼如下:
// vue.config.js
if (process.env.NODE_ENV === 'development') {
config.plugins.push(new webpack.NormalModuleReplacementPlugin(
/src\/router\/config.ts/,
'../../dev.routerConfig.ts'
)
)
}
上面的程式碼功能是將實際使用的 config.ts
替換為自定義設定的 dev.routerConfig.ts
檔案,那麼 dev.routerConfig.ts
檔案的內容又是如何產生的呢,其實就是藉助了 inquirer 與 EJS 模板引擎,通過一個互動式的命令列問答,選取需要的模組,基於選擇的內容,動態的生成新的 dev.routerConfig.ts
程式碼,這裡直接上程式碼。
改造一下我們的啟動指令碼,在執行 vue-cli-service serve
前,先跑一段我們的前置指令碼:
{
// ...
"scripts": {
- "dev": "vue-cli-service serve",
+ "dev": "node ./script/dev-server.js && vue-cli-service serve",
},
// ...
}
而 dev-server.js
所需要做的事,就是通過 inquirer
實現一個互動式命令,使用者選擇本次需要啟動的模組列表,通過 ejs
生成一份新的 dev.routerConfig.ts
檔案。
// dev-server.js
const ejs = require('ejs');
const fs = require('fs');
const child_process = require('child_process');
const inquirer = require('inquirer');
const path = require('path');
const moduleConfig = [
'moduleA',
'moduleB',
'moduleC',
// 實際業務中的所有模組
]
//選中的模組
const chooseModules = [
'home'
]
function deelRouteName(name) {
const index = name.search(/[A-Z]/g);
const preRoute = '' + path.resolve(__dirname, '../src/router/modules/') + '/';
if (![0, -1].includes(index)) {
return preRoute + (name.slice(0, index) + '-' + name.slice(index)).toLowerCase();
}
return preRoute + name.toLowerCase();;
}
function init() {
let entryDir = process.argv.slice(2);
entryDir = [...new Set(entryDir)];
if (entryDir && entryDir.length > 0) {
for(const item of entryDir){
if(moduleConfig.includes(item)){
chooseModules.push(item);
}
}
console.log('output: ', chooseModules);
runDEV();
} else {
promptModule();
}
}
const getContenTemplate = async () => {
const html = await ejs.renderFile(path.resolve(__dirname, 'router.config.template.ejs'), { chooseModules, deelRouteName }, {async: true});
fs.writeFileSync(path.resolve(__dirname, '../dev.routerConfig.ts'), html);
};
function promptModule() {
inquirer.prompt({
type: 'checkbox',
name: 'modules',
message: '請選擇啟動的模組, 點選上下鍵選擇, 按空格鍵確認(可以多選), 回車執行。注意: 直接敲擊回車會全量編譯, 速度較慢。',
pageSize: 15,
choices: moduleConfig.map((item) => {
return {
name: item,
value: item,
}
})
}).then((answers) => {
if(answers.modules.length===0){
chooseModules.push(...moduleConfig)
}else{
chooseModules.push(...answers.modules)
}
runDEV();
});
}
init();
模板程式碼的簡單示意:
// 模板程式碼示意,router.config.template.ejs
import { RouteConfig } from 'vue-router';
<% chooseModules.forEach(function(item){%>
import <%=item %> from '<%=deelRouteName(item) %>';
<% }) %>
let routesConfig: Array<RouteConfig> = [];
/* eslint-disable */
routesConfig = [
<% chooseModules.forEach(function(item){%>
<%=item %>,
<% }) %>
]
export default routesConfig;
dev-server.js
的核心在於啟動一個 inquirer 互動命令列服務,讓使用者選擇需要構建的模組,類似於這樣:
模板程式碼示意 router.config.template.ejs
是 EJS 模板檔案,chooseModules
是我們在終端輸入時,獲取到的使用者選擇的模組集合陣列,根據這個列表,我們去生成新的 routesConfig
檔案。
這樣,我們就實現了分模組構建,按需進行依賴收集。以我們的專案為例,我們的整個專案大概有 20 個不同的模組,幾十萬行程式碼:
構建模組數 | 耗時 |
---|---|
冷啟動全量構建 20 個模組 | 4.5MIN |
冷啟動只構建 1 個模組 | 18s |
有快取狀態下二次構建 1 個模組 | 4.5s |
實際效果大致如下,無需啟動所有模組,只啟動我們選中的模組進行對應的開發即可:
這樣,如果單次開發只涉及固定的模組,單次專案冷啟動的時間,可以從原本的 4min+ 下降到 18s 左右,而有快取狀態下二次構建 1 個模組,僅僅需要 4.5s,屬於一個比較大的提升。
受限於 Webpack 所使用的語言的效能瓶頸,要追求更快的構建效能,我們不可避免的需要把目光放在其他構建工具上。這裡,我們的目光聚焦在了 Vite 與 esbuild 上。
Vite,一個基於瀏覽器原生 ES 模組的開發伺服器。利用瀏覽器去解析 imports,在伺服器端按需編譯返回,完全跳過了打包這個概念,伺服器隨起隨用。同時不僅有 Vue 檔案支援,還搞定了熱更新,而且熱更新的速度不會隨著模組增多而變慢。
當然,由於 Vite 本身特性的限制,目前只適用於在開發階段替代 Webpack。
我們都知道 Vite 非常快,它主要快在什麼地方?
那麼是什麼讓它這麼快?
我們先來看看 Webpack 與 Vite 的在構建上的區別。下圖是 Webpack 的遍歷遞迴收集依賴的過程:
上文我們也講了,Webpack 啟動時,從入口檔案出發,呼叫所有設定的 Loader 對模組進行編譯,再找出該模組依賴的模組,再遞迴本步驟直到所有入口依賴的檔案都經過了本步驟的處理。
這一過程是非常非常耗時的,再看看 Vite:
Vite 通過在一開始將應用中的模組區分為 依賴 和 原始碼 兩類,改進了開發伺服器啟動時間。它快的核心在於兩點:
使用 Go 語言的依賴預構建:Vite 將會使用 esbuild 進行預構建依賴。esbuild 使用 Go 編寫,並且比以 JavaScript 編寫的打包器預構建依賴快 10-100 倍。依賴預構建主要做了什麼呢?
按需編譯返回:Vite 以 原生 ESM 方式提供原始碼。這實際上是讓瀏覽器接管了打包程式的部分工作:Vite 只需要在瀏覽器請求原始碼時進行轉換並按需提供原始碼。根據情景動態匯入程式碼,即只在當前螢幕上實際使用時才會被處理。
使用 Vite 的另外一個大的好處在於,它的熱更新也是非常迅速的。
我們首先來看看 Webpack 的熱更新機制:
一些名詞解釋:
Webpack-complier
:Webpack 的編譯器,將 Javascript 編譯成 bundle(就是最終的輸出檔案)HMR Server
:將熱更新的檔案輸出給 HMR RuntimeBunble Server
:提供檔案在瀏覽器的存取,也就是我們平時能夠正常通過 localhost 存取我們本地網站的原因HMR Runtime
:開啟了熱更新的話,在打包階段會被注入到瀏覽器中的 bundle.js,這樣 bundle.js 就可以跟伺服器建立連線,通常是使用 Websocket ,當收到伺服器的更新指令的時候,就去更新檔案的變化bundle.js
:構建輸出的檔案Webpack 熱更新的大致原理是,檔案經過 Webpack-complier 編譯好後傳輸給 HMR Server,HMR Server 知道哪個資源 (模組) 發生了改變,並通知 HMR Runtime 有哪些變化,HMR Runtime 就會更新我們的程式碼,這樣瀏覽器就會更新並且不需要重新整理。
而 Webpack 熱更新機制主要耗時點在於,Webpack 的熱更新會以當前修改的檔案為入口重新 build 打包,所有涉及到的依賴也都會被重新載入一次。
而 Vite 號稱 熱更新的速度不會隨著模組增多而變慢。它的主要優化點在哪呢?
Vite 實現熱更新的方式與 Webpack 大同小異,也通過建立 WebSocket 建立瀏覽器與伺服器建立通訊,通過監聽檔案的改變向用戶端發出訊息,使用者端對應不同的檔案進行不同的操作的更新。
Vite 通過 chokidar
來監聽檔案系統的變更,只用對發生變更的模組重新載入,只需要精確的使相關模組與其臨近的 HMR 邊界連線失效即可,這樣 HMR 更新速度就不會因為應用體積的增加而變慢而 Webpack 還要經歷一次打包構建。所以 HMR 場景下,Vite 表現也要好於 Webpack。
通過不同的訊息觸發一些事件。做到瀏覽器端的即時熱模組更換(熱更新)。通過不同事件,觸發更細粒度的更新(目前只有 Vue 和 JS,Vue 檔案又包含了 template、script、style 的改動),做到只更新必須的檔案,而不是全量進行更新。在些事件分別是:
本文不會在 Vite 原理上做太多深入,感興趣的可以通過官方檔案瞭解更多 -- Vite 官方檔案 -- 為什麼選 Vite
基於 Vite 的改造,相當於在開發階段替換掉 Webpack,下文主要講講我們在替換過程中遇到的一些問題。
基於 Vue-cli 4 的 Vue2 專案改造,大致只需要:
<script type="module" src="...">
標籤指向原始碼)scripts
模組下增加啟動命令 "vite": "vite"
當以命令列方式執行 npm run vite
時,Vite 會自動解析專案根目錄下名為 vite.config.js
的檔案,讀取相應設定。而對於 vite.config.js
的設定,整體而言比較簡單:
.tsx
、.jsx
當然,對於專案的原始碼,可能需要一定的改造,下面是我們遇到的一些小問題:
@vitejs/plugin-vue-jsx
,使其支援 Vue2 下的 jsxrequire
改為 import
/deep/
,可使用 ::v-deep
替換對於需要修改到原始碼的地方,我們的做法是既保證能讓 Vite 進行適配,同時讓該改動不會影響到原本 Webpack 的構建,以便在關鍵時刻或者後續迭代能切回 Webpack
解決完上述的一些問題後,我們成功地將開發時基於 Webpack 的構建打包遷移到了 Vite,效果也非常驚人,全模組構建耗時只有 2.6s:
至此,開發階段的構建耗時從原本的 4.5min 優化到了 2.6s:
構建模組數 | 耗時 |
---|---|
Webpack 冷啟動全量構建 20 個模組 | 4.5MIN |
Webpack 冷啟動只構建 1 個模組 | 18s |
Webpack 有快取狀態下二次構建 1 個模組 | 4.5s |
Vite 冷啟動 | 2.6s |
好,上述我們基本已經完成了整個開發階段的構建優化。下一步是優化生產構建。
我們的生產釋出是基於 GitLab 及 Jenkins 的完整 CI/CD 流。
在優化之前,看看我們的整個專案線上釋出的耗時:
可以看到,生產環境構建時間較長, build 平均耗時約 9 分鐘,整體釋出構建時長在 15 分鐘左右,整體構建環節耗時過長, 效率低下,嚴重影響測試以及回滾 。
好,那我們看看,整個構建流程,都需要做什麼事情:
其中, Build base 和 Build Region 階段存在較大優化空間。
Build base 階段的優化,涉及到環境準備,映象拉取,依賴的安裝。前端能發揮的空間不大,這一塊主要和 SRE 團隊溝通,共同進行優化,可以做的有增加快取處理、外掛檔案系統、將依賴寫進容器等方式。
我們的優化,主要關注 Build Region 階段,也就是核心關注如何減少 npm run build
的時間。
文章開頭有貼過 npm run build
的耗時分析,簡單再貼下:
這個上面說了,還是比較好理解的,在生產構建階段,eslint 提示資訊價值不大,考慮在 build 階段去除,步驟前置。
比如在 git commit
的時候利用 lint-staged
及 git hook
做檢查, 或者利用 CI 在 git merge
的時候加一條流水線任務,專門做靜態檢查。
我們兩種方式都有做,簡單給出接入 Gitlab CI 的程式碼:
// .gitlab-ci.yml
stages:
- eslint
eslint-job:
image: node:14.13.0
stage: eslint
script:
- npm run lint
- echo 'eslint success'
retry: 1
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "test"'
通過 .gitlab-ci.yml
組態檔,指定固定的時機進行 lint 指令,前置步驟。
這裡,我們主要藉助了 esbuild-loader。
上面其實我們也有提到 esbuild,Vite 使用 esbuild 進行預構建依賴。這裡我們藉助的是 esbuild-loader,它把 esbuild 的能力包裝成 Webpack 的 loader 來實現 Javascript、TypeScript、CSS 等資源的編譯。以及提供更快的資源壓縮方案。
接入起來也非常簡單。我們的專案是基於 Vue CLi 的,主要修改 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 }]);
}
}
移除 ESLint,以及接入 esbuild-loader 這一番組合拳打完,本地單次構建可以優化到 90 秒。
階段 | 耗時 |
---|---|
優化前 | 200s |
移除 ESLint、接入 esbuild-loader | 90s |
再看看線上的 Jenkins 構建耗時,也有了一個非常明顯的提升:
整體而言,上述優化完成後,對整個專案的打包構建效率是有著一個比較大的提升的,但是這並非已經做到了最好。
看看我們旁邊兄弟組的 Live 構建耗時:
在專案體量差不多的情況下,他們的生產構建耗時(npm run build
)在 2 分鐘出頭,細究其原因在於:
vue-loader
;是的,後續我們還有許多可以嘗試的方向,譬如我們正在做的一些嘗試有:
npm install
時間的消耗等同時,我們也必須看到,前端技術日新月異,各種構建工具目不暇給。前端從最早期的刀耕火種,到逐步向工程化邁進,到如今的泛前端工程化囊括的各式各樣的標準、規範、各種提效的工具。構建效率優化可能會處於一種一直在路上的狀態。當然,這裡不一定有最佳實踐,只有最適合我們專案的實踐,需要我們不斷地去摸索嘗試。
本文到此結束,希望對你有幫助