性能优化

122 阅读9分钟

打包阶段

编译/压缩耗时长

大部分的执行时长应该都是消耗在编译 JS、CSS 的 Loader 以及对这两类代码执行压缩操作的 Plugin 上。

image.png

之所以构建时长会集中消耗在代码的编译或压缩过程中,正是因为它们需要去遍历树以替换字符或者说转换语法,因此都需要经历"转化 AST -> 遍历树 -> 转化回代码"这样一个过程

优化方向

  1. 缓存
  2. 多核
  3. 抽离
  4. 拆分

缓存

每次执行构建却会把所有的文件都重复编译一遍,这样的重复工作是否可以被缓存下来呢,就像浏览器加载资源一样?

大部分 Loader 都提供了 cache 配置项,比如在 babel-loader 中,可以通过设置 cacheDirectory 来开启缓存,这样,babel-loader 就会将每次的编译结果写进硬盘文件(默认是在项目根目录下的node_modules/.cache/babel-loader目录内,当然你也可以自定义)

如果 loader 不支持缓存呢?我们也有方法。接下来介绍一款神器:cache-loader ,它所做的事情很简单,就是 babel-loader 开启 cache 后做的事情,将 loader 的编译结果写入硬盘缓存,再次构建如果文件没有发生变化则会直接拉取缓存。而使用它的方法很简单,正如官方 demo 所示,只需要把它卸载在代价高昂的 loader 的最前面即可:

module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: ['cache-loader', ...loaders],
        include: path.resolve('src'),
      },
    ],
  },
};

小贴士cache-loader 默认将缓存存放的路径是项目根目录下的 .cache-loader 目录内,我们习惯将它配置到项目根目录下的 node_modules/.cache 目录下,与 babel-loader 等其他 Plugin 或者 Loader 缓存存放在一块

同理,同样对于构建流程造成效率瓶颈的代码压缩阶段,也可以通过缓存解决大部分问题,以 uglifyjs-webpack-plugin 这款对于我们最常用的 Plugin 为例,它就提供了如下配置:

module.exports = {
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
      }),
    ],
  },
};

通过开启 cache 配置开启我们的缓存功能,也可以通过开启 parallel 开启多核编译功能。

多核

这里的优化手段大家肯定已经想到了,自然是我们的 happypack。这似乎已经是一个老生常谈的话题了,从3时代开始,happypack 就已经成为了众多 webpack 工程项目接入多核编译的不二选择,几乎所有的人,在提到 webpack 效率优化时,怎么样也会说出 happypack 这个词语。

const HappyPack = require('happypack')
const os = require('os')
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })

module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'happypack/loader?id=js',
      },
    ],
  },
  plugins: [
    new HappyPack({
      id: 'js',
      threadPool: happyThreadPool,
      loaders: [
        {
          loader: 'babel-loader',
        },
      ],
    }),
  ],
}

用 happypack 提供的 Plugin 为你的 Loaders 做一层包装就好了,向外暴露一个id ,而在你的 module.rules 里,就不需要写loader了,直接引用这个 id 即可。

PS:这里需要特别提及一个在 production 模式下容易遇到的坑,因为有个特殊的角色出现了 —— mini-css-extract-plugin,坑在哪呢?有两点(这也是笔者在书写本文时还未解决的问题):

  1. MiniCssExtractPlugin 无法与 happypack 共存,如果用 happypack 对 MiniCssExtractPlugin 进行包裹,就会触发这个问题:github.com/amireh/happ…
  2. MiniCssExtractPlugin 必须置于 cache-loader 执行之后,否则无法生效,参考issue:github.com/webpack-con…

所以最后,在 production 模式下的 CSS Rule 配置就变成了下面这样:

module.exports = {
    ...,
    module: {
        rules: [
            ...,
            {
                test: /.css$/
                exclude: /node_modules/,
                use: [
                    _mode === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader,
                    'happypack/loader?id=css'
                ]
            }
        ]
    },
    plugins: [
        new HappyPack({
          id: 'css',
          threadPool: happyThreadPool,
          loaders: [
            'cache-loader',
            'css-loader',
            'postcss-loader',
          ],
        }),
    ],
}

抽离

设法将这些依赖从每一次的构建逻辑中抽离出去,以提升我们每次构建的构建效率。

常见的方案有两种,一种是使用 webpack-dll-plugin 的方式,在首次构建时候就将这些静态依赖单独打包,后续只需要引用这个早就被打好的静态依赖包即可,有点类似“预编译”的概念;另一种,也是业内常见的 Externals的方式,我们将这些不需要打包的静态资源从构建逻辑中剔除出去,而使用 CDN 的方式,去引用它们。

webpack-dll-plugin 与 Externals 的抉择

webpack-dll-plugin 的三宗原罪:

  1. 需要配置在每次构建时都不参与编译的静态依赖,并在首次构建时为它们预编译出一份 JS 文件(后文将称其为 lib 文件),每次更新依赖需要手动进行维护,一旦增删依赖或者变更资源版本忘记更新,就会出现 Error 或者版本错误。

  2. 无法接入浏览器的新特性 script type="module",对于某些依赖库提供的原生 ES Modules 的引入方式(比如 vue 的新版引入方式)无法得到支持,没法更好地适配高版本浏览器提供的优良特性以实现更好地性能优化。

  3. 将所有资源预编译成一份文件,并将这份文件显式注入项目构建的 HTML 模板中,这样的做法,在 HTTP1 时代是被推崇的,因为那样能减少资源的请求数量,但在 HTTP2 时代如果拆成多个 CDN Link,就能够更充分地利用 HTTP2 的多路复用特性。口说无凭,直接上图验证结论:

    • 使用 webpack-dll-plugin 生成的 lib 文件,整体资源作为一个文件加载,需要 400 多毫秒

    • 使用 Externals 配合 HTTP2,所有资源并行加载,整体时长不超过 100ms

这,就是我选择 Externals 的原因。

但是,如果你的公司没有成熟的 CDN 服务,但又想对项目中的静态依赖进行抽离该怎么办呢?那笔者的建议还是选择 webpack-dll-plugin 来优化你的构建效率。如果你还是觉得每次更新依赖都需要去维护一个 lib 文件特别麻烦,那我还是特别提醒你,在使用 Externals 时选择一个靠谱的 CDN 是一件特别重要的事,毕竟这些依赖比如 React 都是你网站的骨架,少了他们可是连站点都运行不起来了噢。

如何更为优雅地编写 Externals

我们都知道,在使用 Externals 的时候,还需要同时去更新 HTML 里面的 CDN,有时候时常会忘记这一过程而导致一些错误发生。那作为一名追求极致的前端,我们是否可以尝试利用现有资源将这一过程自动化呢?

这里我就给大家提供一个思路,我们先来回顾及分析一下,在我们配置 Externals 时,需要配置那些部分。

首先,在 webpack.config.js 配置文件内,我们需要添加 webpack 配置项:

module.exports = {
  ...,
  externals: {
    // key是我们 import 的包名,value 是CDN为我们提供的全局变量名
    // 所以最后 webpack 会把一个静态资源编译成:module.export.react = window.React
    "react": "React",
    "react-dom": "ReactDOM",
    "redux": "Redux",
    "react-router-dom": "ReactRouterDOM"
  }
}

与此同时,我们需要在模板 HTML 文件中同步更新我们的 CDN script 标签,一般一个常见的 CDN Link 就像这样:

https://cdn.bootcss.com/react/16.9.0/umd/react.production.min.js

这里以 BootCDN 提供的静态资源 CDN 为例(但不代表笔者推荐使用 BootCDN 提供的 CDN 服务,它上次更换域名的事件可真是让我踩了不少坑),我们可以发现,一份 CDN Link 其实主要也只是由四部分组成,它们分别是:CDN 服务 host、包名、版本号以及包路径,其他 CDN 服务也是同理。以上面的 Link 为例,这四部分对应的内容就是:

  • CDN 服务 host:cdn.bootcss.com/
  • 包名:react
  • 版本号:16.9.0
  • 包路径:umd/react.production.min.js

到了这一步,大家应该想到了吧。我们完全可以自己编写一个 webpack 插件去自动生成 CDN Link script 标签并挂载在 html-webpack-plugin 提供的事件钩子上以实现自动注入 HTML,而我们所需要的一个 CDN Link 的四部分内容,CDN 服务 host 我们只需要与公司提供的服务统一即可,包名我们可以通过 compiler.options.externals 拿到,而版本号我们只需要读取项目的 package.json 文件即可,最后的包路径,一般都是一个固定的值。

拆分

虽然说在大前端时代下,SPA 已经成为主流,但我们不免还是会有一些项目需要做成 MPA(多页应用),得益于 webpack 的多 entry 支持,因此我们可以把多页都放在一个 repo 下进行管理和维护。但随着项目的逐步深入和不断迭代,代码量必然会不断增大,有时候我们只是更改了一个 entry 下的文件,但是却要对所有 entry 执行一遍构建,因此,这里为大家介绍一个集群编译的概念:

什么是集群编译呢?这里的集群当然不是指我们的真实物理机,而是我们的 docker。其原理就是将单个 entry 剥离出来维护一个独立的构建流程,并在一个容器内执行,待构建完成后,将生成文件打进指定目录。为什么能这么做呢?因为我们知道,webpack 会将一个 entry 视为一个 chunk,并在最后生成文件时,将 chunk 单独生成一个文件,

因为如今团队在实践前端微服务,因此每一个子模块都被拆分成了一个单独的repo,因此我们的项目与生俱来就继承了集群编译的基因,但是如果把这些子项目以 entry 的形式打在一个 repo 中,也是一个很常见的情况,这时候,就需要进行拆分,集群编译便能发挥它的优势。因为团队里面不需要进行相关实践,因此这里笔者就不提供细节介绍了,只是为大家提供一个方向,如果大家有疑问也欢迎在评论区与我讨论。

提升构建体验的插件

这里主要是介绍几款 webpack 插件来帮助大家提升构建体验,虽然说它们在提升构建效率上对你没有什么太大的帮助,但能让你在等待构建完成的过程中更加舒服。

1. progress-bar-webpack-plugin

这是一款能为你展示构建进度的 Plugin,它的使用方法和普通 Plugin 一样,也不需要传入什么配置。下图就是你加上它之后,在你的终端面板上的效果,在你的终端底部,将会有一个构建的进度条,可以让你清晰的看见构建的执行进度:

2. webpack-build-notifier

这是一款在你构建完成时,能够像微信、Lark这样的APP弹出消息的方式,提示你构建已经完成了。也就是说,当你启动构建时,就可以隐藏控制台面板,专心去做其他事情啦,到“点”了自然会来叫你,它的效果就是下面这样,同时还有提示音噢~

3. webpack-dashboard

当然,如果你对 webpack 原始的构建输出不满意的话,也可以使用这样一款 Plugin 来优化你的输出界面,它的效果就是下面这样,这里我就直接上官图啦:

其他

1