# 前言
在我刚学习 vue
的时候,接触vue-cli
的时候,在开发是被其提供的热加载功能深深的吸引住了。很好奇它是如何将我们所修改的代码通过不刷新方式,来更新 web
应用,与此同时还能保持应用的一些状态,这在我们平时日常开发之中是非常有用。后来再知道提供浙一功能的是 webpack
的一个插件模块 hotModuleReplace
所提供的能力,本文就带大家一起来探究一下它是如何实现的。
# 抛出疑问
对于我们不熟悉或者压根没有接触的东西,通过抛出疑问,自己解决这些疑问可以很好帮我们去提升自己的能力,这是很好成长的机会。
通过
webpack
打包之后的文件存放在哪里?在没有writeToDisk: true
这个配置项时。打包器在工作时是如何和
client
进行交互的?新生成的模块是如何作用于浏览器应用的。
一开始的时候我们抛出的问题的颗粒度比较粗,没有关系,随着我们一步步的探索,我们又会遇到一些细节的问题,把这细节问题梳理清楚了,从而对整个问题就有了更深的理解了。
# HMR 流程图解
上图描述了一个完整HMR
流程,我们来看看它是如何运转的。
在
webpack
的监听模式下,在我们所编写的文件保存之后,会触发其重新编译,完成之后的bundle
保存在内存之中。在编译打包时,webpack
的ProgressPlugin
这个插件将编译进度通过websocket
推送到浏览器端。在
webpack-dev-server
与webpack
之间主要是由webpack-dev-middleware
来进行交互的,这里它主要做了两件事:一是,通过webpack-dev-middleware
调用webpack
生成的compiler
的相关api,告诉其将打包之后的文件是写入内存之后,还是写到硬盘上,依据writeToDisk
配置项。二是,webpack-dev-middleware
调用返回的是一个express
标准的中间件,向app
上注册路由,拦截HTTP
收到的请求,根据请求路径响应对应的文件内容;这一步,浏览器主要是通过
sockjs
这个库与服务器端的websocket
服务建立长连接通信,将webpack
编译打包的各个阶段推送浏览器端,依此会有不同的操作,最为核心的就是服务端推送新hash
时,浏览器端会依据这个hash
来进行后续的热更新逻辑。当服务端推送的消息
type
为ok
时,webpack-dev-server/client
会执行reloadApp
方法,主要是为了触发webpackHotUpdate
事件,在webpack/hot/dev-server
依据相关的配置和传递的信息决定是否要刷新页面还是要热更新,这两者联系的纽带是webpack/hot/emitter
,依赖的是node
的events
模块,在浏览器也能生效,说明其是和平台无关的。在
webpack/hot/dev-server
中会通过调用module.hot.check
方法映射到HotModuleReplacement.runtime
中的hotCheck
方法,这些方法是通过HotModuleReplacementPlugin
注入到bendle
之中。hotCheck
然后会调用JsonpMainTemplate.runtime
中的hotDownloadManifest
和hotDownloadUpdateChunk
方法。hotDownloadManifest
携带上推送的hash
发起ajax
请求,获取最新的文件列表。hotDownloadUpdateChunk
依据文件列表发起jsonp
请求相应的最新的代码块,继续后续代码更新的逻辑。代码更新时,会进行新旧模块的对比,决定是否要更新。如果要更新,将梳理清楚模块之间的依赖,然后更新模块以及相关的依赖;如果更新失败了,就会通过刷新的方式来进行兜底操作。
# 运行Demo
下面我们通过一个简单的例子来说明。来说明一些其中的一些细节问题,以大家最为熟悉的 vue
为例。
我们来初始化一个基本vue
项目,相关目录以及核心配置如下:
Demo
├─ main.js
├─ package.json
├─ public
│ └─ index.html
├─ test.vue
├─ webpack.config.js
2
3
4
5
6
7
webpack.config.js
的配置如下,
{
devtool: 'source-map',
mode: 'development',
entry: path.resolve(__dirname, './main.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
devServer: process.env.NODE_ENV == 'production' ? {}:{
contentBase: path.join(__dirname, 'dist'),
hot: true,
writeToDisk: true,
},
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader']
},
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './public/index.html'
})
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package.json
的配置如下,
"scripts": {
"dev": "rm -rf ./dist && NODE_ENV=development webpack-dev-server --config webpack.config.js --open --inline --progress"
}
2
3
test.vue
中的文件大家看心情来就好,main.js
就是正常的vue
启动入口,这里我就不多赘述了。
当我们修改的test.vue
时,
# 第一步:webpack将打包之后的文件放入内存之中
webpack-dev-middleware
通过调用 webpack
的 compiler
的 api
将打包之后的文件放到内存之中。核心代码如下:
// webpack-dev-middleware/index.js
if (!options.lazy) {
context.watching = compiler.watch(options.watchOptions, (err) => {
// ...
})
}
2
3
4
5
6
在配置了 writeToDisk
这个配置项时,打包之后的文件会写入到相应的文件夹中。但是在没有这个配置的时候,那么文件有存放在哪里呢?这里我们提到webpack-dev-middleware
中的一个依赖memory-fs
,看这个名字我们大概就能猜到他是干什么的了。在 webpack
的compiler.outputFileSystem
会被挂在上memory-fs
的实例。核心代码如下:
// webpack-dev-middleware/lib/fs.js
const isMemoryFs = !isConfiguredFs && !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if (isConfiguredFs) {
const { fs } = context.options;
compiler.outputFileSystem = fs;
fileSystem = fs;
} else if (isMemoryFs) {
fileSystem = compiler.outputFileSystem;
} else {
fileSystem = new MemoryFileSystem();
compiler.outputFileSystem = fileSystem;
}
2
3
4
5
6
7
8
9
10
11
12
如果当前 compiler.outputFileSystem
不是 MemoryFileSystem
,那就用 MemoryFileSystem
替换,这样打包的文件就存放在 MemoryFileSystem
所构建的文件系统之中,加快了访问读取的速度了。
# 第二步:devServer在文件编译通知浏览器端
在webpack
的compiler
会添加ProgressPlugin
插件,统计编译打包的进度,然后通过sockjs
与服务端 websocket
通信,将打包的各个状态推送到客户端。此时,还需要知道打包完成的状态,在 compiler.hooks
中监听了 done
事件,然后通过调用 _sendStats
将打包之后的 stats.hash
推送浏览器端。核心代码如下:
// webpack-dev-server/lib/Server.js
new webpack.ProgressPlugin((percent, msg, addInfo) => {
// ... 使用 ProgressPlugin 插件
this.sockWrite(this.sockets, 'progress-update', { percent, msg });
// ...
}).apply(this.compiler);
setupHooks() {
// ...
const addHooks = (compiler) => {
const { compile, invalid, done } = compiler.hooks;
// ...
// 添加done事件监听
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
});
};
if (this.compiler.compilers) {
this.compiler.compilers.forEach(addHooks);
} else {
addHooks(this.compiler);
}
}
_sendStats(sockets, stats, force) {
// ...
// 推送新的 hash 到浏览器端
this.sockWrite(sockets, 'hash', stats.hash);
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 第三步:webpack-dev-server/client 接受和处理服务端推送的信息
在一开始梳理这逻辑的时候,我有点好奇 webpack-dev-server/client
这部分的处理代码是如何打到 bundle
之中的?其实在 webpack-dev-server
启动的时候,回去更新 webpack
的 compiler
, 然后通过 addEntries
这个插件将这部分的代码注入到客户端之中。核心代码如下:
// webpack-dev-server/lib/Server.js
updateCompiler(this.compiler, this.options)
// webpack-dev-server/lib/utils/updateCompiler.js
addEntries(webpackConfig, options);
compilers.forEach((compiler) => {
// ...
const providePlugin = new webpack.ProvidePlugin({
__webpack_dev_server_client__: getSocketClientPath(options),
});
providePlugin.apply(compiler);
});
2
3
4
5
6
7
8
9
10
11
webpack-dev-server/client
在处理 websocket
消息会依据不同的 type
来处理消息。当收到 type
为 hash
时记录下传递过来的hash
值,当前消息的type
为ok
时,会调用 reloadApp
。如下图所示:
在 reloadApp
这个函数中,主要是通过webpack/hot/emitter
触发webpackHotUpdate
事件,并将最新的 hash
作为参数传递过去,如果没有配置hot参数
,则会在宏任务中来进行页面刷新的操作。
核心代码如下:
// webpack-dev-server/client-src/default/index.js
const onSocketMessage = {
hash(hash) {
status.currentHash = hash;
},
ok() {
reloadApp(options, status);
}
}
function reloadApp(
{ hotReload, hot, liveReload },
{ isUnloading, currentHash }
) {
if (hot) {
const hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
} else if (liveReload) {
// ...
self.setInterval(() => {
rootWindow.location.reload()
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 第四步:webpack接受到最新的hash验证以及更新模块
在上一步当中我们派发了 webpackHotUpdate
事件,要看在哪里消费了这个事件。在 webpack/hot/dev-server
中消费的回调函数中主要是调用了 module.hot.check
方法,
module.hot
这个对象是通过HotModuleReplacementPlugin
插件将HotModuleReplacement.runtime
之中的代码注入。核心代码如下:
// webpack/hot/dev-server.js
hotEmitter.on("webpackHotUpdate", function(currentHash) {
//...
module.hot.check(true).then(() => {
//...
})
});
// webpack/lib/HotModuleReplacementPlugin.js
const Template = require("./Template")
const hotInitCode = Template.getFunctionContent(
require("./HotModuleReplacement.runtime")
)
mainTemplate.hooks.moduleObj.tap(
"HotModuleReplacementPlugin",
(source, chunk, hash, varModuleId) => {
return Template.asString([
`${source},`,
`hot: hotCreateModule(${varModuleId}),`, // 将 hmr.runtime 中的 api 注入到 module 之中
"parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),",
"children: []"
]);
}
);
// webpack/lib/HotModuleReplacement.runtime.js
function hotCreateModule(moduleId) {
var hot = {
check: hotCheck,
// other api
};
return hot;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
hotCheck
方法会调用 JsonpMainTemplate.runtime
中的两个方法 hotDownloadManifest
和hotDownloadUpdateChunk
,前者通过上一步传入的hash
发起ajax
请求服务端获取文件更新列表,如图所示;后者依据文件列表发起jsonp
请求相应的最新的代码块,继续后续代码更新的逻辑。
hotDownloadManifest
方法获取更新文件列表
hotDownloadUpdateChunk
获取到更新的新模块代码。
在这里我一直有一个疑问,并且我自己想了一段时间也没有想通用的问题,就是为什么要通过上述的这种方式来更新。从技术来说,当服务端推送消息type
为ok
时,可以吧更新之后的代码块也一并推送过来,但是为什么没有这么做呢?看了其他的解释是,模块解耦,dev-server/client
只负责消息的传递,HotModuleReplacement
来负责代码模块的的更新,这样复合单一设计的原则,而且 webpack
在不使用 dev-server
的情况下,可以配合 webpack-hot-middleware
来实现热更新功能,webpack-hot-middleware
使用的协议并不是websocket
而是SSE
协议,这种协议在flutter-devtools
也有使用到,可以很好的把模块的功能进行解耦。
# 第五步:HotModuleReplacement.runtime 更新模块
在上述 jsonp
请求中我们看到其调用了 webpackHotUpdate
这个方法,通过打包出来的 bundle.js
可以看到它又调用了JsonpMainTemplate.runtime
中的webpackHotUpdateCallback
,这其中主要是调用了 HotModuleReplacement.runtime
的 hotAddUpdateChunk
,后面一系列的调用就不一一详述了,我们来看一下最为核心的方法hotApply
。由于这个方法实现的代码较多,我们可以追踪hotStatus
的状态变化来梳理更新的逻辑。
- "idle" => "check",这个阶段
module.hot.check
方法。 - "check" => "prepare",这个阶段通过上述的
hotDownloadManifest
获取到了最新的文件列表。 - "prepare" => "ready",这个阶段,将新的模块存贮到了
hotUpdate
之中。 - "ready" => "dispose",这个阶段经历了两个循环,在第一个循环中,先通过
getAffectedStuff
方法找到当前这个新模块所有相关的模块outdatedModules和依赖outdatedDependencies,result
返回值如下,如果没有找到的话,将result.type
表示为disposed
。正常返回result.type
为accepted
。
let result = {
"type":"accepted",
"moduleId":"./lib/vue-loader/index.js?!./test.vue?vue&type=script&lang=js&",
"outdatedModules":[
"./lib/vue-loader/index.js?!./test.vue?vue&type=script&lang=js&",
"./test.vue?vue&type=script&lang=js&",
"./test.vue"
],
"outdatedDependencies":{}
}
2
3
4
5
6
7
8
9
10
在第二个循环中,找出outdatedModules中,并且已经被使用的模块中 hot._selfAccepted
为 true
的模块,这些模块通过都自己调用的 这个方法,对于
vue
组件来说,这部分的代码是在vue-loader
中注入的,核心代码如下:
if (
installedModules[moduleId] &&
installedModules[moduleId].hot._selfAccepted &&
// removed self-accepted modules should not be required 不需要移除自我接受的模块
appliedUpdate[moduleId] !== warnUnexpectedRequire
) {
outdatedSelfAcceptedModules.push({
module: moduleId,
errorHandler: installedModules[moduleId].hot._selfAccepted
});
}
2
3
4
5
6
7
8
9
10
11
- "dispose" => "apply",在这个阶段,遍历outdatedModules,从installedModules已经安装的模块中删除过期模块以及被该模块引用的子模块,还要删除删除依赖,核心代码如下:
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
if (!module) continue;
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for (j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if (!child) continue;
idx = child.parents.indexOf(moduleId);
if (idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- "apply" => "fail" or "idle",在这个阶段主要是更新模块,先将可被更新的模块
appliedUpdate
挂载到全局的modules
上,对于outdatedSelfAcceptedModules
中的模块,会调用$require$(moduleId)
,这里的$require$
就是打包出来的__webpack_require__
函数,主要是用来加载模块的。
# 第六步:对 vue 组件是如何自定更新的
test.vue
这个模块在被vue-loader
解析时,会通过vue-loader/codegen/hotReload.js
中的genHotReloadCode
方法添加如下代码,其中实现不刷新页面来更新vue
组件的核心逻辑在vue2
都是由vue-hot-reload-api
这个库提供的。
// vue-loader/codegen/hotReload.js genHotReloadCode
if (module.hot) {
var api = require("xxx/node_modules/vue-hot-reload-api/dist/index.js")
api.install(require('vue'))
if (api.compatible) {
module.hot.accept()
if (!api.isRecorded('13429420')) {
api.createRecord('13429420', component.options)
} else {
api.reload('13429420', component.options)
}
module.hot.accept("./test.vue?vue&type=template&id=13429420&scoped=true&", function () {
api.rerender('13429420', {
render: render,
staticRenderFns: staticRenderFns
})
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这里 vue-hot-reload-api
为了支持更热新,它的核心实现逻辑是,在组件的生命周期的钩子函数中,插入保存组件上下文的对象的函数,初次渲染是保存当前的组件上下文;当第二次热更新的,先替换组件的 options
, 然后调用组件的 $forceUpdate
方法来重新渲染。
# 总结
- 通过本文大概了解
webpack hmr
整个工作的流程,还有很多的细节没有捕捉到位,比如webpack-dev-server/client
代码的插入,hotapply
里面的弯弯绕绕,以及整个webpack
的模块机制的实现,欢迎小伙伴积极探讨。 - 本文参考了 Webpack HMR 原理解析 (opens new window),感谢大佬的付出,觉得这样的行文思路非常值得借鉴。