[效能優化] 使用 esbuild 為你的構建提速 ?

皮小蛋發表於2022-02-25

image.png

背景

最近發現專案(基於Vue2)構建比較慢, 一次上線釋出需要 15 分鐘, 效率低下。

如今這個時代,時間就是金錢,效率就是生命

於是這兩天抽空對專案做了一次構建優化,線上(多國家)構建時間, 從 10分鐘 優化到 4分鐘, 本地單次構建時間, 從 300秒 優化到 90秒, 效果還不錯。

整個過程,改造成本不大, 但是收益很可觀。

今天把 詳細的改造過程 和 相關 技術原理 整理出來分享給大家, 希望對大家有所幫助。

正文

首先看一下襬在面前的問題:

WechatIMG37.png

可以明顯看出: 整體構建環節耗時過長, 效率低下,影響業務的釋出和回滾

線上構建流程:

image.png

其中, Build baseBuild Region 階段存在優化空間。

Build base 階段的優化, 和運維團隊溝通過, 後續會增加快取處理。

本次主要關注 Build Region 階段。

初步優化後,達到效果如下:

image.png

下面介紹這次優化的細節。

專案優化實戰

面對耗時大這個問題,首先要做耗時資料分析。

這裡引入 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

於此,初步確定兩個優化方向:

  1. 優化構建流程, 在生產構建階段去除不必要的檢查。
  2. 整合 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 外掛到腳手架中

具體程式碼的修改,要看具體情況,大體分為兩類:

  1. 自己用 webpack 實現了打包邏輯。
  2. 用的是 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 在程式碼壓縮上,也有不錯的表現:

image.png

詳細對比資料見: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

配置比較簡單,直接貼程式碼了:

image.png

// 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 }]);
  }
}

這一番組合拳打完,本地單次構建:

image.png

效果還是比較明顯的。

一次線上構建, 整體時間從 10 分鐘縮短為 4 分鐘。

image.png

然而,開心不到兩分鐘,發現隔壁專案竟然可以做到 2 分鐘...

image.png

這我就不服氣了,同樣是 esbuild , 為何你的就這麼秀?

去研究了一下, 找到了原因。

  1. 他們的專案是 React + TSX, 我這次優化的專案是 Vue, 在檔案的處理上就需要多過一層 vue-loader
  2. 他們的專案採用了微前端, 對專案對了拆分,主專案只需要載入基座相關的程式碼, 子應用各自構建。 需要構建的主應用程式碼量大大減少, 這是主要原因。

這種微前端的拆分方式在我之前的文章中提到過, 看興趣的可以去看看。

你需要了解的 esbuild

第一部分主要介紹了一些實踐中的細節, 基本都是配置, 沒有太多有深度的內容, 這部分將介紹 更多 esbuild 原理性的內容作為補充。

去年也寫過兩篇相關的內容, 感興趣的可以去看看。

  1. 「 不懂就問 」esbuild 快在哪裡 ?
  2. 「 不懂就問 」webpack 打包的效能瓶頸在哪裡 ?

本部分將從 4 個方面為大家介紹。

  1. 前端遇到了什麼瓶頸 & esbuild 能解決什麼問題
  2. 效能優先的設計哲學 & 與其它工具合作共贏
  3. esbuild 官方的定位
  4. 暢想 esbuild 的未來

1. 前端遇到了什麼瓶頸 & esbuild 能解決什麼問題

前端工程化的瓶頸

image.png

image.png

JS 之外的構建工具

image.png

esbuild 解決的問題

image.png

社群外掛集

2. 效能優先的設計哲學 & 與其它工具合作共贏

image.png

為何 esbuild 速度如此之快?

  1. 使用了 Golang 編寫,執行效率與 JS 有數量級的差距
  2. 幾乎所有的設計都以效能優先

效能優先的設計哲學

image.png

esbuild 整體架構

https://github.com/evanw/esbuild/blob/master/docs/architecture.md

詳見: https://github.com/evanw/esbu...

如果未配置 GOMAXPROCS,在執行了大量 goroutine 的情況下,Golang 會佔滿全部 CPU 核數。

上圖表明,除了與依賴圖和 IO 相關的操作之外,所有的操作都是並行的,且不需要昂貴的序列化和拷貝成本。

可以簡單理解為:由於有並行,八核 CPU 可以將編譯和壓縮速度提升接近八倍(不考慮其它程式開銷)。

image.png

一般來說,直接用命令列呼叫 esbuild 是最快的,但作為前端,我們暫時還無法避免用 Node.js 來寫打包的配置。

當通過 Node.js 呼叫 esbuild 二進位制程式時,會先 spawn 一個子程式,然後將 Node.js 的標準輸入輸出通過管道連線至子程式。將資料寫入子程式 stdin 表示傳送資料,監聽 stdout 表示接收子程式的輸出資料。

在 Golang 側,如果發現了 --service 啟動引數則會執行 runService,這會生成一個 channel 叫 outgoingPackets,寫入到這裡的資料最終會被寫入到 stdout(表示傳送資料),在 main loop 中從 stdin 讀資料表示接收資料。

image.png

其實 esbuild 的專案結構並不複雜,去除掉文件等一些與程式碼無關的東西后是這樣的,遵循 Golang 標準專案結構,大概的呼叫鏈路就是 cmd -> pkg -> internal。

由於 esbuild 的功能更多一些,因此 internal 目錄裡面的包比 Babel 要複雜。此外 Babel 大部分的轉換是基於 preset 和 plugin 做的,但 esbuild 是程式本身自帶,所以擴充套件性差了一些。

最下面的 pkg 包是一些可以被其它 Golang 專案呼叫的包,開發者可以在 Golang 專案裡輕鬆呼叫 esbuild API 來構建(就好比寫了一個 Webpack 來呼叫 Babel)。

golang內部實現一覽:

image.png

https://dreampuf.github.io/Gr...

godepgraph -s -novendor ./cmd/esbuild

與其它工具合作共贏

image.png

使用 Golang 與 Node.js 呼叫 esbuild 的示例(esbuild 作為其它工具流程的一部分):

image.png

3. esbuild 官方的定位

image.png

雖然 esbuild 已經很優秀、功能比較齊全了,但作者的意思是“探尋前端構建的另一種可能”,而不是要替代掉 Webpack 等工具。

目前看來,對於大部分專案來說,最好的做法可能還是用 esbuild-loader,將 esbuild 只作為轉換器和程式碼壓縮工具,成為流程的一部分。

esbuild 最近半年的 changelog 都是非常邊緣的問題修復,加上有 Vite 背書,因此可以認為基本穩定了。

esbuild 接入方式

  1. 通過 esbuild-loader 接入

image.png

  1. 直接呼叫 esbuild 二進位制

image.png

  1. Umi 自帶啟用 esbuild 功能

image.png

兩點結論:

  1. 需要根據自己專案的情況來決定使用哪種方式來接入。
  2. 優化效果因專案而異,因為構建速度不完全取決於 esbuild。

4. 暢想 esbuild 的未來

image.png

結語

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...

相關文章