Webpack优化——将你的构建效率提速翻倍

0. 背景

随着构建体系不断完善、构建体验不断优化,webpack 已经逐渐成为了前端构建体系的一大霸主,对于工做中的真正意义上的前端工程项目,webpack 已经成为了咱们前端构建技术选型的不二选择,包括 create-react-app 以及 vue-cli 等等业内常见的脚手架工具的构建体系,也都是基于 webpack 进行了上层封装。但随着业务代码不断增长,项目深度不断延伸,咱们的构建时长也会所以不断增长。渐渐的,总会有人抛出这样的结论:webpack 构建太慢了、太“重”了。就以笔者本次近期为团队优化的项目为例,以下图所示,咱们能够看到,随着项目的不断堆砌以及一些不正确的引用,团队内的项目单次构建时长已经达到了40s,这就形成了工程师若是须要重启 devServer 或者执行 build,都会形成很很差的体验。css

而通过优化后,二次启动的时长能接近8s。那是什么样的神仙操做能有如此效果呢?不着急,咱们一步步往下看,只要你跟着个人步骤,或许只须要一个晚上,你也能将大家的团队项目的构建体系作出进一步优化。html

不过在正文开始以前,首先须要提早说明一点,本次文章介绍的构建效率提高手段是基于 webpack4 进行的,对于使用老版本的项目,如何从老版本升级到 webpack4 的流程我就不作过多介绍了,由于不管是掘金仍是各类论坛上你都能搜到太多优质的文章了,因此对于大部分的基础知识,好比 webpack-dev-server 相关配置,还有一些常见的 plugin,在本文就不会较多说起。而对于那些持续跟进 webpack 版本的同窗,我相信大家也知道现阶段 webpack5 也已经呼之欲出了,下图是官方给出的里程碑进度,趁着目前只更新到64%,笔者赶忙先发一波软文,或许到了5的时代,笔者今天所介绍的优化方式,或许都已经被集成到 webpack 自身的体系中了,谁让它一每天都在不断变好呢😊。前端

这一年来,前先后后忙于毕业和工做,在掘金里的角色逐渐从一名做者转变为读者。现在工做和生活也基本趋于稳定,笔者也但愿能像曾经那样,将本身的工做学习积淀,与你们悉数分享。做为笔者回归掘金的开篇之做,但愿在看完这篇文章后,可以让你们在工做中,对于现在前端而言不可或缺的构建体系,有新的认知以及更为大胆的尝试。固然,若是在这过程当中遇到了问题,我也特别欢迎能和你们一块交流、一块学习、一块进步。vue

闲话很少说,接下来就进入我们本次的正题。node

本文将以笔者在实践中解决问题的思路为索引,逐步带着你们以剖析问题 -> 发现问题 -> 解决问题的流程去了解对构建体系进行优化的整个过程。react

1. 构建打点

要作优化,咱们确定得知道要从哪里作优化对吧。那在咱们的一次构建流程中,是什么拉低了咱们的构建效率呢?咱们有什么方法能够将它们测量出来呢?webpack

要解决这两个问题,咱们须要用到一款工具:speed-measure-webpack-plugin,它可以测量出在你的构建过程当中,每个 Loader 和 Plugin 的执行时长,官方给出的效果图是下面这样:git

而它的使用方法也一样简单,以下方示例代码所示,只须要在你导出 Webpack 配置时,为你的原始配置包一层 smp.wrap 就能够了,接下来执行构建,你就能在 console 面板看到如它 demo 所示的各种型的模块的执行时长。github

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
 
const smp = new SpeedMeasurePlugin();
 
module.exports = smp.wrap(YourWebpackConfig);
复制代码

小贴士:因为 speed-measure-webpack-plugin 对于 webpack 的升级还不够完善,目前(就笔者书写本文的时候)还存在一个 BUG,就是没法与你本身编写的挂载在 html-webpack-plugin 提供的 hooks 上的自定义 Plugin (add-asset-html-webpack-plugin 就是此类)共存,所以,在你须要打点以前,若是存在这类 Plugin,请先移除,不然会产生如我这篇 issue 所提到的问题。web

能够断言的是,大部分的执行时长应该都是消耗在编译 JS、CSS 的 Loader 以及对这两类代码执行压缩操做的 Plugin 上,若是你的执行结果和我所说的同样,请不要吝啬你的手指,为个人文章点个赞吧😁。

为何会这样呢?由于在对咱们的代码进行编译或者压缩的过程当中,都须要执行这样的一个流程:编译器(这里能够指 webpack)须要将咱们写下的字符串代码转化成 AST(语法分析树),就是以下图所示的一个树形对象:

显而易见,编译器确定不能用正则去显式替换字符串来实现这样一个复杂的编译流程,而编译器须要作的就是遍历这棵树,找到正确的节点并替换成编译后的值,过程就像下图这样:

这部分知识我在以前的一篇文章 Webpack揭秘——走向高阶前端的必经之路 中曾详细介绍过,若是你有兴趣了解,能够翻阅噢~

你们必定还记得曾经在学习《数据结构与算法》或者是面试时候,被树形结构的各类算法虐待千百遍的日子吧,你必定也还记得深度优先遍历和广度优先遍历的实现思路对吧。可想而知,之因此构建时长会集中消耗在代码的编译或压缩过程当中,正是由于它们须要去遍历树以替换字符或者说转换语法,所以都须要经历"转化 AST -> 遍历树 -> 转化回代码"这样一个过程,你说,它的时长能不长嘛。

2. 优化策略

既然咱们已经找到了拉低咱们构建速率的“罪魁祸首”,接下来咱们就点对点逐个击破了!这里,我就直接开门见山了,既然咱们都知道构建耗时的缘由,天然就能得出针对性的方略。因此咱们会从四个大方向入手:缓存、多核、抽离以及拆分,你如今看到这四个词或许脑海里又能浮现出了一些熟悉的思路,这很棒,这样的话你对我接下来将介绍的手段必定就能更快理解。

2.1. 缓存

咱们每次的项目变动,确定不会把全部文件都重写一遍,可是每次执行构建却会把全部的文件都重复编译一遍,这样的重复工做是否能够被缓存下来呢,就像浏览器加载资源同样?答案确定是能够的,其实大部分 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 开启多核编译功能,这也是咱们下一章节立刻就会讲到的知识。而另外一款咱们比较经常使用于压缩 CSS 的插件—— optimize-css-assets-webpack-plugin,目前我还未找到有对缓存和多核编译的相关支持,若是读者在这块领域有本身的沉淀,欢迎在评论区提出批正。

小贴士:目前而言笔者暂不建议将缓存逻辑集成到 CI 流程中,由于目前还仍会出现更新依赖后依旧命中缓存的状况,这显然是个 BUG,在开发机上咱们能够手动删除缓存解决问题,但在编译机上过程就要麻烦的多。为了保证每次 CI 结果的纯净度,这里建议在 CI 过程当中仍是不要开启缓存功能。

2.2. 多核

这里的优化手段你们确定已经想到了,天然是咱们的 happypack。这彷佛已是一个老生常谈的话题了,从3时代开始,happypack 就已经成为了众多 webpack 工程项目接入多核编译的不二选择,几乎全部的人,在提到 webpack 效率优化时,怎么样也会说出 happypack 这个词语。因此,在前端社区繁荣的今天,从 happypack 出现的那时候起,就有许多优秀的质量文如雨后春笋般层出不穷。因此今天在这里,对于 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 便可,因此赶忙用 happypack 对那些你测出来的代价比较昂贵的 loaders 们作一层多核编译的包装吧。

而对于一些编译代价昂贵的 webpack 插件,通常都会提供 parallel 这样的配置项供你开启多核编译,所以,只要你善于去它的官网发现,必定会有意想不到的收获噢~

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',
          ],
        }),
    ],
}
复制代码

2.3. 抽离

对于一些不常变动的静态依赖,好比咱们项目中常见的 React 全家桶,亦或是用到的一些工具库,好比 lodash 等等,咱们不但愿这些依赖被集成进每一次构建逻辑中,由于它们真的太少时候会被变动了,因此每次的构建的输入输出都应该是相同的。所以,咱们会设法将这些静态依赖从每一次的构建逻辑中抽离出去,以提高咱们每次构建的构建效率。常见的方案有两种,一种是使用 webpack-dll-plugin 的方式,在首次构建时候就将这些静态依赖单独打包,后续只须要引用这个早就被打好的静态依赖包便可,有点相似“预编译”的概念;另外一种,也是业内常见的 Externals的方式,咱们将这些不须要打包的静态资源从构建逻辑中剔除出去,而使用 CDN 的方式,去引用它们。

那对于这两种方式,咱们又该如何选择呢?

2.3.1.webpack-dll-plugin 与 Externals 的抉择

团队早期的项目脚手架使用的是 webpack-dll-plugin 进行静态资源抽离,之因此这么作的缘由是由于原先也是使用的 Externals,可是因为公司早期 CDN 服务并不成熟,项目使用了线上开源的 CDN 却由于服务不稳定致使了团队项目出现问题的状况,因此在一次迭代中统一替换成了 webpack-dll-plugin,但随着公司创建起了成熟的 CDN 服务后,团队的脚手架却由于各类缘由迟迟没再更新。

而我,是坚决的 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 都是你网站的骨架,少了他们但是连站点都运行不起来了噢。

2.3.2.如何更为优雅地编写 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 文件便可,最后的包路径,通常都是一个固定的值。

具体代码实现我就不做详细介绍了,团队在项目脚手架更新迭代期间,笔者已经根据公司提供的 CDN 服务定制了一款 Webpack 插件,实现逻辑就如上述所示,因此后续工程师们就再也不须要去关注同步 script 标签了,一切都被集成进 Plugin 逻辑自动化处理了,固然,你们若是对插件的源码有兴趣,能够在评论区提出噢~笔者会考虑做为团队的开源项目贡献给社区。

2.4. 拆分

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

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

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

3. 提高体验

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

3.1. progress-bar-webpack-plugin

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

3.2. webpack-build-notifier

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

3.3. webpack-dashboard

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

4. 总结

综上所述,其实本质上,咱们对与webpack构建效率的优化措施也就两个大方向:缓存和多核。缓存是为了让二次构建时,不须要再去作重复的工做;而多核,更是充分利用了硬件自己的优点(我相信现现在你们的电脑确定都是双核以上了吧,我本身这台公司发的低配 MAC 都有双核),让咱们的复杂工做都能充分利用咱们的 CPU。而将这两个方向化为实践的主角,也是咱们前面介绍过的两大王牌,就是:cache-loaderhappypack,因此你只要知道它并用好它,那你就能作到更好的构建优化实践。因此,别光看看,快拿着你的项目动手实践下,让你优化后的团队项目在你的 leader 面前眼前一亮吧!

可是,你们必定要记着,这些东西并非说用了效果就必定会是最好的,咱们必定要切记把它们用在刀刃上,就是那些在第一阶段咱们经过打点得出的构建代价高昂的 Loader 或者 Plugin,由于咱们知道,像本地缓存就须要读写硬盘文件,系统IO须要时间,像启动多核也须要 IPC 通讯时间,也就是说,若是原本构建时长就不长的模块,有可能由于添加了缓存或者多核会有得不偿失的结果,所以这些优化手段也须要合理的分配和使用。

现在,webpack 自身也在不断的迭代与优化,它早就已经不是两三年前那个一直让咱们吐槽构建慢、包袱重的构建新星了,之因此会成为主流,也正是由于 webpack 团队已经在效率及体验上为咱们作出不少了,而咱们须要作的,已经不多了,并且我坚信,未来还会更少。

最后,附上一段硬广,若是你有兴趣加入咱们,和咱们一块儿探寻前端的奥秘,请戳👇👇👇下方图片👇👇👇便可打开个人内推连接噢~

若是你想了解咱们的团队,也能够点击这个连接查看详情噢👉:字节跳动最“挣钱”的前端团队招人啦~

相关文章
相关标签/搜索