Webpack 4 配置最佳实践

做者 Daniel 蚂蚁金服·数据体验技术团队javascript

Webpack 4 发布已经有一段时间了。Webpack 的版本号已经来到了 4.12.x。但由于 Webpack 官方尚未完成迁移指南,在文档层面上还有所欠缺,大部分人对升级 Webpack 仍是一头雾水。前端

不过 Webpack 的开发团队已经写了一些零散的文章,官网上也有了新版配置的文档。社区中一些开发者也已经成功试水,升级到了 Webpack 4,而且总结成了博客。因此我也终于去了解了 Webpack 4 的具体状况。如下就是我对迁移到 Webpack 4 的一些经验。vue

本文的重点在:java

  • Webpack 4 在配置上带来了哪些便利?要迁移须要修改配置文件的哪些内容?
  • 以前的 Webpack 配置最佳实践在 Webpack 4 这个版本,还适用吗?

Webpack 4 以前的 Webpack 最佳实践

这里以 Vue 官方的 Webpack 模板 vuejs-templates/webpack 为例,说说 Webpack 4 以前,社区里比较成熟的 Webpack 配置文件是怎样组织的。node

区分开发和生产环境

大体的目录结构是这样的:react

+ build
+ config
+ src

复制代码

在 build 目录下有四个 webpack 的配置。分别是:webpack

  • webpack.base.conf.js
  • webpack.dev.conf.js
  • webpack.prod.conf.js
  • webpack.test.conf.js

这分别对应开发、生产和测试环境的配置。其中 webpack.base.conf.js 是一些公共的配置项。咱们使用 webpack-merge 把这些公共配置项和环境特定的配置项 merge 起来,成为一个完整的配置项。好比 webpack.dev.conf.js 中:git

'use strict'
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')

const devWebpackConfig = merge(baseWebpackConfig, {
   ...
})
复制代码

这三个环境不只有一部分配置不一样,更关键的是,每一个配置中用 webpack.DefinePlugin 向代码注入了 NODE\_ENV 这个环境变量。github

这个变量在不一样环境下有不一样的值,好比 dev 环境下就是 development。这些环境变量的值是在 config 文件夹下的配置文件中定义的。Webpack 首先从配置文件中读取这个值,而后注入。好比这样:web

build/webpack.dev.js

plugins: [
  new webpack.DefinePlugin({
    'process.env': require('../config/dev.env.js')
  }),
]
复制代码

config/dev.env.js

module.exports ={
  NODE_ENV: '"development"'
}
复制代码

至于不一样环境下环境变量具体的值,好比开发环境是 development,生产环境是 production,实际上是你们约定俗成的。

框架、库的做者,或者是咱们的业务代码里,都会有一些根据环境作判断,执行不一样逻辑的代码,好比这样:

if (process.env.NODE_ENV !== 'production') {
  console.warn("error!")
}
复制代码

这些代码会在代码压缩的时候被预执行一次,而后若是条件表达式的值是 true,那这个 true 分支里的内容就被移除了。这是一种编译时的死代码优化。这种区分不一样的环境,并给环境变量设置不一样的值的实践,让咱们开启了编译时按环境对代码进行针对性优化的可能。

Code Splitting && Long-term caching

Code Splitting 通常须要作这些事情:

  • 为 Vendor 单独打包(Vendor 指第三方的库或者公共的基础组件,由于 Vendor 的变化比较少,单独打包利于缓存)
  • 为 Manifest (Webpack 的 Runtime 代码)单独打包
  • 为不一样入口的公共业务代码打包(同理,也是为了缓存和加载速度)
  • 为异步加载的代码打一个公共的包

Code Splitting 通常是经过配置 CommonsChunkPlugin 来完成的。一个典型的配置以下,分别为 vendor、manifest 和 vendor-async 配置了 CommonsChunkPlugin。

new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks (module) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
        )
      }
    }),

    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity
    }),

    new webpack.optimize.CommonsChunkPlugin({
      name: 'app',
      async: 'vendor-async',
      children: true,
      minChunks: 3
    }),
复制代码

CommonsChunkPlugin 的特色就是配置比较难懂,你们的配置每每是复制过来的,这些代码基本上成了模板代码(boilerplate)。若是 Code Splitting 的要求简单倒好,若是有比较特殊的要求,好比把不一样入口的 vendor 打不一样的包,那就很难配置了。总的来讲配置 Code Splitting 是一个比较痛苦的事情。

而 Long-term caching 策略是这样的:给静态文件一个很长的缓存过时时间,好比一年。而后在给文件名里加上一个 hash,每次构建时,当文件内容改变时,文件名中的 hash 也会改变。浏览器在根据文件名做为文件的标识,因此当 hash 改变时,浏览器就会从新加载这个文件。

Webpack 的 Output 选项中能够配置文件名的 hash,好比这样:

output: {
  path: config.build.assetsRoot,
  filename: utils.assetsPath('js/[name].[chunkhash].js'),
  chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
},
复制代码

Webpack 4 下的最佳实践

Webpack 4 的变与不变

Webpack 4 这个版本的 API 有一些 breaking change,但不表明说这个版本就发生了翻天覆地的变化。其实变化的点只有几个。并且只要你仔细了解了这些变化,你必定会拍手叫好。

迁移到 Webpack 4 也只须要检查一下 checklist,看看这些点是否都覆盖到了,就能够了。

开发和生产环境的区分

Webpack 4 引入了 mode 这个选项。这个选项的值能够是 development 或者 production。

设置了 mode 以后会把 process.env.NODE\_ENV 也设置为 development 或者 production。而后在 production 模式下,会默认开启 UglifyJsPlugin 等等一堆插件。

Webpack 4 支持零配置使用,能够从命令行指定 entry 的位置,若是不指定,就是 src/index.js。mode 参数也能够从命令行参数传入。这样一些经常使用的生产环境打包优化均可以直接启用。

咱们须要注意,Webpack 4 的零配置是有限度的,若是要加上本身想加的插件,或者要加多个 entry,仍是须要一个配置文件。

虽然如此,Webpack 4 在各个方面都作了努力,努力让零配置能够作的事情更多。这种内置优化的方式使得咱们在项目起步的时候,能够把主要精力放在业务开发上,等后期业务变复杂以后,才须要关注配置文件的编写。

在 Webpack 4 推出 mode 这个选项以前,若是想要为不一样的开发环境打造不一样的构建选项,咱们只能经过创建多个 Webpack 配置且分别设置不一样的环境变量值这种方式。这也是社区里的最佳实践。

Webpack 4 推出的 mode 选项,实际上是一种对社区中最佳实践的吸取。这种思路我是很赞同的。开源项目来自于社区,在社区中成长,从社区中吸取营养,而后回报社区,这是一个良性循环。最近我在不少前端项目中都看到了相似的趋势。接下来要讲的其余几个 Webpack 4 的特性也是和社区的反馈离不开的。

那么上文中介绍的使用多个 Webpack 配置,以及手动环境变量注入的方式,是否在 Webpack 4 下就不适用了呢?其实否则。在Webpack 4 下,对于一个正经的项目,咱们依然须要多个不一样的配置文件。若是咱们对为测试环境的打包作一些特殊处理,咱们还须要在那个配置文件里用 webpack.DefinePlugin 手动注入 NODE\_ENV 的值(好比 test)。

Webpack 4 下若是须要一个 test 环境,那 test 环境的 mode 也是 development。由于 mode 只有开发和生产两种,测试环境应该是属于开发阶段。

第三方库 build 的选择

在 Webpack 3 时代,咱们须要在生产环境的的 Webpack 配置里给第三方库设置 alias,把这个库的路径设置为 production build 文件的路径。以此来引入生产版本的依赖。

好比这样:

resolve: {
  extensions: [".js", ".vue", ".json"],
  alias: {
    vue$: "vue/dist/vue.runtime.min.js"
  }
},
复制代码

在 Webpack 4 引入了 mode 以后,对于部分依赖,咱们能够不用配置 alias,好比 React。React 的入口文件是这样的:

'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

复制代码

这样就实现了 0 配置自动选择生产 build。

但大部分的第三库并无作这个入口的环境判断。因此这种状况下咱们仍是须要手动配置 alias。

Code Splitting

Webpack 4 下还有一个大改动,就是废弃了 CommonsChunkPlugin,引入了 optimization.splitChunks 这个选项。

optimization.splitChunks 默认是不用设置的。若是 mode 是 production,那 Webpack 4 就会开启 Code Splitting。

默认 Webpack 4 只会对按需加载的代码作分割。若是咱们须要配置初始加载的代码也加入到代码分割中,能够设置 splitChunks.chunks'all'

Webpack 4 的 Code Splitting 最大的特色就是配置简单(0配置起步),和__基于内置规则自动拆分__。内置的代码切分的规则是这样的:

  • 新 bundle 被两个及以上模块引用,或者来自 node_modules
  • 新 bundle 大于 30kb (压缩以前)
  • 异步加载并发加载的 bundle 数不能大于 5 个
  • 初始加载的 bundle 数不能大于 3 个

简单的说,Webpack 会把代码中的公共模块自动抽出来,变成一个包,前提是这个包大于 30kb,否则 Webpack 是不会抽出公共代码的,由于增长一次请求的成本是不能忽视的。

具体的业务场景下,具体的拆分逻辑,能够看 SplitChunksPlugin 的文档以及 webpack 4: Code Splitting, chunk graph and the splitChunks optimization 这篇博客。这两篇文章基本罗列了全部可能出现的状况。

若是是普通的应用,Webpack 4 内置的规则就足够了。

若是是特殊的需求,Webpack 4 的 optimization.splitChunks API也能够知足。

splitChunks 有一个参数叫 cacheGroups,这个参数相似以前的 CommonChunks 实例。cacheGroups 里每一个对象就是一个用户定义的 chunk。

以前咱们讲到,Webpack 4 内置有一套代码分割的规则,那用户也能够自定义 cacheGroups,也就是自定义 chunk。那一个 module 应该被抽到哪一个 chunk 呢?这是由 cacheGroups 的抽取范围控制的。每一个 cacheGroups 均可以定义本身抽取模块的范围,也就是哪些文件中的公共代码会抽取到本身这个 chunk 中。不一样的 cacheGroups 之间的模块范围若是有交集,咱们能够用 priority 属性控制优先级。Webpack 4 默认的抽取的优先级是最低的,因此模块会优先被抽取到用户的自定义 chunk 中。

splitChunksPlugin 提供了两种控制 chunk 抽取模块范围的方式。一种是 test 属性。这个属性能够传入字符串、正则或者函数,全部的 module 都会去匹配 test 传入的条件,若是条件符合,就被归入这个 chunk 的备选模块范围。若是咱们传入的条件是字符串或者正则,那匹配的流程是这样的:首先匹配 module 的路径,而后匹配 module 以前所在 chunk 的 name。

好比咱们想把全部 node_modules 中引入的模块打包成一个模块:

vendors1: {
    test: /[\\/]node_modules[\\/]/,
    name: 'vendor',
    chunks: 'all',
  }
复制代码

由于从 node_modules 中加载的依赖路径中都带有 node_modules,因此这个正则会匹配全部从 node_modules 中加载的依赖。

test 属性能够以 module 为单位控制 chunk 的抽取范围,是一种细粒度比较小的方式。splitChunksPlugin 的第二种控制抽取模块范围的方式就是 chunks 属性。chunks 能够是字符串,好比 'all'|'async'|'initial',分别表明了所有 chunk,按需加载的 chunk 以及初始加载的 chunk。chunks 也能够是一个函数,在这个函数里咱们能够拿到 chunk.name。这给了咱们经过入口来分割代码的能力。这是一种细粒度比较大的方式,以 chunk 为单位。

举个例子,好比咱们有 a, b, c 三个入口。咱们但愿 a,b 的公共代码单独打包为 common。也就是说 c 的代码不参与公共代码的分割。

咱们能够定义一个 cacheGroups,而后设置 chunks 属性为一个函数,这个函数负责过滤这个 cacheGroups 包含的 chunk 是哪些。示例代码以下:

optimization: {
    splitChunks: {
      cacheGroups: {
        common: {
          chunks(chunk) {
            return chunk.name !== 'c';
          },
          name: 'common',
          minChunks: 2,
        },
      },
    },
  },
复制代码

上面配置的意思就是:咱们想把 a,b 入口中的公共代码单独打包为一个名为 common 的 chunk。使用 chunk.name,咱们能够轻松的完成这个需求。

在上面的状况中,咱们知道 chunks 属性能够用来按入口切分几组公共代码。如今咱们来看一个稍微复杂一些的状况:对不一样分组入口中引入的 node_modules 中的依赖进行分组。

好比咱们有 a, b, c, d 四个入口。咱们但愿 a,b 的依赖打包为 vendor1,c, d 的依赖打包为 vendor2。

这个需求要求咱们对入口和模块都作过滤,因此咱们须要使用 test 属性这个细粒度比较小的方式。咱们的思路就是,写两个 cacheGroup,一个 cacheGroup 的判断条件是:若是 module 在 a 或者 b chunk 被引入,而且 module 的路径包含 node\_modules,那这个 module 就应该被打包到 vendors1 中。 vendors2 同理。

vendors1: {
    test: module => {
      for (const chunk of module.chunksIterable) {
			if (chunk.name && /(a|b)/.test(chunk.name)) {
				if (module.nameForCondition() && /[\\/]node_modules[\\/]/.test(module.nameForCondition())) {
                 return true;
             }
			}
	   }
      return false;
    },
    minChunks: 2,
    name: 'vendors1',
    chunks: 'all',
  },
  vendors2: {
    test: module => {
      for (const chunk of module.chunksIterable) {
			if (chunk.name && /(c|d)/.test(chunk.name)) {
				if (module.nameForCondition() && /[\\/]node_modules[\\/]/.test(module.nameForCondition())) {
                 return true;
             }
			}
	   }
      return false;
    },
    minChunks: 2,
    name: 'vendors2',
    chunks: 'all',
  },
};

复制代码

Long-term caching

Long-term caching 这里,基本的操做和 Webpack 3 是同样的。不过 Webpack 3 的 Long-term caching 在操做的时候,有个小问题,这个问题是关于 chunk 内容和 hash 变化不一致的:

在公共代码 Vendor 内容不变的状况下,添加 entry,或者 external 依赖,或者异步模块的时候,Vendor 的 hash 会改变

以前 Webpack 官方的专栏里面有一篇文章讲这个问题:Predictable long term caching with Webpack。给出了一个解决方案。

这个方案的核心就是,Webpack 内部维护了一个自增的 id,每一个 chunk 都有一个 id。因此当增长 entry 或者其余类型 chunk 的时候,id 就会变化,致使内容没有变化的 chunk 的 id 也发生了变化。

对此咱们的应对方案是,使用 webpack.NamedChunksPlugin 把 chunk id 变为一个字符串标识符,这个字符包通常就是模块的相对路径。这样模块的 chunk id 就能够稳定下来。

Screen Shot 2018-06-03 at 12.59.28 AM.png | left

这里的 vendors1 就是 chunk id

HashedModuleIdsPlugin 的做用和 NamedChunksPlugin 是同样的,只不过 HashedModuleIdsPlugin 把根据模块相对路径生成的 hash 做为 chunk id,这样 chunk id 会更短。所以在生产中更推荐用 HashedModuleIdsPlugin。

这篇文章说还讲到,webpack.NamedChunksPlugin 只能对普通的 Webpack 模块起做用,异步模块,external 模块是不会起做用的。

异步模块能够在 import 的时候加上 chunkName 的注释,好比这样:import(/* webpackChunkName: "lodash" */ 'lodash').then() 这样就有 Name 了

因此咱们须要再使用一个插件:name-all-modules-plugin

这个插件中用到一些老的 API,Webpack 4 会发出警告,这个 pr 有新的版本,不过做者不必定会 merge。咱们使用的时候能够直接 copy 这个插件的代码到咱们的 Webpack 配置里面。

作了这些工做以后,咱们的 Vendor 的 ChunkId 就不再会发生不应发生的变化了。

总结

Webpack 4 的改变主要是对社区中最佳实践的吸取。Webpack 4 经过新的 API 大大提高了 Code Splitting 的体验。但 Long-term caching 中 Vendor hash 的问题仍是没有解决,须要手动配置。本文主要介绍的就是 Webpack 配置最佳实践在 Webpack 3.x 和 4.x 背景下的异同。但愿对读者的 Webpack 4 项目的配置文件组织有所帮助。

另外,推荐 SURVIVEJS - WEBPACK 这个在线教程。这个教程总结了 Webpack 在实际开发中的实践,而且把材料更新到了最新的 Webpack 4。

对咱们团队感兴趣的能够关注专栏,关注github或者发送简历至'tao.qit####alibaba-inc.com'.replace('####', '@'),欢迎有志之士加入~

原文地址:github.com/ProtoTeam/b…