Webpack 4 构建大型项目实践 / 优化

6,643 阅读7分钟

本文所用示例的仓库地址: gayhub

上一节我们解决了工程的开发调试问题,项目的生产和开发环境也已配置完成,还约定了 Webpack 配置文件规范。但它还很粗糙,这一节我们就来一起打磨这套配置。

开发体验优化

热模块替换

在之前的配置中我们使用使用 MiniCssExtractPlugin.loader 来代替 style-loader ,因为我们需要把 CSS 从 JS 中分离出来。但 MiniCssExtractPlugin 目前还存在一个隐患,那就是它可能会影响到 hmr (热模块替换)功能,在它对 hmr 的支持前,我们只能在生产环境中使用它。

webpack.base.conf.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        use: [
          process.env.NODE_ENV !== 'production' ?
          'vue-style-loader' : {
            loader: resolve('node_modules/mini-css-extract-plugin/dist/loader.js'),
            options: {
              publicPath: '../'
            }
          },
          {
            loader: 'css-loader',
            options: {
              importLoaders: 2 // 在 css-loader 前执行的 loader 数量
            }
          },
          'postcss-loader',
          {
            loader: 'stylus-loader',
            options: {
              preferPathResolver: 'webpack' // 优先使用 webpack 用于路径解析,找不到再使用 stylus-loader 的路径解析
            }
          }
        ]
      }
    ]
  }
}

实际上我上次使用它时看见有 hmr 配置项,就以为已经支持了,具体支持与否请看 MiniCssExtractPlugin Docs

提升构建速度:使用 DllPlugin & DllReferencePlugin 提前打包公共依赖

当项目达到一定体量,打包速度、热加载性能优化的需求就会被提出来,毕竟谁也不愿意修改后花上十几秒甚至几分钟等待修改视图更新。接下里我会介绍一些通用的优化策略,但需要注意的是,项目本身不能去踩一些无法优化的坑,已知两坑:超多页( html-webpack-plugin 热更新时更新所有页面)和动态加载未指明明确路径(打包目录下所有页面)。

DllPlugin 和 DllReferencePlugin 绝对是优化打包速度的最佳利器,它可以把部分公共依赖提前打包好,在之后的打包中就不再打包这些依赖而是直接取用已经打包好的代码,通常情况能降低 20% ~ 40% 打包时间,当然它也有缺点:

  • 需要在初始化和相关依赖更新时,额外执行一条命令
  • 通常 dll 是在 .html 文件中引入,滥用会导致首屏加载变慢

但总归来说是利大于弊。

  1. 新增 webpack.dll.conf.js

    const webpack = require('webpack')
    const { CleanWebpackPlugin } = require('clean-webpack-plugin')
    const { resolve } = require('./utils')
    
    const libs = {
      _frame: ['vue', 'vue-router', 'vuex'],
      _utils: ['lodash']
    }
    
    module.exports = {
      mode: 'production',
      entry: { ...libs },
      performance: false,
      output: {
        path: resolve('dll'),
        filename: '[name].dll.js',
        library: '[name]' // 与 DllPlugin.name 保持一致
      },
      plugins: [
        new CleanWebpackPlugin({
          cleanOnceBeforeBuildPatterns: []
        }),
        new webpack.DllPlugin({
          name: '[name]',
          path: resolve('dll', '[name].manifest.json'),
          context: resolve('')
        })
      ]
    }
    
  2. webpack.common.conf.js 使用 DllReferencePlugin

    webpack.common.conf.js

     const { generateDllReferences, generateAddAssests } = require('./utils')
    
     module.exports = {
       plugins: [
         ...generateAddAssests(),
         ...generateDllReferences()
       ]
     }
    
    # add-asset-html-webpack-plugin 用于把 dll 添加到 `index.html` 的 script 标签中
    # glob 支持正则匹配文件
    yarn add add-asset-html-webpack-plugin glob -D
    

    utils.js

     const webpack = require('webpack')
     const glob = require('glob')
     const AddAssestHtmlWebpackPlugin = require('add-asset-html-webpack-plugin')
    
     const generateDllReferences = function() {
       const manifests = glob.sync(`${resolve('dll')}/*.json`)
    
       return manifests.map(file => {
         return new webpack.DllReferencePlugin({
           // context: resolve(''),
           manifest: file
         })
       })
     }
    
     const generateAddAssests = function() {
       const dlls = glob.sync(`${resolve('dll')}/*.js`)
    
       return dlls.map(file => {
         return new AddAssestHtmlWebpackPlugin({
           filepath: file,
           outputPath: '/dll',
           publicPath: '/dll'
         })
       })
     }
    
  3. 添加 npm scripts

    package.json

     "scripts": {
       "dll": "webpack --config build/webpack.dll.conf.js"
     },
    

然后就可以用 yarn dll 打包配置好的全局公共依赖了,打包后会在 src/dll 目录生成 *dll.js*dll.json ,前者是依赖经压缩合并后的文件( mode: production ),后者是 *dll.js 文件和原始依赖的映射文件,用于被 DllReferencePlugin 解析建立引用和 *dll.js 之间的映射关系。

构建中最耗时的两步是 babel 和压缩, babel 一般会配置忽略 node_modules 所以 DllPlugin 节约的是部分公共依赖的压缩时间,所以你如果不想用 DllPlugin 也可以在 externals 中将他们配置为外部依赖,用其他方式去压缩并引入他们

提升构建速度:生成合理的 Source Map

在 webpack 4 中,是否生成 Source Map 以及生成怎样的 Source Map 是由 devtool 配置控制的,选择合理的 Source Map 可以有效的缩短打包时间。在选择前我们还是应该明白,不设置 Source Map 时打包是最快的,之所以需要 Source Map ,是因为打包后的代码结构、文件名和打包前完全不一致,当存在报错时我们只能直接定位到打包后的某个文件,无法定位到源文件,极大程度增加了调试难度。而 Source Map 就是为了增强打包后代码的可调试性而存在的,所以我们在开发环境总是需要它,在生产环境则有更多选择。

devtool 可选配置有 noneevalcheap-eval-source-map 等 13 种,各自功能和性能比较在 文档 中有详细介绍。

配置项由一个或多个单词和连字符组成,每个单词都有其含义和性能损耗,每个配置项最终意义就由这些单词决定:

  • none 不生成 Source Map ,性能 +++
  • eavl 每个模块由 eval 执行,不能正确显示行数,不能用生产模式,性能 +++
  • module 报错显示原始代码,性能 -
  • source 报错显示行列信息,显示 babel 转译后代码,性能 --
  • cheap 低开销模式,不映射列,性能 +
  • inline 不生成单独的 Source Map 文件,性能 o

开发环境

由于开发模式建议显示报错源码和行信息,所以 modulesource 都是需要的,为了性能我们又需要 evalcheap ,所以参照配置项能找到最适合开发环境的配置是 devtool: cheap-module-eval-source-map

生产环境

生产环境由于几乎不存在调试需求( JS 相关调试),所以建议大家设置 devtool: none ,在需要调试的时候再更改设置为 devtool: cheap-module-source-map

提升构建速度:其他优化

本小节中提到的优化其实几乎都是我们之前配置中的某一个默认配置

JS 多线程压缩

之前有提到过,压缩是构建中耗时占比较大的一环,我们可以启用 terser-webpack-plugin 的多线程压缩,减少压缩时间。

module.exports = {
  optimization: {
    minimizer: [
      new TerserJSPlugin({
        parallel: true // 开启多线程压缩
      })
    ]
  }
}

babel 范围限定和缓存

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        // 不转译 node_modules
        // exclude: [resolve('node_modules')]
        // 转译 src 目录下的文件
        include: [
          resolve('src')
        ],
        options: {
          cacheDirectory: true // 默认目录 node_modules/.cache/babel-loader
          // cacheDirectory: resolve('/.cache/babel-loader')
        }
      }
    ]
  }
}

vue-loader 缓存

vue-loader 的 cacheDirectory 配置项依赖 cache-loader

yarn add cache-loader -D
module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: {
          loader: 'vue-loader',
          options: {
            prettify: false,
            cacheDirectory: resolve('node_modules/.cache/vue-loader'),
            cacheIdentifier: 'vue'
          }
        }
      }
    ]
  }
}

resolve.modules 使用绝对路径

resolve.modules 告知 Webpack 解析模块时应该搜索的目录,可以设置相对路径和绝对路径。设置相对路径时,比如 resolve.modules: [node_modules] ,在解析依赖时会从当前目录向上查找,直到找到 node_modules 目录。设置绝对路径时能减少了这个遍历过程,直接定位目录。

module.exports = {
  resolve: {
    modules: [resolve('node_modules')]
  }
}

我之前也说了,这个优化项聊胜于无。

使用 ES6

ES6 不仅在原有对象上添加了一些常用方法,还新增了一些新的词法和语法给开发者带来了极大便利,它自然是不能缺席本项目的。但不同浏览器、不同版本对 ES6 的支持不一致,导致使用 ES6 是还存在些许阻碍,我们需要用 babel-loader 把 ES6 的词法和语法转换为 ES5。

前段时间刚介绍过 babel-loader / babel 7 ,这里就不再重复介绍,详情见 babel-loader 使用指南

使用 EditorConfig 和 eslint

多人合作项目约定编码规范是非常重要的,因为它能有效提高协作效率并抑制程序员怒气值增长。当然我认为个人项目也是需要的,因为 6 个月前的代码和别人的代码一样。

EditorConfig

EditorConfig 是一个跨编辑器的代码规范解决方案,获得了众多编辑器的支持(编辑器或插件实现支持),这意味着不同编辑器可以格式化出同样风格的代码,比如 vscode 和 sublime 。配置方式是在项目根目录增加一个 .editorconfig 文件,部分编辑器可以通过命令一键生成,通常其配置如下:

.editorconfig

root = true

[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
  • root 是否为顶级配置文件,通常设置为 true 表示搜索到该配置文件后不再继续向上查找配置
  • indent_style Tab 缩进方式
  • indent_size 缩进大小,上面示例就表示:缩进表现为两个空格
  • charset 编码方式
  • trim_trailing_whitespace 是否删除行尾空格
  • insert_final_newline 文件是否以空行结束

eslint

Javascript 是一门动态语言(弱类型语言),灵活但易错,所以在协作开发中需要制定一些规则保证各个成员输出风格一致的代码。 eslint 正是用于应对这个问题的开源工具,你可以设定规则,它则基于规则检查 Javascript 是否合法,不合法则返回错误或警告。

  1. 安装

    yarn add eslint -D
    
  2. 初始化配置文件

    npx eslint --init
    

    根据提示选择需要的项并安装对应的 plugin 和 config ,我这里还需要安装 eslint-config-standardeslint-plugin-vue

    yarn add eslint-config-standard eslint-plugin-vue -D
    

    .eslintrc.js

    module.exports = {
      "env": {
        "browser": true,
        "es6": true
      },
      "extends": [
        "plugin:vue/essential",
        "standard"
      ],
      "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
      },
      "parserOptions": {
        "ecmaVersion": 2018,
        "sourceType": "module"
      },
      "plugins": [
        "vue"
      ],
      "rules": {
      }
    }
    
  3. 在 rules 配置相应规则

    中文文档

使用 webpack-bundle-analyzer 分析打包信息

有时候我们会发现有些生成物的大小不对劲,但在控制台又很难看出来原因,这个时候就需要模块分析工具的帮助。这里我推荐使用 webpack-bundle-analyzer ,它会启动一个服务,在浏览器中很清楚地展现生成物和源文件的映射关系和层级,如图所示(图来源于 Github ):

图片来源于 Github

安装

yarn add webpack-bundle-analyzer -D

使用

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

用户体验优化

在构建层面优化用户体验在于以下几个方面: 缩小生成代码体积、合理的加载方式、合理减少 HTTP 请求数,本小节主要讲前两者。

减少 HTTP 的优化,我们在使用 url-loader 处理图片时就提及到了

压缩 CSS

Webpack 4 在 production 模式下是会默认压缩 JS 代码的,使用 TerserWebpackPlugin,但 CSS 不会( Webpack 5 会作为内置功能 ),所以我们需要 OptimizeCSSAssetsPlugin 的帮助。

安装

yarn add optimize-css-assets-webpack-plugin -D

使用

Webpack 插件使用大多大同小异,但在 Webpack 4 中使用这个插件需要特别注意,使用它时会重写 optimization.minimizer 选项,而压缩 JS 的插件 TerserWebpackPlugin 恰好就在这个选项的默认值中,重写会导致默认值失效,所以你还需要显式地声明 TerserWebpackPlugin 实例。

webpack.prod.conf.js

const TerserJSPlugin = require("terser-webpack-plugin")
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin")

module.exports = {
  optimization: {
    minimizer: [
      new TerserJSPlugin({
        parallel: true // 开启多线程压缩
      }),
      new OptimizeCSSAssetsPlugin({})
    ]
  }
}

压缩是生产环境下的优化,开发环境去设置它反而会影响到热加载性能、适得其反

代码分离

代码分离有两个优点:

  • 剥离公共代码和依赖,避免重复打包

  • 避免单个文件体积过大

    总加载体积一致,浏览器加载多个文件通常快于单个文件

分离前测试

我们先在项目中加上会被 home 和 page-a 公共引用的资源: src/utils/index.js & src/styles/main.styl ,然后再在两个页面分别引用他们,以 page-a.vue 举例。

为了方便检查生成代码,我们设置 mode: development 以获得未被压缩的代码

page-a.vue

<template>
  <div class="page-a">
    <h1>
      This is page-a
    </h1>
  </div>
</template>

<script>
import { counter } from '@/utils'

export default {
  name: 'page-a',
  created() {
    counter()
    console.log('page-a:', counter.count)
  }
}
</script>

<style lang="stylus" scoped>
@import '~@/styles/main.styl';

.page-a {
  background: blue;
}
</style>

执行 yarn build 打包项目,然后我们就会在 home.vue 对应的生成物( dist/css/views/home.[contentHash].cssdist/views/home.[contentHash].js. )中看到,他们包含了 src/styles/main.stylsrc/utils/index.js 文件中的所需内容。然而,我们再去检查 page-a.vue 对应的生成物,发现他们同样包含了这些内容,所以一份源码被打包到了两个页面对应的生成物中。

被重复打包是因为这两个页面同时引用了他们,当引用次数是 3 次、 10 次或者更多,这些公共资源(包括公共依赖)甚至可以占到生成物体积的 95% 以上,这显然是不可接受的。

SplitChunksPlugin

为了解决公共资源被重复打包问题,我们就需要 SplitChunksPlugin 的帮助,它可以把代码分离成不同的 bundle ,在页面需要时被加载。另外 SplitChunksPlugin 是 webpack 4 的内置插件,所以我们不需要去独立安装它。

使用

webpack.prod.conf.js

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 1, // 正常设置 20000+ 即 20k+ ,但这里我们的公共文件只有几行代码,所以设置为 1
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '/',
      name(mod, chunks) {
        return ${chunks[0].name}
      },
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
}

执行 yarn build 打包项目,我们可以看到只有 dist/views/home.[contentHash].js. (或者一个单独的 JS 中) 才会包含 utils.js 内容,而 dist/views/page-a.[contentHash].js. 中只有引用: _utils__WEBPACK_IMPORTED_MODULE_0__["counter"]

更改输出命名

我们可以使用 VSCode 来调试打包配置代码( nodejs ),得到 name 函数中的 mod / chunks 的对象结构,根据信息返回我们需要的文件名。

当然你也可以用 -inspect调试代码。

// 命名和代码分离息息相关,这里仅为使用示例,具体命名请根据项目情况更改
name(mod, chunks) {
  if (chunks[0].name === 'app') return 'app.vendor'

  if (/src/.test(mod.request)) {
    let requestName = mod.request.replace(/.*\\src\\/, '').replace(/"/g, '')
    if (requestName) return requestName
  } else if (/node_modules/.test(mod.request)) {
    return 'dependencies/' + mod.request.match(/node_modules.[\w-]+/)[0].replace(/node_modules./, '')
  }

  return null
}

更多的情况是设置魔法注释来规定文件名,而不是通过 name 函数设置,因为后者往往会将一些不该分离的代码分离

tree shaking

上面我们分离代码,解决了项目中部分代码被重复打包到多个生成物中的问题,有效地缩小了生成物体积,但其实我们还可以在此基础上进一步缩小体积,这就涉及本小节的概念 tree shaking 。

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。

你可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

我们回头看在使用 SplitChunksPlugin 时生成的文件,可以发现 say 函数没有使用但是却被打包进来了,它实际上是无用的代码,也就是文档中说的 dead-code 。要删除这些代码,只需要把 mode 修改为 production (让 tree shaking 生效),再次打包~

不过需要注意的是, tree shaking 能移除无用代码的同时,也有一定的副作用(错误识别无用代码)。比如你可能会遇到 UI 组件库没有样式的问题,这个问题原因在于 tree shaking 不仅对 JS 生效,也对 CSS 生效 。我们通常在导入 CSS 时使用 import 'xxx.min.css' , ES6 的静态导入 + 生产环境满足了 tree shaking 的生效条件,并且 Webpack 无法判断 CSS 有效,所以它被当做了 dead-code 然后被删除。为了解决这个问题,你可以在 package.json 中添加一个 sideEffects 选项,告知 Webpack 那些文件是可以直接引入而不用 tree shaking 检查的,使用如下:

package.json

{
  "sideEffects": [
    "*.css",
    "*.styl(us)?"
  ]
}

资源加载

合理的资源加载方式有时比缩小代码体积更重要

按需加载

按需加载又名懒加载,是指当需要依赖的页面被打开采取加载这个依赖,这样就减少了主页的负担,提升首屏渲染速度。而要做到按需加载,你只需在导入依赖的时候用 import()require.ensure 这两种动态加载方式。我们添加 lodash 依赖来做测试: yarn add lodash

page-a.vue

// 静态加载
import _ from 'lodash'
// 懒加载
// import(/* webpackChunkName: "dependencies/lodash" */ 'lodash')

export default {
  name: 'page-a',
  created() {
    console.log(_.now())
  }
}

此时启动一下开发服务,我们可以看到,虽然这里用了静态加载,但其实 lodash 依赖还是在点击进入了 page-a 才会被加载(懒加载)。因为我们在使用设置路由的时候,就已经使用过了 import() 动态加载(这一点我忘记了),所以 page-a 页面的静态资源也一起变作了懒加载。

我们再看一下之前的动态加载语句 import(/* webpackChunkName: "views/home" */ '@/views/home/main.vue') ,这其中有一个值得注意的知识点 /* webpackChunkName: "views/home" */ ,它是 Webpack 的魔法注释,这里是通过魔法注释指定生成 chunk 的文件名,所以该 src/views/home/main.vue 文件打包后的 JS 就在 dist/views/home.[contentHash].js

预加载、预取

上面讲到使用魔法注释为生成物命名,其实预加载 preload 和预取 prefetch 也是通过魔法注释来设置的。这里是官方文档上有他们的异同介绍:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

但在我的测试中,无论是 preload 还是 prefetch 都是并行加载的,但他们优先级会比当前页面所需依赖更低,不会影响到页面加载。你可以在 main.js 中添加以下代码进行测试:

src/main.js

// 对比测试
// import 'lodash'
// 预加载
// import(/* webpackPreload: true, webpackChunkName: "dependencies/lodash" */ 'lodash')
// 预取
import(/* webpackPrefetch: true, webpackChunkName: "dependencies/lodash" */ 'lodash')

本小节的测试结果由于和文档不符,希望大家自行验证,不可偏信

用预加载和预取处理体积较大的依赖效果尤为明显,比如图表、富文本编辑器

externals 外部扩展

有时我们会在项目中直接引入一些体积不小 JS 库(本文以 lodash 举例), Webpack 会去解析并压缩它们。但仔细想想,lodash 本身就存在已经压缩好的版本 lodash/lodash.min.js ,再加上其体积也不小没必要再与其他 JS 合并( 未压缩 700k ,压缩后 70k ),我们去解析和压缩它的意义并不大。所以我们可以把它放到 static 文件夹(或 CDN),并在 index.html 中用 script 标签引入,如果项目中有引入 lodash ( import 'lodash' )则可以配置 externals 在打包时忽略它,没有则不用。

如果这里想用 link[ref=prefetch/preload] 进行预加载,那么一定不要忘了在合适的地方再用 script 标签引入,预加载只是为了缓存

  1. 新增 /static 文件夹,加入 lodash.min.js

  2. 代码改动

    yarn add copy-webpack-plugin -D
    

    /build/webpack.prod.conf.js

    const CopyWebpackPlugin = require('copy-webpack-plugin')
    
    module.exports = {
      externals: {
        lodash: {
          commonjs: 'lodash',
          umd: 'lodash',
          root: '_' // 默认执行环境已经存在全局变量: _ ,浏览器中就是 window._
        }
      },
      plugins: [
        new CopyWebpackPlugin([
          {
            from: 'static/',
            to: 'static/'
          }
        ])
      ]
    }
    

    /src/main.js

    import _ from 'lodash'
    console.log(_.now())
    

    /index.html

    <body>
      <script type="text/javascript" src="static/lodash.min.js"></script>
    </body>
    

有些朋友可能认为解析 lodash 可以让 Webpack 知道哪些函数是没用到的,然后 tree shaking 掉它们,但其实 lodash 并不是 ES6 模块语法的静态导出,所以 tree shaking 不会生效。如果项目并不是重度依赖 lodash ,只是使用了其中几个函数,建议导入单个函数,如下:

import now from 'lodash/now'

console.log(now())

module.noParse 在文档中被介绍也可以忽略打包某些模块,但遗憾的是当前它还无甚用处。因为要让它生效你就不能在代码中去引用相关依赖,实际上没有引用就不会记录到依赖图中,自然就不会被打包(所以这个配置项什么都没做)。

参考文档