嫌啰嗦想直接看最终的配置请戳这里webpack-workbench

用两个自问自答来当作序吧:

  • Q:为什么要写这篇文章?
  • A:因为我在将自己的一个项目 angularjs-es6-seed 从 webpack3.x 升级到 4.x 的时候发现,作为一个熟练的 GitHub 搬运工,改起来还是很费力,主要是因为对其没有一个更完整的认知,因此有必要写一篇文章强化认知。

  • Q:既然是写给自己看的,那对于其他人有帮助吗?

  • A:如果你对 webpack 有少许的了解(至少知道webpack是干什么用的),那这篇文章应该还是有帮助的。

Why webpack?

一个工具的诞生,必然有其诞生的原因,也许是为了简化工作,也许是为了解决某些痛点,也可能是今年的kpi压力很大…

今天的主角 webpack 的诞生就是为了解决前端开发长久以来的痛点:模块化 ,这是也它的前辈 gruntgulp 所不具备的功能。

回想一下那个前端还被称作切图仔的时代,我们是怎么组织多个 .js 文件的:

<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>

且不说这样写有多low,就单从代码维护角度来说,b.js 可能使用了 a.js 中的某个方法; c.js 同样如此,可能还用到了 b.js 中的某些方法。单看每个文件,是根本不知道这些方法是哪来的,也不清楚这三个文件之间的依赖关系的。

为了解决的这个问题,requirejs 诞生了,这是一套 AMD 的模块化实现方案。而此时 node 已经出现有些时日,其遵循的是 CommonJS ,同样是 JavaScript 模块化,却有两套实现方案,语法也不一样。于是又出现了 CMD 和其实现 seajs ,它是为了让服务端模块化和浏览器端模块化的差异能够最小化。

以上这些都是前辈们对 JavaScript 模块化的探索,虽然不是标准,但却推动了标准的发展,于是在 ES6 中,终于有了标准的、原生的模块化方案了,然鹅…

浏览器厂商:标准是标准,至于什么时候实现,fucked say (日后再议)。虽然现在大部分浏览器内核都实现了原生的模块化,但是我们不能确保用户都已将浏览器更新至最新了。

所以,在所有浏览器都实现模块化标准之前,我们还是不能够愉快的使用 importexport ,于是 webpack 来了,给乡亲们带了希望,让乡亲们再也不用看浏览器脸色,从此过上了没羞没臊幸福的生活了。

简单介绍

为了有更好更清晰的认识,建议读者跟着文章一起做一遍,可以先创建一个新的文件夹 learn-webpack ,在该目录中打开命令行,输入 npm init 命令初始化 package.json 文件。

安装

npm i webpack webpack-cli -D

可以全局安装,也可以本地安装,建议本地安装,因为 webpack 不同的版本之间还是有一定的差异,为了避免这个问题,我们选择本地。

在上面安装命令中,除了安装了 webpack 外,还安装了 webpack-cli 。那么这个工具是干什么用的?在 webpack4.x 之后,webpack 把命令行单独提取出来了,也就是说,我们想在命令行中执行 webpack xxx 等命令时,就需要先安装 webpack-cli 。 所以如你的使用的4.x版本的 webpack ,还需要额外安装一下 webpack-cli 。

使用

webpack 的使用还是比较简单的,并且提供了三种使用方法:

  1. 不使用配置文件
webpack <entry> <output>

entry:要打包的文件,可以是一个文件,也可以是一组文件。

output:打包后生成的文件。

例如将 ./src/index.js 打包到 dist/app.js

webpack ./src/index.js dist/app.js
  1. 使用配置文件

不使用配置文件的方式显然不够灵活多变,所以通常都是先编写 webpack 配置文件,然后根据配置文件内容进行打包。在根目录下创建 webpack.config.js 文件,然后在命令行中输入 webpack ,webpack 会自动读取 webpack.config.js 中的配置内容,然后进行打包,下文将会着重介绍如果编写配置文件。

  1. 在node中启动
const webpack = require('webpack');
webpack({
/* webpack配置内容 */
}, (err, stats) => {
/* 打包后回调 */
});

npm script

在使用第二种方式的时候,我们也可以将一些配置内容以参数的形式添加在命令后面,比如我们想设置环境为 production ,可以在 webpack.config.js 中将 mode 设置为 production ,也可以在命令后面添加 --mode production

webpack --mode production

如果还需要其他的配置参数,可有继续在后面添加。这样做的好处是可以将一些多变的参数从配置文件中抽离出来,使用起来很灵活。

但是如果参数太多,每次使用的时候又要敲好多命令,可能还会敲错,为了方便管理我们可以将这些命令全部保存在 package.jsonscripts 属性中:

{
"scripts": {
"dev": "webpack --mode development",
"build": "webpack --mode production"
}
}

这样就可以通过 npm run build 命令进行打包了。

核心概念

经常看到有人抱怨 webpack 太难太复杂了,“我们万事俱备,就差一个webpack配置工程师了”。确实如此,相比于 gulp 简洁的 api ,webpack 确实复杂了许多。

其实仔细的梳理一下,webpack 最重要也就4个核心概念:

  1. entry 入口
  2. output 出口
  3. loader 模块转换器
  4. plugins 插件

除了这四个核心的概念,剩下的那些都是为了优化代码、让我们能有更好的开发体验而设计的。

mode

开头先讲一个 webpack4 中新增的选项:mode。可能是受 parcel 的刺激,webpack4 终于也可以零配置打包了,主要原因是 webpack 终于明白了一个道理:约定大于配置。

model 的值有三种:productiondevelopmentnone ,分别表示不同模式。

在 production 模式下,会默认启用下面这些插件:

  • process.env.NODE_ENV 的值设为 production
  • FlagDependencyUsagePlugin:删除无用代码
  • FlagIncludedChunksPlugin:删除无用代码
  • ModuleConcatenationPlugin:作用域提升
  • NoEmitOnErrorsPlugin:编译出现错误,跳过输出阶段
  • OccurrenceOrderPlugin
  • SideEffectsFlagPlugin
  • UglifyJsPlugin:js代码压缩

在 development 模式下,会默认启用下面这些插件:

  • process.env.NODE_ENV 的值设为 development
  • devtool 设置为 evel
  • NamedChunksPlugin
  • NamedModulesPlugin

entry

既然是模块化开发,就需要有一个入口文件,相关的模块就可以根据这个入口文件形成一个树形的依赖关系。

模块依赖关系
模块依赖关系

当然 webpack 还没有智能到可以自动识别出你的模块依赖关系,所以需要咱们来告诉它,如果你不告诉它则会默认把 src/index.js(webpack4.x+) 当做入口文件。

入口文件可以是一个文件(string):

// webpack.config.js
module.exports = {
entry: 'src/main.js'
}

也可以是多个文件(array):

// webpack.config.js
module.exports = {
entry: ['src/login.js', 'src/logout.js']
}

甚至也可以是一个对象(object):

// webpack.config.js
module.exports = {
entry: {
login: 'src/login.js',
logout: 'src/logout.js',
}
}

这三种写法的区别是:

  1. 传入一个文件(string)的时候,会把所有具有依赖关系的模块打包生成一个文件;
  2. 传入多个文件(array)的时候,还是会打包生成一个文件,webpack会把这些文件合并在一起,但是执行的时候会按照数组内文件的顺序依次执行;
  3. 传入对象的时候,则会根据对象key的个数,打包出对应数量的文件;

很显然,传入对象的方式更复杂,但也更利于扩展,同时也适合用来打包多页应用。

output

有进必有出,webpack 也需要我们指定打包后的文件存放位置,也叫做出口文件,和 entry 一样,output 也有默认值 dist/main.js(webpack4.x+) 。

下面是 output 常见的配置项:

// webpack.config.js
module.exports = {
output: {
path: __dirname + '/dist',
filename: '[name].bundle.js',
publicPath: '/assets/'
}
}
  • path

指定打包后的文件存放位置,注意这是一个 绝对路径 !上面的例子中用了 node 内置的常量 __dirname ,该常量表示当前执行文件所在的目录,所以我们打包出的文件就存放在和 webpack 配置文件同级的 dist 目录下面。

  • filename

打包后的文件名称,该选项有5个可配置项:

配置项 作用
[name] 模块名称,对应 entry 中的 key 值,如果 entry 传入的是 string 或 array 默认为 main
[id] 模块id,由 webpack 生成
[hash] 模块的 hash 值,当有文件修改时,这个值就会重新计算并改变
[chunkhash] 这也是一个 hash 值,webpack中每打包生成一个文件,就叫一个chunk ,它是 chunk 本身的 hash ,通常用它来做文件缓存

补充一个小知识,如果 entry 中传入的是对象,且对象的 key 值像这种形式 "a/b" ,并且在 output.filename 中设置了 [name] 那么打包出的文件会存放在 a 文件夹下的 b.js 中(a/b.js)。

  • publicPath

关于这个配置,笔者曾经纠结了好久,知道它的作用,却总是无法理解,在网上看了很多关于 publicPath 的介绍,包括 webpack 的官网,但一直没有豁然开朗的感觉,直到后来在自己的项目中遇到了一些问题,才算是明白了为什么会有这个选项。

如果不想看下面这些内容,可以直接查看 总结 ,建议第一次阅读的时候跳过下面这一小段,等到了 devServer.publicPath 再回过来看一遍。

这里我们可以反向的分析一下。首先,在设置了 path 和 filename 这两个属性之后,便可以确定打包出的文件在本机存放的具体路径了。然后需要明确一点,打包出的代码需要上传到 Web 服务器上,这些文件中可能有 .css .js .png 等等,它们最终都要以 .html 为载体,假设这个文件是 index.html 就像这样:

注意:index.html 是通过 html-webpack-plugin 插件生成的,下文会介绍到。

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>webpack</title>

<link href="index.css" rel="stylesheet">

</head>

<body>
<img src="hello.png" />

<script type="text/javascript" src="index.bundle.js"></script>
</body>

</html>

从这段简单的 html 中我们可以得到一个信息,那就是 index.csshello.pngindex.bundle.js 这个三个文件都放在相对于 index.html 的 同一级别下面,就像这样:

dist
├── index.html
├── hello.png
├── index.css
└── index.bundle.js

如果我们的 webpack 设置是下面这样的:

// webpack.config.js
module.exports = {
entry: {
app: './src/main.js',
},
output: {
path: __dirname + '/dist',
filename: '[name].bundle.js'
}
}

那么打包出来的文件结构应该是和上面的一模一样的,我们按照这个结构上传到 Web 服务器,不需要修改什么就可以直接访问了。

如果你公司条件好,有自己的 cdn 服务器,那么你可以把 index.csshello.pngindex.bundle.js 这些资源传到自己的 cdn 服务器上,假设你 cdn 地址是 https://mycdn.com 那么你可以通过 https://mycdn.com/index.css 的方式来访问相应的资源。这时候为了使我们的网站不报错,我们就需要将 index.html 中的资源引用方式改为 :

<link href="https://mycdn.com/index.css" rel="stylesheet">

<img src="https://mycdn.com/hello.png" />

<script type="text/javascript" src="https://mycdn.com/index.bundle.js"></script>

而如果我们不想做这样的修改的话,只需要将 output.publicPath 设置为 https://mycdn.com/ ,打包出来的 index.html 便会自动加上 output.publicPath 设置的值。

还有一种情况,就是笔者所遇到的情况了。假设你的项目还是传统的开发方式,并没有采用前后端分离,用的还是后端模板的方式。而你作为一个前端开发,你想要模块化开发所以引入了 webpack 来打包,后端的哥们跟你说,你把你打包出的文件放在咱们项目的 static 文件夹下面就行了。一开始都没什么问题,但是在做某一个功能的时候,你发现打包出来的文件体积有点大,而且有些代码可以通过按需加载的方式拆分一下,这时候你想到了用 import() 来动态加载,于是除了打包出了 index.js ,还有一些需要动态加载的 .js 文件,你把它们都放进了 static 下面。但是在调试的时候却发现,那些需要按需加载的资源无法加载了,全都是 404 ,咦?怎么回事小老弟!打开控制台看一下,所有 404 的资源地址都是 https://test.com/assets/xxx.js 。干!说好的 static 怎么变成了 /assets/ 了?后端的哥们跟你说,这是后端框架的原因,虽然你是放在 static 下面,但是请求的时候请求的是 相对于当前页面的 /assets/ 这个路径 ,总之 后端没法改,需要前端想办法解决。这个时候,我们只要把 output.publicPath 设置为 /assets/ 就可以解决这个问题了。


总结:

这个选项默认是 '' ,一般情况是不需要修改的。但是在有些情况下,打包出的资源部署上线后,可能会出现 404 访问不到的情况。这个时候就需要配置一下这个选项来解决这个问题了。

如果你将打包后的资源上传到 cdn 上面,那么需要将它设置为可以通过 cdn 方式访问的地址,比如 publicPath: 'https://mycdn.com/assets/'

如果你的项目在服务器上面目录结构和你打包出的文件结构不一样,比如你打包出来的 .html 和 .js 是平级的,但是在服务器上却把 .js 文件都放在 /assets 下面,那你需要设置为 publicPath: /assets/'

所以这个值并不会影响你打包出的文件路径,它只是用来设置在线上运行的时候,所请求的资源相对于 服务 /html页面 的路径

简单的说,在线上运行的时候,所请求的资源具体路径是 https://你的域名/publicPath/资源 或者 https://你设置的cdn地址/资源

output 的常用配置项就这三个,如果你想用 webpack 把你的代码打包成类库,你还需要配置一下 output.libraryoutput.libraryTarget 等,不过笔者建议直接使用 rollup 打包类库。

所以如果有下面这样一份 webpack 配置文件:

// webpack.config.js
module.exports = {
entry: {
app: './src/main.js',
},
output: {
path: __dirname + '/dist',
filename: '[name].bundle.js'
}
}

会打包出如下这些文件:

project
├── src // 源代码文件夹
│ ├── main.js
│ ├── login.js
│ └── logout.js
├── dist // 打包后生成的文件夹
│ └── app.bundle.js
└── webpack.config.js // webpack 位置文件

loader

个人认为 loader 是 webpack 中最厉害的一个功能了,它让我们可以在项目随意 import 各种类型的文件,css scss html img 等等都不在话下,如果有相关的 loader 支持,甚至可以 import 其它语言的代码。

简单的说 loader 就是一个处理器,在 webpack 中配置好相应的 loader 之后,就可以在代码中像加载 JavaScript 模块一样使用 import 把其它类型的代码当做 JavaScript 模块加载。

loader 的用法有三种

  1. webpack.config.js 中配置,这种方式是最常用的,下面会着重介绍。
  2. 在代码中显示的指定 loader ,下面的代码表示从 styles.css 加载样式文件,用 style-loadercss-loader 来处理 css 文件。
import styles from 'style-loader!css-loader?modules!./styles.css';
  1. 在命令行中为某些类型文件执行 loader 。下面的命令表示在打包过程中,对 .css 文件使用 style-loadercss-loader 来处理。
webpack --module-bind 'css=style-loader!css-loader'

loader 的配置

loader 的配置其实比较简单,只是提供了太多简写,让新手有点摸不着头脑,首先用 JavaScript、TypeScript、css、scss来展示常用的几种配置方式:

// webpack.config.js
module.exports = {
entry,
output,
module: {
rules: [{
test: /\.js$/,
loader: 'babel-loader',
options: { presets: ['env'] },
include: __dirname + '/src'
},
{ test: /\.tsx?$/, use: 'ts-loader' },
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.scss$/,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: { modules: true }
},
{ loader: 'postcss-loader' },
{ loader: 'sass-loader' }
]
}]
}
}

从上面的代码中我们可以发现,use 这个选项的配置是最没节操了,它可以是字符串、数组、甚至被 loader 这个选项代替,其实这都是简写。rules.loaderloader.optionsrules.use: [ {loader, options} ] 的简写。这些配置项的含义分别是:

  • test: 正则表达式,用来匹配文件的扩展名。
  • use: 对匹配出的文件使用的 loader 配置,如上面所说,该选项配置灵活,可以简写
  • loader: loader 名称
  • options: loader 的额外配置选项
  • include / exclude: 包括 或 排除 的文件夹,两个选项只能同时出现一个,上面的例子中 include: __dirname + '/src' 表示 babel-loader 只编译 /src 文件下的文件,其它的不做处理;相反的,exclude: __dirname + '/src' 表示不编译 /src 下的文件。

下面就来详细的介绍一下常用的 loader 和其配置。

(题外话,本来是先将 babel-loader 放到第一个介绍的,但是由于篇幅较长,且有些难懂,所以将其放到了后面)

样式处理

npm i style-loader css-loader less less-loader node-sass sass-loader postcss-loader autoprefixer -D

对于样式文件的处理,我们(我)通常会用到以下这些 loader :

  • style-loader
  • css-loader
  • postcss-loader
  • less-loader
  • sass-loader

那么这些 loader 的使用场景和区别是什么呢?

  1. 首先介绍 less-loader 和 sass-loader 。less 和 sass 都是 css预处理器,可以让 css 编写起来更爽,但是不能直接在浏览器中运行,所以需要先将 .less.scss 文件先转换成 css 。这就是 less-loader 和 sass-loader 的作用。

  2. 无论是直接编写的 css ,还是由 less 或 sass 转换而来的 css 都不是 JavaScript 模块,这时候就要用到 css-loader ,它的作用就是把 css 转成 JavaScript 模块插入到代码中。

  3. 样式文件已经转换好了,但并不会产生任何效果。因为这些样式还没有添加到页面中,这时候就该轮到 style-loader 出场了,它的作用就是把转换后的样式添加到页面中,就像下面这样。

style-loader将样式插入到页面中
style-loader将样式插入到页面中
  1. 最后还有 postcss-loader 它的作用也很强大,最常用的功能就是帮助我们自动为一些样式属性名添加私有前戳(-moz、-ms、-webkit)。写过 vue 的同学都知道,当我们给 style 标签添加 scope 属性的时候,打包后的类名会自动添加自定义属性(例如 .panel[_v-72f4cef2]),这个功能就是基于 postcss-loader 实现的。

    postcss 需要一份配置文件,这份配置文件以写在单独的文件中 (postcss.config.js),也可以写在 package.jsonpostcss 属性中:

    {
    "postcss": {
    "plugins": {
    "autoprefixer":{}
    }
    }
    }

对 postcss 感兴趣的同学可以看看这篇文章: PostCSS真的太好用了!

这些 loader 的执行顺序是 :

sass-loader or less-loaderpostcss-loadercss-loaderstyle-loader

通过对这些 loader 的配置,我们就可以把样式文件当做 js 文件一样引入了。

// styles.css
.red { color: red; }
// index.js
import './styles.css';

这里需要在额外提一下 css module ,这也是一个很好的特性,写 react 的朋友对它应该很熟悉:

// index.js
import styles from './styles.css';

export default () => (
<h2 className={styles.red}>css module</h2>
);

从上面的代码中可以看出,我们将样式当做 对象 styles 导入 jsx 中,那么该样式下的所有类名就是 styles 的属性名了。

这样的写法也同样适用于 ES6 的模板字符串:

// index.js
import styles from './styles.css';

const html = `<h2 class="${styles.title}">css module</h2>`;

document.body.innerHTML = html;

只要在 css-loader 的 options 中设置 { modules: true } 既可以开启此功能。

上面的这些配置,只能帮我们将样式文件以 <style></style> ,但我们更希望可以将这些样式从 js 文件中抽取出来放到 css 文件,一来这样显得更优雅一些,二来可以减少 js 为文件体积,避免动态创建 style 标签所带来的性能损耗。这个功能需要在 plugins 中进行设置,下面也会讲到。

file-loader、url-loader

npm i file-loader url-loader -D

如果我们在页面中通过相对路径来引入图片、音频、视频、字体等文件资源时,在 webpack 环境中可能出现路径怎写都不对的问题。主要原因是 开发时的目录结构打包后的目录结构 一般都是不一样的,因此导致路径失效,而 file-loader 就是为了解决这个问题的。

  • file-loader 可以解析页面中引入的资源的路径,然后根据配置,将这些资源拷贝到打包后的目录中。

  • url-loader 则是对 file-loader 进行了一次封装,如果解析的资源是图片,则可以将改图片转成 base64 从而减少 http 请求一提升性能,同时也可以设置 limit。 只对指定大小的图片进行转换。

同样的也可以在 js 中引入资源

// index.js
import logo from './images/logo.png';

const img = new Image();
img.addEventListener('load', () => document.body.appendChild(img));
img.src = logo;

下面是 url-loader 的简单配置参考:

// webpack.config.js
module.exports = {
entry,
output,
module: {
rules: [{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: [{
loader: 'url-loader',
options: {
limit: 10000, // 10KB 转换为base64
name: 'images/[name].[ext]' // 拷贝到 images 目录下
}
}]
}]
}
}

html-loader

npm i html-loader -D

在 Web 开发中,通常会用到很多 html 模板,传统的方式是将模板存在服务端,前端通过 http 请求加载模板,或者在 JavaScript 中拼接字符串,或者在页面中将模板内容写在 <script type="text/template"></script> 内。

而在 webpack 环境下,我们也可以把 html模板 当做 JavaScript 的模块来加载,以 Vue 为例:

<!-- template.html -->
<h2>{{ title }}</h2>
// index.js
import tpl from './template.html'

new Vue({
el: '#app',
template: tpl,
data: {
title: 'Hello Webpack'
}
});

在上面代码中,我们将 template.html 的内容以字符串方式导出,这正是 html-loader 的功能,也可以在配置只启用压缩功能。

// webpack.config.js
module.exports = {
entry,
output,
module: {
rules: [{
test: /\.html$/,
use: [{
loader: 'html-loader',
options: {
minimize: true // 开启压缩
}
}]
}]
}
}

babel-loader(重点)

npm i @babel/core babel-loader @babel/preset-env @babel/runtime @babel/plugin-transform-runtime -D

Babel is a compiler for writing next generation JavaScript.

从官方的简短介绍中可以知道, babel 属于编译器,输入 JavaScript 源码,输出 JavaScript 源码(source to source),其作用就是将目前部分浏览器目前还不支持的 ES2015+ 语法转换为 ES5 语法。

babel-loader 则是让 babel 可以在 webpack 中使用的工具,同理如果你使用的是 gulp ,则需要用到 gulp-babel 这个包。

实际上,如果只是用 babel 的话,输入的代码和编译后输出的代码是相同的(被 webpack 混淆打包的代码与 babel 无关)。因为 babel 的转换工作全都是由 babel 的插件来完成的

关于 babel 的介绍和使用,仅仅一个小节的篇幅是完全不够,所以这里贴一个链接,有兴趣的读者一点要点进去看一下 一口(很长的)气了解 babel

babel 也是需要进行配置的,一般有两种方式:

  1. 在根目录创建 .babelrc
  2. package.jsonbabel 属性中进行配置

我更倾向于在 package.json 进行配置,因为根目录放置太多文件,强迫症实在无法接受。无论是在 .babelrc 还是 package.json 中配置,配置的内容都是一样的,下面以在 package.json 中配置为例:

{
"babel": {
"presets": [
[
"@babel/preset-env",
{
"modules": false,
"targets": {
"browsers": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}
}
]
],
"plugins": [
"@babel/plugin-transform-runtime",
"@babel/plugin-syntax-dynamic-import"
]
}
}

在该配置中,presetsplugins 对应的值都是数组,同时数组的每一项可以是 string (只指定名字),也可以是 array (指定名字,并进行更具体的配置)

plugins 表示用到的插件,比如我们在代码中使用到了 import() 动态加载模块这个语法,那么就要在 plugins 添加 @babel/plugin-syntax-dynamic-import 这个插件了;我们需要对 babel 编译后的代码进行去重,就需要用到 @babel/plugin-transform-runtime 。 当然,这两个插件也是需要单独安装的 npm i @babel/runtime @babel/plugin-transform-runtime @babel/plugin-syntax-dynamic-import -D

presets 一组 plugins 的集合。比如我们可以把 @babel/plugin-transform-runtime 和 @babel/plugin-syntax-dynamic-import 打包到一起,叫 preset-my ,这样我们只需要在 presets 中添加 preset-my 就可以了,省去了对 plugins 的配置 。上面的配置文件只配置一个 @babel/preset-env ,这是最常用的配置,@babel/preset-env 后面的对象是对 @babel/preset-env 具体配置。我们注意到,其中有一个 targets.browsers 属性,指定了浏览器版本,这个属性也可以放在 package.jsonbrowserslist 中。

为什么配置了 presets 还需要配置 plugins 呢?很简单,如上面所说, presets 是一组 plugins 的集合,也就说 babel 对不同阶段的语法做了整合,方便我们使用。但是在上面的配置中,我们只使用了 @babel/preset-env 这个集合里的插件,而 import() 处于 stage-3 阶段(记不太清了,也可能是 stage-2),不包含于 @babel/preset-env ,所以就需要在 plugins 单独添加 @babel/plugin-syntax-dynamic-import 插件来对 import() 语法进行转换了。

社区中也提供了一些 presets ,比如 react 的 @babel/preset-react , vue 的 @vue/babel-preset-app

babel 的执行顺序是:

读取plugins数组按正序执行plugins内插件读取presets数组按倒序执行presets内容

简单的介绍了 babel 后,开始配置 babel-loader :

// webpack.config.js
module.exports = {
entry,
output,
module: {
rules: [{
test: /\.js$/,
loader: 'babel-loader',
// options: { presets: ['env'] }, 该项的配置和上面babel的配置完全相同,已经在package.json配置过,这里不需要再配置
include: __dirname + '/src' // 只对 ./src 目录下的代码进行编译
}]
}
}

ts-loader

npm i typescript ts-loader -D

如果你的项目是用 typescript 开发的,这时候就要样到 ts-loader 了。

ts-loader 的配置比较简单,但是有许多需要注意的细节,详情可以参照这里:https://github.com/TypeStrong/ts-loader/blob/master/README.md#configuration

plugins

讲完了 entry、output 和 loader,下面开始讲讲 plugins 。细心的读者应该已经发现,还没有提到代码的压缩,而且按照上面的方式打包会把 .css.js 文件打包在一起,并且打包后的文件体积很大,可能还会存在冗余的代码等等一些问题,plugins 就是为了解决这类问题而产生的。

这里不要把 loaderplugins 搞混了,laoder 只是把特定的文件类型转换成 JavaScript 模块plugins 是在打包过程中对所有模块进行特定的操作plugins 的值是一个数组,所有的 webpack 都需要手动通过关键字 new 来实例化。 下面就介绍一些常见的插件。

html-webpack-plugin

npm i html-webpack-plugin -D

webpack 是对 JavaScript 进行打包的,打包出的只能是 .js 文件。 而 JavaScript 要想在浏览器中运行,那就必须在 html 中通过 script 的方式引入。在没有其他工具帮助的情况下,我们只能手动创建 html 文件,然后再把打包后的 .js 文件和 .css 文件写到这个文件中,这样做很麻烦。这时候可以用 html-webpack-plugin 这个插件来自动完成上面的工作。

html-webpack-plugin 提供了一些配置项,如果不行配置,它会自动帮我创建一个空的 html 文件,然后将打包后的资源插入到这个页面内:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry,
output,
plugins: [
new HtmlWebpackPlugin() // 创建 /dist/index.html 文件,并将 index_bundle.js 插入到这个页面中。
]
}

同样,我们也可以为其指定一个模板页:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry,
output,
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html', // 生成的文件名称,默认为 index.html
template: 'src/index.html', // 以 src/index.html 为模板文件
inject: 'body', // 将打包后的文件注入到 body 区域内
title: 'Hello webpack', // 生成文件的标题
minify: { // 对生成的文件进行压缩,可以设置为 true ,也可以是对向,进行更具体的配置
collapseWhitespace: true, // 删除空格
minifyCSS: true,
minifyJS: true,
removeAttributeQuotes: true,
removeComments: true,
removeTagWhitespace: true,
}
})
]
}

插件也可以通过多次实例化来重复使用:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry,
output,
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'src/index.html',
chunks: ['index', 'vendor'] // 只注入 index.bundle.js 和 vendor.bundle.js
}),
new HtmlWebpackPlugin({
filename: 'about.html',
template: 'src/about.html',
excludeChunks: ['index'] // 将 index.bundle.js 排除,其余的都注入
})
]
}

分离css和js

  • webpack v4
    npm i mini-css-extract-plugin -D

前面在介绍用 loader 处理样式的时候说到,这些样式最终会被混入到打包后的 .js 文件中,在页面运行的时候,在以 <style></style> 的方式动态的插入到 DOM 节点中,这种做法有两个很明显的缺点:

  1. js 和 css 糅杂在一起,增加了单个文件的体积。
  2. 在页面运行时动态的去创建 style 标签,多多少少会有些性能影响

如果能把这些 css 从打包后的 js 中抽取出来,就可以解决上面的两个问题,这时候就要用到 mini-css-extract-plugin 这个插件了。

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
entry,
output,
module: {
rules: [{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css',
})
]
}

从上面的配置中可以看出,mini-css-extract-plugin 并不是单独作为一个 plugin 来使用的,它还充当了 loader 的作用,代替了 style-loader 。前面在介绍 style-loader 的时候提到,它的作用是将转换后的样式插入到页面中,既然我们现在需要将 css 和 js 分离开,所以也就不需要再用到 style-loader 了。

当作为插件使用的时候, mini-css-extract-plugin 可以接受两个可选参数:

  • filename :分离出的css文件名称,写法和 output 的 filename 选项相同,唯一区别是当你想使用缓存的时候,填写的是 contenthash 而不是 chunkhash
  • chunkFilename :切割出的css文件块名称,写法和 filename 相同

最近发现 extract-text-webpack-plugin 也支持 webpack4 用法了 mini-css-extract-plugin 完全相同,而且相较于 mini-css-extract-plugin 还多了一些可选的配置

npm i extract-text-webpack-plugin -D
// webpack.config.js
const ExtractCssChunksPlugin = require('extract-css-chunks-webpack-plugin');

module.exports = {
entry,
output,
module: {
rules: [{
test: /\.css$/,
use: [ExtractCssChunksPlugin.loader, 'style-loader']
}]
},
plugins: [
new ExtractCssChunksPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css',
hot: true, //HMR 下面会着重介绍
orderWarning: true, // Disable to remove warnings about conflicting order between imports
reloadAll: true, //当启用HMR时,强制重新加载所有css
cssModules: true //如果启用了 cssModules 此选项设置为 true
})
]
}

压缩css

npm i optimize-css-assets-webpack-plugin -D

在将 css 从 js 中分离出来不之前,我们是不需要考虑压缩 css 的,因为样式都被打包进了 js 文件中,当我们设置 mode 为 production 时,webpack 会自动压缩 js 文件。但是我们现在将 css 从 js 中分离出来了,webpack 目前还不能自动压缩 css 文件。干!真是麻烦!这时候又要用到插件来帮我压缩分离出来的 css 文件了。

// webpack.config.js
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
entry,
output,
plugins: [
new OptimizeCssAssetsPlugin()
]
}

这里讲一个坑,在 webpack4 之前,压缩都是通过 webpack.optimize.UglifyJsPlugin 这个插件来完成。webpack4 新增了 mode 和 optimization 两个选项,当 mode 设置为 production 时会自动压缩 js 文件(这个已经提过多次了),其实将 mode 设置为 production 时, optimization.minimize 便会默认设置为 true ,意思就是在打包的时候对 js 进行压缩。而如果你想用第三方压缩插件,你可以将插件写在 plugins 中,也可以写在 optimization.minimizer 中。但是如你将压缩插件写在 optimization.minimizer 中时,webpack 就会默认读取 ptimizatio.minimizer 这个选项了,这也就意味着,这时候如果你不手动的配置 js 压缩插件,js 文件是不会被压缩,这时候又需要寻找压缩 js 的插件,比如 uglifyjs-webpack-plugin ,然后再配置一下,说实话这样真的很烦,所以我直接将压缩的插件配置在了 plugins 中,这样就省去了对 js 压缩插件的配置。webpack 的文档中描述了相关说明 Minimizing For Production

复制静态资源

npm i copy-webpack-plugin -D

有时候我们的项目中会有一些静态资源,比如网站的favicon、你从不知道的地方找来的不知名的js插件等等,这些静态资源并不会在项目中通过 import 的方式显式的加载进来,而是在直接写在页面中

...
<link rel="shortcut icon" href="static/favicon.ico">
...
<script src="static/xxx.js"></script>

对于这些静态资源,webpack 在打包过程中不会对它们进行处理,所有需要我们 copy 到打包后的目录中,从而保证项目不会因为缺少这些静态文件而报错, copy-webpack-plugin 的作用便是 copy 这些静态资源到指定的目录中的。

// webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
entry,
output,
plugins: [
new CopyWebpackPlugin([
{ from:'static/**',to: 'dist/static' }
])
]
}

上面的配置表示将 static 文件夹下所有的文件都复制到 dist/static 下面,如果你熟悉 gulp 的话,你会发现这其实就是一个移除了 pipe 的 gulp。

其实对于copy文件这种脏活累活你也可以用你熟悉的方式来完成,比如 gulp、fs-extra 等。

clean-webpack-plugin

npm i clean-webpack-plugin -D

如果我们打包输出的文件使用了 chunkhash 、 hash 等来命名的话,随着文件的变更和打包次数的增加,dist 目录会淤积很多无用的打包文件,这时候便可以借助 clean-webpack-plugin 帮我们清除一些这些无用的文件

// webpack.config.js
const CleanWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
entry,
output,
plugins: [
new CleanWebpackPlugin('dist', {
root: __dirname,
verbose: true,
dry: false
})
]
}

和 copy 文件一样,删除文件这种话不一定非得让 webpack 来做,我们也可以借助其他的方式来完成,比如我要再提一遍的的 gulp ,又或者 rimrafdel 等。但是区别是你需要手动的控制一下任务的流程,总不能在打包完成才删除问吧,所以用 webpack 提供的插件是不需要考虑任务流程的问题。

上面介绍了5个 webpack 的 plugin ,主要目的是让大家体会 webpack plugin 的作用基本用法。 实际上 webpack 的 plugin 还有很多很多,几乎可以满足你在项目构建中的各种需求,webpack 官网了列举很多官方推荐的 plugin https://webpack.js.org/plugins/ ,有兴趣的同学可以前往查看。

tree-shaking

tree-shaking 这个技术在 webpack2 就已经被添加进来了,作用是在打包过程中,将模块内没有使用的代码删除,从而减小打包后的文件体积。

这个单词表面的意思是,有一棵小树,你去抖动这棵树,那么树上多余没用的树叶就会掉落,那在代码中具体是什么样子呢。假设我们现在将一些常用的方法都封装在了 util.js 这个文件中:

// util.js 
function add(...args) {
return args.reduce((prev, currrent, index) => {
return prev + currrent;
}, 0);
}

function multiply(...args) {
return args.reduce((prev, currrent, index) => {
return prev * currrent;
}, 1);
}

export {
add,
multiply
}

然后我们在 index.js 中需要用到 add 这方法:

// index.js
import { add } from './util.js';

add(1, 2);
add(1, 2, 3);

这样打包后的代码是不含有 multiply 这个函数的,这就是 tree-shaking 的作用。

splitChunk

现在我们再回顾一下 webpack 打包过程

  1. 以 entry 为起点,将所依赖的模块组织成一个树形的结构
  2. 通过不同的 loader 对不同的文件进行编译
  3. 使用 plugins 对文件打包后的文件进行特定的操作
  4. 根据 output 将打包后的文件输出到指定的位置

如果只有一个入口文件,最终也只会打包出一个文件(下文用 chunk 表示,每打包出的一个文件就叫一个 chunk)(排除动态加载的情况(import()))。这里有一个很明显的缺陷,就是将所有的模块打包成一个文件,打包后的体积一定会很大。同时,如果我们使用了 chunkhash 做文件缓存的话,每次项目修改的时候,无论修改哪个文件,即使是修改了一个换行,chunkhash 的值都会发生改变,那么每次改动上线之后,用户都要重新加载这个巨大的文件,这样用户体验非常糟糕。如果你说我不做文件缓存,那么由于浏览器缓存的原因,用户首次加载的文件会被缓存到本地,下次即使你更新了代码,用户执行的还是首次加载的文件,这样老板会找你聊天的。

为了解决这个问题,我们可以考虑设置多个入口文件,就像在介绍 entry 的例子代码中那样:

// webpack.config.js
module.exports = {
entry: {
login: 'src/login.js',
logout: 'src/logout.js',
}
}

通过这样的配置,我们就可以将 login.js 和 logout.js 打包成两个文件,而且修改其中一个文件不会影响到另一个的 chunkhash。看起来好像已经解决了上面的问题,但是我们再结合实际的项目深入的分析一下,我们通常会在项目中引入一些类库,比如常见的 lodash ,假设 login.js 和 logout.js 中都用到了 lodash ,这就需要在这个两个文件中显式的 import _ from 'lodash'; 这样一来,打包出来的两个文件都包含了 lodash ,这就属于重复引用了,另外如果我们的项目是单页应用,理应只有一个入口,在需要的时候再去加载 login.js 或 logout.js 的代码。

所以我们要解决我们一开始的问题,应该从下面两个点出发:

  1. 分离代码中公共的部分,打包成一个或多个chunk
  2. 将不需要立刻执行的代码分离出来,打包成多个 chunk ,然后通过动态加载的加载这些chunk

针对第一点,我们可以使用 webpack 提供的 SplitChunksPlugin 插件,这个插件和上面介绍的 minimize 一样,需要在 optimization.splitChunks 中配置。在 production 模式下 webpack 会默认做一下代码分离的工作,但是没多大的卵用,所以还是需要我们自己动手配置。

第一步先将来自 node_modules 中的包分离出来,因为这些都是项目所依赖的第三方库,我们是不会改动的(除非升级版本),这些可以做通过 chunkhash 做长期缓存,我们把这写代码打包为 chunk-vendors

// webpack.config.js
module.exports = {
entry,
output: {
path: './dist',
filename: '[name].[chunkhash:8].js' // 只取chunkhash的前8位
},
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
name: `chunk-vendors`,
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
}
}
}
}
}

在上面的配置中,我们用 cacheGroups 将 node_modules 的代码全部分离出来。 cacheGroups 直译成中文就是缓存组,其实就是存放分离代码块规则的对象,第一个对象的 key 值是 vendors ,这个 key 值没什么用,主要还是看对应的 val 。

  • name:分离后打包出的文件名称。我们设置为 chunk-vendors ,那么打包出来的文件就叫 chunk-vendors.js 。因为在 output.filename 设置了 chunkhash:8,所以最终打包出的文件名称是 chunk-vendors.ac96737b.js 。后面的一串字符就是 chunkhash 的前8位。前面介绍过 chunkhash 是每一个打包出来的文件的 hash ,只要文件的内容没有改变,这个值就不会发生变化,所以只要不对我们依赖的包进行版本升级,或者增加新的包,这个值就不会变动,因此可以用这个办法进行长期缓存。
  • test:用于匹配的文件位置,test: /[\\/]node_modules[\\/]/ 表示所有来自 node_modules 下面的代码,可以填写具体的路径
  • priority:权重,这个值还是很重要的,webpack 会优先分离权重高的 cacheGroups 。
  • chunks:作用范围,可以设置为 async 表示对异步模块起作用, initial 表示对初始模块起作用, all 则表示对所有模块起作用。

如果打包出的 chunk-vendors 体积很大,而且包含一些经常升级的依赖,那么我们可以继续做拆分

// webpack.config.js
module.exports = {
entry,
output: {
path: './dist',
filename: '[name].[chunkhash:8].js'
},
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
name: `chunk-vendors`,
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
},
vue: {
name: 'chuank-vue',
test: /[\\/]node_modules[\\/]vue[\\/]/,
priority: 10,
chunks: 'initial'
}
}
}
}
}

这样我就将 vue 分离成单独一个 chunk 了,不仅减小了 chunk-vendors 的体积 ,当我们升级 vue 版本的时候,也不会影响 chunk-vendors 的 chunkhash 。注意:不要忘了设置 priority 。

除了将 node_modules 中的类库分离出来,我们自己写的代码中也有些公共的部分,比如在讲 tree-shaking 提到了 util.js ,作为一个工具方法,跟定会在项目中好多处用到,那么我们也可以将会这个公共代码分离出来:

// webpack.config.js
module.exports = {
entry,
output: {
path: './dist',
filename: '[name].[chunkhash:8].js'
},
optimization: {
splitChunks: {
cacheGroups: {
common: {
name: `chunk-common`,
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
}
}
}
}

在上面的配置中,我们把被依赖超过两次(minChunks: 2)的 chunk 都分离到了 chunk-common.f4786e34.js 中。

在解决了对公共代码的分离,下一步即使处理动态加载的代码,之一部分相对简单一些,就像在介绍 babel 时提到的那样,通过 import() 来切分动态加载的代码。

webpack 在将我们的代码打包后,也会生成一些在运行时所必须的代码,这些代码默认会打包进主文件中,我们也可以将它分离出来单独打包成一个文件,这需要在 optimization.runtimeChunk 中单独配置:

// webpack.config.js
module.exports = {
entry,
output,
optimization: {
runtimeChunk: {
name: 'manifest'
}
}
}

这样就可以将运行时的代码也分里出来,打包为 manifest.js。

其实代码拆分是需要反复尝试的,一般情况下我们只会将 node_modules 里的包分离成一份(chunk-vendors.js), 业务中公共的代码分离成一分(chunk-common.js),剩下的都放在了主模块(main.js) 和动态加载的 chunk 中了。但是由于项目的不同,这种方式未必是最好的,所以这需要我们反复的去尝试一各种分离的方式,为了让我们对打包后的代码有更为直观的认识,我们可以借助 webpack-bundle-analyzer 来帮我们很直观的看到打包后每一个 chunk 的大小。

webpack-bundle-analyzer
webpack-bundle-analyzer

webpack-dev-server

在上面的介绍中,都是面向打包的,也就是说我们默认代码是无误可以直接打包上线运行,当然这是不可能滴,实际开中需要配合 Google 和 fuck 来 debug 代码,如果用上面的方法来 debug 我相信不管是谁,都会想砸电脑的,因为每次 debug 都要重新的打包,然后再想办法再本地启动一个web服务,用来托管我们打包出的静态文件。那么 webpack 可不可以帮我做到这两点呢:

  1. 监听文件变化,自动重新编译
  2. 创建一个web服务,用来托管打包后的静态文件,方便本地调试

为了解决上面两点,webpack 提供了 webpack-dev-server 这个包,它可以轻松的帮助我们实现上面两功能,这个包需要单独安装一下

npm i webpack-dev-server -D

然后在 npm script 中添加一行 :

// package.json
{
"scripts": {
"dev": "webpack-dev-server --mode development",
"build": "webpack --mode production"
}
}

这时候在命令行中执行 npm run dev ,便会在本地启动一个Web服务,当命令行中出现 Compiled successfully 便表示服务启动成功,然后打开浏览器,输入 localhost:8080 便可以直接访问项目了。当源代码发生变化时,便会自动重新编译,然后刷新浏览器。

webpack-dev-server 同样也提供了一些配置选项,可以在配置文件的 devServer 中进行配置:

// webpack.config.js
module.exports = {
entry,
output,
devServer: {
port: 8080, // 设置端口为8080,默认就是8080
open: true, // 编译完成后自动打开浏览器
historyApiFallback: true, // 如果你的项目使用了 HTML5 history API ,开启此项可以将所有的跳转将指向index.html
}
}

这些配置也可以以参数的形式添加在加命令行后面,但是有的配置只能以参数的形式使用,比如我们想查看编译的进度,就需要加上 --progress :

// package.json
{
"scripts": {
"dev": "webpack-dev-server --mode development --progress",
"build": "webpack --mode production"
}
}

学会了如何使用,在简单的介绍一下 webpack-dev-server 的工作原理,webpack-dev-server 是一个基于 express 封装的 Web 服务,当我们在执行 webpack-dev-server 时候,虽然可以看到打包之后的运行效果,但是实际上并没有生成打包后的文件,这是因为 webpack-dev-server 将打包后的内容放在了内存中,当某一个源代码文件发生变更的时候,它也不会重新的再将所有的文件打包一遍,而是只更新了一部分文件,这样的好处是可以加快重新编译的速度,加大程度的减少了开发模式下的编译时间。

讲到这里,你可能也意识到了,如果是开发模式下,有许多事情都不需要做。比如不需要设置 output ,不需要对代码压缩,不需要分离 css 和 js 等等,如果省去这些工作,首次编译的速度又会有大幅度的提升,这是一个优化点,会在后面讲到。

HMR

HMR (hot module replace) 模块热替换,在不刷新页面的情况下更新代码。

在引入了 webpack-dev-server 之后,我们可以做到监听源代码变化,然后刷新浏览器及时看到修改效果。但是在前端开发中,每一步操作往往都伴随着状态和 dom 的变化,比如我们开发一个定外卖的网站,此时正在调试购物车功能,先加了一份煲仔饭,为了满减,再加一份荷包蛋,但是这时候后出现了bug,加了荷包蛋还是没有满减,原来是计算满减的方法写错了,修复这个bug之后,我们发现页面刷新了,回到最开始的样子,于是又要从选择店铺开始在走一遍流程。那可不可以在修复计算满减的方法之后,不要刷新页面也能看到正确的效果呢?这就是 HMR 实现的功能了。

开启 HMR 需要将 devServer.hot 设置为 true ,然后在 plugins 中添加 HotModuleReplacementPlugin 插件,该插件是 webpack 自带的一个插件:

// webpack.config.js
module.exports = {
entry,
output,
plugins: [
new webpack.HotModuleReplacementPlugin()
]
devServer: {
hot: true,
/* 其他配置 */
/* ... */
}
}

还有一种更简便的方法来开启 HRM ,那就是在命令行中添加参数 --hot ,然后在执行 npm run dev 的时候也会自动添加 HotModuleReplacementPlugin 插件。

现在我们在 webpack 中开启了 HMR 功能,webpack 可以将老模块替换为编译后的新模块,但是从浏览器层面考虑,浏览拿到新模块之后,并不知道要做什么处理,就像我们前面举的例子中提到,在修改计算满减方法之后,我们希望重新执行一遍这个方法,很明显这个需求不太现实,浏览名没那么聪明。所有这就需要我们显式的用代码来告诉浏览器来做哪些事情。

我们可以在项目代码中通过 module.hot 来判断是否启用了 HMR ,通过 module.hot.accept 来处理模块更新后的要做的事情,现在假设我们的项目入口文件是 index.js ,还有一个 util.js 里面封装了 add 方法:

project
├── src
│ ├── index.js
│ ├── util.js
│ └── index.html
└── webpack.config.js
// util.js
function add(...args) {
return args.reduce((prev, currrent, index) => {
return prev + currrent;
}, 0);
}

export { add }

然后我们在 index.js 中导入 add 方法,并且将计算结果显示在页面上:

// index.js
import { add } from './util.js';

const h2 = document.createElement('h2');
h2.innerHTML = add('1', '2');

document.body.appendChild(h2);

将项目跑起来之后,发现 add 方法计算的结果错了,经排查发现原来 add 方法忽略了对 string 类型的转换,只要修改一下 util.js 中的 add 函数就好了:

// util.js
function add(...args) {
return args.reduce((prev, currrent, index) => {
return prev + currrent * 1;
}, 0);
}

export { add }

这时候可以发现,页面中虽然显示了正确的结果,但是页面刷新了,而我们希望的是在页面不刷新的情况下显示正确结果,这时候就要在 index.js 添加热更新后需要执行的代码了:

// index.js
import { add } from './util.js';

const h2 = document.createElement('h2');
h2.innerHTML = add('1', '2');

document.body.appendChild(h2);

if (module.hot) {
module.hot.accept('./util.js', () => {
h2.innerHTML = add('1', '2');
});
}

这样再去修改 add 方法的时候,h2 显示的内容会发生变化,但是页面却不会刷新,这才是我们想要的热更新。

讲到这里你可能已经发现,实现一个完美的热更新,难点不是在 webpack 的配置,而是在我们的项目代码中,我们要针对所有需要热更新的模块加上热更新之后的回调( module.hot.accept ),不过社区中已经提供了一些 loader 使 HMR 与各种框架平滑地进行交互 https://webpack.js.org/guides/hot-module-replacement/#other-code-and-frameworks

如果需要样式热更新的话,我们需要判断当前的环境变量是否为 development ,然后将 MiniCssExtractPlugin.loader 换成 style-loader ,因为 MiniCssExtractPlugin 还不支持 HMR :

// webpack.config.js
const styleLoader = process.env.NODE_ENV === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader;

module.exports = {
entry,
output,
module: {
rules: [{
test: /\.css$/,
use: [styleLoader, 'css-loader']
}]
}
}

content-base

在 plugins 这一章节中,提到了 copy-webpack-plugin 这个插件,它是用来将一些静态资源拷贝到打包后的目录,但是在开发环境下,我们是通过 webpack-dev-server 创建一个 Web 服务,它的根目录默认是配置文件所在的目录,所以在开发模式下,如果需要请求一些静态资源,那么我们就需要设置一下 contentBase

假设我们的静态资源放在了项目根目录下的 static 文件夹下面,而且配置文件 webpack.config.js 也放在了项目根目录下,那么我么就可以将 devServer.contentBase 设置为 static

// webpack.config.js
module.exports = {
entry,
output,
devServer: {
contentBase: 'static'
}
}

假设 static 下面有一个图片 logo.png ,我们就可以通过 localhost:8080/logo.png 来访问这张图片了。

public-path

在介绍 output.publicPath 的时候提到,这个值并不会影响打包后输出的文件路径,他只是设置在线上运行的时候,所请求的资源路径,当我们在 webpack-dev-server 这个 Web 服务下调试我们的代码的时候,可能也会出现和类型的情况,这时候就需要设置一下 devServer.publicPath 了。它 output.publicPath 的区别的是 一个作用于线上环境,一个作用于我们调试的开发环境。

proxy

在开发过程,经常需要调用后端提供的接口,一般情况会把接口部署在测试环境,比如 http://test.api.com 然后我们在项目中通过 ajax 的方式去调用。由于同源策略,我们在开发的时候通过 webpack-dev-server 启动的 Web 服务的域是 localhost:8080 ,很明显跨域了,接口无法调用了。这个时候有两种办法解决,一是在测试上环境上配置 cors ,将我们的 localhost 加入允许跨域的名单;二是我们在本地利用 node 去请求这个接口,然后再将请求内容发送给前端,在整个过程中 node 扮演的角色就是一个可靠的跑腿子,你去把请求交给它,它把请求送给测试环境,测试环境把响应交给它,它再把响应送到你这边。

proxy示意图
proxy示意图

在 webpack-dev-server 集成了中间件 (http-proxy-middleware)[https://github.com/chimurai/http-proxy-middleware] 可以很轻松的完成接口转发,比如我们想将所有的以 /api 开头的请求都转发到 http://test.api.com 只要在 devServer.proxy 像下面这样配置即可:

// webpack.config.js
module.exports = {
entry,
output,
devServer: {
proxy: {
'/api': 'http://test.api.com'
}
}
}

devServer.proxy 暴露出的配置项和 (http-proxy-middleware)[https://github.com/chimurai/http-proxy-middleware] 的配置项完全一样,具体可以点击链接查看。

mock

后端已经写好的接口我们我们可以用转发的方式调用,而对于还没有写好的接口我们可以通过 mock 的方式来调用,这样可以解决因为接口调用通而导致我们开发不畅的问题。因为 webpacl-dev-server 是基于 express 封装了,并且将 express 的实例暴露在了 devServer.beforedevServer.after 这两个配置项下面,所以我们完全可以将后端没有写好的接口在 devServer.before 通过 express 去 mock 。假设我们现在需要调用 /api/user/creation 这个接口来创建用户,我们可以这样 mock

// webpack.config.js
module.exports = {
entry,
output,
devServer: {
befor(app) {
app.post('/api/user/creation', (req, res) => {
// some code
res.json({success: true});
});
}
}
}

如果你需要 mock 的接口后端,那你完全可以像写 express 那样去写接口,当然有些常用的中间件需要我们自己去安装。

其它

source-maps

webpack 打包压缩之后的代码可读性几乎为零,同时也不方便调试,这时候可以通过设置 devtool 选项来帮助我们在开发环境调试,具体效果是:在 chrome 中(其它高级浏览器同样支持)打开控制台,我们可以在 Sources 中看到一个以 webpack:// 开头的资源,里面的内容和我们编写代码大致相同(这取决于 devtool 的值)。

source maps
source maps

由于 devtool 会影响打包的速度和打包后的代码质量,所以在生产环境的构建中,不建议开启此项(默认为none),只要在开环境设置为 eval-source-map 即可。其它配置和打包速度可以参考 官网

alias

当项目的目录结构越来越深,模块变得越来越多的时候,模块间的引用会变得很混乱,时常会看到下面这样的代码:

import ComponentA from '../../../../../components/a.component.js';
import ServiceA from '../../../../../service/a.service.js';

有没有想骂人的冲动?这时候可以使用 webpack 的 alias 选项来解决这个问题,配置文件的内容如下:

// webpack.config.js
module.exports = {
entry,
output,
resolve: {
alias: {
'@': path.resolve(__dirname, "src"),
'components': path.resolve(__dirname, "src/components"),
'services': path.resolve(__dirname, "src/services"),
}
}
}

上面的配置表示为 srcsrc/componentssrc/services 分别设置一个别名,我们就可以在代码中用 @ 表示相对路径 src 而不必再使用 ../../ 一层一层的向上查找了。假设我们现在的项目结构是下面这样子:

project
├── src
│ ├── components
│ └── services
└── webpack.config.js

这样我们可以在任意文件夹下的代码内使用 @ 来表示根目录 src/,使用 components 来表示路径 src/components/ ,所以上面例子中的代码可以在简化为:

import ComponentA from '@/components/a.component.js';
import ServiceA from 'services/a.service.js';

这样配置之后,webpack 在打包编译的时候能识别简化之后的路径,但是编辑器却未必能识别,这又给我们开发带来了一些困扰,如果你是 vscode 用户的话,这个问题可以很好的解决。只要在项目的根目录添加一份配置文件 jsconfig.json 即可,配置文件的内容如下:

{
"compilerOptions": {
"baseUrl": ".", // 根目录
"paths": {
"@/*": [ "./src/*" ],
"components/*": [ "./src/components/*" ],
"services/*": [ "./src/services/*" ],
}
}
}

这个配置文件和 webpack 是没有关系的,它是给 vscode 用的,想请可以查看这里:https://code.visualstudio.com/docs/languages/jsconfig

extensions

在原生的 JavaScript 中,使用 import 加载一个模块是可以不用写文件的扩展名的,nodejs 中的 require 也是一样,就像这样:import ModuleA from 'a' ,现在有了 loader 我们也希望 import 其它类型文件的时候也不写扩展名,比如

import styles from '@/styles/common';
import html from '@/tpl/login';

只需在 webpack 中配置 extensions 即可,具体代码如下:

// webpack.config.js
module.exports = {
entry,
output,
resolve: {
extensions: ['.js', '.json', '.css', '.html']
}
}

该选项的值是一个数组,默认值为 ['.js', '.json'] ,当我们手动配置之后,默认值会被覆盖,所以为了不影响之前的写法,要在配置中将 .js.json 也加上。

个人建议不要配置此项,尽量把文件的扩展名写全,这样不仅可以知道引入的文件是什么类型,而且在打包的时候速度也相对快一些。

externals

开发一个 Web 项目肯定会用到第三方的类库比如 jQuerylodash 等,有人会选从 npm 下载,有人会选择从 cdn 加载。这两种方式使用起来都很简单:

  • 从 npm 下载的包只需在用到的时候 import 就行了:import _ from 'lodash'
  • 从 cdn 加载类库只要在 html 页面通过 script 引入之后(注意引用顺序),便可以在任何地方使用

但是从 cdn 引入的资源在开发过程有一个很不好的地方:既然已经是模块化开发了,突然冒出一个全局变量会让人觉得很莫名其妙,而且这个变量也不能类型提示。

那可不可以这样子呢:

  1. 从 cdn 加载第三方类库(速度快)
  2. 在代码中依然使用 import 的方式来引入资源(代码模块清晰)
  3. 打包的时候排除从cdn加载的资源(减小打包后的代码体积)

答案是可以的,配置一下 externals 就可以轻松实现,以 jQuery 为例,具体代码如下:

// webpack.config.js
module.exports = {
entry,
output,
externals: {
jquery: 'jQuery'
}
}

在代码中就可以这样使用 jQuery :

import $ from 'jquery';

$(() => {
console.log('hello jQuery');
});

而且打包的时候会自动的把 jquery 排除掉。

从上面的配置中可以看出,externals 是一个对象,它的 key (jquery) 对应的是代码中引入的包名,也就是 from 后面的字符串,它的 val (jQuery) 就是暴露在全局的变量名,jQuery 暴露在全局的变量名为 jQuery$ ,所以这里换成 $ 同样是可以的。

所以上面的代码可以理解为是下面这种写法:

const $ = window.jQuery;

$(() => {
console.log('hello jQuery');
});

如果想对这个选项有更多的理解,可以参考这里:https://github.com/zhengweikeng/blog/issues/10

分离配置文件

上面的配置中,有的是适用于生产环境的,有的是适用于开发环境的,所以我们要将配置文件做一下分离。在项目中创建 build 文件夹,用来存放我们的构建脚本,在 build 中创建 webpack.common.js 我们可以将一些通用的配置写在这里面,比如 entry、output、loader 等等。然后我们在创建 webpack.prod.jswebpack.dev.js 两份配置文件,分别用来编写打包和开发是的脚本,已经在webpack.common.js 中写好的配置,就不需要在写了。然后我们利用 webpack-merge 将通用的配置分别和 dev、prod 的配置合并:

// build/webpack.prod.js
const merge = require('webpack-merge');

const webpackCommonConfig = require('./webpack.common');

module.exports = merge(webpackCommonConfig, {

/** 针对打包到生产环境的配置 */

});

最后再利用 npm script 设置不同的脚本

{
"scripts": {
"dev": "webpack-dev-server --mode development --color --progress --config build/webpack.dev.js",
"build": "node build/build.js"
}
}

这里我已经写好一份可以直接使用的配置,大家可以参考一下 webpack-workbench