Webpack 打包

1,348 阅读39分钟

模块打包工具的由来

模块化确实很好地解决了在复杂应用开发过程中的代码组织问题,但是随着我们引入模块化应用也会产生新的问题:

  • ES Modules 存在环境兼容问题
  • 模块文件过多,网络请求频繁(通过模块化的方式划分出来的模块较多,而前端应用是运行在浏览器当中,每一个应用当中所需要的文件都需要从服务器当中请求回来)
  • 所有的前端资源都需要模块化(html、css等) 对于整个开发过程而言,毋庸置疑,模块化是必要的。不过,我们需要在原有的基础上去引入更好的方案或工具去解决以上问题或需求,让开发者在开发阶段可以继续享受模块化带来的优势又不必担心模块化对生产环境产生的影响。

更好的方案或工具的设想:

  • 编译代码 将开发阶段编写的包含新特性的代码直接转换为能够兼容绝大多数环境的代码,解决环境兼容问题。

  • 模块打包 将散落的文件打包到一起,解决浏览器频繁对模块文件发出请求的问题。

  • 多类型模块支持 将开发过程当中所涉及到的样式图片字体等所有资源文件都当作模块使用。

针对前两个需求完全可以借助构建系统配合编译工具就可以实现,但是对于第三个需求就很难通过这种方式解决了,所以就有了接下来介绍的主题——前端模块打包工具。

模块打包工具概要

最主流的打包工具:Webpack Parcel Rollup

以Webpack为例,它的一些核心特性就很好地满足了上述所说的需求。

  • 模块打包器(Module bundler)
  • 模块加载器(Loader)
  • 代码拆分(Code Spliting)
  • 资源模块(Asset Module) 首先Webpack作为一个模块打包工具本身就可以解决模块化Javascript的打包问题,通过Webpack就可以将零散的模块代码打包到一个JS文件当中,对于那些有环境兼容问题的代码就可以在打包的过程当中通过模块加载器(Loader)对其进行编译转换;其次Webpack还具备代码拆分(Code Spliting)的能力,它能够将应用中所有代码按照我们的需要去打包,这样就不用担心将所有的代码都打包到一起产生文件较大的问题,我们可以把应用加载过程中初次运行的时候所必须的模块打包到一起,对于其他模块再单独存放,等到应用工作过程中实际去需要到某个模块再异步加载这个模块从而实现增量加载或渐进式加载,这样就不用了担心文件太碎或太大这两个极端问题;最后,对于前端模块类型的问题,Webpack支持在JavaScript当中以模块化的方式去载入任意类型的资源文件,例如在Webpack当中通过JavaScript直接import一个css类型的文件,这些css文件最终会通过style标签的形式工作,其他类型的文件也可以有类似的实现。

所有的打包工具都是以模块化为目标,这里所说的模块化是对整个前端项目的模块化,并不单指JavaScript模块化,比JavaScript模块化要更为宏观。

Webpack 快速上手

Webpack作为目前最主流的前端模块打包器,它提供了一整套前端项目模块方案而不仅仅局限于对JavaScript模块化,通过Webpack提供的前端模块化方案我们就可以很轻松地对前端项目开发过程中涉及到所有资源进行模块化。

Webpack是一个npm模块,所以先在项目中yarn init --yes初始化package.json,然后再安装Webpack所需要的核心模块以及它对应的CLI模块yarn add webpack webpack-cli --dev 安装完成之后就可以在node_modules/.bin下找到webpck-cli,查看版本yarn webpack --version,然后就可以打包src下的js代码。yarn webpack会自动从src/index.js开始打包,打包过后项目根目录会多出dist目录,打包结果就会存到dist/main.js当中,将index.html中脚本的路径改成dist/main.js。由于打包过程中会把importexport转换,所以就不需要script标签中通过type = module这样的模块化的方式引入。每次通过webpack运行命令会很麻烦,可以在package.json中添加"build": "webpack"script命令,这样直接运行yarn build就可以了。

Webpack 配置文件

Webpack 4 以后的版本支持零配置直接启动打包,整个打包过程会按照约定将src/index.js(入口) -> dist/main.js(打包结果)。但很多时候我们需要自定义这些路径。具体做法在项目根目录下添加.webpack.config.js配置文件,这个文件是运行在node环境中的,也就是说我们需要以CommonJS规范编写代码。

.webpack.config.js

const path = require('path')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'output') // 必须是绝对路径
  }
}

配置完成后,Webpack会以./src/main.js为打包入口,把打包结果放在output/bundle.js中。

Webpack 工作模式

  • production:生产模式下,Webpack 会自动优化打包结果;
  • development:开发模式下,Webpack 会自动优化打包速度,添加一些调试过程中的辅助;
  • none:None 模式下,Webpack 就是运行最原始的打包,不做任何额外处理。 Webpack 4 新增了一个工作模式的用法大大简化了Webpck配置的复杂程度,可以把它理解成针对环境不同的几组预设的配置。

之前运行Webpack命令,会出现配置警告我们没有设置mode属性,Webpack会默认使用production模式工作,在这个模式下Webpack会自动启动优化插件,例如自动把代码进行压缩,这对实际生产环境是十分友好的,但是打包结果我们没办法阅读,我们可以通过cli参数去指定打包的模式,具体用法是给Webpack命令传入--mode参数,这个属性有三种取值,分别是 production、development 和 none,默认就是production。 尝试yarn webpack --mode development,开发模式下,Webpack 会自动优化打包速度,添加一些调试过程中的辅助到代码当中。除此之外还有一个None模式,None 模式下,Webpack 就是运行最原始的打包,不做任何额外处理。处理cli参数指定工作模式还可以在Webpack配置文件添加mode属性mode: 'development',这样Webpack命令就会根据我们的配置去工作了。

Webpack 打包结果运行原理

为了更好的理解打包过后的代码,我们将Webpack的工作模式设为none,这样就是以最原始的方式去打包我们的代码了。 整体生成代码是一个立即执行函数,这个函数是Webpack的工作入口,接受modules参数,调用时传入了一个数组。

展开数组,数组当中每一个元素都是一个参数列表相同的函数,函数对应的就是我们源代码当中的模块,也就是说我们每一个模块最终都会被包裹到这样一个函数当中,从而实现模块的私有作用域。

再来展开Webpack的工作入口函数,最开始定义了一个对象用于存放或缓存我们加载过的模块,紧接着定义了一个require函数专门用来加载模块。

再然后就是往require函数挂载了一些数据和工具函数,这个工作入口函数在最后调用了这个require函数,传入了一个0开始去加载我们的模块,这个模块id就是模块数组的元素下标,这里才开始加载源代码当中的入口模块。

为了更好的理解执行过程,可以通过浏览器的开发工具单步调试。调试过后会发现原理也比较简单,Webpack只是把所有模块放到了同一个文件当中。提供基础代码保持模块的依赖关系。

Webpack 资源模块加载

正如一开始所提到的,Webpack不仅仅是JavaScript的模块化打包工具,它应该是整个前端项目或前端工程的模块打包工具,这也就是说我们还可以通过Webpack去引入前端项目当中的任意类型文件,接下来去尝试打包项目当中的css文件。在Webpack配合文件中将入口文件指定为./src/main.css,运行打包命令,此时报了一个模块解析错误(在解析模块的过程当中遇到了非法字符),这是因为Webpack内部默认只会去处理JavaScript的文件,也就是它在打包过程中遇到的文件都当作JavaScript去解析。此时我们让它处理的是css文件所以说自然会报错。错误信息的最后Webpack已经给我们相应的提示,我们当前并没有去配置用来处理此文件的加载器。我们需要适当的加载器去处理这种类型的文件。

Webpack内部的loader只能处理JS文件

可以为其他类型的文件添加不同的loader

这里处理css需要一个css的loader yarn add css-loader --dev,安装完成后我们需要在配置文件当中添加相应的配置,具体就是在配置对象的 module 属性中添加一个 rules 数组,这个数组就是针对其他资源模块的加载规则配置,每个规则对象都需要设置两个属性,首先是test属性,它是一个正则表达式,用来去匹配打包过程中所遇到的文件路径,use属性用来去指定匹配到的文件需要去使用的loader。

module: {
  rules: [
    {
      test: /.css$/,
      use: [
        'css-loader'
      ]
    }
  ]
}

运行打包命令后,发现样式并没生效。css-loader的作用就是将css文件转换成一个JS模块,具体实现就是将css代码push到一个数组当中,整个过程没有一个地方用到了这个数组,所以还需要安装一个loaderyarn style-loder --dev,这个loader的作用就是将css loader转换过后的结果通过style标签的形式追加到页面上,安装过后修改配置文件,需要注意,如果我们配置了多个loader,执行时是从后往前执行。

module: {
  rules: [
    {
      test: /.css$/,
      use: [
        'style-loader',
        'css-loader'
      ]
    }
  ]
}

重新打包后样式就可正常工作了。

以上就是对Loader的探索,Loader是Webpack实现前端整个模块化的核心,借助于Loader就可以加载任何类型的资源

Webpack 导入资源模块

Webpack的打包入口一般是JavaScript,因为打包入口在某种程度上来可以说是应用的运行入口,就目前而言,JavaScript可以说是驱动了整个前端应用的业务,所以说应该把JS文件作为打包的入口,然后在JS文件当中通过import的方式去引入css文件。

配置文件中将入口改为./src/main.js,在main.jsd当中通过import导入css文件import './main.css' ,运行打包命令,页面样式可以正常工作。

这里可能会产生疑惑,传统做法当中我们是将样式与行为分离开单独维护单独引入,而Webpack又建议我们在JS当中去载入css,这到底是为什么呢?其实Webpack不仅仅是建议我们在JS文件当中去引入css,而是建议我们在编写代码过程当中去引入当前代码所需要的资源文件,这是因为真正需要这个资源的不是应用而是你此时需要编写的代码,是你此时的代码想要正常工作就必须加载对应的资源,这就是Webpack的哲学。可以对比一下,如果样式还是单独引入到页面当中,试想一下如果代码更新了不再需要这个样式资源了那又会怎么样?所以说通过JavaScript代码去引入样式文件,或者叫建立JS与资源文件的依赖关系是有一个很明显的优势的。JavaScript代码是要完成整个业务的功能,放大来讲,JavaScript驱动了整个前端应用,而在实现业务功能的过程当中可能需要样式、图片等一系列的资源文件,如果建立了这种依赖关系,一来逻辑合理,JS确实需要这些资源文件的配合才能去实现对应的功能,二来确保上线资源不缺失,而且每个上线文件都是必要的

学习新事物,新事物的思想才是突破点

Webpack 文件资源加载器

目前Webpack社区提供了非常多的资源加载器,基本上你能想到的合理需求都会有对应的Loader,接下再去尝试一些非常有代表性的Loader。

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

准备一张图片,根据Webpack的思想我们应该在用到这个资源的地方通过import 的方式导入图片,然后让Webpack去处理资源的加载。 由于这里又导入了Webpack不能识别的文件类型,所以同样我们需要去安装一个额外的加载器yarn add file-loader --dev,安装完成后,我们需要为.png文件添加加载规则配置,这样Webpack打包时就会以file-loader去处理我们的图片文件了。

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

运行打包命令,打包过后dist目录会多出一张图片,这张图片就是我们在代码中导入的图片,不过文件的名称发生了改变,关于文件名称改变后面再详细介绍。再来看这张图片在bundle.js当中是如何体现的。找到最后一个模块,因为我们在源代码中图片是最后导入的。展开函数发现它就是把刚刚打包生成图片的名称导出去了。

再回到入口模块当中,它就直接使用了导出的这个文件路径,并没有任何复杂的地方。

用浏览器打开index.html运行了一下这个应用,此时发现图片并不能正常展示,打开开发人员工具,发现是直接加载了我们网站根目录下的图片,而根目录下并没有这张图片,正确的地址应该是网站根目录下的/dist/目录当中,这个问题是由于index.html并没有生成在dist目录,而是放在了项目的根目录下,所以就把项目的根目录作为网站的根目录,而Webpack会默认认为所有打包的结果都会放在网站的根目录下面,所以就造成了这样一个问题。

解决的方法也非常简单,通过配置文件告诉Webpack打包过后的这个文件最终在网站的位置,具体做法,在output中添加publicPath属性,这个属性的默认值是一个空字符串,表示的就是我们网站的根目录,因为我们生成的文件是放在dist目录下,所以设置为dist/,重新打包过后,打开bundle.js

解决了这个问题重新运行浏览器就可以正常看到图片效果啦。

file-loader的工作过程:Webpack在打包时遇到了图片文件,根据配置文件当中的配置匹配到对应的文件加载器,此时文件加载器就开始工作了,它先是将我们导入的这个文件拷贝到输出的目录,然后再将我们文件拷贝到输出目录过后的路径作为当前这个模块的返回值返回,那这样对于我们的应用来说所需要的资源就被发布出来了,同时我们也可以通过模块的导出成员拿到这个资源的访问路径。

Webpack URL加载器

除了file-loader这种通过拷贝物理文件的形式处理文件资源以外,还有一种通过Date URLs的形式去表示文件也非常常见。Date URLs是一种特殊的URL协议,它可以用来直接去表示一个文件,传统的URL一般要求服务器上有一个对应的文件然后我们通过请求这个地址得到服务器上对应的这个文件,而Data URLs是一种当前URL就可以直接去表示文件内容的方式,也就是说这种URL当中的文本就已经包含了文件的内容,在使用这种URL的时候就不会去发送任何的http请求。

例如这段Data URLs: 浏览器就能根据这个URL解析出来这是一个html文件类型的内容,编码是UTF-8,内容是包含h1标签的html代码。

如果是图片、字体这一类无法直接通过文本去表示的二进制类型的文件,可以通过将文件的内容进行base64编码 以base64编码过后的结果也就是一个字符串去表示这个文件的内容,例如以下Data URLs 这个URL就表示了一个png类型的文件,文件的编码就是base64,后面就是文件编码后的内容。一般base64编码会比较长,浏览器同样可以解析出来对应的文件。

在Webpack打包静态资源模块时,我们同样使用这种方式去实现,通过data url就可以以代码的形式表示任何类型的文件了。具体做法需要用到一个专门的加载器url-loader,先来安装一下yarn add url-loader --dev,然后修改配置文件

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

打包过后dist目录下就不会有之前生成的图片了,打开bundle.js定位到图片模块,发现导出的不再是之前的路径,而是一个完整的data url,由于data url就已经包含了文件内容,所以就不需要有独立的物理文件了。

这种方式适合项目当中体积比较小的资源,因为体积过大的话就会造成打包结果非常大,从而影响我们的运行速度。

最佳实践方式:

  • 小文件使用 Data URLs,减少请求次数 小文件通过url-loader转换成data urls,然后在代码当中嵌入,从而减少应用发送请求的次数。
  • 大文件单独提取存放,提高加载速度; 对于较大的文件应该仍然通过传统的file-loader的方式以单个文件方式去存放,从而提高应用的加载速度。 url-loader支持通过配置选项来实现以上最佳实践方式:
{
  test: /.png$/,
  use: {
    loader: 'url-loader',
    options: {
      limit: 10 * 1024 // 10 KB
    }
  }
}

这样一来,url-loader只将10KB一下的文件转换成data url,对于超过10KB的文件仍然会去交给file-loader去处理。运行打包,dist目录下仍然会出现超过10KB没被处理成data url的图片文件。这样项目当中

  • 超过10KB的文件单独提取存放。
  • 小于10KB的文件转换成Data URLs嵌入代码当中。 注意如果使用以上方式处理,就一定要同时安装file-loader。

Webpack 常用加载器分类

Webpack资源加载器有点像工厂里的生产车间,它是用来处理和加工打包过程中所遇到的资源文件。除了以上介绍到的加载器,社区当中还有其他很多加载器,对于常用的Loader之后我们基本都会用到,在这之前为了更好去了解他们,先将这些loader大致分为三类:

  • 编译转换类 这种类型的loader会把加载到的资源模块转换为JavaScript代码,例如css-loader,就是将css代码转换为了bundle.js里以JS形式工作的css模块,从而去实现通过JavaScript去运行css。

  • 文件操作类 通常文件类型的加载器都会把加载到的资源模块拷贝到输出的目录,同时呢,又将这个文件的访问路径向外导出,例如file-loader,就是一个非常典型的文件操作加载器。

  • 代码检查类 对加载到的资源文件,一般是代码进行校验的加载器,目的是统一我们代码的风格,从而提高代码质量。这种类型的加载器一般不会去修改我们生产环境的代码。

后续我们在接触一个loader过后,需要先明确它到底是一个什么样类型的加载器,它的特点是什么,它的使用又需要注意什么。

Webpack 处理ES2015

由于Webpack默认就能处理我们代码当中的import和export,所以很自然会有人认为Webpack会自动编译ES6的代码,实则不然,Webpack仅仅是对模块完成打包工作,所以才会对代码当中的import和export作一些相应的转换,也就是说因为模块打包需要,所以处理import和export。除此之外,它并不能去转换代码当中其他的ES6特性。

如果在打包过程中需要处理其它ES6特性的转换,我们需要为JS文件配置一个额外的编译型loader,最常见的就是babel-loader,具体做法 yarn add babel-loader --dev,由于babel需要额外依赖babel的核心模块,所以需要安装@babel/core模块,以及用于具体特性转换插件的一个集@babel/preset-envyarn add babel-loader @babel/core @babel/preset-env --dev,安装完成后,在配置文件中指定JS加载器,这样babel加载器就会取代默认加载器,在打包过程中就能帮我们处理代码当中的新特性了。

{
  test: /.js$/,
  use: 'babel-loader',
}

再次运行打包命令,打包过后打开bundle.js发现新特性仍然没有被转换,原因是因为babel严格意义上说只是一个转换JS代码的一个平台,我们需要去基于babel这样一个平台去通过不同的插件来去转换代码当中的新特性,所以需要为babel配置它所需要使用的插件,@babel/preset-env就已经包含了全部的ES最新特性。

{
  test: /.js$/,
  use: {
    loader: 'babel-loader',
    options: {
      presets: ['@babel/preset-env']
    }
  }
}

重新打包过后,查看生成代码,此时代码的新特性都被转换了。

总结

  • Webpack只是打包工具,它不会去处理代码当中的新特性
  • 加载器可以用来编译转换代码

Webpack 模块加载方式

除了代码当中的import能够去触发模块的加载,Webpack中还提供了其他几种方式

  • 遵循 ES Modules 标准的import声明
  • 遵循 CommonJS 标准的require函数 不过,如果通过require函数去载入ESM的话,需要注意对于ESM的默认导出我们需要通过导入后defalut属性去获取。

  • 遵循 AMD 标准的define函数和require函数 Webpack兼容多种模块化标准,建议不要在项目中混合使用这些标准,因为会降低项目的可维护性,每个项目统一使用一个标准就可以了。
    • 样式代码中的 @import指令和url函数
    • HTML代码中图片标签的src属性 除了以上三种方式,还有一些独立的加载器它在工作时也会去处理所加载到资源当中的一些导入模块,例如css-loader加载的css文件样式代码中的@import指令和url函数,它们也会去触发相应的模块加载。还有html-loader加载的HTML代码中图片标签的src属性以及a标签的href属性,也会去触发相应的模块加载。
{
  test: /.html$/,
  use: {
    loader: 'html-loader',
    options: {
      attrs: ['img:src', 'a:href']
    }
  }
}

通过以上方式,代码中有引用的地方都会被Webpack找出来,然后根据配置交给不同的loader去处理,最后将处理的结果整体打包到输出目录,Webpack就是通过这样一个特点去实现我们整个项目的模块化。

Webpack核心工作原理

Webpack官网首屏就很清楚地描述了它的工作原理

简单理解一下Webpack打包的核心工作过程。以一个普通的前端项目为例,在项目当中,一般都会散落着各种各样的代码及资源文件, Webpack会根据我们的配置找到其中的一个文件作为打包的入口,一般情况下这个文件都是JavaScript文件, 然后会顺着我们入口文件当中的代码,根据代码中出现的import或者是像reuqire之类的语句解析推断出来这个文件所依赖的资源模块,然后分别去解析每个资源模块对应的依赖,最后就形成了整个项目当中文件依赖关系的关系树, 有了这个依赖关系树后Webpack会遍历或者说递归这个依赖树,然后找到每个节点所对应的资源文件,最后根据我们配置文件当中的rules属性,去找到该模块对应的加载器,然后交给这个加载器去加载这个模块,最后会将加载后的结果放入到bundle.js也就是我们的打包结果当中,从而实现我们整个项目的打包。

整个过程当中,loader机制是Webpack的核心,因为没有loader就没办法实现各种资源文件的加载。对Webpack来说,它也只能算是一个打包合并JS模块代码的一个工具了。

Webpack 开发一个Loader

Loader作为Webpack的核心机制,内部的工作原理也非常简单。接下开发自己的loader,通过这个过程来深入了解loader的工作原理。需求是markdown文件的加载器,有了这个加载器就可以在代码当中直接去导入Markdown文件,Markdown是被转换为HTML过后再呈现在页面上的,所以希望导入的Markdown文件得到的结果就是Markdown转换过后的HTML字符串。

在项目根目录下新建markdown-loader.js,每个Webpack的loader都需要导出一个函数,这个函数就是loader对所加载的资源的处理过程,输入就是所加载到的资源模块的文件内容,输出就是此次加工过后的结果,通过source参数去接受输入,通过返回值去输出。

  1. 首先测试打印一下文件内容,在配置文件中配置该loader,use属性不仅可以是模块的名称,还可以是模块的相对路径,与require是一样的 markdown-loader.js
module.exports = source => {
  console.log(source)
  return 'hello ~'
}

webpack.config.js

rules: [
  {
    test: /.md$/,
    use: './markdown-loader'
  }
]

运行打包命令,控制台打印出了文件内容,也就说明source确实是导入的文件内容,但是同时也报出了解析错误You may need an additional loader to handle the result of these loaders.,说的意思就是我们还需要额外的loader去处理当前的加载结果,这究竟是为什么呢?其实Webpack加载资源的过程优点类似于一个工作管道,可以在这个过程中依次去使用多个loader,但是要求最终这个管道工作过后的结果必须是一段JavaScript代码,因为这里返回的是一个hello ~字符串,不是标准的JavaScript代码,所以才会出现这样的错误提示。解决这个错误要么返回一段JavaScript代码要么就是找一个合适的加载器继续处理这里返回结果。这里先来尝试第一种办法,将return 'hello ~'改为return 'console.log("hello ~")',这就是一段标准的JavaScript代码,再次运行打包,打包结果如下,Webpack就是把返回的结果直接放在了模块当中,这也解释了管道为什么最后必须是JavaScript的原因,因为其他的放在这里会语法不通过。 bundle.js

/***/ (function(module, exports) {

console.log("hello ~")

/***/ })

接着去完成我们的需求,安装Markdown解析模块,yarn add marked --dev,接着使用这个模块去解析source,得到的就是一段html字符串,如果直接返回这个html就会出现同样的问题,正确的做法应该是把这段html变成一段JavaScript代码。我们希望这段html可以成为这个模块导出的字符串,也就是希望通过module.exports导出,但是如果我们只是简单拼接,那么html的换行符还有内部的一些引号拼接在一起就可能就会造成语法错误。这里使用一个小技巧,先用JSON.stringgfy将这个字符串转换为一个标准的JSON格式字符串,此时它内部的一些引号以及换行符都会被转义过来,然后再参与拼接这样就不会有问题了。打包后的结果

/***/ (function(module, exports) {

module.exports =  "<h1 id=\"关于我\">关于我</h1>\n<p>我是XXX,一个手艺人~</p>\n"

/***/ })

除了module.exports这种方式以外,Webpack还允许我们在返回的代码当中直接去使用ESM的方式return export default ${JSON.stringify(html)},Webpack内部会自动转换导出代码当中的ESM代码

/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = ("<h1 id=\"关于我\">关于我</h1>\n<p>我是XXX,一个手艺人~</p>\n");

/***/ })

第二种方法,就是在markdown-loader当中去返回一个html字符串,然后我们交给下一个loader去处理这个字符串。这里直接返回mark解析过后的HTML,然后再安装一个用于去处理HTML加载的loaderyarn ass html-loader --dev,然后在配置文件中配置:

rules: [
  {
    test: /.md$/,
    use: [
      'html-loader',
      './markdown-loader'
    ]
  }
]

打包结果

/***/ (function(module, exports) {

module.exports = "<h1 id=\"关于我\">关于我</h1>\n<p>我是XXX,一个手艺人~</p>\n";

/***/ })

通过以上就会发现loader的工作原理非常简单,Loader就是负责资源文件从输入到输出的转换,除此之外还了解了Loader实际上是一种管道的概念,可以将此次loader的结果交给下一个loader去处理,对于通过一个资源可以依次使用多个loader,通过多个loader去完成一个功能,例如css-loader -> style-loader的配合。

Webpack 插件机制

插件机制是Webpack当中另外一个核心特性,它的目的是为了增强Webpack自动化能力,而Loader专注实现资源模块的加载,从而实现整体项目的打包,而Plugin是用来解决项目中除了资源加载以外其他自动化工作,例如Plugin可以实现在打包之前自动清除dist目录(上一次打包的结果);又或是可以帮我们拷贝那些不需要参与打包的静态文件到输出目录;又或是可以用来去帮我们压缩打包结果输出的代码。总之,有了Plugin的Webpack几乎无所不能地实现大多前端工程化工作,这也就是初学者认为Webpack就是前端工程化的原因。

Webpack 常用插件

自动清除输出目录插件 clean-webpack-plugin

通过之前的尝试会发现Webpack的每次打包结果都是覆盖到dist目录,而在打包之前dist中可能就已经存在一些之前的遗留文件,再次打包只能覆盖掉那些同名的文件,对于其他那些已经移除的资源就会一直积累在里面非常不合理,更为合理的办法就是在每次打包之前自动去清理dist目录,这样dist中只会存在那些我们需要的文件。clean-webpack-plugin插件就很好地实现了这个需求。它是一个第三方的插件,需要安装一下yarn add clean-webpack-plugin --dev,然后在配置文件中导入这个插件,大多数插件模块导出的都是一个类型,通过创建实例,然后把这个实例放在Plugin数组当中。

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

plugins: [
  new CleanWebpackPlugin()
]

运行打包命令,则dist目录下就是本次的打包结果,非常干净。

自动生成HTML插件 html-webpack-pligin

  • 基本使用 这个插件用于自动生成使用bundle.js的HTML,之前html都是通过硬编码的方式单独去存放在项目的根目录下的,这个方式有两个问题,第一在项目发布时需要同时去发布根目录下的html文件和dist目录下所有的打包结果,这样相对麻烦一些,而且上线过后还要去确保html代码中路径引用都是正确的;第二个问题就是如果说输出的目录或输出的文件名,也就是打包的配置发生了变化那html代码当中script标签所引用的那个路径也需要我们手动地去修改。这是硬编码的方式存在的两个问题。解决这两个问题最好的办法就是通过Webpack自动输出HTML文件,让HTML也去参与Webpack的构建过程,在构建过程中,Webpack知道生成了多少个bundle它会自动地把打包后的bundle添加到我们的页面当中,这样一来我们的HTML页输出到了dist目录,上线时我们只需要将dist目录发布出去就可以了;而来HTML当中对于bundle的引用是动态注入的,不需要我们手动地硬编码,所以说它可以确保路径的引用是正常的。具体实现,借助html-webpack-pligin去实现,同样它是第三方的模块,需要安装yarn add html-webpack-pligin --dev,然后在配置中导入这个插件,它不同于clean-webpack-pluginhtml-webpack-pligin默认导出就是一个插件的类型,不需要去解构它内部的成员
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
...

plugins: [
  new CleanWebpackPlugin(),
  new HtmlWebpackPlugin()
]

运行打包命令,打包过程中就会自动生成index.html文件输出到dist目录。打开index.html,发现路径有问题,正确的应该是当前路径的bundle.js。 这是因为之前去尝试其他特性的时候,我们把output中publicPath设置成了dist/,现在html是自动生成在了dist目录,就不要这样一个配置了,去掉后重新打包,打包后HTML当中对应bundle的引用路径就正常了。 至此,我们就不需要根目录下写死的HTML文件了。

  • 自定义输出选项 以上的结果仍然存在一些需要改进的地方,首先对于默认生成的HTML当中标题是必须修改的,另外我们很多时候还需要去自定义页面当中的原数据标签以及一些基础的DOM结构,对于简单的自定义,我们可以通过修改html-webpack-pligin的一些属性来实现,给HtmlWebpackPlugin这个对象的构造函数传入一些参数用于指定配置选项。
new HtmlWebpackPlugin({
  title: 'Webpack Plugin Sample',
  meta: {
    viewport: 'width=device-width'
  },
}),

如果我们需要大量的自定义,更好的做法是在源代码当中添加一个用于生成HTML的模板,然后让插件根据模板去生成页面。在src下新建index.html模板,可以根据需要在该文件当中添加一些元素,对于一些动态内容可以根据lodash模板语法输出,例如

<h1><%= htmlWebpackPlugin.options.title %></h1>

在配置文件当中通过template属性指定模板

new HtmlWebpackPlugin({
  title: 'Webpack Plugin Sample',
  meta: {
    viewport: 'width=device-width'
  },
  template: './src/index.html'
}),

运行打包命令,此时输出的html就是根据我们的模板去生成的。

  • 多实例 除了自定义文件内容,同时输出多个页面文件也是一个常见的需求,除非我们的应用是一个单页面用用程序,否则就需要多个html文件。在配置文件中,之前通过HtmlWebpackPlugin创建的的对象用于生成index.html这个文件,那完全可以通过这个类型创建一个新的实例对象用于创建额外的html文件。例如通过创建一个新的实例用于创建about.html的页面文件,通过filename制定输出的文件名,它的默认值是index.html。
new HtmlWebpackPlugin({
  filename: 'about.html'
})

运行打包命令,dist目录下就同时生成了about.html、index.html两个页面文件。所以如果我们需要创建多个页面就可以在插件列表中去加入多个HtmlWebpackPlugin的实例对象,每个对象负责生成一个页面文件。

自动拷贝文件插件 copy-webpack-plugin

在我们的项目中一般还有不需要参与构建的文件,它们最终也需要发布到线上,例如网站的favicon.ico,一般把这些文件统一放在根目录的public目录,希望Webpack打包时可以将它们一并复制到输出目录,对于这种需求我们可以借助copy-webpack-plugin去实现。安装插件yarn add copy-webpack-plugin --dev,在配置文件中导入插件,在plugin属性中添加CopyWebpackPlugin这个类型的实例,传入一个数组,用于指定需要拷贝的文件路径,可以是通配符、目录、文件的相对路径。

const CopyWebpackPlugin = require('copy-webpack-plugin')

new CopyWebpackPlugin([
  // 'public/**'
  // './public'
  'public'
])

运行打包命令,打包完成后,public目录下的文件就会被全部拷贝到输出目录了。

Webpack 开发一个插件

相比于Loader,Plugin拥有更宽的能力范围,因为Loader只是加载模块时工作。而Plugin的工作范围几乎可以触及到Webpack工作的每一个环节。这样的插件机制究竟是如何实现的呢?Webpack的插件机制其实就是软件开发中最常见到的钩子机制,钩子机制也特别容易理解,,它有点类似于我们web当中的事件。在Webpack工作过程中会有很多环节,为了便于插件的扩展Webpack几乎在每一个环节都埋下了钩子,这样的话在开发插件的时候就可以通过往这些不同的节点去挂载不同的任务就可以轻松地扩展Webpack的能力,具体有哪些钩子我们可以参考官方的API文档。接下来我们定义一个插件,看看如何在这些钩子上挂载任务。

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

在配置文件中定义一个MyPlugin类型,在类型中定义一个apply方法,apply方法在Webpack启动时被调用。

class MyPlugin {
  apply (compiler) {
    console.log('MyPlugin 启动')
  }
}

apply方法接受一个compiler对象参数,这个对象就是Webpack工作过程中一个核心的对象,它包含此次构建的所有配置信息,我们也就是通过这个对象去注册钩子函数。

我们的需求是利用这个插件去清除Webpack打包生成的JS文件里那些没有必要的注释,让bundls.js去掉这些注释后更加容易阅读。

首先明确任务的执行时机,也就是这个任务挂载到哪个钩子上面。显然,应该在bundle.js文件内容明确过后,才实施相应的动作。查看官网,发现emit这个钩子是在即将要往输出目录输出文件时执行,这就非常符合我们的要求。

通过compiler.hooks.emit访问到访问到emit钩子,然后通过tap()方法注册一个钩子函数。这个方法接受两个参数,第一个参数是插件的名称,第二个参数就是我们要挂载到这个钩子上的函数,这个函数可以接受compilation参数,这个参数可以理解为此次打包的上下文,所有打包结果都会放到这个对象当中,根据assets属性获取即将写入的文件、目录信息。通过for...in打印它,将该插件实例放到插件列表中,运行打包命令,此时就会打印输出文件名称。再尝试打印每个每个文件内容compilation.assets[name].source()

class MyPlugin {
  apply (compiler) {
    console.log('MyPlugin 启动')

    compiler.hooks.emit.tap('MyPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        // console.log(name)
        // console.log(compilation.assets[name].source())
        if (name.endsWith('.js')) {
          const contents = compilation.assets[name].source()
          const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
          compilation.assets[name] = {
            source: () => withoutComments,
            size: () => withoutComments.length
          }
        }
      }
    })
  }
}

通过这个过程我们了解到插件是通过生命周期的钩子函数中挂载函数实现扩展

Webpack 开发体验问题

编写代码 -> Webpack打包 -> 运行应用 -> 刷新浏览器这种周而复始的方式过于原始会大大降低我们的效率。那如何去提高我们的开发效率呢?

设想: 理想的开发环境

  • idea1 以HTTP Server运行,而不是以文件的形式预览 一来可以接近生产环境的状态,二来我们可能需要Ajax的API,这些API以文件的形式访问时不被支持的。
  • idea2 自动刷新 + 自动刷新 修改源代码后,Webpack会自动完成构建,浏览器可以显示最新的结果。
  • idea3 提供sourceMap支持 运行过程出现错误就可以根据错误的堆栈信息快速定位到源代码中对应的位置,便于我们调试应用。

对于以上需求,Webpack都已经有对应的功能实现了,接下来了解如何增强Webpack的开发体验。

Webpack 增强开发体验

自动编译

目前,我们修改完代码都是通过命令行手动重复去运行Webpack命令从而得到最新的打包结果。我们也可以使用Webpack-Cli提供的watch工作模式去解决这个问题。这种模式监听文件变化,自动重新打包。具体的用法,在启动Webpack命令时添加 --watch参数,yarn webpack --watch,这样Webpack就会以监视模式运行,打包完成过后,我们的Cli不会立即退出,它会等待文件的变化然后再次工作直到我们手动结束这个Cli。在这种模式下我们只需要专注编码,不必要手动完成打包工作。

打开命令行终端,以http server运行我们的应用server dist,尝试修改源代码保存过后,以观察模式工作的Webpack就会自动重新打包,打包过后就可刷新浏览器查看最新的结果。

自动刷新浏览器

如果浏览器能够在自动编译过后自动刷新开发体验会更好一些,BrowserSync就可以帮我们实现自动刷新的功能。全局安装它,然后使用browser-sync去启动http服务,browser-sync dist --files "**/*同时监听dist目录下的文件变化。尝试修改源代码,保存过后浏览器会自动刷新显示最新的结果。原理就是Webpack自动打包代码到dist目录当中,dist文件变化被browser-sync监听从而实现自动编译并且自动刷新浏览器。不过这种方式去解决有很多弊端: 1.操作上麻烦,因为需要同时使用两个工具。 2.开发效率降低,因为这个过程中Webpack会不断将文件写入磁盘然后browser-sync再把它读出来,这个过程就会多出两步的磁盘读写操作,所以说我们要继续去改善这个开发体验。

Webpack Dev Server

基本使用介绍

Webpack Dev Server是Webpack官方推出的一个开发工具,根据它的名字可知它提供了一个用于开发的HTTP Server,并且集成了自动编译和自动刷新浏览器等功能。我们可以使用这个工具去解决之前的问题。

这是一个高度集成的工具,使用起来非常简单。安装yarn add webpack-dev-server --dev,这个模块提供了一个webpack-dev程序,可以通过yarn直接运行这个cli,或者把它定义到NPM Script当中,运行这个命令yarn webpack-dev-server,它内部会自动使用Webpack打包并且启动HTTP Server运行我们的打包结果,运行过后它会监听代码变化自动打包,这一点和我们的watch模式是一样的,不过webpack-dev-server为了提高工作效率,所以并没有将打包结果写入磁盘当中,它是将打包结果存放在内存中,内部的HTTP Server也是从内存当中把这些文件读出来然后发送给浏览器,这样一来它会就会减少很多不必要的磁盘读写操作大大提高构建效率。还可以为这个命令传递一个--open参数,它可以自动唤起我们的浏览器打开运行地址。此时我们就可以体验一边编码一边预览开发环境了。

静态资源访问

Webpack Dev Server默认会将构建结果输出的文件全部作为开发服务器的资源文件,也就是说只要是通过Webpack输出的文件都可以直接被访问到,此时如果你还有一些资源文件也需要作为开发服务器的资源被访问的话,那就需要额外地告诉Webpack Dev Server。具体的做法就是在配置文件中添加配置。添加contentBase属性,通过contentBase指定额外的资源路径,可以是一个字符串或是一个数组(一个或多个路径)。

devServer: {
  contentBase: './public'
}

到这里可能会有一个疑问,因为之前已经通过插件将这个目录输出到了dist目录,按照以上的说法,这个目录下的文件应该可以被访问到,所以这些文件就不需要再做额外的指定了。但是我们在实际使用Webpack时一般都会把copy-webpack-plugin这样的插件留在上线前的那次打包中使用,在开发过程中一般不会去使用copy-webpack-plugin,因为开发过程中我们会频繁重复打包任务,如果每次都去执行这个插件开销会比较大。尝试把copy-webpack-plugin注释掉,重新运行webpack-dev-server,此时,即使public目录即使没有拷贝到dist目录,我们依旧能访问到其中的资源文件。终上,contentBase额外为开发服务器指定查找资源目录

代理API服务

因为开发服务器的缘故,我们这里会将应用运行在localhost这一端口上,而最终上线过后,我们应用一般又会和API部署到同源地址下面,这样就会有一个常见的问题,实际生产环境当中可以直接去访问API,但是回到开发环境就会产生跨域请求问题,有人会说使用跨域资源共享(CORS)去解决这个问题,事实也如此,如果请求的API支持CORS这个问题也就不成立了。但是并不是任何情况下API都应该支持CORS,如果说前后端同源部署的话就不必要开启CORS,所以以上问题还是经常会出现。问题:开发阶段的接口跨域,最好的解决的方法就是在开发服务器中配置代理服务,也就是把接口服务代理到本地的开发服务地址上。Webpack Dev Server支持通过配置的方式添加代理服务。

目标:将Github API 代理到本地开发服务器

先将浏览器尝试访问一个接口(api.github.com api.github.com/users )github接口的EndPoint(可以理解为接口端点/入口)一般都在根目录下,例如api.github.com/users 这个EndPoint。在配置文件当中,在devServer当中添加proxy属性,proxy属性是一个对象,它的每一个属性都可以配置代理规则,属性名称就是要被代理的请求路径前缀,它的值就是这个规则所匹配到的代理配置。

devServer: {
  proxy: {
    '/api': {
      // http://localhost:8080/api/users -> https://api.github.com/api/users
      target: 'https://api.github.com',
      // http://localhost:8080/api/users -> https://api.github.com/users
      pathRewrite: {
        '^/api': ''
      },
      // 不能使用 localhost:8080 作为请求 GitHub 的主机名
      changeOrigin: true
    }
  }
},

以上的代理配置当我们请求以/api开头,代理目标就是https://api.github.com,例如请求http://localhost:8080/api/users就相当于请求https://api.github.com/api/users,此时我们需要重写的方式将后者的api去掉,通过pathRewrite实现代理路径的重写,重写规则就是路径以api开头的这段字符替换为空,替换后就是https://api.github.com/users。除此之外我们还需要设置changeOrign属性为true,这是因为默认代理服务器会以我们实际在浏览器中请求的主机名(localhost:8080)作为代理请求的主机名。一般情况下,服务器会根据主机名把请求指派给对应的网站。localhost:8080对于github来说肯定是不认识的,所以要去修改。changeOrigin为true就会实际以我们代理请求发生过程中的主机名去请求。重新运行webpack-dev-server,在浏览器中直接请求http://localhost:8080/api/users,可以看到此时就会代理到了github的用户数据接口,再尝试在代码中使用fetch('api/users')可正常返回数据。

SourceMap

介绍

通过构建编译之类的操作可以将开发阶段的源代码转换为能够在生产环境中运行的代码这是一种进步,但是这种进步就意味着在实际生产环境中运行的代码与源代码之间有很大的差异,如果要调试应用或是在运行过程中出现错误将无法定位,因为调试和报错都是基于运行代码进行的。Source Map就是解决这一问题的最好办法,它的名字(源代码地图)就表示了它的作用,它就是用来映射转换过后的代码与源代码之间的关系,转换过后的代码通过Source Map文件就可逆向得到源代码。目前很多第三方库发布的文件当中都有一个以.map后缀的的Source Map文件,例如jquery。

jquery-3.4.1.js
jquery-3.4.1.min.js
jquery-3.4.1.min.map

总之,Source Map解决了源代码与运行代码不一致所产生的问题

Webpack 配置 Source Map

devtool就是用来配置开发过程的辅助工具,也就是与Source Map相关的配置,将属性直接设置为source-map,运行打包,dist目录就会生成bundle.js与它对应的Source Map文件bundle.js.map,在bundle.js文件最后通过注释引入了Source Map文件。通过serve dist把打包结果运行起来,在开发人员工具就可以根据console当中的提示直接定位到错误所在源代码当中的位置,也可直接调试源代码,如果说只是使用Source Map这里就已经可以实现了。但是只是这么使用的话实际的效果会差得比较远,因为截至目前Webpack对Source Map支持12中不同的方式,每种方式生成Source Map的效果以及速度各不相同,效率和效果成反比的。具体哪种方式是是最好的最适合我们的,继续探索~

Webpack中的devtool除了Source Map还支持其他的模式,具体我们参考文档当中不同模式的对比表,表中分别对初次构建速度、监视模式重新打包速度、是否适合在生产环境当中使用以及生成的Source Map的质量四个维度对比不同方式的差异。接下来配合表格中的介绍通过具体的尝试来体会不同模式之间的差异,从而找到适合自己的最佳实践。

eval模式下的 Source Map

eval是JS当中的一个函数,它可以用来去运行字符串中的JS代码,例如eval('console.log(123)'),默认情况下这段代码会运行在一个临时的虚拟机环境当中,可以通过sourceURL来声明这段代码所属的文件路径,eval('console.log(123)' //# sourceURL-./foo/bar.js),此时这段代码所运行的环境就是./foo/bar.js,这就意味着可以通过sourceURL来去改变eval执行这段代码所属环境的名称,其实它还是运行在虚拟机环境当中,只不过它告诉了执行引擎这段代码所属的文件路径,只是一个标识而已。了解了这一个特点后,将devtool属性设置为eval,再次打包,打包过后运行应用,此时根据控制台的提示就能找到错误所在的文件,但是打开这个文件看到的却是打包过后的模块代码,这是因为在这种模式下它会将每个模块转换的代码都放到eval函数中去执行,并且在eval函数执行的字符串最后通过sourceURL-webpack.///.src/main.js说明所对应的文件路径,这样的话浏览器通过eval去执行这段代码就知道这段代码的所对应的源代码是哪个文件从而去实现定位错误的文件,只能定位文件。这种模式下不会去生成Source Map文件根Source Map没有多大关系,所以说它的构建速度也是最快的但是它的效果也很简单只能定位源代码文件的名称而不知道具体的行列信息

不同devtool之间的差异

为了更好的对比不同Source Map模式之间的差异,这里我们使用一个新的项目来创建出不同模式下的打包结果,通过具体实验横向对比它们的差异。

Webpack的配置对象可以是一个数组,数组中的每一个元素就是单独的打包配置,这样一来我们就可以在一次打包过程中执行多个打包任务。

module.exports = allModes.map(item => {
  return {
    devtool: item,
    mode: 'none',
    entry: './src/main.js',
    output: {
       filename: `js/${item}.js`
    },
    module: {
      rules: [
          {
              test: /\.js$/,
              use: {
                  loader: 'babel-loader',
                  options: {
                      presets: ['@babel/preset-env']
                  }
              }
          }
      ]
    },
    plugins: [
      new HtmlWebpackPlugin({
          filename: `${item}.html`
      })
    ]
  }
})

运行打包,会生成不同模式下的打包结果,通过serve dist运行打包结果,此时就可在页面当中看到所有模式下html,如果没有把js输出到单独的目录,这个页面的文件就会特别多。接下来就可以对比不同模式之间的差异了。

eval

只能定位文件

eval-source-map

同样使用eval函数去执行模块代码,不过它除了定位文件还可以定位到具体的行列信息,相比eval,它生成了source map。

cheap-eval-source-map

它也生成了source map,相比eval-source-map,它只能定位到,没有列的信息,自然生成速度也会快很多。它显示的是ES6转换过后的结果

cheap-module-eval-source-map

可定位到行,相比cheap-eval-source-map,定位到的源代码与编写时的源代码一模一样(没有经过Loader加工)。

了解了以上这些模式过后,基本上就可以算是通盘了解了所有的模式,因为其他的模式无外乎就是把这几个特点再次排列组合罢了。

  • eval - 使用eval执行模块代码
  • cheap - Source Map不能够得到列信息
  • module - 能够得到Loader处理之前的源代码

例如cheap-source-map,它没有eval,就代表它没有使用eval的方式去执行模块代码,没有module就意味着得到的是loader处理过后的代码

inline-source-map

跟普通的source map效果是一样的,其他的source map文件是以物理文件的形式存在,而它的source map文件是以data url的方式嵌入带我们的代码当中。eval方式也是把source map嵌入进来的。这种方式个人认为是最不可能用到的,因为它会把代码文件体积变大很多。

hidden-source-map

在开发工具当中看不到source map的效果,但是它在打包构建过程中确实生成了source map文件,但是在代码当中没有引入所以看不到效果,在开发第三方包的时候比较有用

nosources-source-map

可以看到错误出现的位置(行列信息)但是点击错误信息后是看不到源代码的。这个模式是为了在生产环境保护我们的源代码不会被暴露。

选择合适的Source Map

开发模式下

cheap-module-eval-source-map

原因

  • 代码每行不会超过80个字符,定位到行就够了
  • 代码经过Loader转换过后的差异较大,需要定位到源代码
  • 首次打包速度慢无所谓,重写打包相对较快

生产模式下

none(不生成source map)

发布前的打包 原因 Source Map会暴露源代码

调试是开发阶段的事情,在开发阶段就应该把所有隐患都找出来而不是到生产环境让全民帮你公测。如果你对代码没有信心,可选择 nosources-source-map模式。

没有绝对的选择,应该理解不同模式的差异,适配不同的环境

Webpack 自动刷新的问题

在此之前我们已经了解Webpack Dev Server的基本用法和特性,它主要是提供对开发者友好的开发服务器。但是在实际使用它去完成开发任务时会发现还是会有一些问题。例如编译器应用想要即时调试文本的样式,首先我们会在浏览器当中输入一些文本样例,然后修改样式文件,原本想着看到最新的界面效果,但是这时编辑器中的内容却没有了,这时不得不再输入一些文本,此时对样式还是不满意的话,还要在去重复这个过程。这时发现自动刷新这个功能并不是那么好用。这是因为我们修改完代码Webpack监视到我们的文件变化过后自动打包自动刷新浏览器,一旦页面整体刷新,页面中之前的任何操作状态都会丢失,所以就会出现上述情况。聪明的人都会有办法,例如1.代码中先写死编辑器的内容2.额外代码实现刷新前保存(例如localStorage),刷新后读取。确实都是好办法,但是这都是有动补动的操作并不能根治自动刷新导致的页面状态丢失,而且还要编写一些与我们业务无关的代码。更好的办法就是页面不刷新的前提下,模块也可以及时更新,针对这一的需求Webpack同样也可以满足。

HMP 介绍

全称是Hot Module Replacement(模块热替换|热更新),计算机行业经常听到一个叫热拔插的名词,指的是我们可以在一个正在运行的机器上随时拔插设备,而机器的运行状态不受影响,插上的设备可以立即开始工作,例如电脑上的USB端口可以热拔插。 模块热替换中热是一个道理,它们都是在运行过程中的及时变换。Webpack的模块热替换指的就是应用程序运行过程中实时替换某个模块应用运行状态不受影响。例如在程序运行时我们修改某个模块,自动刷新会导致页面状态丢失热替换就可以实现只将修改的模块实时替换至应用中,不必完全刷新应用。

HMP是Webpack中最强大的功能之一,也是最受欢迎的一个特性,因为它极大程度的提高了开发者的工作效率。

Webpack 开启HMR

HMR已经集成在webpack-dev-server中,通过webpack-dev-server --hot开启这个特性,也可以通过配置文件开启 将devServer的hot属性设为true,然后载入一个Webpack内置的插件,先导入webpack模块,通过new webpack.HotModuleReplacementPlugin()使用这个插件。

const webpack = require('webpack')
devServer: {
  hot: true
},
plugins: [
  new webpack.HotModuleReplacementPlugin()
]

在浏览器中运行后,尝试修改样式文件保存过后样式模块就可以以热更新的方式作用到页面当中,再尝试修改JS文件,保存过后发现页面却自动刷新了,好像并没有热更新的体验,这是因为什么呢?具体该如何实现所有模块的热替换?

Webpack HMR的疑问

上述问题是因为相比Webpack其他特性,Webpack中的HMR并不是可以开箱即用的,也就是说HMR需要再做一些额外的操作才可以正常工作。Webpack中的HMR需要手动处理模块热替换逻辑(模块更新过后如何把更新的模块去替换到运行的页面当中),那么会有以下疑问

  • Q1:为什么样式文件的热更新可以开箱即用? 这是因为样式文件是经过Loader处理的,在style-loader当中就已经处理了样式的热更新。
  • Q2:凭什么样式可以自动处理而脚本文件需要手动处理? 这是因为样式模块更新过后它只需要把更新过后的CSS及时替换到页面当中覆盖掉之前的样式从而实现样式文件的更新,而编写的JS模块它是没有任何规律的,因为可能在一个模块中导出的是一个对象也有可能导出的是一个字符串,还有可能导出的是一个函数,另外对导出成员的使用也是各不相同,所以说Webpack面对这些毫无规律的JS模块根本不知道怎么去处理更新后的模块,所以没有办法去实现一个通用的模块替换方案。
  • Q3:我的项目没有手动处理,JS照样可以热替换。 因为使用vue-cli,create-react-app这类框架下的开发,每种文件都是有规律的,因为框架提供的就是一些规则,例如react当中要求每个模块文件必须要导出一个函数或是一个类,有了这样一个规律就可能有通用的替换办法,例如每个文件导出的是一个函数的话,可以自动把函数拿回来再执行一下。通过脚手架创建的项目都集成了HMR方案,也就是说这些工具内部都已经提供了通用的HMR替换模块,所以就不需要手动处理了。

总结:我们需要手动处理JS模块热更新后的热替换

Webpack 使用 HMR API

尝试使用HMR API来手动处理模块更新过后的热替换。打开main.js入口文件,也就是在这个文件中才开始去加载别的模块,就是因为这个文件当中使用导入的模块,一旦当这些模块更新过后就必须重新使用这些模块,所以说需要在这个文件当中处理所依赖的模块更换更新过后的热替换。这套API为module提供了一个hot属性,这个属性是HMR的核心对象,它提供了accept方法用于注册模块更新过后模块处理函数,第一个参数接收的是依赖模块的路径,第二个参数就是依赖模块更新后的处理函数。

尝试注册一下editor模块更新过后的处理函数

module.hot.accept('./editor', () => {
  console.log('editor 模块更新了,需要这里手动处理热替换逻辑')
})

修改editor模块保存过后浏览器控制台就会自动打印输出内容,不会触发自动刷新了,一旦模块的更新被我们这样手动处理了,它就不会触发自动刷新,反之如果没有手动处理这个模块的模块热替换那HMR就会自动fallback到自动刷新从而导致页面的自动刷新。

Webpack 处理JS模块热替换

处理editor热替换逻辑,它导出的是一个函数createEditor用于创建文本框,热替换逻辑:1.保存上一次文本框的状态2.移除上一次的文本框元素3.创建新的文本框元素(带上一次状态)挂到页面上。这不是通用的方式,这只针对editor模块。

import createEditor from './editor'

module.hot.accept('./editor', () => {
  const value = lastEditor.innerHTML
  document.body.removeChild(lastEditor)
  const newEditor = createEditor()
  newEditor.innerHTML = value
  document.body.appendChild(newEditor)
  lastEditor = newEditor
})

Webpack 处理图片模块热替换

相比于JS模块的热替换,图片模块的热替换逻辑就会简单得多。只需要把图片的src属性设置为新的图片路径就可以了,因为在图片修改过后,图片文件名是会发生变化的,background拿到的就是更新过后的路径,所以说重新设置图片的src即可。

import background from './better.png'

module.hot.accepe('./better.png',()=>{
  img.src = background
})

纯原生的方式使用HMR会麻烦一点,框架就比较简单了,这也是为什么大部分人都喜欢集成式框架的原因,因为足够简单!

Webpack HMR注意事项

  1. 处理HMR的代码报错会导致自动刷新 刷新过后错误信息被清除,这样一来就不容易发现哪些地方出错,这种情况下可以使用hotOnly的方式解决,默认使用的hot方式热替换失败会自动回退去使用自动刷新的功能,而hotOnly就不会去使用自动刷新
devServer: {
  // hot: true
  hotOnly: true // 只使用 HMR,不会 fallback  live reloading
},

再去修改代码,此时无论说该模块是否被处理的热替换,浏览器都不会自动刷新了,而且错误信息也可看到了。 2. 没启用HMR的情况下,HMR API报错 module.hot是HMR提供的,没开启HMR自然不存在。解决的办法:

if (module.hot) {
  ...
}
  1. 代码中多了一些与业务无关的代码,会不会有影响 关掉HMR重新打包后发现,处理热替换的代码后被移除掉了,只剩下一个if(false){}的空判断,这种没有意思的判断在代码压缩过后也会自动去掉。所以说根本不会影响我们生产环境的运行状态。

Webpack 生产环境优化

前面了解到一些用法和特性都是为了在开发阶段拥有更好的开发体验,而这些体验提升的同时,代码结果也随之变得臃肿,这是因为在这个过程中Webpack为了实现这些特性它会自动往打包结果中添加一些额外的内容,例如Source Map和HMR它们都会往输出结果当中添加额外的代码实现各自的功能,但是这些额外的代码对生产环境来说是冗余的,因为生产环境和开发环境有很大的差异,在生产环境中注重运行效率以更少量更高效的代码完成业务功能,开发环境注重开发效率,针对这个问题,Webpack4推出了模式(mode)的用法,它为我们提供了不同模式下的预设配合,其中生产模式中就已经包括了很多在生产环境当中所需要的优化配置,同时,Webpack也建议为不同的工作环境创建不同的配置,以便于让打包结果适应于不同的环境。接下来探索生产环境有哪些值得我们优化的地方以及一些注意事项。

Webpack 不同环境下的配置

创建不同环境配置的方式主要有两种:

  1. 配置文件根据环境不同导出不同配置(添加环境判断条件)
  • 只适用于中小型项目
  1. 一个环境对应一个配置文件
  • 适用于大型项目 尝试一下这两种方式: 1.配置文件根据环境不同导出不同配置 Webpack配置文件还支持导出一个函数,在函数当中返回所需要的配置对象,函数接收两个参数,第一参数是env,也就是通过Cli传递的环境名参数,第二参数是argv,是指运行Cli过程中所传递的所有参数。

将开发环模式的配置定义在config变量当中,然后去判断env是否为production,是的话就将mode属性设置为production,将devtool设置为false禁用source-map,将CleanWebpackPlugin、CopyWebpackPlugin添加到plugin列表。

module.exports = (env, argv) => {
  const config = {
    mode: 'development',
    entry: './src/main.js',
    output: {
      filename: 'js/bundle.js'
    },
    devtool: 'cheap-eval-module-source-map',
    devServer: {
      hot: true,
      contentBase: 'public'
    },
    module: {
      rules: [
        {
          test: /\.css$/,
          use: [
            'style-loader',
            'css-loader'
          ]
        },
        {
          test: /\.(png|jpe?g|gif)$/,
          use: {
            loader: 'file-loader',
            options: {
              outputPath: 'img',
              name: '[name].[ext]'
            }
          }
        }
      ]
    },
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Webpack Tutorial',
        template: './src/index.html'
      }),
      new webpack.HotModuleReplacementPlugin()
    ]
  }

  if (env === 'production') {
    config.mode = 'production'
    config.devtool = false
    config.plugins = [
      ...config.plugins,
      new CleanWebpackPlugin(),
      new CopyWebpackPlugin(['public'])
    ]
  }

  return config
}

运行yarn webpack没有传递参数就会以开发模式运行打包,dist目录下不会有public目录拷贝过来的文件。运行yarn webpack --env production会以生产模式打包,就可以看到public目录下的文件已经被拷贝到dist目录了。 2.一个环境对应一个配置文件 三个配置文件:

  1. 开发环境 webpack.dev.js
  2. 生产环境 webpack.prod.js
  3. 公共配置 webpack.common.js webpack.common.js
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'js/bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /\.(png|jpe?g|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            outputPath: 'img',
            name: '[name].[ext]'
          }
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Webpack Tutorial',
      template: './src/index.html'
    })
  ]
}

webpack.prod.js 先导入公共的配置对象,可以用Object.assign方法把公共对象复制到当前配置对象当中,并且可以通过最后一个对象覆盖公共配置中的配置,但是Object.assign方法会完全覆盖掉前一个对象的同名属性,这样的特点对普通值类型的覆盖没有什么问题,但是像plugins这种数组,我们是希望往里添加一些插件而不是覆盖。所以Object.assign是不合适的。这里就需要一个更合适的方法来合并配置。社区中提供了专业的webpack-merge这样一个模块,用来专门满足合并Webpack配置的需求。安装yarn add webpack-merge --dev,载入,这个模块导出的就是一个merge函数。

const merge = require('webpack-merge')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const common = require('./webpack.common')

module.exports = merge(common, {
  mode: 'production',
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin(['public'])
  ]
})

webpack.dev.js

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

module.exports = merge(common, {
  mode: 'development',
  devtool: 'cheap-eval-module-source-map',
  devServer: {
    hot: true,
    contentBase: 'public'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
})

运行打包,由于已经没有默认的配置文件,所以我们需要一个参数指定配置文件,yarn webpack --config webpack.prod.js,yarn webpack --config webpack.dev.js,也同样可以把构建命令定义到NPM Script当中。

"scripts": {
  "build": "webpack --config webpack.prod.js"
},

Webpack DefinePlugin

在Webpack4新增的prodution模式下面内部就开启了很多公用的优化功能,对于使用者而言这种开箱即用的体验是非常好的。但是对学习者而言,这种开箱即用会让我们忽略掉很多东西,以至于出现问题过后无从下手。先来看看几个主要的优化配置。

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

DefinePlugin是一个内置的插件,首先导入webpack模块,然后到plugins数组中添加,它的构造函数接受一个对象,这个对象中每一个键值都会被注入到我们的代码当中,例如API_BASE_URL注入一个API服务地址

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

回到代码当中Log一下

console.log(API_BASE_URL)

运行打包,找到模块对应的函数发现DefinePlugin直接把注入的值直接替换到了代码当中

/***/ (function(module, exports) {

console.log("https://api.example.com")


/***/ })

使用这个插件可以往代码当中注入一些可能变化的值,例如API的根路径,开发环境和生产环境肯定是不一样的。

Webpack 体验 Tree Shaking

字面意思就是摇树,一般伴随着摇树这一个动作树上的枯树枝和树叶就会掉落下来,tree-shaking也是相同的道理,不过我们摇掉的是代码没有用到的部分,这部分代码更专业的说法是未引用的代码(dead-code),Webpack生产模式中就有这样一个非常有用的功能,它可以自动检测出我们代码中哪些未引用的代码然后移除。 例如: componets.jsconsole.log('dead-code')就属于未引用代码

export const Button = () => {
  return document.createElement('button')

  console.log('dead-code')
}

export const Link = () => {
  return document.createElement('a')
}

export const Heading = level => {
  return document.createElement('h' + level)
}

index.js中就只导入了Button,意味着componets.js中很多其他成员都没用到,这些没用到的地方对于我们打包结果就是冗余的

import { Button } from './components'

document.body.appendChild(Button())

去除冗余代码在生产环境当中是一个非常重要的工作,而Webpack的tree-shaking就很好地实现了这一点。 以production模式运行打包后打开bundle.js,发现未引用的代码根本就没有输出。这就是tree-shaking工作之后的效果, tree-shaking会在生产模式下自动去开启。

Webpack 使用shaking-tree

需要注意的是,Tree Shaking不是指Webpack某个配置选项,它是一组功能搭配使用后的优化效果,这组功能会在production模式下自动开启,但是由于官网文档对Tree-Shaking的介绍有点混乱,所以下面会介绍一下在其他模式下如何手动一步一步开启,了解Tree-Shaking的工作过程,以及其他的优化功能。

这里还是上面那个项目,不再使用production模式而是使用None,运行打包后打开bundle.js,发现并没有去掉未引用的代码。 接下来借助一些优化功能把它们去掉。

在配置文件中添加optimization属性,这个属性是集中去配置Webpack的优化功能的,先开启usedExports选项,表示输出结果中只导出外部使用了的成员,重新打包,打包结果就不再再去导出Link和Heading这两个函数了。此时就可开启代码压缩功能,压缩没有用到的代码,在optimization属性开启minimize,重新打包,那些未引用的代码就被移除掉了。这就是Tree-Shaking的实现。

optimization: {
  // 模块只导出被使用的成员
  usedExports: true,
  // 压缩输出结果
  minimize: true
}

usedExports- 负责标记哪些是枯树叶 minimize - 负责摇掉它们

Webpack 合并模块

除了usedExports以外,我们还可以使用concatenateModules继续优化我们的输出,普通的打包结果是将我们每一个模块最终放在单独的函数当中,这样的话如果我们的模块很多,也就意味着我们在输出结果中会有很多这样的模块函数。在optimization属性开启concatenateModules,为了更好的看到效果,关掉minimize重新运行打包,此时bundle.js当中就不再是一个模块对应一个函数了,而是把所有的模块都放在了同一个函数当中。concatenateModules的作用就是尽可能将所有模块合并合并输出到一个函数中这样既提升了运行效率,又减少了代码的体积,这个特性又被称之为Scope Hoisting(作用域提升),它是Webpack3中添加的一个特性,此时再去配合minimize代码体积又会减小很多。

Webpack Tree-shaking & Babel

很多资料中表示,如果使用了Babel-loader就会导致Tree-shaking失效,针对这个问题,这里统一说明一下。首先需要明确一点的是Tree-shaking的实现前提是需要ES Modules组织我们的代码,也就是说由Webpack打包的代码必须使用ESM实现模块化,为什么这么说呢? Webpack打包模块之前先是根据配置交给不同的Loader去处理最后再将所有loader处理过后的结果打包到一起,那么为了转换代码中的ECMAScript新特性,很多时候都会选择babel-loader去处理JS,而在babel转换我们的代码时就有可能处理掉我们的代码当中的ESModules(ESModules->CommonJS),当然这取决于有没有使用转换ESM的插件,例如@babel/preset-env中就有这么一个插件,所以说当@babel/preset-env这个插件集合工作的时候代码当中ESM的部分就应该会被它转换成CommonJS的方式,那Webpack在去打包时它拿到的代码就是以CommonJS组织的代码,所以说Tree-shaking就不能生效。但是最新版本的babel-loader当中就已经自动帮我们关闭了ESM转换的插件。所以说Webpack最终打包时得到的还是ESM的代码,Tree-shaking也就可以正常工作了。

可以尝试一下开启@babel/preset-env中转换ESM转换的插件,

rules: [
  {
    test: /\.js$/,
    use: {
      loader: 'babel-loader',
      options: {
        presets: [
          // 如果 Babel 加载模块时已经转换了 ESM,则会导致 Tree Shaking 失效
          ['@babel/preset-env', { modules: 'commonjs' }]
          // ['@babel/preset-env', { modules: false }]
          // 也可以使用默认配置,也就是 auto,这样 babel-loader 会自动关闭 ESM 转换
          // ['@babel/preset-env', { modules: 'auto' }]
        ]
      }
    }
  }
]

运行打包打开bundle.js此时就会发现配置的usedExports(模块只导出被使用的成员)就没法生效了,即便开启压缩代码,Tree-shaking也是没办法正常工作的。

总结一下,通过实验发现最新的Babel-loader并不会导致Tree-shaking失效。如果不确定的话,也可通过['@babel/preset-env', { modules: false }]确保关闭了ESM转换的插件,这样就确保了Tree-shaking正常工作的前提。 Webpack sideEffects 副作用 Webpack4中还新增了sideEffects的新特性,它允许我们通过配置的方式去标识我们的代码是否有副作用,从而为Tree-shaking提供更大的压缩空间,副作用是指模块执行时除了导出成员之外所做的事情sideEffects一般用于npm包标记是否有副作用,sideEffects只有在开发npm包时才用到,但是因为官网中把sideEffects的介绍跟Tree-shaking混到了一起,所以很多人去认为它俩是因果关系,其实它俩真的没有那么大的关系。

把componets拆分成了多个组件文件,然后在index.js中集中导出便于外界的导入,这是一种非常常见的同类文件组织方式。在入口文件中导入Button这个成员,这样的话就会出现一个问题,因为载入的是componets下的index,index中又载入了所有的组件模块,就会导致我们只想导入Button组件但是所有的组件模块都会被加载执行。运行打包后找到打包结果会发现所有组织的模块确实都被打包了。

sideEffects特性就可以用来解决此类问题,配置文件中在optimization中开启这个属性,注意这个特性同样会在production模式下自动开启。

optimization: {
  sideEffects: true
}

开启这个特性后,Webpack在打包时就会先检查当前代码所属的package.json当中有没有sideEffects标识,因此来判断这个模块是否有副作用。

Webpack 代码分割

Code splitting 代码分包/代码分割 代码不分割的弊端:所有代码最终都被打包到一起会造成bundle体积过大(超过2、3M很常见),大多数时候应用开始工作时并不是每个模块在启动时都是必要的,就意味着要浪费很多的流量和带宽。更合理的方案应该是分包按需加载,提高响应速度和运行效率。代码分包就是按照规则打包到不同的bundle中。目前Webpack实现分包的方式有两种:

  • 多入口打包(多输出结果)
  • 动态导入(Webpack把动态导入的模块单独输出到一个bundle中)

Webpack 多入口打包

使用

多入口打包一把适用于多页应用程序,做常见的划分规则就是一个页面对应一个打包入口,不同页面间的公共部分单独提取。

配置多个入口:

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

多个输出

output: {
  filename: '[name].bundle.js'
},

但是打包后,每个html页面都同时引入两个bundle.js,因为html插件会自动注入所有打包结果,用HtmlWebpackPlugin的chunks属性设置

new HtmlWebpackPlugin({
  title: 'Multi Entry',
  template: './src/index.html',
  filename: 'index.html',
  chunks: ['index']
}),
new HtmlWebpackPlugin({
  title: 'Multi Entry',
  template: './src/album.html',
  filename: 'album.html',
  chunks: ['album']
})

这样打包后就能正常了。

提取公共模块