webpack 从入门到放弃!

webpack

webpack于2012年3月10号诞生,作者是Tobias(德国)。参考GWT(Google Web Toolkit)的code splitting功能在webpack中进行实现。然后在2014年Instagram团队分享性能优化时,提出使用webpack的code splitting特性从而大火。 现在webpack的出现模糊了任务和构建的边界在webpack出现之前,我们使用gulp、grunt做任务的,构建是用其他工具实现,而现在webpack使其融为一体。

webpack 从入门到放弃!_第1张图片

之前我们在html加载js资源的时候,需要使用script标签,加载css也需要编写css文件进行加载,这样我们每次html加载的时候就需要加载多个资源。而webpack将所有的资源都打包到js中,会有一个entry入口文件,entry引入了js、css等资源文件,打包到一个bundle文件中,这样就加载一个资源。

webpack最初核心解决的问题就是代码合并与拆分,它的核心理念是将资源都视为模块,统一进行打包和处理,然后再按规则进行拆分,提供了loader和plugin完成功能扩展。

webpack5知识体系:https://gitmind.cn/app/docs/m1foeg1o

核心概念

  • entry:入口模块文件路径
  • output: 输出bundle文件路径
  • module:模块,webpack构建 对象
  • bundle:输出文件,webpack构建产物
  • chunk:构建生成bundle过程中,产生的中间文件,webpack构建的中间产物
  • loader:文件转换器
  • plugin:插件,执行特定任务
  • mode:工作模式,默认采用production模式,

项目初始化流程

  1. 创建npm项目
  2. 安装webpack依赖 webpack和webpack-cli
  3. 创建js入口文件
  4. 创建webpack配置文件
  5. 配置package.json的build命令
  6. 执行npm run build 打包

Webpack工作模式

Webpack 4 新增了一个工作模式的用法,这种用法大大简化了 Webpack 配置的复杂程度。你可以把它理解为针对不同环境的几组预设配置:

  • production 模式下,启动内置优化插件,自动优化打包结果,打包速度偏慢;
  • development 模式下,自动优化打包速度,添加一些调试过程中的辅助插件;
  • none 模式下,运行最原始的打包,不做任何额外处理。

针对工作模式的选项,如果你没有配置一个明确的值,打包过程中命令行终端会打印一个对应的配置警告。在这种情况下 Webpack 将默认使用 production 模式去工作。

production 模式下 Webpack 内部会自动启动一些优化插件,例如,自动压缩打包后的代码。这对实际生产环境是非常友好的,但是打包的结果就无法阅读了。

修改 Webpack 工作模式的方式有两种:

  • 通过 CLI --mode 参数传入;
  • 通过配置文件设置 mode 属性。

上述三种 Webpack 工作模式的详细差异我们不再赘述了,你可以在官方文档中查看:https://webpack.js.org/configuration/mode/

打包结果运行原理

为了更好的理解打包后的代码,我们先将 Webpack 工作模式设置为 none,这样 Webpack 就会按照最原始的状态进行打包,所得到的结果更容易理解和阅读。

按照 none 模式打包完成后,我们打开最终生成的 bundle.js 文件,如下图所示:

webpack 从入门到放弃!_第2张图片

我们可以先把代码全部折叠起来,以便于了解整体的结构

webpack 从入门到放弃!_第3张图片
整体生成的代码其实就是一个立即执行函数,这个函数是 Webpack 工作入口(webpackBootstrap),它接收一个 modules 参数,调用时传入了一个数组。

展开这个数组,里面的元素均是参数列表相同的函数。这里的函数对应的就是我们源代码中的模块,也就是说每个模块最终被包裹到了这样一个函数中,从而实现模块私有作用域,如下图所示:

webpack 从入门到放弃!_第4张图片

我们再来展开 Webpack 工作入口函数,如下图所示:

webpack 从入门到放弃!_第5张图片
这个函数内部并不复杂,而且注释也很清晰,最开始定义了一个 installedModules 对象用于存放或者缓存加载过的模块。紧接着定义了一个 require 函数,顾名思义,这个函数是用来加载模块的。再往后就是在 require 函数上挂载了一些其他的数据和工具函数,这些暂时不用关心。

这个函数执行到最后调用了 require 函数,传入的模块 id 为 0,开始加载模块。模块 id 实际上就是模块数组的元素下标,也就是说这里开始加载源代码中所谓的入口模块。

Webpack资源模块加载

Webpack 想要实现的是整个前端项目的模块化,项目中的各种资源(包括 CSS 文件、图片等)都应该属于需要被管理的模块。换句话说, Webpack 不仅是 JavaScript 模块打包工具,还是整个前端项目(前端工程)的模块打包工具。也就是说,我们可以通过 Webpack 去管理前端项目中任意类型的资源文件。

首先,我们尝试通过 Webpack 打包项目中的一个 CSS 文件,将 Webpack 配置中的入口文件路径指定为 main.css 的文件路径,让 Webpack 直接打包 CSS 资源文件,具体配置如下所示:

module.exports = {
  // 样式文件路径
  entry: './src/main.css',
  output: {
    filename: 'bundle.js'
  }
}

配置完成过后回到命令行终端运行 Webpack 打包命令,此时你会发现命令行报出了一个模块解析错误,如下所示:

webpack 从入门到放弃!_第6张图片

错误信息大体的意思是说,在解析模块过程中遇到了非法字符,而且错误出现的位置就是在我们的 CSS 文件中。

出现这个错误的原因是因为 Webpack 内部默认只能够处理 JS 模块代码,也就是说在打包过程中,它默认把所有遇到的文件都当作 JavaScript 代码进行解析,但是此处我们让 Webpack 处理的是 CSS 代码,而 CSS 代码是不符合 JavaScript 语法的,所以自然会报出模块解析错误。

这里有一个非常重要的提示:You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. (我们需要用适当的加载器来处理这种文件类型,而当前并没有配置一个可以用来处理此文件的加载器)。

根据这个错误说明,我们发现 Webpack 是用 Loader(加载器)来处理每个模块的,而内部默认的 Loader 只能处理 JS 模块,如果需要加载其他类型的模块就需要配置不同的 Loader。

webpack 从入门到放弃!_第7张图片

解决上面的问题需要的是一个可以加载 CSS 模块的 Loader,最常用到的是 css-loader。我们需要通过 npm 先去安装这个 Loader,然后在配置文件中添加对应的配置,具体操作和配置如下所示:

$ npm install css-loader --save-dev 
# or yarn add css-loader --dev
// ./webpack.config.js
module.exports = {
  entry: './src/main.css',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        use: 'css-loader' // 指定具体的 loader
      }
    ]
  }
}

在配置对象的 module 属性中添加一个 rules 数组。这个数组就是我们针对资源模块的加载规则配置,其中的每个规则对象都需要设置两个属性:

  • 首先是 test 属性,它是一个正则表达式,用来匹配打包过程中所遇到文件路径,这里我们是以 .css 结尾;
  • 然后是 use 属性,它用来指定匹配到的文件需要使用的 loader,这里用到的是 css-loader。

配置完成过后,我们回到命令行终端重新运行打包命令,打包过程就不会再出现错误了,因为这时 CSS 文件会交给 css-loader 处理过后再由 Webpack 打包。

尝试在页面中使用这里输出的 bundle.js 文件,会发现刚刚的这个 main.css 模块并没有工作。我们找到刚刚生成的 bundle.js 文件,因为这个文件是 Webpack 打包后的结果,所有的模块都应该在这个文件中出现。由于默认打包入口在 Webpack 输出的结果中就是第一个模块,所以我们只需要看第一个模块目前是什么样的,如下图所示:

webpack 从入门到放弃!_第8张图片

仔细阅读这个文件,你会发现 css-loader 的作用是将 CSS 模块转换为一个 JS 模块,具体的实现方法是将我们的 CSS 代码 push 到一个数组中,这个数组是由 css-loader 内部的一个模块提供的,但是整个过程并没有任何地方使用到了这个数组。

因此这里样式没有生效的原因是: css-loader 只会把 CSS 模块加载到 JS 代码中,而并不会使用这个模块。

所以这里我们还需要在 css-loader 的基础上再使用一个 style-loader,把 css-loader 转换后的结果通过 style 标签追加到页面上。

安装完 style-loader 之后,我们将配置文件中的 use 属性修改为一个数组,将 style-loader 也放进去。这里需要注意的是,一旦配置多个 Loader,执行顺序是从后往前执行的,所以这里一定要将 css-loader 放在最后,因为必须要 css-loader 先把 CSS 代码转换为 JS 模块,才可以正常打包,具体配置如下:

// ./webpack.config.js
module.exports = {
  entry: './src/main.css',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        // 对同一个模块使用多个 loader,注意顺序
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  }
}

配置完成之后,再次回到命令行重新打包,此时 bundle.js 文件中会额外多出两个模块。篇幅的关系,我们这里不再仔细解读。style-loader 的作用总结一句话就是,将 css-loader 中所加载到的所有样式模块,通过创建 style 标签的方式添加到页面上。

Webpack导入资源模块

一般 Webpack 打包的入口还是 JavaScript。因为从某种程度上来说,打包入口就是应用的运行入口,而目前前端应用中的业务是由 JS 驱动的,所以更合理的做法还是把 JS 文件作为打包的入口,然后在 JS 代码中通过 import 语句去加载 CSS 文件。

webpack 从入门到放弃!_第9张图片
即便是通过 JS 代码去加载的 CSS 模块,css-loader 和 style-loader 仍然可以正常工作。因为 Webpack 在打包过程中会循环遍历每个模块,然后根据配置将每个遇到的模块交给对应的 Loader 去处理,最后再将处理完的结果打包到一起。

其实 Webpack 不仅是建议我们在 JavaScript 中引入 CSS,还会建议我们在代码中引入当前业务所需要的任意资源文件。因为真正需要这个资源的并不是整个应用,而是你此时正在编写的代码。这就是 Webpack 的设计哲学。

可能你乍一想好像不太容易理解,那你可以做一个假设:假设我们在开发页面上的某个局部功能时,需要用到一个样式模块和一个图片文件。如果你还是将这些资源文件单独引入到 HTML 中,然后再到 JS 中添加对应的逻辑代码。试想一下,如果后期这个局部功能不用了,你就需要同时删除 JS 中的代码和 HTML 中的资源文件引入,也就是同时需要维护这两条线。而如果你遵照 Webpack 的这种设计,所有资源的加载都是由 JS 代码控制,后期也就只需要维护 JS 代码这一条线了。

所以说,通过 JavaScript 代码去引入资源文件,或者说是建立 JavaScript 和资源文件的依赖关系,具有明显的优势。因为 JavaScript 代码本身负责完成整个应用的业务功能,放大来说就是驱动了整个前端应用,而 JavaScript 代码在实现业务功能的过程中需要用到样式、图片等资源文件。如果建立这种依赖关系:

  • 一来逻辑上比较合理,因为 JS 确实需要这些资源文件配合才能实现整体功能;
  • 二来配合 Webpack 这类工具的打包,能确保在上线时,资源不会缺失,而且都是必要的。

常见的loader

目前webpack社区提供了非常多的资源加载器,基本上你能想到的需求都有对应的loader。

名称 描述 链接
style-loader 将css-loader转换后的结果,通过style标签的方式追加到页面中 https://webpack.js.org/loaders/style-loader
css-loader 将css文件转换为js模块 https://webpack.js.org/loaders/css-loader
file-loader 把文件输出到一个文件夹中,在代码中通过相对URL去引用输出文件 https://webpack.js.org/loaders/file-loader
url-loader 和file-loader类似,但是能在文件很小的情况下以base64的方式把文件内容注入到代码中去 https://webpack.js.org/loaders/url-loader
source-map-loader 加载额外的source Map文件,以方便断点调试 https://webpack.docschina.org/loaders/source-map-loader/
image-loader 加载并且压缩图片文件 https://github.com/tcoopman/image-webpack-loader
babel-loader 把ES6转换成ES5 https://webpack.js.org/loaders/babel-loader
eslint-loader 通过ESLint检查JavaScript代码 https://github.com/webpack-contrib/eslint-loader
sass-loader 加载 Sass/SCSS 文件并将其编译为 CSS https://webpack.js.org/loaders/sass-loader
postcss-loader 处理 CSS 的加载器PostCSS https://webpack.js.org/loaders/postcss-loader
vue-loader Vue 单文件组件的 webpack 加载器 https://github.com/vuejs/vue-loader

上述loader大体分为三类:

  • 编译转换类:将加载到的资源模块转换为JavaScript代码,例如css-loader。
  • 文件操作类:将加载到的资源模块拷贝到输出的目录,同时将访问路径向外导出,例如file-loader。
  • 代码检查类:对加载到的资源文件代码去进行校验的加载器,目的是统一代码风格,提高代码质量,例如eslint-loader。

::: tip
注意:在Webpack中,loader的执行顺序是从右向左执行的,因为Webpack选择了compose这样的函数式编程方式,这种方式的表达式执行时从右向左的。
:::

文件资源加载器

大多数加载器都类似于css-loader,都是将资源模块转换为JS代码的实现方式去工作,但是还有一些我们常用的资源文件,例如项目当中的图片或字体,这些文件无法通过JS的方式去表示,对于这类的资源文件,我们需要用到文件资源加载器,也就是file-loader。

{
    test: /.png$/,
    use: 'file-loader'
}

webpack 从入门到放弃!_第10张图片

webpack在打包时遇到了图片文件,然后根据配置文件的配置,找到文件加载器,文件加载器先是将导入的文件拷贝到输出的目录,然后将文件拷贝到输出目录后的路径作为当前模块的返回值返回,对于应用来说需要的资源就被发布出来了,通过模块的导出成员拿到资源的访问路径。

除了file-loader这种拷贝物理文件的形式处理资源以外,还有一种通过Data URLs的形式去表示文件。Data URLs是一种特殊的URL协议,它可以用来直接去表示一个文件,传统的url需要服务器上有一个对应的文件,然后通过请求地址得到服务器上的这个文件。而Data URLs是一种当前可以直接表示文件内容的一种方式。

webpack 从入门到放弃!_第11张图片

所以这种url当中的文本已经包含了文件的内容,那么我们在访问这种url的时候不会发送任何的http请求。例如data:text/html;charset=UTF-8,

html content

,但是像图片和字体这种无法通过文本表示的二进制文件,可以通过将其base64编码,以编码结果字符串表示内容data:image/png;base64,iVBORw0GgoAAAANSUHE...SuQmCC。在webpack打包静态资源是也可以使用这种方式去实现,使用Data URLs表示任何类型的文件了,这时我们需要一个专门的加载器url-loader。

{
    test: /.png$/,
    use: 'url-loader'
}

这样遇到png文件就会将其转换成url的形式。这种方式比较适合于项目中体积比较小的资源,如果体积过大就会造成打包结果也会很大,从而影响运行速度。

那么最佳实践是小文件使用Data URLs的方式减少请求次数,而大文件单独提取存放,提高加载速度。

{
    test: /.png$/,
    use:{
        loader: 'url-loader',
        options:{
            limit: 10*1024 //10KB
        }
    }
}

这样只将10KB一下的文件转换为Data URLs,超过10KB的仍然会交给file-loader。

自定义loader

开发一个Loader

Loader 作为 Webpack 的核心机制,内部的工作原理却非常简单。接下来我们一起来开发一个自己的 Loader,通过这个开发过程再来深入了解 Loader 的工作原理。

这里我的需求是开发一个可以加载 markdown 文件的加载器,以便可以在代码中直接导入 md 文件。我们都应该知道 markdown 一般是需要转换为 html 之后再呈现到页面上的,所以我希望导入 md 文件后,直接得到 markdown 转换后的 html 字符串,如下图所示:

86.png
在项目根目录下创建一个 markdown-loader.js 文件

// ./markdown-loader.js

module.exports = source => {
  // 加载到的模块内容 => '# About\n\nthis is a markdown file.'
  console.log(source)
  // 返回值就是最终被打包的内容
  return 'hello loader ~'
}

每个 Webpack 的 Loader 都需要导出一个函数,这个函数就是我们这个 Loader 对资源的处理过程,它的输入就是加载到的资源文件内容,输出就是我们加工后的结果。

完成以后,我们回到 Webpack 配置文件中添加一个加载器规则,这里匹配到的扩展名是 .md,使用的加载器就是我们刚刚编写的这个 markdown-loader.js 模块,具体代码如下所示:

// ./webpack.config.js
module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        // 直接使用相对路径
        use: './markdown-loader'
      }
    ]
  }
}

::: tip
这里的 use 中不仅可以使用模块名称,还可以使用模块文件路径,这点与 Node 中的 require 函数是一样的。
:::

配置完成后,我们打开命令行终端运行打包命令,如下图所示:

webpack 从入门到放弃!_第12张图片

打包过程中命令行确实打印出来了我们所导入的 Markdown 文件内容,这就意味着 Loader 函数的参数确实是文件的内容。

但同时也报出了一个解析错误,说的是: You may need an additional loader to handle the result of these loaders.(我们可能还需要一个额外的加载器来处理当前加载器的结果)。

那这究竟是为什么呢?其实 Webpack 加载资源文件的过程类似于一个工作管道,你可以在这个过程中依次使用多个 Loader,但是最终这个管道结束过后的结果必须是一段标准的 JS 代码字符串。

88.png
所以我们这里才会出现上面提到的错误提示,那解决的办法也就很明显了:

  • 直接在这个 Loader 的最后返回一段 JS 代码字符串;
  • 再找一个合适的加载器,在后面接着处理我们这里得到的结果。

我们将返回的字符串内容修改为 console.log(‘hello loader~’),然后再次运行打包,此时 Webpack 就不再会报错了,代码如下所示:

// ./markdown-loader.js

module.exports = source => {
  // 加载到的模块内容 => '# About\n\nthis is a markdown file.'
  console.log(source)
  // 返回值就是最终被打包的内容
  // return 'hello loader ~'
  return 'console.log("hello loader ~")'
}

我们打开输出的 bundle.js,找到最后一个模块(因为这个 md 文件是后引入的),如下图所示:

webpack 从入门到放弃!_第13张图片

这个模块里面非常简单,就是把我们刚刚返回的字符串直接拼接到了该模块中。这也解释了刚刚 Loader 管道最后必须返回 JS 代码的原因,因为如果随便返回一个内容,放到这里语法就不通过了。

实现Loader的逻辑

安装一个能够将 Markdown 解析为 HTML 的模块,叫作 marked。安装完成后,我们在 markdown-loader.js 中导入这个模块,然后使用这个模块去解析我们的 source。这里解析完的结果就是一段 HTML 字符串,如果我们直接返回的话同样会面临 Webpack 无法解析模块的问题,正确的做法是把这段 HTML 字符串拼接为一段 JS 代码。

此时我们希望返回的代码是通过 module.exports 导出这段 HTML 字符串,这样外界导入模块时就可以接收到这个 HTML 字符串了。如果只是简单地拼接,那 HTML 中的换行和引号就都可能会造成语法错误,所以我这里使用了一个小技巧,具体操作如下所示:

// ./markdown-loader.js
const marked = require('marked')
module.exports = source => {
  // 1. 将 markdown 转换为 html 字符串
  const html = marked(source)
  // html => '

About

this is a markdown file.

'
// 2. 将 html 字符串拼接为一段导出字符串的 JS 代码 const code = `module.exports = ${JSON.stringify(html)}` return code // code => 'export default "

About

this is a markdown file.

"'
}

先通过 JSON.stringify() 将字段字符串转换为标准的 JSON 字符串,然后再参与拼接,这样就不会有问题了。

我们回到命令行再次运行打包,打包后的结果就是我们所需要的了。

除了 module.exports 这种方式,Webpack 还允许我们在返回的代码中使用 ES Modules 的方式导出,例如,我们这里将 module.exports 修改为 export default,然后运行打包,结果同样是可以的,Webpack 内部会自动转换 ES Modules 代码。

// ./markdown-loader.js
const marked = require('marked')

module.exports = source => {
  const html = marked(source)
  // const code = `module.exports = ${JSON.stringify(html)}`
  const code = `export default ${JSON.stringify(html)}`
  return code 
}

多个Loader的配合

我们还可以尝试一下刚刚说的第二种思路,就是在我们这个 markdown-loader 中直接返回 HTML 字符串,然后交给下一个 Loader 处理。这就涉及多个 Loader 相互配合工作的情况了。

我们回到代码中,这里我们直接返回 marked 解析后的 HTML,代码如下所示:

// ./markdown-loader.js
const marked = require('marked')
module.exports = source => {
  // 1. 将 markdown 转换为 html 字符串
  const html = marked(source)
  return html
}

然后我们再安装一个处理 HTML 的 Loader,叫作 html-loader,代码如下所示:

// ./webpack.config.js

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        use: [
          'html-loader',
          './markdown-loader'
        ]
      }
    ]
  }
}

安装完成过后回到配置文件,这里同样把 use 属性修改为一个数组,以便依次使用多个 Loader。不过同样需要注意,这里的执行顺序是从后往前,也就是说我们应该把先执行的 markdown-loader 放在后面,html-loader 放在前面。

通过以上的尝试发现了Loader的内部原理非常简单,就是负责资源文件从输入到输出的转换,除此之外还了解了Loader其实是一种管道的概念,我们可以将此次Loader的结果交给下一个Loader去处理,通过多个Loader完成一个功能,例如css-loader与style-loader的配合。

Webpack插件

插件机制是Webpack的另外一个重要的核心特性,Webpack 插件机制的目的是为了增强 Webpack 在项目自动化构建方面的能力。我们都知道Loader 就是负责完成项目中各种各样资源模块的加载,从而实现整体项目的模块化,而 Plugin 则是用来解决项目中除了资源模块打包以外的其他自动化工作,所以说 Plugin 的能力范围更广,用途自然也就更多。

几个插件最常见的应用场景:

  • 实现自动在打包之前清除 dist 目录(上次的打包结果);
  • 自动生成应用所需要的 HTML 文件;
  • 根据不同环境为代码注入类似 API 地址这种可能变化的部分;
  • 拷贝不需要参与打包的资源文件到输出目录;
  • 压缩 Webpack 打包完成后输出的文件;
  • 自动发布打包结果到服务器实现自动部署。

总之,有了 Plugin 的 Webpack 几乎“无所不能”。借助插件,我们就可以轻松实现前端工程化中绝大多数经常用到的功能,这也正是很多初学者会认为 “Webpack 就是前端工程化,或者前端工程化就是 Webpack” 的原因。

插件开发

通过前面的介绍,我们知道相比于 Loader,插件的能力范围更宽,因为 Loader 只是在模块的加载环节工作,而插件的作用范围几乎可以触及 Webpack 工作的每一个环节。

那么,这种插件机制是如何实现的呢?其实说起来也非常简单,Webpack 的插件机制就是我们在软件开发中最常见的钩子机制。

钩子机制也特别容易理解,它有点类似于 Web 中的事件。在 Webpack 整个工作过程会有很多环节,为了便于插件的扩展,Webpack 几乎在每一个环节都埋下了一个钩子。这样我们在开发插件的时候,通过往这些不同节点上挂载不同的任务,就可以轻松扩展 Webpack 的能力。

具体有哪些预先定义好的钩子,我们可以参考官方文档的 API:

  • Compiler Hooks
  • Compilation Hooks
  • JavascriptParser Hooks

接下来,我们来开发一个自己的插件,看看具体如何往这些钩子上挂载任务。

这里我的需求是,希望我们开发的这个插件能够自动清除 Webpack 打包结果中的注释,这样一来,我们的 bundle.js 将更容易阅读,如下图所示:

webpack 从入门到放弃!_第14张图片
在项目根目录下添加一个单独的 JS 文件。

Webpack 要求我们的插件必须是一个函数或者是一个包含 apply 方法的对象,一般我们都会定义一个类型,在这个类型中定义 apply 方法。然后在使用时,再通过这个类型来创建一个实例对象去使用这个插件。

所以我们这里定义一个 RemoveCommentsPlugin 类型,然后在这个类型中定义一个 apply 方法,这个方法会在 Webpack 启动时被调用,它接收一个 compiler 对象参数,这个对象是 Webpack 工作过程中最核心的对象,里面包含了我们此次构建的所有配置信息,我们就是通过这个对象去注册钩子函数,具体代码如下:

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    console.log('RemoveCommentsPlugin 启动')
    // compiler => 包含了我们此次构建的所有配置信息
  }
}

知道这些过后,还需要明确我们这个任务的执行时机,也就是到底应该把这个任务挂载到哪个钩子上。

我们的需求是删除 bundle.js 中的注释,也就是说只有当 Webpack 需要生成的 bundle.js 文件内容明确过后才可能实施。

webpack 从入门到放弃!_第15张图片
那根据 API 文档中的介绍,我们找到一个叫作 emit 的钩子,这个钩子会在 Webpack 即将向输出目录输出文件时执行,非常符合我们的需求。

我们回到代码中,通过 compiler 对象的 hooks 属性访问到 emit 钩子,再通过 tap 方法注册一个钩子函数,这个方法接收两个参数:

  • 第一个是插件的名称,我们这里的插件名称是 RemoveCommentsPlugin
  • 第二个是要挂载到这个钩子上的函数

根据 API 文档中的提示,这里我们在这个函数中接收一个 compilation 对象参数,这个对象可以理解为此次运行打包的上下文,所有打包过程中产生的结果,都会放到这个对象中。

我们可以使用这个对象中的 assets 属性获取即将写入输出目录的资源文件信息,它是一个对象,我们这里通过 for in 去遍历这个对象,其中键就是每个文件的名称,我们尝试把它打印出来,具体代码如下:

// ./remove-comments-plugin.js

class RemoveCommentsPlugin {
  apply (compiler) {
    compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        console.log(name) // 输出文件名称
      }
    })
  }
}

完成以后,我们将这个插件应用到 Webpack 的配置中,然后回到命令行重新打包,此时打包过程就会打印我们输出的文件名称,代码如下:

webpack 从入门到放弃!_第16张图片
我们再回到代码中,来打印一下每个资源文件的内容,文件内容需要通过遍历的值对象中的 source 方法获取,具体代码如下:

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        // console.log(name)
        console.log(compilation.assets[name].source()) // 输出文件内容
      }
    })
  }
}

回到命令行,再次打包,此时输出的文件内容也可以正常被打印。

能够拿到文件名和文件内容后,我们回到代码中。这里需要先判断文件名是不是以 .js 结尾,因为 Webpack 打包还有可能输出别的文件,而我们的需求只需要处理 JS 文件。

那如果是 JS 文件,我们将文件内容得到,再通过正则替换的方式移除掉代码中的注释,最后覆盖掉 compilation.assets 中对应的对象,在覆盖的对象中,我们同样暴露一个 source 方法用来返回新的内容。另外还需要再暴露一个 size 方法,用来返回内容大小,这是 Webpack 内部要求的格式,具体代码如下:

// ./remove-comments-plugin.js

class RemoveCommentsPlugin {
  apply (compiler) {
    compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        if (name.endsWith('.js')) {
          const contents = compilation.assets[name].source()
          const noComments = contents.replace(/\/\*{2,}\/\s?/g, '')
          compilation.assets[name] = {
            source: () => noComments,
            size: () => noComments.length
          }
        }
      }
    })
  }
}

完成以后回到命令行终端,再次打包,打包完成过后,我们再来看一下 bundle.js,此时 bundle.js 中每行开头的注释就都被移除了。

webpack 从入门到放弃!_第17张图片
以上就是我们实现一个移除注释插件的过程,通过这个过程我们了解了:插件都是通过往 Webpack 生命周期的钩子中挂载任务函数实现的。

常用的plugin

  • html-webpack-plugin:该插件将为你生成一个 HTML5 文件, 在 body中使用 script 标签引入你所有 webpack 生成的 bundle
  • copy-webpack-plugin:在 webpack 中拷贝文件和文件夹
  • clean-webpack-plugin:用于删除/清理构建文件夹的 webpack 插件。
  • ProvidePlugin:注册全局引用的变量
  • mini-css-extract-plugin:将 css 提取到单独的文件中,为每个包含 css 的 js 文件创建一个 css 文件,并且支持 css 和 SourceMaps 的按需加载。
  • webpack-dev-server:提供了一个基本的 web server,并且具有 livereloading(实时重新加载) 功能
  • optimize-css-assets-webpack-plugin:压缩 css 文件
  • define-plugin:定义环境变量
  • uglifyjs-webpack-plugin:通过UglifyES压缩ES6代码
  • webpack-parallel-uglify-plugin:多核压缩,提高压缩速度
  • webpack-bundle-analyzer:可视化webpack输出文件的体积

clean-webpack-plugin

Webpack 每次打包的结果都是直接覆盖到 dist 目录。而在打包之前,dist 目录中就可能已经存入了一些在上一次打包操作时遗留的文件,当我们再次打包时,只能覆盖掉同名文件,而那些已经移除的资源文件就会一直累积在里面,最终导致部署上线时出现多余文件,这显然非常不合理。

更为合理的做法就是在每次完整打包之前,自动清理 dist 目录,这样每次打包过后,dist 目录中就只会存在那些必要的文件。

clean-webpack-plugin 这个插件就很好的实现了这一需求。它是一个第三方的 npm 包,我们需要先通过 npm 安装一下,具体操作如下:

$ npm install clean-webpack-plugin --save-dev

安装过后,我们回到 Webpack 的配置文件中,然后导入 clean-webpack-plugin 插件,这个插件模块导出了一个叫作 CleanWebpackPlugin 的成员,我们先把它解构出来,具体代码如下。

const { CleanWebpackPlugin } = require('clean-webpack-plugin')

回到配置对象中,添加一个 plugins 属性,这个属性就是专门用来配置插件的地方,它是一个数组,添加一个插件就是在这个数组中添加一个元素。

绝大多数插件模块导出的都是一个类型,我们这里的 CleanWebpackPlugin 也不例外,使用它,就是通过这个类型创建一个实例,放入 plugins 数组中,具体代码如下:

// ./webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin()
  ]
}

完成以后我们来测试一下 clean-webpack-plugin 插件的效果。回到命令行终端,再次运行 Webpack 打包,此时之前的打包结果就不会存在了,dist 目录中存放的就都是我们本次打包的结果。

一般来说,当我们有了某个自动化的需求过后,可以先去找到一个合适的插件,然后安装这个插件,最后将它配置到 Webpack 配置对象的 plugins 数组中,这个过程唯一有可能不一样的地方就是,有的插件可能需要有一些配置参数。

html-webpack-plugin

我们的 HTML 文件一般都是通过硬编码的方式,单独存放在项目根目录下的,这种方式有两个问题:

  • 项目发布时,我们需要同时发布根目录下的 HTML 文件和 dist 目录中所有的打包结果,非常麻烦,而且上线过后还要确保 HTML 代码中的资源文件路径是正确的。
  • 如果打包结果输出的目录或者文件名称发生变化,那 HTML 代码中所对应的 script 标签也需要我们手动修改路径。

解决这两个问题最好的办法就是让 Webpack 在打包的同时,自动生成对应的 HTML 文件,让 HTML 文件也参与到整个项目的构建过程。这样的话,在构建过程中,Webpack 就可以自动将打包的 bundle 文件引入到页面中。

相比于之前写死 HTML 文件的方式,自动生成 HTML 的优势在于:

  • HTML 也输出到 dist 目录中了,上线时我们只需要把 dist 目录发布出去就可以了;
  • HTML 中的 script 标签是自动引入的,所以可以确保资源文件的路径是正常的。

具体的实现方式就需要借助于 html-webpack-plugin 插件来实现,这个插件也是一个第三方的 npm 模块,我们这里同样需要单独安装这个模块,具体操作如下:

$ npm install html-webpack-plugin --save-dev

安装完成过后,回到配置文件,载入这个模块,不同于 clean-webpack-plugin,html-webpack-plugin 插件默认导出的就是插件类型,不需要再解构内部成员,具体如下:

const HtmlWebpackPlugin = require('html-webpack-plugin')

有了这个类型过后,回到配置对象的 plugins 属性中,同样需要添加一下这个类型的实例对象,完成这个插件的使用,具体配置代码如下:

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin()
  ]
}

最后我们回到命令行终端,再次运行打包命令,此时打包过程中就会自动生成一个 index.html 文件到 dist 目录。我们找到这个文件,可以看到文件中的内容就是一段使用了 bundle.js 的空白 HTML,具体结果如下:

webpack 从入门到放弃!_第18张图片

至此,Webpack 就可以动态生成应用所需的 HTML 文件了,但是这里仍然存在一些需要改进的地方:

  • 对于生成的 HTML 文件,页面 title 必须要修改;
  • 很多时候还需要我们自定义页面的一些 meta 标签和一些基础的 DOM 结构。

也就是说,还需要我们能够充分自定义这个插件最终输出的 HTML 文件。

如果只是简单的自定义,我们可以通过修改 HtmlWebpackPlugin 的参数来实现。

我们回到 Webpack 的配置文件中,这里我们给 HtmlWebpackPlugin 构造函数传入一个对象参数,用于指定配置选项。其中,title 属性设置的是 HTML 的标题,我们把它设置为 Webpack Plugin Simple。meta 属性需要以对象的形式设置页面中的元数据标签,这里我们尝试为页面添加一个 viewport 设置,具体代码如下:

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      meta: {
        viewport: 'width=device-width'
      }
    })
  ]
}

完成以后回到命令行终端,再次打包,然后我们再来看一下生成的 HTML 文件,此时这里的 title 和 meta 标签就会根据配置生成,具体结果如下:

webpack 从入门到放弃!_第19张图片
如果需要对 HTML 进行大量的自定义,更好的做法是在源代码中添加一个用于生成 HTML 的模板,然后让 html-webpack-plugin 插件根据这个模板去生成页面文件。

我们这里在 src 目录下新建一个 index.html 文件作为 HTML 文件的模板,然后根据我们的需要在这个文件中添加相应的元素。对于模板中动态的内容,可以使用 Lodash 模板语法输出,模板中可以通过 htmlWebpackPlugin.options 访问这个插件的配置数据,例如我们这里输出配置中的 title 属性,具体代码如下:


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><%= htmlWebpackPlugin.options.title %>title>
head>
<body>
  <div class="container">
    <h1>页面上的基础结构h1>
    <div id="root">div>
  div>
body>
html>

有了模板文件过后,回到配置文件中,我们通过 HtmlWebpackPlugin 的 template 属性指定所使用的模板,具体配置如下:

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      template: './src/index.html'
    })
  ]
}

完成以后我们回到命令行终端,运行打包命令,然后再来看一下生成的 HTML 文件,此时 HTML 中就都是根据模板生成的内容了,具体结果如下:

webpack 从入门到放弃!_第20张图片

至此,你应该了解了如何通过 html-webpack-plugin 自定义输出 HTML 文件内容。

关于 html-webpack-plugin 插件,除了自定义输出文件的内容,同时输出多个 HTML 文件也是一个非常常见的需求,除非我们的应用是一个单页应用程序,否则一定需要输出多个 HTML 文件。

如果需要同时输出多个 HTML 文件,其实也非常简单,我们回到配置文件中,这里通过 HtmlWebpackPlugin 创建的对象就是用于生成 index.html 的,那我们完全可以再创建一个新的实例对象,用于创建额外的 HTML 文件。

例如,这里我们再来添加一个 HtmlWebpackPlugin 实例用于创建一个 about.html 的页面文件,我们需要通过 filename 指定输出文件名,这个属性的默认值是 index.html,我们把它设置为 about.html,具体配置如下:

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },

  plugins: [
    new CleanWebpackPlugin(),
    // 用于生成 index.html
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      template: './src/index.html'
    }),

    // 用于生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    })
  ]
}

完成以后我们再次回到命令行终端,运行打包命令,然后我们展开 dist 目录,此时 dist 目录中就同时生成了 index.html 和 about.html 两个页面文件。

根据这个尝试我们就应该知道,如果需要创建多个页面,就需要在插件列表中加入多个 HtmlWebpackPlugin 的实例对象,让每个对象负责一个页面文件的生成。

当然了,对于同时输出多个 HTML,一般我们还会配合 Webpack 多入口打包的用法,这样就可以让不同的 HTML 使用不同的打包结果。

copy-webpack-plugin

在我们的项目中一般还有一些不需要参与构建的静态文件,那它们最终也需要发布到线上,例如网站的 favicon、robots.txt 等。

一般我们建议,把这类文件统一放在项目根目录下的 public 或者 static 目录中,我们希望 Webpack 在打包时一并将这个目录下所有的文件复制到输出目录。

我们们可以使用 copy-webpack-plugin 插件来帮我们实现。先安装一下 copy-webpack-plugin 插件,安装完成过后,回到配置文件中,导入这个插件类型。然后同样在 plugins 属性中添加一个这个类型的实例,具体代码如下:

// ./webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      template: './src/index.html'
    }),

    new CopyWebpackPlugin({
      patterns: ['public'] // 需要拷贝的目录或者路径通配符
    })
  ]
}

这个插件类型的构造函数需要我们传入一个字符串数组,用于指定需要拷贝的文件路径。它可以是一个通配符,也可以是一个目录或者文件的相对路径。我们这里传入的是 public 目录,表示将这个目录下所有文件全部拷贝到输出目录中。当然了,你还可以在这个数组中继续添加其它路径,这样它在工作时可以同时拷贝。

配置完成以后回到命令行终端,再次运行 Webpack,此时 public 目录下的文件就会同时拷贝到输出目录中。

增强Webpack的开发体验

了解了 Webpack 的相关概念、基本用法,以及核心工作原理,看似好像已经掌握了 Webpack,但是如果以目前的认知状态去应对日常的开发工作,其实还远远不够。

因为“编写源代码 → Webpack 打包 → 运行应用 → 浏览器查看”这种周而复始的开发方式过于原始,在实际开发过程中,如果你还是按照这种方式来工作,开发效率必然会十分低下。那究竟该如何提高我们的开发效率呢?这里我先对一个较为理想的开发环境做出设想:

  • 首先,它必须能够使用 HTTP 服务运行而不是文件形式预览。这样的话,一来更接近生产环境状态,二来我们的项目可能需要使用 AJAX 之类的 API,以文件形式访问会产生诸多问题。
  • 其次,在我们修改完代码过后,Webpack 能够自动完成构建,然后浏览器可以即时显示最新的运行结果,这样就大大减少了开发过程中额外的重复操作,同时也会让我们更加专注,效率自然得到提升。
  • 最后,它还需要能提供 Source Map 支持。这样一来,运行过程中出现的错误就可以快速定位到源代码中的位置,而不是打包后结果中的位置,更便于我们快速定位错误、调试应用。

对于以上的这些需求 Webpack 都已经提供了相对应的功能实现了。

Webpack自动编译

如果我们每次修改完代码,都是通过命令行手动重复运行 Webpack 命令,从而得到最新的打包结果,那么这样的操作过程根本没有任何开发体验可言。

针对上述这个问题,我们可以使用 Webpack CLI 提供的另外一种 watch 工作模式来解决。

如果你之前了解过其它的一些构建工具,你应该对 watch 模式并不陌生。在这种模式下,Webpack 完成初次构建过后,项目中的源文件会被监视,一旦发生任何改动,Webpack 都会自动重新运行打包任务。

具体的用法也非常简单,就是在启动 Webpack 时,添加一个 --watch 的 CLI 参数,这样的话,Webpack 就会以监视模式启动运行。在打包完成过后,CLI 不会立即退出,它会等待文件变化再次工作,直到我们手动结束它或是出现不可控的异常。

在 watch 模式下我们就只需专注编码,不必再去手动完成编译工作了,相比于原始手动操作的方式,有了很明显的进步。

我们还可以再开启另外一个命令行终端,同时以 HTTP 形式运行我们的应用,然后打开浏览器去预览应用。

我们可以将浏览器移至屏幕的左侧,然后将编辑器移至右侧,此时我们尝试修改源代码,保存过后,以 watch 模式工作的 Webpack 就会自动重新打包,然后我们就可以在浏览器中刷新页面查看最新的结果。那此时我们的开发体验就是:修改代码 → Webpack 自动打包 → 手动刷新浏览器 → 预览运行结果。

::: tip
这里我使用的静态文件服务器是一个 npm 模块,叫作 serve
:::

如果浏览器能够在 Webpack 打包过后自动刷新,那我们的开发体验将会更好一些。

如果你已经了解过一个叫作 BrowserSync 的工具,你应该知道 BrowserSync 就可以帮我们实现文件变化过后浏览器自动刷新的功能。

所以,我们就可以使用 BrowserSync 工具替换 serve 工具,启动 HTTP 服务,这里还需要同时监听 dist 目录下文件的变化,具体命令如下:

# 可以先通过 npm 全局安装 browser-sync 模块,然后再使用这个模块
$ npm install browser-sync --global
$ browser-sync dist --watch

# 或者也可以使用 npx 直接使用远端模块
$ npx browser-sync dist --watch

启动过后,我们回到编辑器,然后尝试修改源文件,保存完成以后浏览器就会自动刷新,显示最新结果。

它的原理就是 Webpack 监视源代码变化,自动打包源代码到 dist 中,而 dist 中文件的变化又被 BrowserSync 监听了,从而实现自动编译并且自动刷新浏览器的功能,整个过程由两个工具分别监视不同的内容。

这种 watch 模式 + BrowserSync 虽然也实现了我们的需求,但是这种方法有很多弊端:

  • 操作烦琐,我们需要同时使用两个工具,那么需要了解的内容就会更多,学习成本大大提高;
  • 效率低下,因为整个过程中, Webpack 会将文件写入磁盘,BrowserSync 再进行读取。过程中涉及大量磁盘读写操作,必然会导致效率低下。

Webpack Dev Server

webpack-dev-server 是 Webpack 官方推出的一款开发工具,根据它的名字我们就应该知道,它提供了一个开发服务器,并且将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起。

Webpack 官方推出 webpack-dev-server 这款工具的初衷,就是为了提高开发者日常的开发效率,使用这个工具就可以解决我在开头所提出的问题。而且它是一个高度集成的工具,使用起来十分的方便。

webpack-dev-server 同样也是一个独立的 npm 模块,所以我们需要通过 npm 将 webpack-dev-server 作为项目的开发依赖安装。安装完成过后,这个模块为我们提供了一个叫作 webpack-dev-server 的 CLI 程序,我们同样可以直接通过 npx 直接去运行这个 CLI,或者把它定义到 npm scripts 中,具体操作如下:

# 安装 webpack-dev-server
$ npm install webpack-dev-server --save-dev

# 运行 webpack-dev-server
$ npx webpack-dev-server

运行 webpack-dev-server 这个命令时,它内部会启动一个 HTTP Server,为打包的结果提供静态文件服务,并且自动使用 Webpack 打包我们的应用,然后监听源代码的变化,一旦文件发生变化,它会立即重新打包,大致流程如下:

webpack 从入门到放弃!_第21张图片

不过这里需要注意的是,webpack-dev-server 为了提高工作速率,它并没有将打包结果写入到磁盘中,而是暂时存放在内存中,内部的 HTTP Server 也是从内存中读取这些文件的。这样一来,就会减少很多不必要的磁盘读写操作,大大提高了整体的构建效率。

99.png
我们还可以为 webpack-dev-server 命令传入一个 --open 的参数,用于自动唤起浏览器打开我们的应用。打开浏览器过后,此时如果你有两块屏幕,就可以把浏览器放到另外一块屏幕上,然后体验一边编码,一边即时预览的开发环境了。

配置选项

Webpack 配置对象中可以有一个叫作 devServer 的属性,专门用来为 webpack-dev-server 提供配置,具体如下:

// ./webpack.config.js
const path = require('path')

module.exports = {
  // ...
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    port: 9000
    // ...
    // 详细配置文档:https://webpack.js.org/configuration/dev-server/
  }
}

静态资源访问

webpack-dev-server 默认会将构建结果和输出文件全部作为开发服务器的资源文件,也就是说,只要通过 Webpack 打包能够输出的文件都可以直接被访问到。但是如果你还有一些没有参与打包的静态文件也需要作为开发服务器的资源被访问,那你就需要额外通过配置“告诉” webpack-dev-server。

具体的方法就是在 webpack-dev-server 的配置对象中添加一个对应的配置。我们回到配置文件中,找到 devServer 属性,它的类型是一个对象,我们可以通过这个 devServer 对象的 contentBase 属性指定额外的静态资源路径。这个 contentBase 属性可以是一个字符串或者数组,也就是说你可以配置一个或者多个路径。具体配置如下:

// ./webpack.config.js
module.exports = {
  // ...
  devServer: {
    contentBase: 'public'
  }
}

我们这里将这个路径设置为项目中的 public 目录。可能有人会有疑问,之前我们在使用插件的时候已经将这个目录通过 copy-webpack-plugin 输出到了输出目录,按照刚刚的说法,所有输出的文件都可以直接被 serve,也就是能直接访问到,按道理应该不需要再作为开发服务器的静态资源路径了。

确实是这样的,而且如果你能想到这一点,也就证明你真正理解了webpack-dev-server 的文件加载规则。

但是在实际使用 Webpack 时,我们一般都会把 copy-webpack-plugin 这种插件留在上线前的那一次打包中使用,而开发过程中一般不会用它。因为在开发过程中,我们会频繁重复执行打包任务,假设这个目录下需要拷贝的文件比较多,如果每次都需要执行这个插件,那打包过程开销就会比较大,每次构建的速度也就自然会降低。

至于如何实现某些插件只在生产模式打包时使用,是额外的话题,这里我们先移除 CopyWebpackPlugin,确保这里的打包不会输出 public 目录中的静态资源文件,然后回到命令行再次执行 webpack-dev-server。

启动过后,我们打开浏览器,这里我们访问的页面文件和 bundle.js 文件均来自于打包结果。我们再尝试访问 favicon.ico,因为这个文件已经没有参与打包了,所以这个文件必然来源于 contentBase 中配置的目录了。

以上就是contentBase额外为开发服务器指定查找资源目录的操作方式。

Proxy 代理

由于 webpack-dev-server 是一个本地开发服务器,所以我们的应用在开发阶段是独立运行在 localhost 的一个端口上,而后端服务又是运行在另外一个地址上。但是最终上线过后,我们的应用一般又会和后端服务部署到同源地址下。

那这样就会出现一个非常常见的问题:在实际生产环境中能够直接访问的 API,回到我们的开发环境后,再次访问这些 API 就会产生跨域请求问题。

可能有人会说,我们可以用跨域资源共享(CORS)解决这个问题。确实如此,如果我们请求的后端 API 支持 CORS,那这个问题就不成立了。但是并不是每种情况下服务端的 API 都支持 CORS。如果前后端应用是同源部署,也就是协议 / 域名 / 端口一致,那这种情况下,根本没必要开启 CORS,所以跨域请求的问题仍然是不可避免的。

那解决这种开发阶段跨域请求问题最好的办法,就是在开发服务器中配置一个后端 API 的代理服务,也就是把后端接口服务代理到本地的开发服务地址。

webpack-dev-server 就支持直接通过配置的方式,添加代理服务。接下来,我们来看一下它的具体用法。这里我们假定 GitHub 的 API 就是我们应用的后端服务,那我们的目标就是将 GitHub API 代理到本地开发服务器中。

webpack 从入门到放弃!_第22张图片

GitHub API 的 Endpoint 都是在根目录下,也就是说不同的 Endpoint 只是 URL 中的路径部分不同,例如 https://api.github.com/users 和 https://api.github.com/events。

知道 API 地址的规则过后,我们回到配置文件中,在 devServer 配置属性中添加一个 proxy 属性,这个属性值需要是一个对象,对象中的每个属性就是一个代理规则配置。

属性的名称是需要被代理的请求路径前缀,一般为了辨别,我都会设置为 /api。值是所对应的代理规则配置,我们将代理目标地址设置为 https://api.github.com,具体代码如下:

// ./webpack.config.js

module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com'
      }
    }
  }
}

那此时我们请求 http://localhost:8080/api/users ,就相当于请求了 https://api.github.com/api/users

100.png

而我们真正希望请求的地址是 https://api.github.com/users,所以对于代理路径开头的 /api 我们要重写掉。我们可以添加一个 pathRewrite 属性来实现代理路径重写,重写规则就是把路径中开头的 /api 替换为空,pathRewrite 最终会以正则的方式来替换请求路径。

// ./webpack.config.js

module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: {
          '^/api': '' // 替换掉代理地址中的 /api
        }
      }
    }
  }
}

这样我们代理的地址就正常了。

101.png
除此之外,我们还需设置一个 changeOrigin 属性为 true。这是因为默认代理服务器会以我们实际在浏览器中请求的主机名,也就是 localhost:8080 作为代理请求中的主机名。而一般服务器需要根据请求的主机名判断是哪个网站的请求,那 localhost:8080 这个主机名,对于 GitHub 的服务器来说,肯定无法正常请求,所以需要修改。

将代理规则配置的 changeOrigin 属性设置为 true,就会以实际代理请求地址中的主机名去请求,也就是我们正常请求这个地址的主机名是什么,实际请求 GitHub 时就会设置成什么。

// ./webpack.config.js

module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: {
          '^/api': '' // 替换掉代理地址中的 /api
        },
        changeOrigin: true // 确保请求 GitHub 的主机名就是:api.github.com
      }
    }
  }
}

完成以后,打开命令行终端,运行 webpack-dev-server。然后打开浏览器,这里我们直接尝试请求 http://localhost:8080/api/users,得到的就是 GitHub 的用户数据。 因为这个地址已经被代理到了 GitHub 的用户数据接口。

SourceMap

通过构建或者编译之类的操作,我们将开发阶段编写的源代码转换为能够在生产环境中运行的代码,这种进步同时也意味着我们实际运行的代码和我们真正编写的代码之间存在很大的差异。

在这种情况下,如果需要调试我们的应用,或是应用运行的过程中出现意料之外的错误,那我们将无从下手。因为无论是调试还是报错,都是基于构建后的代码进行的,我们只能看到错误信息在构建后代码中具体的位置,却很难直接定位到源代码中对应的位置。

SourceMap简介

Source Map(源代码地图)就是解决此类问题最好的办法,从它的名字就能够看出它的作用:映射转换后的代码与源代码之间的关系。一段转换后的代码,通过转换过程中生成的 Source Map 文件就可以逆向解析得到对应的源代码。

webpack 从入门到放弃!_第23张图片

目前很多第三方库在发布的文件中都会同时提供一个 .map 后缀的 Source Map 文件。例如 jQuery。我们可以打开它的 Source Map 文件看一下,如下图所示:

webpack 从入门到放弃!_第24张图片

这是一个 JSON 格式的文件,这个 JSON 里面记录的就是转换后和转换前代码之间的映射关系,主要存在以下几个属性:

  • version 是指定所使用的 Source Map 标准版本
  • sources 中记录的是转换前的源文件名称,因为有可能出现多个文件打包转换为一个文件的情况,所以这里是一个数组
  • names 是源代码中使用的一些成员名称,我们都知道一般压缩代码时会将我们开发阶段编写的有意义的变量名替换为一些简短的字符,这个属性中记录的就是原始的名称
  • mappings 属性,这个属性最为关键,它是一个叫作 base64-VLQ 编码的字符串,里面记录的信息就是转换后代码中的字符与转换前代码中的字符之间的映射关系

一般我们会在转换后的代码中通过添加一行注释的方式来去引入 Source Map 文件。不过这个特性只是用于开发调试的,例如//# sourceMappingURL=jquery-3.4.1.min.map

这样我们在 Chrome 浏览器中如果打开了开发人员工具,它就会自动请求这个文件,然后根据这个文件的内容逆向解析出来源代码,以便于调试。同时因为有了映射关系,所以代码中如果出现了错误,也就能自动定位找到源代码中的位置了。

Webpack 中配置 Source Map

我们使用 Webpack 打包的过程,同样支持为打包结果生成对应的 Source Map。用法上也很简单,不过它提供了很多不同模式,导致大部分初学者操作起来可能会比较懵。那接下来我们就一起研究一下在 Webpack 中如何开启 Source Map,然后再来了解一下几种不同的 Source Map 模式之间存在哪些差异。

我们回到配置文件中,这里我们要使用的配置属性叫作 devtool。这个属性就是用来配置开发过程中的辅助工具,也就是与 Source Map 相关的一些功能。我们可以先将这个属性设置为 source-map,具体代码如下:

// ./webpack.config.js

module.exports = {
  devtool: 'source-map' // source map 设置
}

然后打开命令行终端,运行 Webpack 打包。打包完成过后,我们打开 dist 目录,此时这个目录中就会生成我们 bundle.js 的 Source Map 文件,与此同时 bundle.js 中也会通过注释引入这个 Source Map 文件。

如果你只是需要使用 Source Map 的话,操作到这里就已经实现了。但是只会使用这种最普通的 Source Map 模式还远远不够。因为现阶段 Webpack 支持的 Source Map 模式有很多种。每种模式下所生成的 Source Map 效果和生成速度都不一样。显然,效果好的一般生成速度会比较慢,而生成速度快的一般就没有什么效果。Webpack 中的 devtool 配置,除了可以使用 source-map 这个值,它还支持很多其他的选项,具体的我们可以参考文档中的不同模式的对比表。

devtool取值 初次构建 重新构建 适合生产环境 品质
(none) 最快 最快
eval 最快 最快 转换后代码
cheap-eval-source-map 更快 转换后代码(只有行信息)
cheap-module-eval-source-map 更快 源代码(只有行信息)
eval-source-map 最慢 完整源代码
cheap-source-map 转换后代码(只有行信息)
cheap-module-source-map 更慢 源代码(只有行信息)
inline-cheap-source-map 转换后代码(只有行信息)
inline-cheap-module-source-map 更慢 源代码(只有行信息)
source-map 最慢 最慢 完整源代码
inline-source-map 最慢 最慢 完整源代码
hidden-source-map 最慢 最慢 完整源代码
nosources-source-map 最慢 最慢 无源码内容,只有行列信息

上表分别从初次构建速度、监视模式重新构建速度、是否适合生成环境使用,以及 Source Map 的质量,这四个维度去横向对比了不同的 Source Map 模式之间的差异。

对比不同的devtools模式

eval

它就是将模块代码放到 eval 函数中执行,并且通过 sourceURL 标注所属文件路径,在这种模式下没有 Source Map 文件,所以只能定位是哪个文件出错

eval-source-map

这个模式也是使用 eval 函数执行模块代码,不过这里有所不同的是,eval-source-map 模式除了定位文件,还可以定位具体的行列信息。相比于 eval 模式,它能够生成 Source Map 文件,可以反推出源代码

cheap-eval-source-map

阉割版的 eval-source-map,因为它虽然也生成了 Source Map 文件,但是这种模式下的 Source Map 只能定位到行,而定位不到列,所以在效果上差了一点点,但是构建速度会提升很多

cheap-module-eval-source-map

这里就是在 cheap-eval-source-map 的基础上多了一个 module,这种模式同样也只能定位到行

inline-source-map

它跟普通的 source-map 效果相同,只不过这种模式下 Source Map 文件不是以物理文件存在,而是以 data URLs 的方式出现在代码中。我们前面遇到的 eval-source-map 也是这种 inline 的方式。

hidden-source-map

在这个模式下,我们在开发工具中看不到 Source Map 的效果,但是它也确实生成了 Source Map 文件,这就跟 jQuery 一样,虽然生成了 Source Map 文件,但是代码中并没有引用对应的 Source Map 文件,开发者可以自己选择使用。

nosources-source-map

在这个模式下,我们能看到错误出现的位置(包含行列位置),但是点进去却看不到源代码。这是为了保护源代码在生产环境中不暴露。

模块热替换

当你实际去使用 Webpack Dev Server 自动刷新的特性去完成具体的开发任务时,你会发现还是有一些不舒服的地方。当我们修改完编辑器文本对应的样式过后,原本想着可以即时看到最新的界面效果,但是这时编辑器中的内容却没有了。

出现这个问题的原因,是因为我们每次修改完代码,Webpack 都可以监视到变化,然后自动打包,再通知浏览器自动刷新,一旦页面整体刷新,那页面中的任何操作状态都将会丢失,所以才会出现我们上面所看到的情况。

解决办法是能够实现在页面不刷新的情况下,代码也可以及时的更新到浏览器的页面中,重新执行,避免页面状态丢失。针对这个需求,Webpack 同样可以满足。

介绍

HMR 全称 Hot Module Replacement,翻译过来叫作“模块热替换”或“模块热更新”。

计算机行业经常听到一个叫作热拔插的名词,指的就是我们可以在一个正在运行的机器上随时插拔设备,机器的运行状态不会受插拔的影响,而且插上去的设备可以立即工作,例如我们电脑上的 USB 端口就可以热拔插。

模块热替换中的“热”和这里提到的“热拔插”是相同的意思,都是指在运行过程中的即时变化。

Webpack 中的模块热替换,指的是我们可以在应用运行过程中,实时的去替换掉应用中的某个模块,而应用的运行状态不会因此而改变。例如,我们在应用运行过程中修改了某个模块,通过自动刷新会导致整个应用的整体刷新,那页面中的状态信息都会丢失;而如果使用的是 HMR,就可以实现只将修改的模块实时替换至应用中,不必完全刷新整个应用。

开启HMR

HMR 已经集成在了 webpack 模块中了,所以不需要再单独安装什么模块。使用这个特性最简单的方式就是,在运行 webpack-dev-server 命令时,通过 --hot 参数去开启这个特性。或者也可以在配置文件中通过添加对应的配置来开启这个功能。那我们这里打开配置文件,这里需要配置两个地方:

  • 首先需要将 devServer 对象中的 hot 属性设置为 true
  • 然后需要载入一个插件,这个插件是 webpack 内置的一个插件,所以我们先导入 webpack 模块,有了这个模块过后,这里使用的是一个叫作 HotModuleReplacementPlugin 的插件
// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    // 开启 HMR 特性,如果资源不支持 HMR 会 fallback 到 live reloading
    hot: true
    // 只使用 HMR,不会 fallback 到 live reloading
    // hotOnly: true
  },

  plugins: [
    // ...
    // HMR 特性所需要的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}

生产环境优化

上述一些用法和特性都是为了在开发阶段能够拥有更好的开发体验。而随着这些体验的提升,一个新的问题出现在我们面前:我们的打包结果会变得越来越臃肿。

这是因为在这个过程中 Webpack 为了实现这些特性,会自动往打包结果中添加一些内容。例如我们之前用到的 Source Map 和 HMR,它们都会在输出结果中添加额外代码来实现各自的功能。

但是这些额外的代码对生产环境来说是冗余的。因为生产环境和开发环境有很大的差异,在生产环境中我们强调的是以更少量、更高效的代码完成业务功能,也就是注重运行效率。而开发环境中我们注重的只是开发效率。

那针对这个问题,Webpack 4 推出了 mode 的用法,为我们提供了不同模式下的一些预设配置,其中生产模式下就已经包括了很多优化配置。

同时 Webpack 也建议我们为不同的工作环境创建不同的配置,以便于让我们的打包结果可以适用于不同的环境。

不同环境下的配置

我们先为不同的工作环境创建不同的 Webpack 配置。创建不同环境配置的方式主要有两种:

  • 在配置文件中添加相应的判断条件,根据环境不同导出不同配置;
  • 为不同环境单独添加一个配置文件,一个环境对应一个配置文件。

我们分别尝试一下通过这两种方式,为开发环境和生产环境创建不同配置。

首先我们来看在配置文件中添加判断的方式。我们回到配置文件中,Webpack 配置文件还支持导出一个函数,然后在函数中返回所需要的配置对象。这个函数可以接收两个参数,第一个是 env,是我们通过 CLI 传递的环境名参数,第二个是 argv,是运行 CLI 过程中的所有参数。具体代码如下:

// ./webpack.config.js
module.exports = (env, argv) => {
  return {
    // ... webpack 配置
  }
}

那我们就可以借助这个特点,为开发环境和生产环境创建不同配置。我先将不同模式下公共的配置定义为一个 config 对象,具体代码如下:

// ./webpack.config.js

module.exports = (env, argv) => {
  const config = {
    // ... 不同模式下的公共配置
  }
  return config
}

然后通过判断,再为 config 对象添加不同环境下的特殊配置。具体如下:

// ./webpack.config.js

module.exports = (env, argv) => {
  const config = {
    // ... 不同模式下的公共配置
  }

  if (env === 'development') {
    // 为 config 添加开发模式下的特殊配置
    config.mode = 'development'
    config.devtool = 'cheap-eval-module-source-map'
  } else if (env === 'production') {
    // 为 config 添加生产模式下的特殊配置
    config.mode = 'production'
    config.devtool = 'nosources-source-map'
  }

  return config

}

例如这里,我们判断 env 等于 development(开发模式)的时候,我们将 mode 设置为 development,将 devtool 设置为 cheap-eval-module-source-map;而当 env 等于 production(生产模式)时,我们又将 mode 和 devtool 设置为生产模式下需要的值。

当然,你还可以分别为不同模式设置其他不同的属性、插件,这也都是类似的。

通过这种方式完成配置过后,我们打开命令行终端,这里我们再去执行 webpack 命令时就可以通过 --env 参数去指定具体的环境名称,从而实现在不同环境中使用不同的配置。

那这就是通过在 Webpack 配置文件导出的函数中对环境进行判断,从而实现不同环境对应不同配置。这种方式是 Webpack 建议的方式。

不同环境的配置文件

通过判断环境名参数返回不同配置对象的方式只适用于中小型项目,因为一旦项目变得复杂,我们的配置也会一起变得复杂起来。所以对于大型的项目来说,还是建议使用不同环境对应不同配置文件的方式来实现。

一般在这种方式下,项目中最少会有三个 webpack 的配置文件。其中两个用来分别适配开发环境和生产环境,另外一个则是公共配置。因为开发环境和生产环境的配置并不是完全不同的,所以需要一个公共文件来抽象两者相同的配置。具体配置文件结构如下:

.
├── webpack.common.js ···························· 公共配置
├── webpack.dev.js ······························· 开发模式配置
└── webpack.prod.js ······························ 生产模式配置

首先我们在项目根目录下新建一个 webpack.common.js,在这个文件中导出不同模式下的公共配置;然后再来创建一个 webpack.dev.js 和一个 webpack.prod.js 分别定义开发和生产环境特殊的配置。

在不同环境的具体配置中我们先导入公共配置对象,然后这里可以使用 Object.assign 方法把公共配置对象复制到具体环境的配置对象中,并且同时去覆盖其中的一些配置。具体如下:

// ./webpack.common.js
module.exports = {
  // ... 公共配置
}

// ./webpack.prod.js
const common = require('./webpack.common')

module.exports = Object.assign(common, {
  // 生产模式配置
})
// ./webpack.dev.js
const common = require('./webpack.common')
module.exports = Object.assign(common, {
  // 开发模式配置
})

如果你熟悉 Object.assign 方法,就应该知道,这个方法会完全覆盖掉前一个对象中的同名属性。这个特点对于普通值类型属性的覆盖都没有什么问题。但是像配置中的 plugins 这种数组,我们只是希望在原有公共配置的插件基础上添加一些插件,那 Object.assign 就做不到了。

所以我们需要更合适的方法来合并这里的配置与公共的配置。你可以使用 Lodash 提供的 merge 函数来实现,不过社区中提供了更为专业的模块 webpack-merge,它专门用来满足我们这里合并 Webpack 配置的需求。

我们可以先通过 npm 安装一下 webpack-merge 模块。具体命令如下:

$ npm i webpack-merge --save-dev 
# or yarn add webpack-merge --dev

安装完成过后我们回到配置文件中,这里先载入这个模块。那这个模块导出的就是一个 merge 函数,我们使用这个函数来合并这里的配置与公共的配置。具体代码如下:

// ./webpack.common.js
module.exports = {
  // ... 公共配置
}

// ./webpack.prod.js
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
  // 生产模式配置
})

// ./webpack.dev.jss
const merge = require('webpack-merge')
const common = require('./webpack.common')

module.exports = merge(common, {
  // 开发模式配置
})

使用 webpack-merge 过后,我们这里的配置对象就可以跟普通的 webpack 配置一样,需要什么就配置什么,merge 函数内部会自动处理合并的逻辑。

分别配置完成过后,我们再次回到命令行终端,然后尝试运行 webpack 打包。不过因为这里已经没有默认的配置文件了,所以我们需要通过 --config 参数来指定我们所使用的配置文件路径。例如:

$ webpack --config webpack.prod.js

生产模式下的优化插件

在 Webpack 4 中新增的 production 模式下,内部就自动开启了很多通用的优化功能。对于使用者而言,开箱即用是非常方便的,但是对于学习者而言,这种开箱即用会导致我们忽略掉很多需要了解的东西。以至于出现问题无从下手。

如果想要深入了解 Webpack 的使用,可以单独研究每一个配置背后的作用。这里我们看一下 production 模式下几个主要的优化功能,顺便了解一下 Webpack 如何优化打包结果。

Define Plugin

首先是 DefinePlugin,DefinePlugin 是用来为我们代码中注入全局成员的。在 production 模式下,默认通过这个插件往代码中注入了一个 process.env.NODE_ENV。很多第三方模块都是通过这个成员去判断运行环境,从而决定是否执行例如打印日志之类的操作。

这里我们来单独使用一下这个插件。我们回到配置文件中,DefinePlugin 是一个内置的插件,所以我们先导入 webpack 模块,然后再到 plugins 中添加这个插件。这个插件的构造函数接收一个对象参数,对象中的成员都可以被注入到代码中。具体代码如下:

// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
  // ... 其他配置
  plugins: [
    new webpack.DefinePlugin({
      API_BASE_URL: 'https://api.example.com'
    })
  ]
}

例如我们这里通过 DefinePlugin 定义一个 API_BASE_URL,用来为我们的代码注入 API 服务地址,它的值是一个字符串。

然后我们回到代码中打印这个 API_BASE_URL。具体代码如下:

// ./src/main.js

console.log(API_BASE_URL)

这里我们发现 DefinePlugin 其实就是把我们配置的字符串内容直接替换到了代码中,而目前这个字符串的内容为 https://api.example.com,字符串中并没有包含引号,所以替换进来语法自然有问题.正确的做法是传入一个字符串字面量语句.具体实现如下:

// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ... 其他配置
  plugins: [
    new webpack.DefinePlugin({
      // 值要求的是一个代码片段
      API_BASE_URL: '"https://api.example.com"'
    })
  ]
}

这样代码内的 API_BASE_URL 就会被替换为 “https://api.example.com”

这里有一个非常常用的小技巧,如果我们需要注入的是一个值,就可以通过 JSON.stringify 的方式来得到表示这个值的字面量。这样就不容易出错了。具体实现如下:

// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ... 其他配置
  plugins: [
    new webpack.DefinePlugin({
      // 值要求的是一个代码片段
      API_BASE_URL: JSON.stringify('https://api.example.com')
    })
  ]
}

DefinePlugin 的作用虽然简单,但是却非常有用,我们可以用它在代码中注入一些可能变化的值。

Tree-shaking

Tree Shaking 翻译过来的意思就是“摇树”。伴随着摇树的动作,树上的枯树枝和树叶就会掉落下来。

我们这里要介绍的 Tree-shaking 也是同样的道理,不过通过 Tree-shaking “摇掉”的是代码中那些没有用到的部分,这部分没有用的代码更专业的说法应该叫作未引用代码(dead-code)。

Tree-shaking 最早是 Rollup 中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。

我们使用 Webpack 生产模式打包的优化过程中,就使用自动开启这个功能,以此来检测我们代码中的未引用代码,然后自动移除它们。去除冗余代码是生产环境优化中一个很重要的工作,Webpack 的 Tree-shaking 功能就很好地实现了这一点。

试想一下,如果我们在项目中引入 Lodash 这种工具库,大部分情况下我们只会使用其中的某几个工具函数,而其他没有用到的部分就是冗余代码。通过 Tree-shaking 就可以极大地减少最终打包后 bundle 的体积。

需要注意的是,Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。

开启 Tree Shaking

我们打开 Webpack 的配置文件,在配置对象中添加一个 optimization 属性,这个属性用来集中配置 Webpack 内置优化功能,它的值也是一个对象。

在 optimization 对象中我们可以先开启一个 usedExports 选项,表示在输出结果中只导出外部使用了的成员,具体配置代码如下:

// ./webpack.config.js

module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true
  }
}

如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码。我们可以回到配置文件中,尝试在 optimization 配置中开启 minimize,具体配置如下:

// ./webpack.config.js

module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 压缩输出结果
    minimize: true
  }
}

这就是 Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能:

  • usedExports - 打包结果中只导出外部用到的成员;
  • minimize - 压缩打包结果。

合并模块(扩展)

除了 usedExports 选项之外,我们还可以使用一个 concatenateModules 选项继续优化输出。

普通打包只是将一个模块最终放入一个单独的函数中,如果我们的模块很多,就意味着在输出结果中会有很多的模块函数。

concatenateModules 配置的作用就是尽可能将所有模块合并到一起输出到一个函数中,这样既提升了运行效率,又减少了代码的体积。

我们回到配置文件中,这里我们在 optimization 属性中开启 concatenateModules。同时,为了更好地看到效果,我们先关闭 minimize,具体配置如下:

// ./webpack.config.js
module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true,
    // 压缩输出结果
    minimize: false
  }
}

然后回到命令行终端再次运行打包。那此时 bundle.js 中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中。这个特性又被称为 Scope Hoisting,也就是作用域提升,它是 Webpack 3.0 中添加的一个特性。如果再配合 minimize 选项,打包结果的体积又会减小很多。

结合 babel-loader 的问题

因为早期的 Webpack 发展非常快,那变化也就比较多,所以当我们去找资料时,得到的结果不一定适用于当前我们所使用的版本。而 Tree-shaking 的资料更是如此,很多资料中都表示“为 JS 模块配置 babel-loader,会导致 Tree-shaking 失效”。

针对这个问题,这里我统一说明一下:

首先你需要明确一点:Tree-shaking 实现的前提是 ES Modules,也就是说:最终交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化。

我们都知道 Webpack 在打包所有的模块代码之前,先是将模块根据配置交给不同的 Loader 处理,最后再将 Loader 处理的结果打包到一起。

很多时候,我们为了更好的兼容性,会选择使用 babel-loader 去转换我们源代码中的一些 ECMAScript 的新特性。而 Babel 在转换 JS 代码时,很有可能处理掉我们代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式。

当然了,Babel 具体会不会处理 ES Modules 代码,取决于我们有没有为它配置使用转换 ES Modules 的插件。

很多时候,我们为 Babel 配置的都是一个 preset(预设插件集合),而不是某些具体的插件。例如,目前市面上使用最多的 @babel/preset-env,这个预设里面就有转换 ES Modules 的插件。所以当我们使用这个预设时,代码中的 ES Modules 部分就会被转换成 CommonJS 方式。那 Webpack 再去打包时,拿到的就是以 CommonJS 方式组织的代码了,所以 Tree-shaking 不能生效。

而在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件,你可以参考对应版本 babel-loader 的源码,所以最新版本的 babel-loader 并不会导致 Tree-shaking 失效。如果你不确定现在使用的 babel-loader 会不会导致这个问题,最简单的办法就是在配置中将 @babel/preset-env 的 modules 属性设置为 false,确保不会转换 ES Modules,也就确保了 Tree-shaking 的前提。

sideEffects

Webpack 4 中新增了一个 sideEffects 特性,它允许我们通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。

::: tip
模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情。
:::

这个特性一般只有我们去开发一个 npm 模块时才会用到。Tree-shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特性了。

我们打开 Webpack 的配置文件,在 optimization 中开启 sideEffects 特性,具体配置如下:

// ./webpack.config.js

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    sideEffects: true
  }
}

::: tip
注意这个特性在 production 模式下同样会自动开启。
:::

那此时 Webpack 在打包某个模块之前,会先检查这个模块所属的 package.json 中的 sideEffects 标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没用到的模块就不再被打包。换句话说,即便这些没有用到的模块中存在一些副作用代码,我们也可以通过 package.json 中的 sideEffects 去强制声明没有副作用。

那我们打开项目 package.json 添加一个 sideEffects 字段,把它设置为 false,具体代码如下:

{
  "version": "0.1.0",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },

  "devDependencies": {
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.11"
  },
  "sideEffects": false
}

这样就表示我们这个项目中的所有代码都没有副作用,让 Webpack 放心大胆地去“干”。此时那些没有用到的模块就彻底不会被打包进来了。那这就是 sideEffects 的作用。

目前很多第三方的库或者框架都已经使用了 sideEffects 标识,所以我们再也不用担心为了一个小功能引入一个很大体积的库了。例如,某个 UI 组件库中只有一两个组件会用到,那只要它支持 sideEffects,你就可以放心大胆的直接用了。

使用 sideEffects 这个功能的前提是确定你的代码没有副作用,或者副作用代码没有全局影响,否则打包时就会误删掉你那些有意义的副作用代码。我们在 JS 中直接载入的 CSS 模块,也都属于副作用模块,所以说不是所有的副作用都应该被移除,有一些必要的副作用需要保留下来。最好的办法就是在 package.json 中的 sideEffects 字段中标识需要保留副作用的模块路径(可以使用通配符),具体配置如下:

{
  "version": "0.1.0",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },

  "devDependencies": {
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.11"
  },

  "sideEffects": [
    "./src/extend.js",
    "*.css"
  ]
}

Code Splitting

通过 Webpack 实现前端项目整体模块化的优势固然明显,但是它也会存在一些弊端:它最终会将我们所有的代码打包到一起。试想一下,如果我们的应用非常复杂,模块非常多,那么这种 All in One 的方式就会导致打包的结果过大,甚至超过 4~5M。

在绝大多数的情况下,应用刚开始工作时,并不是所有的模块都是必需的。如果这些模块全部被打包到一起,即便应用只需要一两个模块工作,也必须先把 bundle.js 整体加载进来,而且前端应用一般都是运行在浏览器端,这也就意味着应用的响应速度会受到影响,也会浪费大量的流量和带宽。

所以这种 All in One 的方式并不合理,更为合理的方案是把打包的结果按照一定的规则分离到多个 bundle 中,然后根据应用的运行需要按需加载。这样就可以降低启动成本,提高响应速度。

为了解决打包结果过大导致的问题,Webpack 设计了一种分包功能:Code Splitting(代码分割)。

Code Splitting 通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle 中,从而降低应用的启动成本,提高响应速度。

Webpack 实现分包的方式主要有两种:

  • 根据业务不同配置多个打包入口,输出多个打包结果;
  • 结合 ES Modules 的动态导入(Dynamic Imports)特性,按需加载模块。

多入口打包

多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中。

// ./webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },

  output: {
    filename: '[name].bundle.js' // [name] 是入口名称
  },

  // ... 其他配置
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index'] // 指定使用 index.bundle.js
    }),

    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album'] // 指定使用 album.bundle.js
    })
  ]
}


一般 entry 属性中只会配置一个打包入口,如果我们需要配置多个入口,可以把 entry 定义成一个对象。

::: tip
注意:这里 entry 是定义为对象而不是数组,如果是数组的话就是把多个文件打包到一起,还是一个入口。
:::

在这个对象中一个属性就是一个入口,属性名称就是这个入口的名称,值就是这个入口对应的文件路径。那我们这里配置的就是 index 和 album 页面所对应的 JS 文件路径。

一旦我们的入口配置为多入口形式,那输出文件名也需要修改,因为两个入口就有两个打包结果,不能都叫 bundle.js。我们可以在这里使用 [name] 这种占位符来输出动态的文件名,[name] 最终会被替换为入口的名称。每个页面只使用它对应的那个输出结果指定所使用的 bundle,我们可以通过 HtmlWebpackPlugin 的 chunks 属性来设置

提取公共模块

多入口打包本身非常容易理解和使用,但是它也存在一个小问题,就是不同的入口中一定会存在一些公共使用的模块,如果按照目前这种多入口打包的方式,就会出现多个打包结果中有相同的模块的情况。

所以我们还需要把这些公共的模块提取到一个单独的 bundle 中。Webpack 中实现公共模块提取非常简单,我们只需要在优化配置中开启 splitChunks 功能就可以了,具体配置如下:

// ./webpack.config.js

module.exports = {
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },

  output: {
    filename: '[name].bundle.js' // [name] 是入口名称
  },

  optimization: {
    splitChunks: {
      // 自动提取所有公共模块到单独 bundle
      chunks: 'all'
    }
  }

  // ... 其他配置
}

这里在 optimization 属性中添加 splitChunks 属性,那这个属性的值是一个对象,这个对象需要配置一个 chunks 属性,我们这里将它设置为 all,表示所有公共模块都可以被提取。除此之外,splitChunks 还支持很多高级的用法,可以实现各种各样的分包策略,这些我们可以在文档中找到对应的介绍。

动态导入

除了多入口打包的方式,Code Splitting 更常见的实现方式还是结合 ES Modules 的动态导入特性,从而实现按需加载。

按需加载是开发浏览器应用中一个非常常见的需求。一般我们常说的按需加载指的是加载数据或者加载图片,但是我们这里所说的按需加载,指的是在应用运行过程中,需要某个资源模块时,才去加载这个模块。这种方式极大地降低了应用启动时需要加载的资源体积,提高了应用的响应速度,同时也节省了带宽和流量。

Webpack 中支持使用动态导入的方式实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包。

相比于多入口的方式,动态导入更为灵活,因为我们可以通过代码中的逻辑去控制需不需要加载某个模块,或者什么时候加载某个模块。而且我们分包的目的中,很重要的一点就是让模块实现按需加载,从而提高应用的响应速度。

// ./src/index.js
// import posts from './posts/posts'
// import album from './album/album'

const update = () => {
  const hash = window.location.hash || '#posts'
  const mainElement = document.querySelector('.main')
  mainElement.innerHTML = ''

  if (hash === '#posts') {
    // mainElement.appendChild(posts())
    import('./posts/posts').then(({ default: posts }) => {
      mainElement.appendChild(posts())
    })
  } else if (hash === '#album') {
    // mainElement.appendChild(album())
    import('./album/album').then(({ default: album }) => {
      mainElement.appendChild(album())
    })
  }
}

window.addEventListener('hashchange', update)

update()

为了动态导入模块,可以将 import 关键字作为函数调用。当以这种方式使用时,import 函数返回一个 Promise 对象。这就是 ES Modules 标准中的 Dynamic Imports。

这里我们先移除 import 这种静态导入,然后在需要使用组件的地方通过 import 函数导入指定路径,那这个方法返回的是一个 Promise。在这个 Promise 的 then 方法中我们能够拿到模块对象。由于我们这里的 posts 和 album 模块是以默认成员导出,所以我们需要解构模块对象中的 default,先拿到导出成员,然后再正常使用这个导出成员。

如果你使用的是 Vue.js 之类的 SPA 开发框架的话,那你项目中路由映射的组件就可以通过这种动态导入的方式实现按需加载,从而实现分包。

魔法注释

默认通过动态导入产生的 bundle 文件,它的 name 就是一个序号,这并没有什么不好,因为大多数时候,在生产环境中我们根本不用关心资源文件的名称。

但是如果你还是需要给这些 bundle 命名的话,就可以使用 Webpack 所特有的魔法注释去实现。具体方式如下:

// 魔法注释

import(/* webpackChunkName: 'posts' */'./posts/posts')
  .then(({ default: posts }) => {
    mainElement.appendChild(posts())
  })

所谓魔法注释,就是在 import 函数的形式参数位置,添加一个行内注释,这个注释有一个特定的格式:webpackChunkName: ‘’,这样就可以给分包的 chunk 起名字了。

除此之外,魔法注释还有个特殊用途:如果你的 chunkName 相同的话,那相同的 chunkName 最终就会被打包到一起。

Mini CSS Extract Plugin

对于 CSS 文件的打包,一般我们会使用 style-loader 进行处理,这种处理方式最终的打包结果就是 CSS 代码会内嵌到 JS 代码中。

mini-css-extract-plugin 是一个可以将 CSS 代码从打包结果中提取出来的插件,它的使用非常简单,同样也需要先通过 npm 安装一下这个插件。具体命令如下:

$ npm i mini-css-extract-plugin --save-dev

安装完成过后,我们回到 Webpack 的配置文件。具体配置如下:

// ./webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', // 将样式通过 style 标签注入
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]

}

我们这里先导入这个插件模块,导入过后我们就可以将这个插件添加到配置对象的 plugins 数组中了。这样 Mini CSS Extract Plugin 在工作时就会自动提取代码中的 CSS 了。

除此以外,Mini CSS Extract Plugin 还需要我们使用 MiniCssExtractPlugin 中提供的 loader 去替换掉 style-loader,以此来捕获到所有的样式。

这样的话,打包过后,样式就会存放在独立的文件中,直接通过 link 标签引入页面。

不过这里需要注意的是,如果你的 CSS 体积不是很大的话,提取到单个文件中,效果可能适得其反,因为单独的文件就需要单独请求一次。个人经验是如果 CSS 超过 200KB 才需要考虑是否提取出来,作为单独的文件。

Optimize CSS Assets Webpack Plugin

使用了 Mini CSS Extract Plugin 过后,样式就被提取到单独的 CSS 文件中了。但是这里同样有一个小问题。我们以生产模式运行打包,JavaScript 文件正常被压缩了,而样式文件并没有被压缩。

Webpack 官方推荐了一个 Optimize CSS Assets Webpack Plugin 插件。我们可以使用这个插件来压缩我们的样式文件。

我们回到命令行,先来安装这个插件,具体命令如下:

$ npm i optimize-css-assets-webpack-plugin --save-dev

安装完成过后,我们回到配置文件中,添加对应的配置。具体代码如下:

// ./webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin(),
    new OptimizeCssAssetsWebpackPlugin()
  ]
}

能你会在这个插件的官方文档中发现,文档中的这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。具体如下:

// ./webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  optimization: {
    minimizer: [
      new OptimizeCssAssetsWebpackPlugin()
    ]
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
}

如果我们配置到 plugins 属性中,那么这个插件在任何情况下都会工作。而配置到 minimizer 中,就只会在 minimize 特性开启时才工作。Webpack 建议像这种压缩插件,应该我们配置到 minimizer 中,便于 minimize 选项的统一控制。

输出文件名Hash

一般我们部署前端资源文件时,都会启用服务器静态资源缓存,这样对于用户浏览器而言,它就可以缓存住浏览器中的静态资源,后续就不需要再请求服务器得到这些静态资源文件了,这样我们整体应用的响应速度就有一个大幅度的提升。

不过开启静态资源缓存有一些问题,如果在缓存失效时间中设置时间过短的话,那效果就不会特别明显,如果设置过长,一旦应用发生了更新部署之后不能及时更新到客户端。

为了解决这个问题,我们建议生产模式下,给文件名添加Hash值,这样资源文件发生改变,文件名称也会发生变化,对于客户端而言,全新的文件名就是全新的请求,那就没有缓存的问题。

webpack中filename属性和绝大多数插件的filename属性一样,支持使用占位符的方式来为文件名设置Hash,它们支持三种Hash效果各不相同。

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name]-[hash].bundle.js'
  },
}

最普通的就是[hash]项目级别的,只要项目中任意一个文件发生改动,那么这次打包过程中的hash值都会发生变化。

其次是[chunkhash],这个hash是chunk级别的,也就是在打包过程中,只要是同一路的打包,那chunkhash都是相同的。

最后是[contenthash],它是文件级别的hash,它是根据输出文件的内容生成的hash,也就是不同的文件有不同的hash值。

::: tip
我们可以在占位符中指定hash的长度[contenthash:8]
:::

Webpack打包优化

webpack打包优化方向:

  • 打包速度:优化打包速度,主要是提升了我们的开发效率,更快的打包构建过程。
  • 打包体积:优化打包体积,主要是提升产品的使用体验,降低服务器资源成本,更快的页面加载,将让产品显得更加“丝滑”,同时也可以让打包更快。

webpack打包速度优化

webpack进行打包速度优化有七种常用手段:

优化loader搜索范围

对于 loader 来说,影响打包效率首当其冲必属 Babel了。因为 Babel会将代码转为字符串生成 AST,然后对 AST继续进行转变 最后再生成新的代码,项目越大,转换代码越多,效率就越低。优化正则匹配、使用 include 和 exclude 指定需要处理的文件,忽略不需要处理的文件。

rules: [{ 
    // 优化正则匹配 
    test: /\.js$/, 
    // 指定需要处理的目录 
    include: path.resolve(__dirname, 'src') 
    // 理论上只有include就够了,但是某些情况需要排除文件的时候可以用这个,排除不需要处理文件 
    // exclude: [] 
    }]

多进程/多线程

受限于 node 是单线程运行的,所以 webpack 在打包的过程中也是单线程的,特别是在执行 loader 的时候,长时间编译的任务 很多,这样就会导致等待的情况。我们可以使用一些方法将 loader 的同步执行转换为并行,这样就能充分利用系统资源来提高打包速度了。

{ 
    test: /\.js?$/,
    exclude: /node_modules/, 
    use: [ 
        { 
            loader: "thread-loader", 
            options: { 
                workers: 3 // 进程 3 个 
                } 
            },
            { loader: "babel-loader", 
            options: { presets: ["@babel/preset-env"], 
            plugins: ["@babel/plugin-transform-runtime"] 
            } 
        } 
    ] 
},

分包

在使用 webpack 进行打包时候,对于依赖的第三方库,比如 vue,vuex等这些不会修改的依赖,我们可以让它和我们自己编写 的代码分开打包,这样做的好处是每次更改我本地代码的文件的时候,webpack 只需要打包我项目本身的文件代码,而不会再 去编译第三方库,那么第三方库在第一次打包的时候只打包一次,以后只要我们不升级第三方包的时候,那么 webpack 就不会 对这些库去打包,这样可以快速提高打包的速度。因此为了解决这个问题,DllPlugin和 DllReferencePlugin插件就产生了。这种 方式可以极大的减少打包类库的次数,只有当类库更新版本才需要重新打包,并且也实现了将公共代码抽离成单独文件的优化 方案

// webpack.dll.conf.js 
const path = require('path')
const webpack = require('webpack')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')

module.exports = {
    mode: 'production',
    devtool: false,
    entry: {
        vue: ['vue', 'vue-router', 'iscroll', 'vuex'],
    },
    output: {
        path: path.join(__dirname, '../dist'),
        filename: 'lib/[name]_[hash:4].dll.js',
        library: '[name]_[hash:4]'
    },
    performance: {
        hints: false,
        maxAssetSize: 300000, //单文件超过300k,命令行告警 maxEntrypointSize: 300000, //首次加载文件总和超过300k,命令行告警 
    }, optimization: {
        minimizer: [
            new UglifyJsPlugin({
                parallel: true // 开启多线程并行 
            })
        ]
    },
    plugins: [
        new webpack.DllPlugin({
            context: __dirname,
            path: path.join(__dirname, '../dist/lib', '[name]-manifest.json'),
            name: '[name]_[hash:4]'
        })
    ]
}

// webpack.prod.cong.js 
plugins: [
    new webpack.DllReferencePlugin({
        context: __dirname, manifest: require('../dist/lib/vue-manifest.json')
    }),
]

开启缓存

当设置 cache.type:“filesystem”时,webpack 会在内部以分层方式启用文件系统缓存和内存缓存,将处理结果结存放到内存中, 下次打包直接使用缓存结果而不需要重新打包。

cache: {
    type: "filesystem"
    // cacheDirectory 默认路径是 node_modules/.cache/webpack 
    // cacheDirectory: path.resolve(__dirname, '.temp_cache') 
},

打包分析工具

显示测量打包过程中各个插件和 loader 每一步所消耗的时间,然后让我们可以有针对的分析项目中耗时的模块对其进行处理。

npm install speed-measure-webpack-plugin -D

// webpack.prod.config.js
const SpeedMeatureWebpackPlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeatureWebpackPlugin();

var webpackConfig = merge(baseWebpackConfig,{})
--> 修改为下面格式
var webpackConfig = {...}

module.exports = webpackConfig
--> 修改为下面格式
module.exports = smp.wrap(merge(baseWebpackConfig, webpackConfig));

ignorePlugin

这是 webpack 内置插件, 它的作用是忽略第三方包指定目录,让这些指定目录不要被打包进去,防止在 import 或 require 调用时,生成以下正则表达式匹配的模块。

  • requestRegExp 匹配( test )资源请求路径的正则表达式。
  • contextRegExp( 可选 )匹配( test )资源上下文( 目录 )的正则表达式。
new webpack.IgnorePlugin({ 
    resourceRegExp: /^\.\/test$/, contextRegExp: /test$/, 
})

优化文件路径

  • alias:省下搜索文件的时间,让 webpack 更快找到路径
  • mainFiles:解析目录时要使用的文件名
  • extensions:指定需要检查的扩展名,配置之后可以不用在 require 或是 import 的时候加文件扩展名,会依次尝试添加扩展名进行 匹配
resolve: {
        extensions: ['.js', '.vue'],
        mainFiles: ['index'],
        alias: { '@': resolve('src'), }
    }

webpack打包体积优化

webpack打包体积优化有11种常用优化手段

构建体积分析

npm run build 构建,会默认打开: http://127.0.0.1:8888/,可以看到各个包的体积,分析项目各模块的大小,可以按需优化。

npm install webpack-bundle-analyzer -D

const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

plugins:[ 
    new BundleAnalyzerPlugin() 
]

项目图片资源优化压缩处理

对打包后的图片进行压缩和优化,降低图片分辨率,压缩图片体积等

npm install image-webpack-loader -D

// webpack.base.conf.js

{
    test: /\.(gif|png|jpe?g|svg|webp)$/i,
    type: "asset/resource",
    parser: { dataUrlCondition: { maxSize: 8 * 1024 } },
    generator: { filename: "images/[name].[hash:6][ext]" },
    use: [{
        loader: "image-webpack-loader",
        options: {
            mozjpeg: { progressive: true, quality: 65 },
            optipng: { enabled: false },
            pngquant: { quality: [0.5, 0.65], speed: 4 },
            gifsicle: { interlaced: false },
            webp: {
                quality: 75
            }
        }
    }]
} 

删除无用的css样式

有时候一些项目中可能会存在一些 css 样式被迭代废弃,需要将其删除,可以使用 purgecss-webpack-plugin插件,该插件可以去 除未使用的 css。

npm install purgecss-webpack-plugin glod -D

// webpack.prod.conf.js
const PurgeCSSPlugin = require("purgecss-webpack-plugin");

const glob = require('glob')

const PATHS = { src: path.join(__dirname, 'src') }

// plugins 
new PurgeCSSPlugin({ 
    paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }), 
    safelist: ["body"] 
}),

代码压缩

对 js 文件进行压缩,从而减小 js 文件的体积,还可以压缩 html、css 代码。

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

optimization:{
        minimize: true, //代码压缩 
        usedExports: true, // treeshaking
        minimizer: [ 
            new TerserPlugin({ 
                terserOptions: { 
                    ecma: undefined, 
                    parse: {},
                    compress: {}, 
                    mangle: true, // Note `mangle.properties` is `false` by default. 
                    module: false, // Deprecated 
                    output: null, 
                    format: null, 
                    toplevel: false,
                    nameCache: null, 
                    ie8: false,
                    keep_classnames: undefined,
                    keep_fnames: false, 
                    safari10: false
                } 
            })],
            splitChunks: { 
                cacheGroups: { 
                    commons: { 
                        name: "commons", 
                        chunks: "initial", 
                        minChunks: 2
                    } 
                } 
            } 
        }

使用UglifyjsWebpackPlugin插件对js进行压缩,CssMinimizerWebpackPlugin对css进行压缩。

module.exports = {
    optimization: {
        minimize: true,
        minimizer: [
            new UglifyJsPlugin({sourceMap:true}),
            new CssMinimizerPlugin()
        ]
    }
}

开启Scope Hoisting

Scope Hoisting又译作“作用域提升”。只需在配置文件中添加一个新的插件,就可以让 webpack 打包出来的代码文件更小、运行 的更快, Scope Hoisting会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去,然后适当地重命名一 些变量以防止命名冲突。

new webpack.optimize.ModuleConcatenationPlugin();

提取公共代码

将项目中的公共模块提取出来,可以减少代码的冗余度,提高代码的运行效率和页面的加载速度

new webpack.optimize.CommonsChunkPlugin(options)

代码分离

代码分离能够将工程代码分离到各个文件中,然后按需加载或并行加载这些文件,也用于获取更小的 bundle,以及控制资源加 载优先级,在配置文件中配置多入口,输出多个 chunk。

//多入口配置 最终输出两个chunk
 module.exports = { 
    entry: { 
        index: 'index.js', 
        login: 'login.js' 
    },
    output: { 
        //对于多入口配置需要指定[name]否则会出现重名问题 
        filename: '[name].bundle.js', 
        path: path.resolve(__dirname, 'dist') 
    } 
};

Tree-shaking

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

在ES Module中,通过解构的方式获取方法,会默认触发TreeShaking,代码会自动清除无用代码。前提是调用的库必须使用ES Module的规范。同一文件的TreeShaking必须配置mode=production。一定要注意使用解构来加载模块。

CDN 加速

CDN 的全称是 Content DeliveryNetwork,即内容分发网络。CDN 是构建在网络之上的内容分发网络,依靠部署在各地的边缘 服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响 应速度和命中率。CDN 的关键技术主要有内容存储和分发技术。在项目中以 CDN 的方式加载资源,项目中不需要对资源进行 打包,大大减少打包后的文件体积。

按需加载

在开发项目的时候,项目中都会存在十几甚至更多的路由页面。如果我们将这些页面全部打包进一个文件的话,虽然将多个请 求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了页面能更快地呈现给用户,我们肯定是希望 页面能加载的文件体积越小越好,这时候我们就可以使用按需加载,将每个路由页面单独打包为一个文件。以下是常见的按需 加载的场景

  • 路由组件按需加载
  • 按需加载需引入第三方组件
  • 对于一些插件,如果只是在个别组件中用的到,也可以不要在 main.js 里面引入,而是在组件中按需引入

生产环境关闭sourceMap

sourceMap 本质上是一种映射关系,打包出来的 js 文件中的代码可以映射到代码文件的具体位置,这种映射关系会帮助我们直接 找到在源代码中的错误。但这样会使项目打包速度减慢,项目体积变大,可以在生产环境关闭 sourceMap

相关文章

  • [万字总结] 一文吃透 Webpack 核心原理
  • 【万字】透过分析 webpack 面试题,构建 webpack5.x 知识体系

你可能感兴趣的:(webpack,前端,node.js)