# 构建优化方案

# webpack 能做什么

在整理 Webpack 能做什么之前,先捊一下 Webpack 本身已经做了什么?

  • 让开发者可以使用模块化的形式组织项目

  • 实现项目组件化

  • 可以使用新的 JS 语法

  • 预处理样式、图片等资源

  • 模块热更新

  • 集成 terserPlugin,压缩代码、Tree Shaking、Scope Hoisting

回到正题 Webpack 构建优化主要从两个角度出发:

  • 构建速度方面的优化

  • 开发效率方面的优化

# 打包速度方面的优化

# 缩小搜索范围

webpack工作本身就是一个不断检索文件、转换文件的过程, 所以其中包含大量的检索工作

缩小Loader应用范围

配置 Loader 时合理得使用 testexcludeinclude 属性,优化要使用 Loader 的文件,尽可能少的让文件被Loader处理

module.exports = {
    module: {
        rules:[
            {
                test: /\.js$/, //如果项目源码中只有JS文件,就不要写成/\.jsx?$/,以提升正则表达式的性能
                use: ['babel-loader?cacheDirectory'], // label-loader支持缓存转换出的结果,通过cacheDirectory来启动
                include: path.resolve(__dirname, 'src') // 只对项目根目录下的src目录中的文件采用babel-loader
            }
        ]
    }
}

优化 resolve.modules 配置

resolve.modules 作用:配置 Webpack 去哪个目录寻找第三方模块,Webpack 默认会在当前项目根目录的node_modules 下寻找第三方模块,如果当前 node_modules 没有找到对应的模块则去上一级目录下的node_modules中找,以此类推。

在实际的开发使用场景,我们并不需要一层层地寻找,因此我们可以指定第三方模块的目录,减少 Webpack 的寻找,配置如下

module.exports = {
    resolve: {
    //  dirname表示当前工作目录,也就是项目根目录
        modules: [path.resolve(__dirname, 'node_modules')]
    }
}

优化 resolve.extensions 配置

默认情况下,Webpack 寻找没有带后缀的文件时,会自动带上后缀去寻找,默认是通过 resolve.extension 配置的后缀列表按顺序去寻找,默认是:

extensions:['.js', '.json']

因为我们可以根据以下情况去优化 resolve.extensions

  • 没使用到的后缀名不列表入 resolve.extensions

  • 高频后缀名放在前面

  • 在引入文件时,不要忽略后缀名,将后缀名书完整,避免让 Webpack 自己去匹配

module.exports = {
  resolve: {
    extensions: ['js']
  }
}

优化 resolve.noParse 配置

对于没有模块化的文件,通过这个配置让 Webpack 忽略对这些文件的递归解析,以此提高构建性能。比如 JQueryChartJs 等没用采用模块化的方式,让 Webpack 解析这些文件即耗时又没有意义

module.exports = {
  resolve: {
    noParse: [/react\.min\.js$/]
  }
}

优化resolve.alias配置

resolve.alias 作用:通过别名将原来导入第三方模块的路径修改为新的路径

有些第三方模块安装时,会有多套代码,比如 React 有个 dist 目录,是将所有代码打包好的放在一个单独的文件中,这个文件没有被模块化,可以直接引入执行

另一个是 lib 目录,是一套采用 CommonJs 规范的模块化代码,以 package.json 中指定的入口文件 react.js 为模块的入口

默认情况下,Webpack 引入 lib 目录下的文件时,会递归解析和处理这些文件,这会是一个耗时的操作。这时可以通过resolve.alias 来配置使用单独的完整的 dist 下的文件,减少搜索时间

module.exports = {
  resolve: {
    alias: {
      'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js')
    }
  }
}

但是,使用这种优化虽然可以减少搜索,但是会引入模块中没用到的代码,这不利于 Tree-Sharking 使用,比如在使得 lodash 时,我们可能只用到部分功能,如果使用这个配置优化,将会把所有包括没用到的也引用项目当中

# 预编译

对于比较常用又不常更新的模块可以先将其进行打包,然后在项目中直接引这些库,省去 webpack 对这些模块的编译处理。DllPlugin 就是这么一个插件

# 认识DllPlugin

对于比较常用又不常更新的模块可以使用 DllPlugin 插件将其进行打包,项目中直接引这些库,不需要进行对这些模块做编译打包处理,如 reactreact-dom,所以只要不升级这些模块的版本,动态链接库就不用重新编译。

使用 DllPlugin,需要完成以下几件事情

  • 将网页依赖的基础模块抽离出来,打到一个个单独的动态链接库中。在一个动态链接库可以包含多个模块

  • 将需要导入的模块存在于动态链接库时,不需要重新打包,而是从动态链接库直接导入

  • 页面依赖的所有动态链接库都需要被加载

Webpack中使用dll

Webpack中使用 dll 需要用到两个插件: DllPluginDllReferencePlugin

  • DllPlugin 插件:用于打包一个个单独的动态链接库文件。

  • DllReferencePlugin 插件:用于在主要的配置文件中引入 DllPlugin 插件打包好的动态链接库文件

以 React 项目为例,为其接入 DllPlugin。最终构建出的目录结构:

|-- main.js
|-- polyfill.dll.js
|-- polyfill.mainfest.json
|-- react.dll.js
|-- react.mainifest.json

其中包含两个动态链接库文件

  • polyfill.dll.js :里面包含项目所有依赖的 polyfill,例如 PromisefetchApi

  • react.dll.js:里面包含 React 的基础运行环境,即 reactreact-dom 模块

react.dll.js 文件为例,其文件内容大致如下:

var _dll_react = (function (modules){
  // ......
})(
  function(module, exports, __webpack_require__){
    // ID为0的模块对应的代码
  }function(module, exports, __webpack_require__){
    // ID为1的模块对应的代码
  }
  // .....
])

可见,一个动态链接库包含了大量模块的代码,这些模块被存放在一个数组里,用数组的索引号作为 ID,并且通过 __dll_react_ 变量将自己暴露在全局中,即可以通过 window.__dll_react 访问到其中包含的模块。

其中 polyfill.mainifest.jsonreact.mainfest.json 文件也是由 DllPlugin 生成的,用于描述在动态链接库文件中包含哪些模块

main.js 文件是被编译出来的执行入口文件,在遇到其依赖的模块在 dll.js 文件中时,会直接通过 dll.js 文件暴露的全局变量获取打包在 dll.js 文件中的模块,所以在 index.html 文件中需要将依赖的两个 dll.js 文件加载进去。 此时 index.html 内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>

</body>
<!--导入依赖的动态链接库-->
<sciprt src="./dist/polyfill.dll.js"></sciprt>
<sciprt src="./dist/react.dll.js"></sciprt>
<!--导入执行的入口文件-->
<sciprt src="./dist/main.js"></sciprt>
</html>

webpack打包生成dll

动态链接库文件相关的文件需要由一份独立的构建输出,用于主构建使用。新建一个 Webpack 配置文 件webpack_dll.deploy.config.js 专门构建他们。

  const path = require('path')
  const DllPlugin = require('webpack/lib/DllPlugin')
  modules.exports = {
    // JavaScript执行文件
    entry: {
      // 将React相关的模块放到一个单独的动态链接库中
      react:['react', 'react-dom'],
      // 将项目需要所有的polyfill放到一个单独的动态链接库中
      polyfill:['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
    },
    output: {
      // 输出的动态链接库的文件名称,[name]代表当前动态链接库的名称
      filename: '[name].dll.js',
      // 将输出的文件都放到dist目录下
      path: path.resolve(__dirname, 'dist'),
      // 设置动态链接库全局变量名,对于react就是_dll_react
      // 加上前缀_dll是为了防止与其它全局变量冲突
      library: '_dill_[name]'
    },
    plugins:[
      // 接入DllPlugin
      new DllPlugin({
        // 动态链接库的全局变量名称,需要和output.library中的保持一致
        // 该字段的值也就是输出mainfest.json文件中name字段的值
        // 例如在react_mainfest.jon中就有"name":"_dii_react"
        name: '_dll_[name]',
        // 描述动态链接库的mainfest.json文件输出时的文件名称
        path: path.join(__dirname,'dist', '[name].mainfest.json')
      })
    ]
  }

然后生成dll文件 webapck --config webpack_dll.deploy.config.js

主配置使用动态链接库文件

用于输出 main.js 的主 Webpack 配置文件的内容如下:

  const path = require('path')
  const DllReferencePlugin = require('webpack/lib/DllReferencePlugin.js')
  module.exports = {
    entry: {
      // 定义入口Chunk
      main: './main.js'
    },
    output:{
      // 输出文件的名称
      filename: '[name].js',
      path: path.resolve(__dirname, 'dist'),
    },
    module: {
      rules: [
        {
          // 项目源码使用了ES6和JSX语法,需要使用babel-loader转换
          test: /\.js$/,
          use: ['babel-loader'],
          exclude: path.resolve(__dirname, 'node_modules')
        }
      ]
    },
    plugins: [
      // 告诉 Webpack使用了哪些链接库
      new DllReferencePlugin({
        // 描述react动态链接库的文件内容
        mainfest: require('./dist/react.mainfest.json')
      }),
      // 告诉 Webpack使用了哪些链接库
      new DllReferencePlugin({
        // 描述polyfill动态链接库的文件内容
        mainfest: require('./dist/polyfill.mainfest.json')
      })
    ],
    devtool: 'source-map'
  }

注意:

webapck_dll.deploy.config.js 文件中,DllPlugin 中的 name 参数必须和 output.library 中的保持一致。原因在于 DllPlugin 中的 name 参数会影响输出mainfest.json 文件中的name字段的值。而在 webapck.deploy.config.js 文件中,DllReferencePlugin 会去 mainfest.json 文件中读取 name 字段的值,将值的内容作为在全局变量中获取动态链接库的内容时的全局变量名

# 缓存

充分利用缓存提升二次构建速度

# babel-loader 开启缓存

{
    test: /\.js$/,
    use: [
        {
            loader: 'babel-loader',
            options: {
                cacheDirectory: true
            }
        }
    ]
}

# cache-loader

cache-loader 的配置很简单,放在其他 loader 之前即可。修改Webpack 的配置如下:

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

# hard-source-webpack-plugin

HardSourceWebpackPlugin 为模块提供了中间缓存

yarn add -D hard-source-webpack-plugin

// webpack.config.js
var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  entry: // ...
  output: // ...
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}

# 开启多进程任务

Webpack 构建过程就是对文件进行解析和转换,所以构建是谁的读写和计算密集型的。运行在 Node.js 之后的 Webpack 是单线程模型的,也就是说 Webpack 需要一个一个地处理任务,不能同时处理多个任务。

开启多进程任务后能让 Webpack 在同一时刻处理多个任务,发挥多核 CPU 电脑的功能,他将任务分到给多个子进程去并发执行,子进程处理完后再将结果发送给主进程。

# 使用HappyPack

HappyPack 的作用就是开启多进程来对文件进行解析和转换

  const path = require('path')
  const ExtractTextPlugin = require('extract-text-webpack-plugin')
  const HappyPack = require('happypack')
  
  module.exports = {
    module: {
      rules: [
        {
          test: /\.js$/,
          // 将js文件的处理交给id为babel的happypack实例
          use: ['happypack/loader?id=babel'],
          // 排除node_modules下的目录,因为node_modeles目录下的文件采用了ES5语法,没必要再通过Babel去转换
          exclude: path.resolve(__dirname, 'node_modules'),
        },
        {
          test: /\.css/,
          // 将css文件的处理交给id为babel的happypack实例
          use: ExtranctTextPlugin.extract({
            use: ['happypack/loader?id=css']
          }),
        }
      ]
    },
    plugins: [
      new HappyPack({
        // 定义唯一标识符id
        id: 'babel',
        // 定义这个HappyPack使用哪个loader,用法和Loader配置一样
        loaders: ['babel-loader?cacheDirectory']
      }),
      new HappyPack({
        id: 'css',
        loaders: ['css-loader']
      }),
      new ExtractTextPlugin({
        filename: '[name].css'
      })
    ]
  }

以上配置做了以下几件事情:

  • 在 Loader 配置,将文件的处理将给 happypack/loader ,使用时后面紧跟的 querystring?id=bable 表示使用哪个 HappyPack 实例

  • Plugin 配置中,添加了两个 HappyPack 实例,分别配置了不同的 loaders,用于告诉 happypack/loader 如何处理 .js.css 文件。id 属性的值和上面 querystring?id=bable 对应

在实例 HappyPack 时,除了可以传入 idloaders 外还支持以下属性

  • threads:代表开启几个子进程去处理这一类的文件,默认是 3 个,必须是整数

  • verbose:是否允许 HappyPack 输出日志,默认开户

  • threadPool:代表共享进程池,即多个 HappyPay 实例都使用同一个共享进程池的子进程去处理任务,以防止资源占用太多,相关代码如下

  const path = require('path')
  const ExtractTextPlugin = require('extract-text-webpack-plugin')
  const HappyPack = require('happypack')
  const happyThreadPool = HappyPack.ThreadPool({size: 5})

  module.exports = {
    module: {
      rules: [
        {
          test: /\.js$/,
          // 将js文件的处理交给id为babel的happypack实例
          use: ['happypack/loader?id=babel'],
          // 排除node_modules下的目录,因为node_modeles目录下的文件采用了ES5语法,没必要再通过Babel去转换
          exclude: path.resolve(__dirname, 'node_modules'),
        },
        {
          test: /\.css/,
          // 将css文件的处理交给id为babel的happypack实例
          use: ExtranctTextPlugin.extract({
            use: ['happypack/loader?id=css']
          }),
        }
      ]
    },
    plugins: [
      new HappyPack({
        // 定义唯一标识符id
        id: 'babel',
        // 定义这个HappyPack使用哪个loader,用法和Loader配置一样
        loaders: ['babel-loader?cacheDirectory'],
        // 使用共享进程池中的子进程去处理任务
        threadPool: happyThreadPool
      }),
      new HappyPack({
        id: 'css',
        loaders: ['css-loader'],
        // 使用共享进程池中的子进程去处理任务
        threadPool: happyThreadPool
      }),
      new ExtractTextPlugin({
        filename: '[name].css'
      })
    ]
  }

接入 HappyPack 后,需要为项目安装新的依赖:

npm i -D HappyPack

安装成功后重新执行构建,如果有看到由 HappyPack 输出的日志说明 HappyPack 配置生效了

HappyPack原理

每通过 new HappyPack 实例化一个 HappyPack,并定义该实例使用哪些 Loader 去转换一类文件,并且可以指定如何为这类转换操作分配进程。

核心调度器的逻辑代码在主进程中,也就是运行 Webpack 的进程中,核心调度器会将一个个任务分配给当前空闲的子进程中,子进程处理完后将结果发送给核心调度器,它们之间的数据交换是通过进程间的通信 API 完成的。

核心调度器收到来自子进程处理完毕的结果后,会通知 Webpack 该文件已处理完毕。

# thread-loader

作用同 HappyPack,也是将您的 loader 放置在一个 worker 池里面运行,以达到多线程构建。 把这个 loader 放置在其他 loader 之前, 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池( worker pool )中运行

// vue.deploy.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // 你的高开销的loader放置在此 (e.g babel-loader)
        ]
      }
    ]
  }

# terser-webpack-plugin开启多进程压缩

new UglifyJSPlugin({
    cache: true, // 开启缓存
    parallel: true, // 开启进程并行压缩
    include: /\/includes/, // 缩小压缩文件范围
    exclude: /\/excludes/, // 缩小压缩文件范围
}),

也可以使用webpack-paralle-uglify-plugin插件

# 开发效率优化

# 文件自动监听

文件监听是发现源码文件发生变化时,自动重新编译出新的输出文件

文件监听的相关配置:

module.exports = {
    // 开启监听模式,默认是false
    watch: true,
    // 前提是要watch为true,要不然没意义
    // 监听模式运行时的参数
    watchOptions: {
        // 不监听的文件或文件夹,支持正则匹配
        ignored: /node_modules/,
        // 监听到变化后等300ms再去执行,截流
        aggregateTimeout: 300,
        // 判断文件是否发生变化是通过不断询问系统指定文件有没有更新实现的
        // 默默每秒询问1000次
        poll: 1000
    }
}

Webpack 开启文件监听有两种方式

  1. 配置文件中设置 watch: true

  2. 在执行启动Webpack的命令时带上 --watch 参数,完整的命令是 webapck --watch

# 文件监听的原理

在 Webpack 中监听一个文件是否发生变化的原理,是定时获取这个文件最后更新时间,每次都存下最新的更新时间,如果发现当前时间获取的和最后一次保存的编辑时间不一致,就认为该发生了变化 。配置项中的 watchOptions.poll 用于控制定时检查的周期,具体含义是每秒检查多少次。

怎么确定要监听的文件列表呢?

在默认情况下,Webpack 会从配置的 Entry 文件出发,递归解析 Entry 文件所依赖的文件,将这些文件加入到监听列表中。

由于保存文件的路径和最后的编辑时间需要占用内存,定时检查、周期检查需要占用 CPU 及文件 I/O,所以最好减少需要监听的文件数量和降低检查频率。

优化文件监听的性能

根据前面对文件监听的原理介绍,我们可以做以下优化:

  • 需要监听的文件列表是根据 Entry 配置的入口文件递归得将引用到的文件加入到监听列表中,其中将包含很多的 node_modules 中的模块,一般情况下我们不会修改 node_moduels 里的文件,所以我们可以忽略这个文件的监听,相关配置如下:
module.exports = {
  watchOptions: {
  // 不监听node_module中的文件
   ignored: /node_modules/}
}
  • 配置截流,watchOptions.aggregateTimeout 的值越大越好,因为这能降低重新构建的频率

  • watchOptions.pool 的值越小越好,因为这能降低检查的频率

后面这种方式的优化,负作用是监听模式的反应和灵敏度降低了

自动刷新浏览器

监听到文件的变化后,下一步就是刷新浏览器,Webpack 模块负责监听文件,webapck-dev-server 负责刷新浏览器。使用 webpack-dev-server 模块去启动 webpack 模块时,webpack 模块的监听模式默认会被开启。当 webpack 模块监听到文件变化时,会通过 webpack-dev-server 模块

# 模块热替换

模块热更新技术可以在不刷整个网页的情况下做到起灵敏实时预览。原理在一个源码发生变化时,只需要重新编辑发生变化的的模块,再用新输出的模块替换掉浏览器中对应的模块。

模块热更新的优点如下:

  • 实时预览反应更快,等待时间更短

  • 不刷新浏览器时能保留当前网页的运行状态,例如如果网页使用 Redux 做数据状态管理,那么模块热更新能做到代码更新时 Redux 中的数据操持不变

  module.exports = {
    entry: {
      // 为每个入口注客户端代码
      main: ['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server','./src/main.js'],
    },
    devServer: {
      // 告诉DevServer要开启模块热替换模式
      hot: true// 会自动引入 HotModuleReplacement
    }
  }

或者在启动时,命令行带上 --hot,其实就是自动完成以上配置

# 输出分析

对输出进行分析,我们可以更好得对症下药

在启动 Webpack 时支持如下两个参数

  • --profile: 记录构建过程中的耗时信息

  • --json:以 JSON 的格式输出构建结果 ,最后只输出一个 .json 文件输出,这个文件中包括所有构建相关的信息

在启动 Webpack 时带上以上两个参数,启动命令如下:

webpack --profile --json > stats.json

我们会发现项目中多出了一个 stats.json 文件,这个 stats.json文 件是为后面介绍的可视化分析工具使用的

webpack --profile --json 会输出字符串形式的JSON,> stats.json 是UNIX/Linux系统上的管道命令,其含义是将 webpack --profile --json 输出的内容通过管道输出到 stats.json 文件中

# 官方的可视化分析工具

Webpack 官方提供了一个可视化分析工具 Webpack Analyse(http://webpack.github.io/analyse/) (opens new window),它是一个在线的 Web 应用

根据提示将之前生成的 stats.json 上传到网站中,网站就会自动输出一些信息

该主页被分为如下6大板块。

  • Modules:展示的所有模块,每个模块对应一个文件,并且包含所有模块之间的依赖关系图、模块路径、模块ID、模块所属的 Chunk、模块的大小

  • Chunks:性需求 所有代码块,在一个代码块中包含多个模块,并且包含代码块的ID、名称、大小、每个代码块包含的模块数量 ,以及代码之间的依赖关系图

  • Assets:展示所有输出的文件资源,包括 .js.css、图片等,并且包含文件的名称、大小以及该文件来自哪个代码块

  • Warnings:展示构建过程中出现的所有警告信息

  • Errors:展示构建过程中出现 的所有错误信息

  • Hints:展示处理每个模块所耗费的时间

# webpack-bundle-analyzer

webpack-bundle-analyzer(https://www.npmjs.com/package/webpack-bundle-analyzer) (opens new window)

接入 webpack-bundle-analyzer 的方法很简单

  • 安装 webpack-bundle-analyzer 到全局,执行命令npm i -g webpack-bundle-analyzer

  • 按照上面提到的方法生成 stats.json

  • 在项目根目录执行 webpack-bundle-analyzer 后,浏览器会打开对应的网页并展现以上效果

从网页中我们可以以下信息:

  • 打包出的文件中都包含了什么

  • 每个文件的尺寸在总体中的占比,我们一眼看出哪些文件的尺寸大小

  • 模块之间的包含关系

  • 每个文件的Gzip后的大小

# speed-measure-webpack-plugin

speed-measure-webpack-plugin 插件可以测量各个插件和 loader 所花费的时间