webpack进阶必备

2,252 阅读13分钟

前言

前面我们对webpack的基础知识进行了详细的讲解,下面我们进入webpack的进阶教程,代码分割和按需引入。

chunks以及它引入的modules本质上是通过webpack处理module之间的父子关系所做的关联。在webpack4以前,我们使用CommonsChunkPlugin插件来避免依赖的重复打包,但是这样很难进行更深一步的优化。

从webpack4开始,CommonsChunkPlugin被移除,新增了optimization.splitChunksoptimization.runtimeChunk选项。

译:重心其实就是内置一个配置项,可以把符合指定规则的module提取为chunk,官方说新的方法贼好用(反正在我研究明白之前真没觉得好用,文档实在是不友好,有些概念不好理解)

代码分割

SplitChunksPlugin对大多数用户来说都更加好用。

默认配置下,它只会影响 按需载入 chunks实现方式),而不会影响到 initial chunks (html文件同步加载的script tag),因为更改initial chunks将会影响到HTML文件引入的的script tags。

webpack将会按照以下规则进行代码切割:

  • 新chunk可被公用,或者是从node_modules引入的模块
  • 新chunk大于30kb(在 打包压缩 + 服务器压缩 之前)(此条意在平衡新增一次网络请求与公共代码的长缓存的利弊,可配置)
  • 对于按需加载的模块,数量不能超出5个
  • 对于同步入口chunks,数量不能超出3个(不新增过多的初始网络请求数,可配置)

webpack提供一些可配置项来满足开发者更多定制化的要求。

默认的配置项在我们看来是适合浏览器表现的最好方式,但是对你的项目来说可能会有所差异。如果你尝试覆盖默认项,请确认你的这些配置对于项目来说真的有优化作用。

optimization.splitChunks

以下是SplitChunksPlugin的默认配置:

webpack.config.js

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async', // 仅提取按需载入的module
      minSize: 30000, // 提取出的新chunk在两次压缩(打包压缩和服务器压缩)之前要大于30kb
      maxSize: 0, // 提取出的新chunk在两次压缩之前要小于多少kb,默认为0,即不做限制
      minChunks: 1, // 被提取的chunk最少需要被多少chunks共同引入
      maxAsyncRequests: 5, // 最大按需载入chunks提取数
      maxInitialRequests: 3, // 最大初始同步chunks提取数
      automaticNameDelimiter: '~', // 默认的命名规则(使用~进行连接)
      name: true,
      cacheGroups: { // 缓存组配置,默认有vendors和default
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

splitChunks.automaticNameDelimiter

string

webpack默认使用 origin~chunkName 的形式生成name(例如:index~detail.js)。该选项可以自定义连接符。

splitChunks.chunks

function (chunk) | string

该选项决定优化哪些chunks。如果值为string,有三个可选项:allasyncinitial

设置为all表现会更强大,这样会使公共chunks可能同时被同步和异步引入。

webpack.config.js

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 包含所有chunks
      chunks: 'all'
    }
  }
};

你也可以通过一个function来更精确的进行chunks控制。返回值就是需要优化的chunks。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks (chunk) {
        // 返回所有不是`my-excluded-chunk`的chunks
        return chunk.name !== 'my-excluded-chunk';
      }
    }
  }
};

你可以配合HtmlWebpackPlugin,把提取到的公共chunks注入到html页面中(HtmlWebpackPlugin的 chunks 配置项)。

splitChunks.maxAsyncRequest

number

按需载入的最大请求数。

译注:如果按照着配置规则,最终提取出的按需载入chunks数量大于该配置项,则优先级较低的多余chunks不会被分割成公共chunks(通常是取符合数量的size较大的几个)。

splitChunks.maxIntialRequests

number

单个入口文件的最大请求数。

译注:如果按照着配置规则,最终提取出的初始载入chunks数量大于该配置项,则优先级较低的多余chunks不会被分割成公共chunks(通常是取符合数量的size较大的几个)。 需要注意的是,入口chunk + 默认缓存组的vendors配置项 + 默认缓存组的default配置项,这就已经是3个了,如果自定义的缓存组配置无效,注意查证该配置。

splitChunks.minChunks

number

提取出来的chunk最小共用数量。

splitChunks.minSize

number

提取出来的chunk需要满足的最小size,单位是bytes。

splitChunks.maxSize

number

maxSize项(无论是全局的optimization.splitChunks.maxSize还是每个缓存组内的optimization.splitChunks.cacheGroups[x].maxSize, 或者缓存组回调optimization.splitChunks.fallbackCacheGroup.maxSize)会尝试对大于该值的chunks分割为更小的几部分。 每部分需要大于minSize的值。该算法是固定的,并且模块的改变仅会造成局部影响。所以在使用缓存组并且不需要记录时,可以使用该选项。 如果切割结果不符合minSize要求,maxSize不会生效。

如果该chunk已经有了一个name,分割出的每个chunk都会有一个新名字。optimization.splitChunks.hidePathInfo项会使分割项根据原始chunk的name派生出一个hash值。

maxSize项意在HTTP/2和长缓存中通过增加网络请求数来达到更好的缓存效果。它也可以通过减少文件体积来加快构建速度。

maxSizemaxInitialRequest/maxAsyncRequests有更高的优先级。优先级顺序为maxInitialRequest/maxAsyncRequests < maxSize < minSize

译:官方解释的比较绕,但是其实仔细读几遍,结合理解他提到的其他配置项,会觉得说的很清楚了。这个配置项默认为0,也就是不做限制。如果做了限制,并且提取出来的chunk大于该限制,那么会把这个chunk按照minSize的规则作二次拆分,并且最终拆分出的chunks数量优先级要大于按需载入/初始载入的最大限制数量。

splitChunks.name

boolean: true | function (module) | string

提取出来的chunk name。设置为true会根据chunks和缓存组的key来自动命名。设置为string或者function来确定你想要的命名。如果name和入口文件name相同,入口文件将被移除。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      name (module) {
        // generate a chunk name...
        return; //...
      }
    }
  }
};

如果给不同的spilt chunk分配相同的name( 译:按照缓存组内的filename配置来理解比较好 ),所有的依赖项都将被打包进同一个公共chunk。如果你希望提取出不同的chunks,不要这么做。

splitChunks.cacheGroups

缓存组的配置项会继承splitChunks.*的配置,但是testpriorityreuseExistingChunk只能在缓存组中配置。 如果不需要默认的缓存组,设置为false(这个很重要)

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        default: false
      }
    }
  }
};

splitChunks.cacheGroups.priority

number

如果可能出现一个module同时符合多个缓存组的配置规则,你需要设置优先级,该module会被提取进优先级更高的缓存组配置项内。默认有个负数的优先级。

splitChunks.cacheGroups.{cacheGroup}.reuseExistingChunk

boolean

如果当前chunk包含的模块已经打包进主bundle,将不再被打包进当前chunk。该配置项这会影响到chunk name。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          reuseExistingChunk: true
        }
      }
    }
  }
};

splitChunks.cacheGroups.{cacheGroup}.test

function (module, chunk) | RegExp | string

module的匹配规则。默认会对所有模块进行优化。你可以对module的绝对路径或者chunk name做匹配。如果匹配到一个chunk name,它的所有子模块都会被尝试提取。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          test (module, chunks) {
            //...
            return module.type === 'javascript/auto';
          }
        }
      }
    }
  }
};

splitChunks.cacheGroups.{cacheGroup}.filename

string

配置提取出的chunk name。output.filename的命名规则在这里也适用。

该选项也可以通过splitChunks.filename全局配置,但是我们不建议这么做,因为如果splitChunks.chunks没有设置为initial,有可能引发报错。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          filename: '[name].bundle.js'
        }
      }
    }
  }
};

splitChunks.cacheGroups.{cacheGroup}.enforce

boolean: false

强制提取符合该缓存组策略的modules,并且忽略splitChunks.minSizesplitChunks.minChunkssplitChunks.maxAsyncRequestssplitChunks.maxInitialRequests配置项。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          enforce: true
        }
      }
    }
  }
};

配置详情

了解了splitChunks(公共代码分离)的配置信息,下面我们就来在我们的项目中实践一下。

首先,查看package.json文件,确保你的dependencies字段内仅包含vue,vue-router,lodash三个包文件。也就是 我们的项目目前只依赖这三个包文件。如果我们不做任何配置的话,webpack会把所有的文件打包进一个文件中,如下所示:

splitchunk.gif

接下来,让我们配置下将lodash文件从主文件中剥离,同时vue和vue-router打包进同一文件中。

module.exports = {
    optimization: {
    // 分离公共chunk在vendor文件中
    splitChunks: {
      chunks: 'all', // 仅提取按需载入的module
      minSize: 10, // 提取出的新chunk在两次压缩(打包压缩和服务器压缩)之前要大于30kb
      maxSize: 0, // 提取出的新chunk在两次压缩之前要小于多少kb,默认为0,即不做限制
      minChunks: 1, // 被提取的chunk最少需要被多少chunks共同引入
      maxAsyncRequests: 5, // 最大按需载入chunks提取数
      maxInitialRequests: 6, // 最大初始同步chunks提取数
      automaticNameDelimiter: '-', // 默认的命名规则(使用~进行连接)
      name: true,
      cacheGroups: { // 缓存组配置,默认有vendors和default
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        lodash: {
          test: /lodash/,
          priority: -1,
          name: 'lodash'
        },
        vue: {
          test: /vue/,
          priority: 10,
          name: 'vue'
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
}

注意这里要修改maxInitialRequests字段,因为我们配置了4个chunk,而该字段的默认值为3,已经超出了默认范围,所以webpack会 默认将其余的都打包进主文件app.js中,故这里我们尽可能的加大这个数值,以防止意外发生。

splitchunk1.gif

我们可以发现,根据我们的配置正常生成了vue、lodash、vender等文件,符合打包预期。

注意这里的priority选项,上面说了,这个顺序决定了打包的优先级,同时,这个值也决定了在最终生成的html文件中, 生成的js文件的引用顺序。

splitchunk2.png

将打包后的文件分离,可以有效的减少最终打包文件的体积,同时,也应该考虑,增加一次网络请求与将代码放在一个文件中的 利弊,根据实际情况配置如何分离文件才能达到优化项目加载的目的。

稍等,我们不难发现,我们只用到了lodash的一个工具函数,但是webpack却帮我们把整个lodash库都打包进项目中了,这是非常没有必要的, 要是有一种方法能帮助我们只把我们用到的函数打包进项目,那项目的体积将会进一步减小,有没有这样的方法呢,答案是有的,请继续看下面的内容tree-shaking

tree-shaking

tree-shaking是由rollup的作者首先提出的,这里有一个比喻:

如果把代码打包比作制作蛋糕。传统的方式是把鸡蛋(带壳)全部丢进去搅拌,然后放入烤箱,最后把(没有用的)蛋壳全部挑选并剔除出去。而 treeshaking 则是一开始就把有用的蛋白蛋黄放入搅拌,最后直接作出蛋糕。

因此,相比于排除不使用的代码,tree shaking 其实是找出使用的代码

基于 ES6 的静态引用,tree shaking 通过扫描所有 ES6 的 export,找出被 import 的内容并添加到最终代码中。 webpack 的实现是把所有 import 标记为有使用/无使用两种,在后续压缩时进行区别处理。 因为就如比喻所说,在放入烤箱(压缩混淆)前先剔除蛋壳(无使用的 import),只放入有用的蛋白蛋黄(有使用的 import)。

测试未tree-shaking的情况

为了测试,作者添加了测试代码如下:

export const con = () => {
  console.log('我是测试分离公共模块的');
};

export const cc = () => {
  console.log('测试treecc');
};

export const aa = () => {
  console.log('测试treeaa');
};

export const bb = () => {
  console.log('测试treebb');
};

export const dd = () => {
  console.log('测试treedd');
};

并在detail.vue文件中引入了其中两个方法。

<template>
  <div>
    detail
  </div>
</template>

<script>
import { con, cc } from '../util/dd';
export default {
  name: 'detail',
  methods: {
    go () {
      con();
      cc();
    }
  }
};
</script>

为了能清楚的查看打包后的代码,我们暂时关闭代码压缩(即关闭tree shaking)。

module.exports = {
  optimization: {
    // 关闭代码压缩
    minimize: false
  }
}

打包查看效果:

treeshaking1.gif

我们不难发现,在打包后的文件中,依然存在我们没有用到的函数aabbdd,说明webpack没有做tree shanking,这与我们前面关闭tree shaking的配置是一致的。

我们观察生成的打包文件,会发现如下特征: treeshaking2.png

  • 被使用过的 export 标记为 /* harmony export ([type]) */,其中 [type] 和 webpack 内部有关,可能是 binding, immutable 等等。

  • 没被使用过的 export 标记为 /* unused harmony export [FuncName] */,其中 [FuncName] 为 export 的方法名称。

其实这正是,webpack为tree shaking做的准备工作,如果后续我们启用代码压缩的话,其中被标记/* unused harmony export [FuncName] */的代码会被丢弃,只有 被标记为/* harmony export ([type]) */的代码才会被引用并压缩。

webpack4采用terser-webpack-plugin作为压缩工具,而webpack3采用的是UglifyJSPlugin

配置tree-shaking

第一步

首先必须明确的一点是:(重要的事情说三遍)

  • webpack的tree shaking是基于ES6的模块规范importexport

  • webpack的tree shaking是基于ES6的模块规范importexport

  • webpack的tree shaking是基于ES6的模块规范importexport

而babel的默认使用的模块规范是 CommonJS 规范,经过babel转义的代码都会变成require的形式,所以我们需要告诉babel不要这样转换,在.babelrc中添加"modules": false配置。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}

经过作者测试,在webpack4和babel7中即使不设置"modules": false,也可以正常tree shaking,目前还不知道原因。

第二步

webpack 4 在 package.json 新增了一个配置项叫做 sideEffects, 值为 false 表示整个包都没有副作用; 或者是一个数组列出有副作用的模块

{
  "sideEffects": "['./src/util/cc.js']"
}

我们可以根据项目的需求,根据实际情况进行设置,这里我们设置为false

{
  "sideEffects": false
}

第三步

接下来就需要配置webpack了,我们已经知道了webpack将使用的和未使用的代码做了不同的标记,接下来就需要挑出有用的代码了,这里就需要压缩工具的帮助了,压缩工具 帮助我们压缩代码的同时,丢弃掉未使用的代码,生成最后的文件,这样便实现了tree shaking。

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    // 配置tree shaking
    usedExports: true,
    // 开启压缩
    minimize: true,
    // 压缩工具
    minimizer: [new TerserPlugin()]
  }
}

好啦,配置完成,打包试试看:

treeshaking3.gif

哎呀,怎么没有了css样式,而且css文件也没有生成,这是怎么回事,

原来,webpack认为利用import导入的文件,如果没有被使用的话,他会认为该引用是无用的,无情丢弃,也就造成了最后打包的文件没有css代码的问题。 解决这个问题,我们只需要告诉webpack样式文件是有副作用的,不能丢掉,需要在webpack的配合文件处添加如下语句:

module.exports = {
  module: {
    rules: [{
      test: /\.css$/,
      sideEffects: true,
    },{
      test: /\.s[ac]ss$/,
      sideEffects: true,
    }]
  }
}

所有涉及样式文件的规则都需要添加sideEffects: true,表示这类文件是有副作用的。

接下来,再让我们打包看下:

treeshaking4.gif

运行一切正常,那tree shaking生效了吗?

treeshaking5.gif

treeshaking6.png

很高兴,打包后的项目我们只能找到引入的两个函数的打印语句,其他未使用的方法都没有包含在生产的文件中,这说明,tree shaking生效了。

总结

为了更好的达到tree shaking的效果,我们需要:

  • 使用 ES6 模块语法编写代码

  • 工具类函数尽量以单独的形式输出,不要集中成一个对象或者类

  • 声明 sideEffects

  • 注意样式文件是具有副作用的

至此,我们已经完成了完整的vue项目的webpack配置

真正的自己摆脱脚手架工具的帮助,自己配置一个vue项目,你会从中学习到很多东西,对于webpack的认识也会更近一步,妈妈再也不怕我不会自己配置webpack了。