模块化打包工具的由来
模块化确实解决了我们在复杂应用开发中的代码组织问题,但是随着我们引入模块化,我们的应用又会产生一些新的问题:
- ES Modules 存在环境兼容问题
- 模块文件过多,网络请求频繁(因为每个模块都需要通过 xhr 请求单独获取)
- 除了 js,其他的前端资源如 css 等也需要模块化
那么是否存在一种解决方案可以帮助我们解决以上问题,假设它存在,那么它可能需要完成以下功能:
- 帮我们将开发阶段编写的包含 ES6+ 新特性的代码编译为更为绝大多数浏览器兼容的 ES5(解决了环境兼容问题)
- 将开发阶段散落的模块化文件整合到一起,这就解决了浏览器会对模块文件频繁请求的问题(模块化对于开发阶段非常有必要,但是对于生产环境,文件还是越少越好)
- 需要支持不同种类的前端资源类型,这样我们就可以把前端开发中所涉及到的样式、图片、字体等资源文件当做模块去使用,这样对于整个前端项目来说,就有了一个统一的模块化方案了
模块化打包工具概要
webpack 作为一个模块打包工具,本身就可以解决JavaScript 模块化代码打包整合的问题,它可以将项目中零散的 js 文件打包到同一个 js 中。
同时,在打包过程中,对于模块中那些可能存在环境兼容问题的 ES6+ 特性代码,我们可以通过一些模块加载器(Loader)对其进行编译转换
其次,Webpack 还具有代码拆分的能力(Code Splitting),使得应用中所有的代码都按照我们的需要去打包,这样,我们就不必担心所有的模块文件都被打包到一起而导致生产环境中的单个文件体积过大
我们可以将项目第一次运行时所必须的那些代码打包到一起,对于其他非必须的模块我们再单独打包,等到应用工作过程中实际需要这些模块时,我们再去异步加载这些模块
因此,通过 webpac 提供的模块打包功能以及代码拆分功能,我们就不必担心生产环境下文件零碎化或者单个文件体积过于庞大的问题,同时,它还能实现按需加载的功能
最后,对于其他类型的资源文件,webpack 支持我们在 JavaScript 中以模块化的方式载入任何类型的资源文件,例如,我们通过 webpack 就可以在 JavaScript 中直接引入一个 css 文件,这些 css 内容最终可以以 style 标签的形式去完成它相应的工作,而对于其他类型的资源文件,也有类似的方式去实现
总体来说,webpack 等打包工具以模块化为目标,这里的模块化不仅仅是 javascript 的模块化,而是针对整个前端项目整体所有资源文件的模块化,这类工具使我们在开发阶段可以充分享受模块化带来的优势,同时又不必担心模块化对于生产环境产生的一些负面影响
webpack 快速上手
webpack 作为当前最主流的前端模块打包器,它提供了一整套的前端项目模块化方案,而不仅仅是局限于 JavaScript 的模块化
通过 webpack 提供的前端模块化方案,我们可以很轻松的对我们前端项目开发过程中涉及到的所有资源进行模块化
这里我们通过一个快速案例观察 webpack 的使用
webpack 配置文件
webpack4 之后的版本支持以零配置的方式运行 webpack,此时,整个打包过程会按照默认的约定方式进行,将以src/index.js为入口文件将所有的输出打包至dist/main.js
绝大多数情况下,我们需要在项目中自定义入口文件及输出路径等,此时我们需要在项目根目录下建立一个webpack.config.js文件,webpack 在打包时会读取它的配置信息并按照我们期望的配置去打包
这个文件运行在 Nodejs 环境中,因此,它默认遵循 CommonJS 规范
下面列举一些最常见的配置选项:
- entry (指明打包的入口文件)
- output (指明打包后生成文件的位置及文件名等)
const path = require('path')
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'output') // path 指定打包后的所有文件存放位置,必须是绝对路径
}
}
webpack 工作模式
webpack4 新增了一种工作模式的用法,这种用法大大简化了 webpack 配置的复杂程度,可以将之理解为针对不同环境的预设的配置
默认情况下,webpack 采用一种叫做 production 的模式去工作,该模式下,webpack 自动采用一系列的插件压缩我们的代码,这对实际生产环境是非常友好的,但是打包的结果开发者没发去阅读
我们可以通过 cli 或配置文件去指定打包的模式,具体的用法就是使用 webpack 命令时传入 --mode参数,这个参数有三种取值,默认是production,该模式自动启用优化,另一种模式则是开发模式development,该模式优先考虑打包的速度及代码可阅读、可调试性,另一种模式则是none,该模式是最原始的打包,关于具体差异,可以参考官方文档 mode
webpack 打包结果运行原理
webpack 的核心代码是一种被称作 Webpack Bootstrap 的代码,在这段核心代码中,__webpack_require__函数被用于加载模块。Webpack 从入口文件解析模块化代码并将之打包的过程就可以认为是反复调用__webpack_require__的过程。
webpack 打包后的代码并不会特别的复杂,它只是帮我们把所有的零散模块放到了同一个文件之中,除了放到同一个文件当中,它还得提供一些基础代码,让我们这些模块与模块之间相互依赖的关系还可以保持原有的状态,这实际就是webpack bootstrap的作用
webpack 导入资源模块
webpack 的打包入口从某种程度上说可以是整个项目应用的运行入口,而就目前而言,前端项目中的业务是由 JavaScript 驱动的
虽然 webpack 的打包入口也可以指定为 css 等其他资源文件,但是通常来说,我们的项目依然以 js 作为入口文件,并在 js 中引入 css 等其他资源,关于这个案例,可以参考 webpack-import-css
webpack 文件资源加载器
目前,webpack 社区提供了非常多的资源加载器,基本上你能想到的所有合理的需求都会有对应的 loader,这里尝试一些非常有代表性的 loader
首先是文件资源加载器,大多数加载器都类似于 css-loader,通过将资源模块转为 js 代码的实现方式去工作,但是,还有一些我们经常用到的资源文件,例如我们项目当中的图片,字体,这些文件是没法通过 js 代码的方式去表示的
对于这一类的资源文件,我们需要用到文件资源加载器,也就是 file-loader,那么文件资源加载器究竟是如何工作的呢?
webpack 在打包时遇到了图片文件时,根据我们配置文件的配置,匹配到对应的文件加载器(这里是 file-loader),文件加载器就会开始工作,首先,它会将会将文件拷贝至输出目录(默认位于打包后的根目录,可以通过 publicPath 配置),然后会将拷贝后文件的路径作为返回值返回(js 代码中就可以拿到该路径)
Note:webpack_public_path 可用于在业务代码中查看资源文件的输出路径,该变量由 webpack 提供
通过下面的快速案例了解在 webpack 中使用图片
webpack url 加载器
除了 file-loader 这种通过拷贝物理文件的方式去处理资源文件以外,还有一种通过 Data Url 的方式去表示文件,这种方式也非常的常见,Data Url 是一种非常特殊的 url 形式,它可以用来直接表示一个文件。在 webpack 打包静态资源文件时,这种方式特别适用于体积小的图片等,可以省去 http 请求。
webpack 想要实现这个功能,需要使用一个叫做 url-loader 的加载器,下面通过一个快速案例展示 url-loader 的用法
总结:对于项目中的小文件,通过 url-loader 去处理可以明显的减少请求次数,对于较大的文件,我们应该单独提取存放,提高加载速度,那么 url-loader 支持通过传入一些配置选项来控制文件大小的临界点,
在这个临界点范围之内的都使用 url-loader 处理,需要注意的是,如果使用这个配置选项,那么一定要同时安装 file-loader,否则对于超出临界点的文件,url-loader 还是会去自动调用 file-laoder 处理,如果没有安装,就会出问题,如下报错:
ERROR in ./src/images/yby.jpg
Module build failed (from ./node_modules/url-loader/dist/cjs.js):
Error: Cannot find module 'file-loader'
webpack 常见加载器分类
webpack 资源加载器就类似于我们生活中的生产车间,它是用来处理我们打包过程中遇到的资源文件,除了上面介绍的 file-loader 和 url-loader 加载器,社区中还有很多其他的加载器
针对这些不同效果的加载器,可以将之分为三类:
- 编译转换类,例如 css-loader 就是将项目中的 css 文件处理为在 bundle.js(打包后生成的文件) 中以 js 形式工作的 css 模块,从而实现以 JavaScript 运行 css
- 文件操作类,通常这类加载器都会把资源模块拷贝至打包后的目录,同时又将这个拷贝后的文件的访问路径返回给 js
- 代码质量检查类,这类加载器对加载到的资源模块中的代码进行校验,它的目的是为了统一代码的风格,从而去提高代码质量,这类加载器不会去修改生产环境中的代码
当我们接触到一个新的 Loader 时,我们应该考虑这类加载器属于何种性质的加载器,它的实现原理是什么,它在使用上需要注意哪些?
webpack 处理 ES2015
由于 webpack 默认就能处理我们代码当中的 import 和 export,所以很自然的会有人认为 webpack 会自动编译 ES6 的代码,实际上并不是这样
webpack 仅仅是对模块完成打包工作,所以因为模块打包的需要,它才默认需要去处理 import 和 export,对之做相应的转换,那么除此之外,它并不能去转换我们代码当中其他的 ES6 特性
其实上面的案例中,通过设置 mode 为 none,我们可以观察到打包后的生成文件里并没有去转换例如 const 或者箭头函数等 ES6 特性
那如果我能需要 webpack 在打包过程中帮我们处理其他 ES6 特性的转换,那我们需要为 js 文件配置一款编译型 loader,例如最常见的 babel-loader,babel-loader 同时依赖@babel/core 和 @babel/preset-env
总结:webpack 只是打包工具,它不会帮助我们转换代码当中的 ES6 新特性,如果我们需要去处理这些新特性,我们可以为我们的 js 代码配置单独的加载器去编译转换代码
这里可以参考以下快速案例关于 babel-loader 的使用
webpack 模块加载方式
除了代码当中的 import 能够去触发模块的记载,webpack 还提供了其它几种方式:
- 遵循 CommonJS 标准的 require 函数,不过,当你通过 require 函数去载入一个 ES Module 的模块时,需要注意,对于 ES Module 的默认导出,我们需要通过以下方式才能正确使用:
// heading.js 默认是一个 ESModule 模块
const test = require('./heading.js').default
// 开发微信小程序时有用到一个模块是有赞 ui 的 notify 模块,它的模块内部就是使用了 ESMdoule 的默认导出
// 而我的项目里使用的都是 commonjs 的导入语法,当时就很郁闷,为什么没发使用 Notify 函数,后来打印发现导入的对象下还有个 default 属性才是真正的 Notify 函数
- 遵循 AMD 标准的 define 函数和 require 函数也同样被 webpack 支持
Note:除非必要的情况下,否则不要在 webpack 的项目中混合使用这几种标准,否则可能会大大降低项目的可维护性,同一个项目只需去使用一个统一的标准即可
除了 javascript 代码中的这几种导入资源的方式以外,还有一些独立的加载器在工作时也会去处理加载到的资源文件
例如 css 代码当中的 @import 指令和 url 函数,它们也会去触发资源模块的加载,还有 html 代码当中的 img 标签的 src 属性,html-loader 在处理时也会涉及到资源模块的加载
如下代码:
body{
min-height: 100vh;
background-image: url('./bg.png');
background-size: cover;
}
当 css-loader 处理到如上代码时,url-loader 解析到了 url 函数,便会将 url 函数对应的文件视为一个资源模块加入到打包过程中,并根据该资源模块所属类型去找到相应的 loader 去处理,如上面的 png 文件就会根据配置文件里的 url-loader 去处理
几乎我们在代码当中所有需要引用到的资源,就是有引用资源的可能性的地方,都会被 webpack 找出来,然后根据我们的配置,交给不同的 loader 去处理,最后将处理的结果整体打包到输出目录,webpack 通过这样一个特点实现了项目的模块化
Note:默认情况下,html-loader 只会处理 img 标签的 src 属性,将之对应地址的文件视为一个资源模块加入到打包过程中,而对于其他如 a 标签的 href 属性对应的文件(a 标签的 href 属性是可以指向一个文件的,添加 download 属性)则不会将之视为一个资源模块,如果希望 weboack 打包时能将之视为一个资源模块,则需要额外添加配置处理,详细可以参阅 html-laoder
webpack 核心工作原理
通常,在一个项目中都会散落着代码和各类资源文件,那么 webpack 会根据我们的配置找到其中的一个文件作为打包的入口,正常情况下,这个文件都会是一个 JavaScript 文件
然后它会去顺着我们入口文件的代码,根据代码中出现的 import 或 require 之类的语句,然后解析推断出来这个文件依赖的资源模块,之后分别取解析每一个资源模块对应的依赖,最后就得到了整个项目中文件的依赖关系的一个依赖树
有了这个依赖关系树之后,webpack会递归这个依赖树,找到每个节点对应的资源文件,最后根据我们配置文件中的 rules 属性去找到这个模块对应的加载器,并交给这个加载器去加载这个模块,最后会将加载到的结果放入到 bundle.js,也就是我们的打包结果当中,从而去实现我们整个项目的打包
在这整个过程中,loader 机制起着非常重要的作用,如果没有 loader 的话,就没法实现各个资源的加载和处理,那么对于 webpack来说,如果没有 laoder,它只能算是个打包合并 js 模块代码的一个工具了
loader 的工作原理
loader 作为 webpack 的核心机制,其内部的工作原理也非常简单,oader应该向外导出一个函数,这个函数用以处理相应的资源文件,并返回最终的结果(如果返回的最终结果不是 js,则你必须将这个结果交给其他 loader 处理)
具体的 loader 实例可以通过这个快速案例进行了解
webpack 插件机制
插件机制是 webpack 当中另外一个核心特性,它的目的是为了增强 webpack 的自动化能力,我们知道,loader 负责项目中各种资源文件的加载,从而去实现整体项目的打包,而 plugins 则是用来解决项目中除了资源加载的其他自动化工作
例如,plugins 可以帮助我们在打包之前清除 dist 目录,也就是上一次打包的结果,又或是,它可以帮助我们拷贝那些不需要参与打包的资源文件到输出目录,又或是,它可以帮助我们压缩打包后的输出结果
总之,有了 plugins 的 webpack 可以帮助我们实现前端工程化中绝大多数工作,这也是很多开发者误以为 webpack 就是前端工程化的误解的原因
webpack 常用插件
- 自动清理输出目录的插件
- 自动生成使用 bundle.js 的 html,html-webpack-plugin 可以帮助我们动态生成应用中所需的 html 文件
- 拷贝静态文件的插件,copy-webpack-plugin,该插件在开发阶段不建议使用,因为开发阶段会频繁重复执行打包任务,假设我们拷贝的文件比较多或比较大,每次都执行这个插件的话,每一次打包的时间和资源开销都会增大
webpack plugin 工作原理
相比于 loader,plugin 拥有更宽的能力范围,因为 loader 只是在加载模块的环节去工作,而插件的作用范围几乎触及到 webpack 工作的每一个环节
plugins 的实现依赖于钩子机制,在 webpack 工作的过程当中,会有很多的环节,为了便于插件的扩展,webpack 几乎给每一个环节都埋下了一个钩子,这样的话,我们在开发插件的时候,我们就可以通过往这些不同的节点上去挂载不同的任务,就可以轻松的扩展 webpack 的能力
webpack 要求我们自定义的插件必须是一个函数,或者是一个包含 apply 方法的对象,我们可以将 plugins 定义为一个类,在类中向外暴露一个 apply 方法,然后在 plugins 选项中 new 这个类即可
webpack的插件是通过往 webpack 生命周期里面的一些钩子函数中挂载我们的任务函数来去实现的
webpack 自动编译
webpack 在启动时可以通过传入一个 --watch 参数,该模式下,项目源文件会被监视,当这些源文件发生变化后 webpack 会自动重新打包
webpack 实现自动刷新浏览器
如果希望 webpack 编译后可以自动刷新浏览器,webpack dev server 是 webpack 官方推出的一款工具,它提供了一款用于开发的 HTTP Server,并且它集成了自动编译和自动刷新浏览器等一系列对开发有用的功能,相当于这一个工具就能实现之前同时使用--watch 选项及 browser-sync监视文件的功能
使用 webpack-devserver 时,它在内部会自动去调用 webpack 打包我们的项目,并且会去启动一个 HTTP Server 运行我们打包结果,在运行过后,它还会监听我们的文件变化,当项目中的源文件发生变化,它会自动立即重新打包,这一点和我们的 watch 模式是一样的
不过这里需要注意,webpack 为了提高打包效率,所以并没有将打包结果写入到磁盘当中,它是将打包结果暂时存放在内存当中,而内部的 HTTP Server 也将会从内存中读取这些文件,然后发送给浏览器,这样一来,就会减少很多不必要的磁盘读写操作,从而大大提高我们的构建效率
webpack dev server 静态资源处理
webpack dev server 默认会将构建结果输出的文件全部作为开发服务器的资源文件,也就是说,只要是通过 webpack 打包能够输出的文件,都可以直接被访问
但是如果说你还有一些静态资源也需要作为开发服务器的资源被访问到,那你就需要额外的高速 webpack dev server,具体的方法就是在我们的 webpack 配置文件中,去添加一个对应的配置,如下:
{
devServer: {
// 通过 contentBase 属性指定额外的静态资源路径
// 这个属性可以是一个字符串,也可以是数组,也就是说我们可以配置一个或多个路径
// 这个属性的作用和 copywebpackplugin 的作用是类似的,但是推荐在开发阶段使用它,在上线前使用 copywebpackplugin(理由是出于打包时间和资源消耗的角度考虑)
contentBase: './public'
}
}
总结:webpack dev server 的 contentBase 属性为 webpack 指定了除了打包后生成的文件以外,还应该额外为开发服务器指定查找资源的目录
webpack dev server 代理
由于开发服务器的缘故,我们通常会将服务运行在http:localhost:8080端口上,而最终上线之后,我们的应用API又是一个公网上的域名地址下
因此在开发环境下,我们通常会遇到跨域请求问题,解决这个问题需要通过在 webpack dev serve 中配置代理去将请求代理到线上服务器,如下:
proxy: {
'/api': {
// 如下代理设置,当我们请求http://localhost:8080/api/users 实际上会访问 https://api.github.com/api/users
// 但是我们希望访问的地址没有/api,因此我们可以通过地址重写的方式替换掉部分地址
target: 'https://api.github.com'
pathRewrite: {
'^/api': ''
}
// 这样一来,真正访问的地址就变为了https://api.github.com/users
// changeorigin告诉 webpack dev server 是否修改本次请求的主机名为真正访问的地址,为 false 则使用本地地址
changeorigin:true
}
}
Source Map 介绍
通过构建编译的操作,我们可以将开发阶段的源代码转换为能够在生成环境中运行的代码,这是一种提高效率的操作
但是这种操作的同时,使得在实际生成环境中运行的代码与我们开发阶段的代码在可阅读性上已经完全不同了,基本上就是不可能读懂,所以当我们在生产环境下想要调试代码时,该如何去操作呢
SourceMap 就是解决这一类问题最好的解决办法,它的名字其实就已经表述了它的作用。它用来映射生成环境代码和源代码之间的关系,对于生产环境的代码,可以通过 SourceMap 逆向解析为源码代码
很多第三方的库在发布时,都会给出一个 sourcemap 文件,如 jquery
sourcemap 文件有几个主要属性:
- version:当前 sourcemap 文件使用的关于 sourcemap 的版本
- sources:记录的是转换之前源文件的名称,因为很有可能是多个文件合并转为了一个文件,因此该属性是数组
- names:该属性是一个数组,成员都是我们源代码当中的一些变量成员名称,我们都知道,在压缩代码时,我们会将开发阶段编写的那些有意义的变量名去替换为一些简短的字符,从而去压缩我们整体代码的体积,这个属性记录的是源代码当中那些变量的名称
- mappings:这个属性是整个 surcemap 文件的核心属性,他是一个 base64编码的字符串,这个字符串记录的就是我们转换之后的代码的字符与我们源代码的字符的映射关系
有了这个文件之后,我们通常会在转换之后的代码当中,通过添加一行注释的方式引入这个 sourcemap 文件,不过,sourcemap 这个特性只是帮助开发者更方便去调试和定位错误,所以说,它对生产环境来说,没什么太大意义
在最新版的 jquery 文件中,它已经去除了引用 sourcemap 的注释,如果我们希望在引用 jquery 的生产代码时能调试它,我们还需要手动的修改该文件,给其添加 sourcemap 注释
该注释以特定的格式开头,如下:
//# sourceMappingURL=jquery-3.4.1.min.map
总结:sourcemap 解决的就是我们在前端引入了构建编译的概念之后导致我们前端编写的源代码与实际生产环境运行的代码不一致所产生的调试问题
Note:起初我以为浏览器会通过 sourcemap 帮我们自动生成未压缩的版本的 js 文件,但是我错了,如果想正常调试,网站根目录下必须还有相对应的源代码 js 文件,也就是说 a.min.js、 a.min.map、 a.js 三者缺一不可
webpack 配置 sourcemap
webpack 的打包结果也可以生成对应的 sourcemap,配置上非常简单,不过它提供了非常多的sourcemap 模式可供选择
截止目前,webpack 提供了 12 种不同的 sourcemap 模式可供选择,相对来说,效率越好的 sourcemap 生成时速度也就越慢,因此学会在合适的场景下选择正确的 sourcemap 模式是有必要的
虽然 webpack 支持各种各样的 sourcemap 模式,但是我们在开发时,最多也就用到其中的几种而已,根本就没有选择上需要过多纠结的地方
在开发模式下,使用cheap-module-eval-sourcemap 模式有以下几个好处:
- 调试时可以看到被 loader处理之前的代码
- 虽然首次打包速度较慢,但是使用 webpack dev server 重新构建时速度较快,而我们大多数的场景都是在 webpack dev server 模式下不断调试代码
- 调试时出错位置可以定位到行
至于在生产环境下,sourcemap 模式最好设为 none,也就是不生成 sourcemap,因为调试是开发阶段需要做的事,如果为了方便定位错误代码的位置但是不在生产环境下暴露源代码,也可以选择 nosources-source-map 模式,该模式下出错时,会在控制台提示你源代码出错的位置但是无法查看源代码,从安全性的角度和方便调试的角度来说,是一个妥协的选择
解决模块更新后,webpack dev server 使得浏览器自动刷新的问题
HMR,全称是 Hot Module Replacement,翻译过来叫做模块热替换,或者叫做模块热更新,计算机行业有个叫做热拔插的名词,指的就是我们在一个正在运行的机器上随时拔插设备,而我们机器的运行状态不受插拔设备的影响,而且插拔设备上的程序可以立即开始工作,例如我们在电脑上的 USB 端口就是可以热拔插的,这里的模块热替换和热拔插其实是一个道理
webpack 中的模块热替换指的就是在应用程序运行的过程中实时替换某个模块,而应用的应用状态不受影响,例如我们在应用程序运行的过程中修改了某个模块,通过自动刷新会导致浏览器状态丢失(例如在 input 框里输入了一些字符)的问题
而如果我们使用的是热替换的话,我们就可以实现只将刚刚修改的模块实时替换到正在运行的应用程序中,不必去完全刷新应用
HMR 算是 webpack 中最强大功能之一,同时,它也是最受欢迎的一个特性,因为,它极大程度的提高了开发者的工作效率
开启 HMR
对于热更新这么强大的功能而言,它的使用却是并不是特别复杂,HMR 集成在 webpack-dev-server 中,所以说,不需要单独去安装插件了,要想使用这个特性,我们需要在运行webpack-dev-server --hot时提供一个 --hot 参数,也可以在配置文件中开启单独的配置去开启,如下:
const webpack = require('webpack')
module.exports = {
devServer: {
hot: true
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}
Note:通过以上配置开启或者通过运行 webpack-dev-server 命令时传入参数--hot,默认开启的热更新只针对样式文件和图片文件有效,而对于项目中的 js 文件,热更新并不会有效果,如果希望修改 js 模块后,热更新也能生效,需要我们在通过额外的代码去实现,那为什么使用 vue-cli 或者 create-react-app 时,js 文件默认具有热更新能力呢?这是因为这些框架默认情况下已经帮我们写好了 js 模块相关的热更新处理代码
HMR 的疑问
HMR 并不是开箱即用的特性,也就是 HMR 还需要一些额外的操作才能正常工作,webpack 中的 HMR 需要我们手动的通过代码处理当模块更新过后,我们需要如何去把更新过后的文件替换到我们正在运行的程序上
Q1 为什么样式文件的热更新开箱即用?
这是样式文件是经过 loader 处理的,在 style-loader 当中,已经自动处理了样式文件变更后的替换逻辑,所以就不需要我们额外再手动处理了
Q2 凭什么样式可以自动处理模块变换后的替换逻辑?而我们代码中的 js 文件却需要我们手动去处理替换逻辑?
因为在样式文件变更后,它只需要把更细过后的 css 及时替换到页面当中,它就可以覆盖掉之前样式,从而实现样式文件的热更新
而我们编写的 javascript 模块,它是没有任何规律的,因为你可能在一个模块当中导出的是一个对象,也有可能导出的是一个字符串,还有可能导出的是一个函数,对于导出的成员的使用也是各不相同的,也就是说,webpack 对于这种毫无规律的 js 文件,它就根本不知道如何去处理你更新过后的模块(例如某个模块,你之前导出的是一个函数,它应该被执行后输出一个字符串,但是更新过后,直接导出了一个字符串,它可以直接被使用,这种不同方式的使用模块的方式是无法预知的)
因此,webpack 没有办法帮你实现一个通用的适用于所有 js 模块的热替换方案,这就是为什么样式文件可以直接热更新,而 js 文件更新过后页面还是会被自动刷新的原因
Q3 使用 vue-cli 的项目我没有手动处理热替换逻辑,.vue 文件中的 js 依然可以正常热替换?
这是因为你使用的是框架,使用框架开发时,我们项目当中的每一个文件都是有规律的,例如 .vue 文件,框架提供了规则,开发者也必须遵循,因此,框架就可以在内部提前集成了通用的热更新替换方案,像 .vue 文件里导出的总是一个包含 data、method 等属性的对象,当 method 属性下的方法被替换后,vue 只需去执行那个被替换的方法即可,因为它明确地知道,在 methods 属性下的必然是方法,被修改后,自然也需要被执行,而不是执行其它操作,这就在无形之中消除了 js 模块变更后的不可预知性
总结:在非框架项目中使用 webpack 时,如果希望项目中的 js 文件变更后具有热更新效果,我们还需要手动去处理 js 模块更新后的热替换逻辑
HMR APIS
HotModuleReplacementPlugin 为我们的 js 提供了一套用于去处理 HMR 的 API,我们需要在自己的代码当中去使用这些 API 来处理当某个 js 模块内容变更后,应该如何把它替换到当前正在运行的程序上,如下:
import createHead from './render.js'
import './css/index.css'
import yby from './images/yby.jpg'
console.log('yby', yby)
const img = new Image()
img.src = yby
// module.hot.accept函数用于注册当./render.js内容变更后应该执行的函数,一旦变更函数注册,当该文件内容变更后,webpack dev server 就会自动执行注册的处理函数而不会去刷新浏览器
module.hot.accept('./render.js', () => {
console.log('render.js 内容变更了')
})
const heading = createHead()
document.body.append(heading)
document.body.append(img)
图片模块热替换
相比于 js 模块的热替换,图片热替换的逻辑就会简单的多,我们只需要用新的图片路径直接将旧的图片路径替换即可,如下:
import createHead from './render.js'
import './css/index.css'
import yby from './images/yby.jpg'
console.log('yby', yby)
const img = new Image()
img.src = yby
// 直接替换掉旧的图片地址即可
module.hot.accept('./images/yby.jpg', () => {
img.src = yby
})
const heading = createHead()
document.body.append(heading)
document.body.append(img)
总结:通过图片模块的热更新和 js 模块热更新的设置,我们发现,需要通过 HMR 的 API 去写一些和业务代码无关的更新逻辑,这的确有点麻烦
但是总体来说,对开发效果来说,利大于弊,对于一个长期开发的项目,这点额外的工作并不算什么,而且如果你能为自己的代码设计一些规律的话,便可以实现一些通用的替换方案
当然,如果使用框架开发的话,那么使用 HMR 将十分简单,因为大部分框架中都有十分成熟的 HMR 方案,开发者甚至对此做到无感知使用,非常方便
但是,当使用纯原生的方式去开发项目时,HMR 替换方案就需要自己根据需求自己编写,这也是大多数人为什么喜欢集成式框架的原因,因为,足够简单
HMR 注意事项
那么,刚开始去使用 HMR,肯定会会遇到一些问题,下面是一些容易产生疑惑的地方:
- 处理 HMR 的代码报错会导致浏览器自动刷新,就会导致错误消息无法看到(这时,可以将配置文件中的 hot:true 修改为 hotonly:true,这时即使出错,浏览器也不会自动刷新,这时就可以根据错误信息去定位问题)
- module.hot 这个对象是由 HotModuleReplacementPlugin 插件提供,如果忘记了加载该插件,就会在运行时爆出无法在 undefined 对象上找到 accpet 属性的错误
- 如果在开发阶段书写一堆 HMR 相关但是与业务代码无关的代码,打包到生产环境后不是会增大项目的体积吗?(其实,webpack 在进行生产环境的构建时压根不会讲 HMR 相关的代码打包到生成文件中,因此,不必要特地在生产环境中移除 HMR 相关代码)
生产环境优化
前面所介绍的特性都是为了让我们在开发阶段能够有更好的开发体验,而这些体验提升的同时,我们的打包结果也会随着变得越来越臃肿
这是因为在这个过程中,webpack 为了实现这些特性,会自动往打包结果中添加一些额外的内容,例如之前使用的 sourcemap 和 HMR,他们都会往输出结果中添加额外的代码来去实现各自的功能,但是这些额外的代码,对于生产环境来说,是冗余的
因为生产环境和开发环境有着很大的差异,在生产环境中,我们强调的是以更少量、更高效的代码去完成业务功能,也就是说,我们会更注重运行效率,而在开发环境中,我们只会注重开发效率
针对这个问题,webpack4 中就已经提出了 mode 的用法,不同模式下的预设配置适用于不同的场景,主要就是开发环境和生产环境
其中,mode 为生产环境时,webpack 就已经预设了很多生产环境通用的提高生产效率的配置,例如压缩代码,webpack 建议为不同的环境提供不同的配置,以便于让我们的打包结果适用于不同的环境
不同环境下的配置
创建不同环境下不同配置的方式主要有两种:
- 配置文件根据环境不同导出不同配置,在我们的配置文件中,通过相应的判断条件来判断出于何种环境,不同的环境导出适用于不同场景的配置
- 为不同的环境单独添加一个配置文件,也就是一个环境对应一个配置文件
// webpack 的配置文件支持导出一个函数,在这个函数中接受两个参数
// 其中第一个参数是我们通过 cli 传递的一个环境名参数
// 第二个则是我们运行 cli 过程中传递的所有参数
// 我们可以根据 env 的值去判断是生产环境还是开发环境,从而为不同的环境导出不同的配置
module.exports = (env, argv) => {
const config = {
// webpack 支持以 css 文件作为入口文件进行打包,虽然这在实际工作中没有意义,但是我们需要知道它可以以 css 文件作为入口文件
// entry: './src/css/index.css',
mode: 'development',
devServer: {
hot: true,
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 10 * 1024,
},
},
],
},
]
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}
// 生产环境
if (env === 'production') {
config.mode = 'production'
config.devtool = false
config.plugins = [
...config,
new CleanWebpackPlugin(),
// 这个插件在开发阶段可以省略
new CopyWebpackPlugins(['public'])
]
}
}