6

[性能优化] 使用 esbuild 为你的构建提速 ?

 3 years ago
source link: https://segmentfault.com/a/1190000041455457
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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 原理性的内容作为补充。

去年也写过两篇相关的内容, 感兴趣的可以去看看。

本部分将从 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 是一个强大的工具,希望大家能充分使用起来, 为业务带来更大价值。

好了,今天的内容就这么多,希望对大家有所启发。

才疏学浅,文章若有错误,欢迎留言指出。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK