乐趣区

webpack源码分析之六:hot module replacement

前言
在 web 开发中,webpack 的 hot module replacement(HMR) 功能是可以向运行状态的应用程序定向的注入并更新已经改变的 modules。它的出现可以避免像 LiveReload 那样,任意文件的改变而刷新整个页面。
这个特性可以极大的提升开发拥有运行状态,以及资源文件普遍过多的前端应用型网站的效率。完整介绍可以看官网文档
本文是先从使用者的角度去介绍这个功能,然后从设计者的角度去分析并拆分需要实现的功能和实现的一些细节。
功能介绍
对于使用者来说,体验到这个功能需要以下的配置。
webpack.config.js:
const path = require(‘path’);
const HtmlWebpackPlugin = require(‘html-webpack-plugin’);
const webpack = require(‘webpack’);

module.exports = {
entry: {
app: ‘./src/index.js’
},
devServer: {
contentBase: ‘./dist’,
hot: true,
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new HtmlWebpackPlugin()
],
output: {
filename: ‘[name].bundle.js’,
path: path.resolve(__dirname, ‘dist’)
}
};

代码:index.js 依赖 print.js, 使用 module.hot.accept 接受 print.js 的更新:
import ‘./print’;

if (module.hot) {
module.hot.accept(‘./print’, function () {
console.log(‘i am updated’);
})
}

改变 print.js 代码:
console.log(‘print2’)
console.log(‘i am change’);
此时服务端向浏览器发送 socket 信息,浏览器收到消息后,开始下载以 hash 为名字的下载的 json,jsonp 文件,如下图:

浏览器会下载对应的 hot-update.js,并注入运行时的应用中:
webpackHotUpdate(0,{

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

console.log(‘print2’)
console.log(‘i am change’);

/***/ })

})
0 代表着所属的 chunkid,30 代表着所属的 moduleid。
替换完之后,执行 module.hot.accept 的回调函数,如下图:

简单来讲,开启了 hmr 功能之后,处于 accepted 状态的 module 的改动将会以 jsonp 的形式定向的注入到应用程序中。
一张图来表示 HMR 的整体流程:

功能分析
提出问题
当翻开 bundle.js 的时候,你会发现 Runtime 代码多了许多以下的代码:
/******/ function hotDownloadUpdateChunk(chunkId) {
/******/ …
/******/ }
/******/ function hotDownloadManifest(requestTimeout) {
/******/ …
/******/ }
/******
/******/ function hotSetStatus(newStatus) {
/******/ …
/******/ }
/******/
打包的时候,明明只引用了 4 个文件,但是整个打包文件却有 30 个 modules 之多:

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

console.log(‘print3’)
console.log(‘i am change’);

/***/ })
到现在你可能会有以下几个疑问:

hmr 模式下 Runtime 是怎么加上 HMR Runtime 代码的。
业务代码并没有打包 socketjs,hot 代码的,浏览器为什么会有这些代码的。
浏览器是如何判断并下载如:501eaf61104488775d2e.hot-update.json,。501eaf61104488775d2e.hot-update.js 文件的,并且如何将 js 内容替换应用程序的内容。
编译器如何监听资源文件的变化,并将改动的文件输出到 Server 里面供客户端下载, 如 501eaf61104488775d2e.hot-update.json,0.501eaf61104488775d2e.hot-update.js。
服务器如何监听编译器的生命周期,并在其编译开始,结束的时候,向浏览器发送 socket 信息。
浏览器替换新的 module 之后,如何及时清理缓存。

分析问题

Runtime 代码是根据 MainTemplate 内部实现的, 有多种场景如 normal,jsonp,hot 模式,则可以考虑将字符串拼接改成事件。
编译开始时候,如果是 hot 模式,在编译器层面将 socketjs,hot 代码一并打包进来。
监听文件变化,webpack 封装了 watchpack 模块去监听如 window,linux,mac 系统的文件变化
编译结束后生成 hash,文件变化后对比最近一次的 hash。有变动则生成新的变动文件。
server 层监听编译器的编译开始,结束的事件,如 compile,watch,done 事件,触发事件后,像浏览器发送对应的 websocket 消息。
浏览器接受到了 websocket 消息后,根据 hash 信息,得到 [hash].hot-update.json 文件,从中解析到 chunkId, 在根据 chunkId,hash 信息去下载 [chunkId].[hash]-update.js。
浏览器替换新的 module 之前,installedModules 对象中删除缓存的 module,在替换后,执行__webpack_require__(id), 将其并入到 installedModules 对象中。

功能实现
以上问题,可以从三个不同的角度去解决。server,webpack,brower。
webpack-dev-server
对入口 entry 做包装处理,如将
entry:{app:’./src/index.js’}
, 转换为
entry:{app:[‘/Users/zhujian/Documents/workspace/webpack/webpack-demos-master/node_modules/_webpack-dev-server@2.11.2@webpack-dev-server/client/index.js?http://localhost:8082′],’webpack/hot/dev-server’,’./src/index.js’}
构建业务代码时,附带上 socketjs,hot 代码。
初始化服务端 sockjs, 并注册 connection 事件, 向客户端发送 hot 信息,开启 hmr 功能。
Server.js
if (this.hot) this.sockWrite([conn], ‘hot’);
浏览器
hot: function hot() {
_hot = true;
log.info(‘[WDS] Hot Module Replacement enabled.’);
}

监听编译器的生命周期模块。

socket

监听 compiler 的 compile 事件, 通过 webSocket 向客户端发送 invalid 信息
监听 compiler 的 done 事件, 通过 webSocket 向客户端发送 still-ok,hash 以及 hash 内容,并将所有请求资源文件设置为可用状态

compiler.plugin(‘compile’, invalidPlugin);
compiler.plugin(‘invalid’, invalidPlugin);
compiler.plugin(‘done’, (stats) => {
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});

资源文件锁定

监听 compiler 的 invalid,watch-run,run 事件。将所有请求资源文件设置为 pending 状态,直到构建结束。
监听 compiler 的 done 事件, 将所有请求资源文件重新设置为可用状态

context.compiler.plugin(“done”, share.compilerDone);
context.compiler.plugin(“invalid”, share.compilerInvalid);
context.compiler.plugin(“watch-run”, share.compilerInvalid);
context.compiler.plugin(“run”, share.compilerInvalid);
webpack
Template
MainTemplate 增加 module-obj,module-require 事件
module-obj 事件负责生成以下代码
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {},
/******/ hot: hotCreateModule(moduleId),
/******/ parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),
/******/ children: []
/******/ };
/******/
module-require 事件负责生成以下代码
/******/ modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));
Compiler
新增 Watching 类支持 watch 模式,并结合 watchpack 监听文件变化。
class Watching {
….
}
Module
新增 updateHash 实现
updateHash(hash) {
this.updateHashWithSource(hash);
this.updateHashWithMeta(hash);
super.updateHash(hash);
}
Chunk
新增 updateHash 实现
updateHash(hash) {
hash.update(`${this.id} `);
hash.update(this.ids ? this.ids.join(“,”) : “”);
hash.update(`${this.name || “”} `);
this._modules.forEach(m => m.updateHash(hash));
}
Compilation
增加 createHash 方法,默认调用 md5 计算 compilation hash。调用依赖树 module,chunk 的 updateHash 方法。
createHash() {
….
}
Parser
增加对 ifStatement 的 statement 类的解析支持
如:
if(module.hot){}
编译后
if(true){}
MultiEntryPlugin
增加 MultiEntryDependency,MultiModule,MultiModuleFactory。将数组的 entry 对象,打包为以下的资源文件。
entry:{app:[‘/Users/zhujian/Documents/workspace/webpack/webpack-demos-master/node_modules/_webpack-dev-server@2.11.2@webpack-dev-server/client/index.js?http://localhost:8082′],’webpack/hot/dev-server’,’./src/index.js’}
打包后
/* 5 */
/***/ (function(module, exports, __webpack_require__) {

// webpack-dev-server/client/index.js
__webpack_require__(6);
//webpack/hot/dev-server
__webpack_require__(26);
// .src/index.js
module.exports = __webpack_require__(28);

/***/ })
HotModuleReplacementPlugin

监听 module-require,require-extensions,hash,bootstrap,current-hash,module-obj 等事件生成 HMR Runtime 代码
监听 record 事件,存储最近一次的 compilation hash。

compilation.plugin(“record”, function(compilation, records) {
if(records.hash === this.hash) return;
records.hash = compilation.hash;
records.moduleHashs = {};
this.modules.forEach(module => {
const identifier = module.identifier();
const hash = require(“crypto”).createHash(“md5”);
module.updateHash(hash);
records.moduleHashs[identifier] = hash.digest(“hex”);
});
records.chunkHashs = {};
this.chunks.forEach(chunk => {
records.chunkHashs[chunk.id] = chunk.hash;
});
records.chunkModuleIds = {};
this.chunks.forEach(chunk => {
records.chunkModuleIds[chunk.id] = chunk.mapModules(m => m.id);
});
});
监听 additional-chunk-assets 事件,对比 record 的最近一次 hash,判断变化之后。生成以 [hash].hot-update.json,[chunkId].[hash].hot-update.js 为名称的 assets 对象。
compilation.plugin(“additional-chunk-assets”, function() {
….
this.assets[filename] = source;
});
Brower

初始化 runtime,将所有附加的模块代码统一增加 parents,children 等属性。并提供 check,以及 apply 方法去管理 hmr 的生命周期。

check,发送 http 请求请求并更新 manifest,请求成功之后,会将待更新的 chunk hash 与当前 chunk hash 做比较。多个 chunk,则会等待相应的 chunk 完成下载之后,将状态转回 ready 状态,表示更新已准备并可用。
apply,当应用状态为 ready 时,将所有待更新模块置为无效 (清除客户端缓存),更新中调用新模块 (更新缓存),更新完成之后,应用程序切回 idle 状态。

初始化 websocket,与 server 端建立长链接,并注册事件。如 ok,invalid,hot,hash 等事件。
初始化 hot 代码,注册事件对比新老 hash,不相等则调用 check 方法开启模块更新功能。

module.hot.check(true).then(function(updatedModules) {
….
})
代码实现
本人的简易版 webpack 实现 simple-webpack
(完)

退出移动版