Webpack 热更新机制

想必作为前端大佬的你,工作中应该用过 webpack,并且对热更新的特性也有了解。如果没有,当然也没关系。

下面我要讲的,是我对 Webpack 热更新机制的一些认识和理解,不足之处,欢迎指正。

首先:

热更新是啥?

热更新,是指 Hot Module Replacement,缩写为 HMR

从名字上解读,就是把“热”的模块进行替换。热,是指这个模块已经在运行中。

不知道你有没有听过或看过这样一段话:“在高速公路上将汽车引擎换成波音747飞机引擎”。

虽然有点牵强,但是放在这里,从某些角度上来说,也还算合适吧。

再扯远一点,说下我目前工作中的遇到的情况,相信很多人也遇到过。

微信小程序的开发工具,没有提供类似 Webpack 热更新的机制,所以在本地开发时,每次修改了代码,预览页面都会刷新,于是之前的路由跳转状态、表单中填入的数据,都没了。

哪怕只是一个文案或属性配置的修改,都会导致刷新,而要重新进入特定页面和状态,有时候很麻烦。对于开发时需要频繁修改代码的情况,这样比较浪费时间。

而如果有类似 Webpack 热更新的机制存在,则是修改了代码,不会导致刷新,而是保留现有的数据状态,只将模块进行更新替换。也就是说,既保留了现有的数据状态,又能看到代码修改后的变化。

很美好,但是想想就觉得是一件肯定不简单的事情。

所以,热更新是啥呢?

引用官方文档,热更新是:

使得应用在运行状态下,不重载刷新就能更新、增加、移除模块的机制

热更新解决的问题

那么热更新要解决的问题,在上面也解释了。用我的话来阐述,就是 在应用程序的开发环境,方便开发人员在不刷新页面的情况下,就能修改代码,并且直观地在页面上看到变化的机制

简单来说,就是为了 提升开发效率

联想到我在微信小程序上的开发体验,真心觉得如果有热更新机制的话,开发效率要高很多。

如果你知道微信小程序已经或计划支持热更新,或者有大佬已经做了类似的工作,欢迎告诉我,感谢!

进一步介绍前,我们来看下 Webpack 热更新如何配置。

热更新配置

如果你之前做的项目是其他人搭建配置了 Webpack 和热更新,那么这里可以了解下热更新是怎么配置的。

我的示例采用 Webpack 4,想直接看代码的话,在这里:

https://github.com/luobotang/webpack-hmr-demo

除了 Webpack,还需要 webpack-dev-server(或 webpack-dev-middleware)。

为 Webpack 开发环境开启热更新,要做两件事:

  • 使用 HotModuleReplacementPlugin 插件
  • 打开 webpack-dev-server 的热更新开关

HotModuleReplacementPlugin 插件是 Webpack 自带的,在 webpack.config.js 加入就好:

// webpack.config.js
module.exports = {
  // ...
  plugins: [
    webpack.HotModuleReplacementPlugin(),
   // ...
  ]
}

如果直接通过 webpack-dev-server 启动 Webpack 的开发环境,那么可以这样打开 webpack-dev-server 的热更新开关:

// webpack.config.js
module.exports = {
  // ...
  devServer: {
    hot: true,
    // ...
  }
}

也很简单。

热更新示例

下面通过例子来进一步解释热更新机制。如果你之前对 Webpack 热更新的体验,是 Vue 通过 vue-loader 提供给你的,也就是说你在自己的代码中从没有写过或者见到过类似:

if (module.hot) {
  module.hot.accept(/* ... */)
  // ...
}

这样的代码,那么下面的例子就刚好适合看一看了。

这些例子就在上面的 webpack-hmr-demo,如果你对代码更亲切,那直接去看吧,首页文档里有简单的说明。

示例1:没有热更新的情况

这个例子只是把示例页面的功能简单介绍下,并且让你体会下每次修改代码都要重新刷新页面的痛苦。

页面上只有一个元素,用来展示数值:

<div id="root" class="number"></div>

入口模块(index.js)引用了两个模块:

  • timer.js:只提供了一个 start 接口,传入回调函数,然后 timer 会间隔一段时间调用回调函数,并传入一个每次增加的数值
  • foo.js:没啥功能,就简单暴露一个 message,引入它单纯是区别 timer.js 展示不同的模块更新处理方法

入口模块的功能很简单,调用 timer.start(),再传入的回调函数中,每次将得到的数值更新到页面上显示:

import { start } from './timer'
import { message } from './foo'

var current = 0
var root = document.getElementById('root')
start(onUpdate, current)

console.log(message)

function onUpdate(i) {
  current = i
  root.textContent = '#' + i
}

将这个项目运行起来,打开的页面中就是在一直刷新展示增加的数值而已,类似这样:

hmr-demo-1

一旦修改任何模块的代码,例如改变 timer 中定时器的间隔时间(如从1秒改成3秒),或者 onUpdate 中展示的内容(如 '#' + i 改成 '*' + i),页面都会刷新,已经有的状态清除,重新从0开始计数。

示例2:处理依赖模块的热更新

接下来的例子,展示在 index.js 如何处理其他模块的更新。

依赖的模块发生更新,要么是接受变更(页面不用刷新,模块替换下就好),要么不接受(必须得刷新)。

Webpack 将热更新相关接口以 module.hot 暴露到模块中,在使用前,最好判断下当前的环境是否支持热更新,也就是上面看到的这样的代码:

if (module.hot) {
  // ...
}

延续上一个例子,选择接受并处理 timer 的更新,但对于 foo 模块,不接受:

if (module.hot) {
  module.hot.accept('timer', () => {
    // ...
  })
  module.hot.decline('./foo')
}

所以,在热更新的机制中,其实是以这种“声明”的方式告知 Webpack,哪些模块的更新是被处理的,哪些模块的更新又不被处理。当然对于要处理的模块的更新,自行在 module.hot.accept() 的第二个参数即回调函数中进行处理,会在声明的模块被替换后执行。

下面来看对 timer 模块更新的处理。

timer 模块的 start 函数调用后返回一个可以终止定时器的 stop 函数,借助它我们实现对旧的 timer 模块的清理,并基于当前状态重新调用新的 timer 模块的 start 函数:

var stop = start(onUpdate, current) // 先记录下返回的 stop 函数

// ...

if (module.hot) {
  module.hot.accept('timer', () => {
    stop()
    stop = start(onUpdate, current)
  })
  // ...
}

处理逻辑如上所述,先通过之前记录的 stop 停止旧模块的定时器,然后调用新模块的 start 继续计数,并且传入当前数值从而不必从0开始重新计数。

看起来还是比较简单的吧。运行起来的效果是,如果修改 timer 中的定时器间隔时间,立即在页面上就能看到效果,而且页面并不会刷新导致重新从0开始计数:

hmr-demo-2

在运行几秒后,修改 timer 模块中定时器的间隔时间为 100ms

修改 foo 中的 message,页面还是会刷新。

有几点额外说明下:

  • timer 模块如果修改后不返回 start 接口,那么上述处理机制显然会失效,所以这里的处理是基于模块的接口不变的情况下
  • timer 模块的 start 调用后显然必须返回一个 stop 函数,否则在 index.js 是没法清除 timer 模块内开启的定时器的,这也很重要
  • 或许你也注意到了,就是对 timer 模块的 start 函数的引用貌似一直没有变过,那为什么在回调函数中的 start 就是新模块了呢?这个其实是有 Webpack 在编译时处理掉的,编译后的代码并非当前的样式,对 start 会进行替换,使得回调中的 start 一定引用到的是新的 timer 模块的 start。感兴趣可以看下 Webpack 文档中对此的相关描述。

此外,除了声明其他模块更新的处理,模块也可以声明自身更新的处理,也是同样的接口,不传参数即可:

  • module.hot.accept() 告诉 Webpack,当前模块更新不用刷新
  • module.hot.decline() 告诉 Webpack,当前模块更新时一定要刷新

而且,依赖同一个模块的不同模块,可以有各自不同的声明,这些声明可能是冲突的,比如有的允许依赖模块更新,有的不允许,Webpack 怎么协调这些呢?

Webpack 的实现机制有点类似 DOM 事件的冒泡机制,更新事件先由模块自身处理,如果模块自身没有任何声明,才会向上冒泡,检查使用方是否有对该模块更新的声明,以此类推。如果最终入口模块也没有任何声明,那么就刷新页面了。这也就是为什么在上一个例子中,虽然开启了热更新,但是模块修改后仍旧刷新页面的原因,因为没有任何模块对更新进行处理。

示例3:处理自身模块的热更新

自身模块的更新处理与依赖模块类似,也是要通过 module.hot 的接口向 Webpack 声明。不过模块自身的更新,可能需要在模块被 Webpack 替换之前就做一些处理,更新后的处理则不必通过特别接口来做,直接写到新模块代码里面就好。

module.hot.dispose() 用于注册当前模块被替换前的处理函数,并且回调函数接收一个 data 对象,可以向其写入需要保存的数据,这样在新的模块执行时可以通过 module.hot.data 获取到:

var current = 0
if (module.hot && module.hot.data) {
  current = module.hot.data.current
}

首先,模块执行时,先检查有没有旧模块留下来的数据,如果有,就恢复。

然后在模块被替换前的执行处理,这里就是记录数据、停掉现有的定时器:

if (module.hot)
  module.hot.accept()
  module.hot.dispose(data => {
    data.current = current
    stop()
  })
}

做了这些处理之后,修改 index.js 的 onUpdate,使得渲染到页面的数值改变,也可以在不刷新的情况下体现:

hmr-demo-3

在运行几秒后,修改 onUpdate() 中的 '#' + i'*' + i

总结

看过上面的例子,我们来总结下。

Webpack 的热更新,其实只是提供一套接口和基础的模块替换的实现。作为开发者,需要在代码中通过热更新接口(module.hot.xxx)向 Webpack 声明依赖模块和当前模块是否能够更新,以及更新的前后进行的处理。

如果接受更新,那么需要开发者自己来在模块被替换前清理或保留必要的数据、状态,并在模块被替换后恢复之前的数据、状态。

当然,像我们在使用 Vue 或 React 进行开发时,vue-loder 等插件已经帮我们做了这些事情,并且对于 *.vue 文件在更新时要如果进行处理,很多细节也只有 vue-loader 内部比较清楚,我们就放心使用好了。

但是对于 Webpack 热更新是怎么一回事,如果能够有深入了解当然更好,我就遇到过同事在 Vue 组件中自行对 DOM 进行处理(为了封装一个直接操作 DOM 的组件),结果由于热更新的存在,导致一些状态的清除有问题的情况。

这种情况,只有开发者自己才能处理,vue-loader 可没法处理这样的特殊情况。至少知道如何使用 Webpack 的热更新接口,这种情况下开发者就能自行处理了。

本文对于 Webpack 热更新机制的介绍还只是在接口使用的层面,或者大体的机制上,没有深入说明热更新的实现原理和细节。时间、篇幅有限,那就先放一张图出来,或许有时间再细说一下。

Webpack 热更新流程

上图来源:

Webpack & The Hot Module Replacement
https://medium.com/@rajaraodv/webpack-hot-module-replacement-hmr-e756a726a07

这篇英文文章对 Webpack 热更新实现原理方面有深入介绍。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容