Skip to content

性能优化

分析依赖包大小

在开始进行打包优化前,需要先分析当前依赖包的情况,一般来说,我们需要搞清楚以下几点:

问题解决方案
打包产物中,某些js文件是否过大?合理拆包
第三方依赖是否过多导致打包变慢?依赖外置

Vite实现方案

安装 rollup-plugin-visualizer 插件:

ts
import visualizer from 'rollup-plugin-visualizer'

export default defineConig({
  plugins: [
    visualizer({
      sourcemap: true,
    }),
  ]
})

TIP

如果在分析文件中看不到具体的文件内容,需要将 build.sourcemap 设置为 true

插件设置完成后,再次打包,在项目根目录将生成 stats.html 文件,在浏览器中打开该文件:

在我的这个项目里,第三方依赖被打成了两个 js 文件,每个文件都超过了 1M

很显然,打包产物过大,需要进行拆包处理。

并且,由于项目依赖的第三方库比较多,我们可以通过将依赖外置的方式来减少对第三方库的打包,从而加快构建速度。

拆包

我们先来拆包,拆包的原则是:

  • 保证每个 js 产物不要过大;
  • 如果是在HTTP1.1协议下,还要保证每个 js 产物不要过小,否则会打出太多的包,运行时将会阻塞导致应用启动过慢;
  • js 产物的命名清晰。

Vite实现方案

Vite的打包是基于rollup的(至少在Vite5及以前版本是这样的),因此,Vite没有提供拆包的配置,而是推荐用户直接覆盖rollup配置。

实现思路是修改 build.rollupOptions.output.manualChunks 配置,文档参考rollup的官方文档:https://rollupjs.org/configuration-options/#output-manualchunks

这里给出一份我的实践思路,首先,提供一个配置文件 config.ts (位于项目目录 build/config.ts 中),用户可以配置:

ts

export type Chunks = Record<string, string[]>
export interface Config {
  /** 打包分包 */
  chunks: Chunks
}

export default {
  chunks: {
    'app': ['vue', 'vue-router', 'pinia'],
    'ui': ['element-plus'],
    'chart': ['echarts'],
    'utility': ['lodash', 'dayjs'],
  }
} as Config

TIP

上述设置中,vuevue-routerpinia 三个依赖包将被打入最终的 app.js 中。

然后,在 vite.config.ts 中配置:

ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          for (const [name, packages] of Object.entries(config.chunks)) {
            const pass = packages.some(packageName => id.includes(`node_modules/${packageName}\/`))
            if (pass) return name
          }
          if (id.includes('node_modules')) return 'vendor'
        },
      },
    },
  },
})

TIP

注意看 manualChunks 回调的最后一行,如果没有命中 chunks 中设置的规则,将会将 node_modules 中的依赖全部打进 vendor.js 中。

接下来,执行打包命令验证一下拆包的结果:

依赖外置 + CDN

我们在写下这行代码时:

ts
import { merge } from 'lodash'
merge(a, b)

执行打包时,打包器会将 lodash 的代码打包到最终的产物中。

我们假设这个库的大小为10M,那打包时,光花在这一个包的处理时间都会特别长。

如果我们能让最终的代码变成这样:

ts
const { merge } = window._

然后在入口html中加入这么一段 <script> 脚本:

html
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>

那打包时,我们就可以跳过 lodash 这个库的处理,从而节省一个库的处理时间。如果我们将所有耗时的依赖包全部外置,那理论上,打包就不需要时间。

TIP

无论使用什么打包器(webpack、vite、rollup等),究其本质,其原理都是一样的。

Vite实现方案

在Vite中,我们可以使用 vite-plugin-cdn-import 插件快速实现这件事:

ts
import cdn from 'vite-plugin-cdn-import'

export default defineConig({
  plugins: [
    cdn({
      name: 'lodash',
      var: '_',
      path: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js',
    })
  ]
})

这个插件大致做了这些事情:

  • 在rollup的 external 配置中,新增一条 lodash ,保证打包时,跳过它
  • 在rollup的 output.globals 配置中,新增一条 lodash: '_',保证打包产物中,引用模块时,通过全局方式进行引用
  • 在生成最终的入口html文件时,加入 <script> 脚本,保证最终运行时,在全局可以读取到引用

合理设置浏览器兼容策略

请看这段代码:

ts
const hello = () => {
  return new Promise(() => {
    console.log('hello')
  })
}
hello()

当你把这段代码运行在Chrome 44版本下时,将会发生报错,这是因为,箭头函数最低在Chrome 45版本才被支持;而 Promise 特性在Chrome33版本得到支持。(具体参见 caniuse

为了让代码可以兼容更低版本的浏览器,一般情况下,我们需要做两件事:

  • 将代码转译为旧的语法,如将箭头函数转译为普通函数,这个过程称为 transform
  • 提供环境支持,例如上述代码中使用到了 Promise ,则需要在全局注入该特性,这个过程称为 polyfill

Vite实现方案

在Vite中,想要实现低版本兼容,还是非常简单,只需要使用官方的 @vitejs/plugin-legacy 插件即可。

ts
export default defineConfig({
  legacy({
    // 根据情况设置自己的浏览器版本,或在根目录下新建.browserslistrc文件设置
    // 为了让其他插件共享配置,更推荐配置文件的方式
    // targets: ['chrome>=96'],
  }),
})
bash
# browserslist配置主要有以下两个作用
# 1. vite legacy插件为js提供polyfill
# 2. postcss autoprefixer插件为css提供prefix

[production]
last 2 versions and not dead, > 0.3%, Firefox ESR

WARNING

注意注释的内容,尽管在legacy插件中,可以非常方便地设备目标浏览器的版本,但我们仍然建议使用 .browserslistrc 文件配置。

原因是,除了legacy插件需要做js相关的兼容外,css也需要,例如postcss的 autoprefixer 插件,如果我们在legacy插件中设置一次兼容浏览器版本,再为 autoprefixer 设置一次兼容浏览器版本,那就可能出现不一致的情况,为了保持一致,我们让所有工具都从源头的 .browserslistrc 文件中读取浏览器特性列表,从而实现兼容。

那么,浏览器兼容策略为什么跟性能优化有关呢?原因是:

不合适的(过低的)浏览器兼容版本将会导致打包速度明显下降,因为要转译的语法和补充的polyfill更多,同时,也会导致最终生成的打包产物过大。

因此,设置兼容的浏览器版本范围时,我们需要 切合实际去考量,到底需要最低兼容到什么版本,适当地放弃部分版本太低的用户来换取开发、构建与运行的体验和性能,这是值得的。

TIP

如果你的应用针对现代浏览器用户开发,那就保持默认配置即可,less is more

关于浏览器兼容设置的所有细节,请参见 浏览器兼容

资源压缩(gzip)

注意

此项优化只能提升运行速度,不能提高打包速度。

打包时,除了原有文件外,为每份静态文件(一般是html/js/css)生成一份压缩版本,这其实会降低打包的速度

然后,让浏览器读取更小体积的压缩文件,这样加载的速度会变快,从而让应用启动更快,白屏时间更短。

Vite实现方案

在Vite中,通过 vite-plugin-compression 插件快速实现这件事:

ts
export default defineConfig({
  plugins: [
    compression({
      algorithm: 'gzip',
    }),
  ]
})

打包时效果如下:

去掉预处理器

毫无疑问,预处理器的设计是优秀的。看看这段代码:

css
table {
  height: 300px;
}
table tr {
  background-color: lightblue;
}
table tr td {
  font-size: 14px;
}
table tr td div {
  font-weight: bold;
}

CSS的语法限制,往往让我们不得不写出极度冗余的代码,预处理器的出现,解决了这些问题:

scss
table {
  height: 300px;
  tr {
    background-color: lightblue;
    td {
      font-size: 14px;
      div {
        font-weight: bold;
      }
    }
  }
}

这种嵌套语法,延续了HTML元素嵌套的传统。

简单来讲,预处理器的作用是:通过提前使用最新的CSS语法或使用预处理器自创的语法,更快地书写样式代码,提升研发效率,降低维护成本。

不仅仅是嵌套的语法,预处理器还提供了这些优秀的特性:

特性解决的问题scss样例代码
变量更好地复用,减少常量的重复编写,降低维护难度$color: red;
导入拆分模块,更清晰的结构与更低的维护成本@import('base.scss')
mixin复用,相当于拆分代码片段@mixin base{ color: red; }
逻辑支持分支、循环等逻辑@if $color {}
函数进一步复用@function base() { @return 1 }
其他颜色、数学运算等高级特性,快速编写代码lighten(#e1d7d2, 30%)

好了,让我们回归到问题中来。预处理器确实有诸多优秀的特性,但是:

我们的项目中真的需要这么多的特性吗?我们使用预处理器的程度如何?

事实是,我们高估了自己使用预处理器的程度,以为会用到很多特性,但其实没有。对于绝大多数上层应用而言,使用预处理器的程度仅限于 变量、嵌套、导入 三个重要特性。

而这些特性,都在后处理器PostCSS中得到了很好的支持,让我们来看看怎么使用它们:

特性替代方法
变量直接使用CSS变量,如 --color: red,如果确实需要兼容低版本浏览器,使用 postcss-custom-properties 插件
嵌套使用 postcss-nesting 插件
导入使用 postcss-import 插件,一般上层构建工具(如Vite)默认就提供了支持

也就是说,如果使用Vite,只需要新增一个 nesting 插件即可。

为什么要去除预处理器?

去掉预处理器的原因有二:

  • less is more ,概念简单化,减轻开发者的心智负担;
  • 性能提升 ,预处理与后处理都是要花时间的,同时使用预处理器与后处理器,意味着每次打包至少要启动两个处理CSS的引擎,这会让打包速度下降明显,而如果所有问题都回归到后处理器中,即便处理的流程变长变复杂了,但是速度仍然要快于同时使用两个处理工具。

如何去除?

找到项目中所有使用了预处理器的地方,改掉它们即可。

最理想的情况是,你只需要将 .scss 文件 改为 .css 文件即可。但如果项目使用了预处理器的高级语法,你就得花点心思琢磨如何在PostCSS中重构它们了。

图片压缩

注意

此项优化只能提升运行速度,不能提高打包速度。

图片过大,加载时间慢,会引起 window.onload 回调过晚,从而导致网站的性能指标不佳。

Vite实现方案

首先,安装 vite-plugin-imagemin 插件,然后配置:

ts
export default defineConfig({
  plugins: [
    imagemin({
      mozjpeg: {
        quality: 10,
      },
      pngquant: {
        quality: [0.5, 0.5],
      },
    })
  ]
})

需要注意,该插件在中国安装时会报错,请按照插件文档的解决方案进行解决。

其他小技巧

关闭日志

ts
export default defineConfig({
  logLevel: 'silent'
})

TIP

也可以设置为 error 仅捕捉一些错误日志。

关闭压缩大小报告

ts
export default defineConfig({
  build: {
    reportCompressedSize: false,
  }
})

安装时忽略钩子脚本的执行

package.json 中设置了:

json
{
  "scripts": {
    "prepare": "husky install"
  }
}

钩子脚本在安装前后会自动执行,此时评估如果钩子函数不影响安装与打包的过程,那就可以通过以下命令,在安装时忽略这些脚本的执行,提高安装速度,从而提升整个打包环节的速度。

bash
# pnpm
pnpm i --ignore-scripts

# npm
npm i --ignore-scripts

# yarn
yarn install --ignore-scripts