一、前言
Hot Module Replacement
,简称HMR
,无需完全刷新整个页面的同时,更新模块。好处就是优化了开发体验。
如果你配置过 Webpack
,想必你也不陌生,HotModuleReplacementPlugin
和 devServer
中hot:true
来开启HMR
功能。
二、了解下webpack的日常构建
当我们开启HMR
通过devServer
构建我们项目的时候,我们可以打开控制台network
观察到,它会生成一个hash值41e99be0fd82c43bc67d
,并且引入了main.41e99be0fd82c43bc67d.hot-update.js
这个文件。
同时产生了热更新标识文件41e99be0fd82c43bc67d.update.json
然后我们修改我们的代码重新编译,我们可以在控制台中观察到:
它会产生一个新的hash值(这个hash值是webpack
自动为我们生成的),并且会产生新的js文件
和一个hot-update.json
热更新标识文件。
我们可以发现上一次输出的hash
值会作文新生成文件文件名的前缀。依次类推我们再次修改文件成触发更新会产生新的文件,文件名以这一次的生成hash
为前缀。
具体看下文件的内容。
h
:生成的新hash值c
:当前热更新的是那个模块(我这里是入口文件main
)
那么最后,浏览器是怎么知道我们本地的代码发生改变的呢,并且引入了新的文件?让我们具体来了解下吧!
三、hot热更新实现
热更新hot
是结合dev-server
实现的,所以我们会从webpack-dev-server
开始一步一步实现其中的原理,(^▽^)这里和大家说明文本的代码是我根据blibli
上大佬视频和webpack
源码实现的核心代码。
1. webpack-dev-server启动本地服务
const webpack = require('webpack');//引入webpack
const config = require('../webpack.config');//这个就是我们配置的webpack.config.js文件
const Server = require('./server/Server');
const compiler = webpack(config);//编译器对象
const server = new Server(compiler);
server.listen(9090,'localhost',()=>{
console.log("服务在9090端口");
})
Server.js服务代码
class Server {
constructor(compiler) {
this.compiler = compiler;
this.setupApp();
this.createServer();//创建http服务器,以app作为路由
}
setupApp() {
this.app = express();//本地服务还是依赖于express
}
createServer() {
this.server = http.createServer(this.app);
}
listen(port, host, callback) {//监听服务
this.server.listen(port, host, callback);
}
}
这里的代码的工作内容:
- 引入
webpack
,并且通过webpack.config.js
生成compiler
实例(这里不了解的,大家可以先去了解下webpack
的一个大致构建流程)。 - 依赖
express
框架来启动本地服务。
2.websocket
如何让浏览器知道我们的本地代码发生改变呢?源码里是依据websocket
来实现的。
const socketIO = require('socket.io');
constructor(compiler) {
this.currentHash;//当前hash
this.clientSocketList = [];//websocket 连接列表
this.createSocketServer();//创建socket服务器
}
createSocketServer() {
const io = socketIO(this.server);//websocker协议握手需要依赖http
io.on("connection", (socket) => {//socket和客户端的链接对象
console.log("新的客户端socket");
this.clientSocketList.push(socket);
socket.emit("hash", this.currentHash);//服务端主动发送hash
socket.emit("ok");//发送 ok 字段
socket.on("disconnect", () => {//监听断开
let index = this.clientSocketList.indexOf(socket);
this.clientSocketList.splice(index, 1);//断开需要从列表中删除
})
})
}
这里的代码的工作内容:
- 启动
websocket
服务,可以建立服务器和浏览器之间的双向通信。当监听到本地代码发生改变时,主动向浏览器发送新hash
以及ok
字段。并且往clientSocketList
链接集合中加入当前socket
,同时监听链接断开事件。
3.监听webpack编译完成
监听webpack
编译结束,主要是通过挂载webpack
中生命周期钩子函数done
,为每个socket
都发送hash
和ok
。
constructor(compiler) {
this.setupHooks();//建立钩子
}
setupHooks() {
let { compiler } = this;//拿到compiler对象
//监听webpack编译完成事件
compiler.hooks.done.tap('webpack-dev-server', (state) => {
//state是一个描述对象,里面可以拿到hash值
console.log('hash', state.hash);
this.currentHash = state.hash;
this.clientSocketList.forEach(socket => {
socket.emit("hash", this.currentHash);
socket.emit("ok");//给客户端发一个ok
})
})
}
我们可以看下done
回调函数返回的state
:
4.静态资源访问中间件
在通过webpack-dev-server
来运行我们的项目时,webpack
会在本地启动一个服务,并且我们可以访问到我们打包输出的html
页面,这里主要是通过一个中间件来完成(中间件是express
中的一个概念,这里不做过多解释)。
const MemoryFs = require("memory-fs");
const mime = require('mime');
constructor(compiler) {
this.setupDevMiddleware();//创建中间件
}
setupDevMiddleware() {
this.middleware = this.webpackDevMiddleware();
}
webpackDevMiddleware() {//返回一个express中间件
let { compiler } = this;
// let fs = new MemoryFs();//内存文件系统实例
this.fs = compiler.outputFileSystem = fs;
return (staticDir) => {//静态文件跟目录,他其实就是输出目录
return (req, res, next) => {
let { url } = req;
if (url === '/favicon.ico') {
return res.sendStatus(404);
}
url === '/' ? url = '/index.html' : null;
let fs = new MemoryFs();//内存文件系统实例
let filepath = path.join(staticDir, url);//得到访问的静态路径
try {
let stateObj = this.fs.statSync(filepath);//返回此路径上的描述对象
if (stateObj.isFile()) {//如果是一个文件
let content = this.fs.readFileSync(filepath);
res.setHeader('Content-Type', mime.getType(filepath));//
res.send(content);
} else {
return res.sendStatus(404);
}
} catch (err) {
return res.sendStatus(404);
}
}
}
}
主要完成了对我们本地服务对静态文件的访问,其实就是为了能够本地能够访问我们打包后dist
目录下的html
文件。
5.配置路由
constructor(compiler) {
this.routes();//配置路由
}
routes() {
let { compiler } = this;
let config = compiler.options;
this.app.use(this.middleware(config.output.path));//使用我们上一步生成的静态文件中间件
}
这里主要是使用我们上一步生成的静态文件中间件,我们可以看到它的目录其实就是我们webpack.config.js
文件中配置的输出目录,即dist
。
到此我们webpack-dev-server
的功能已经完成了,我们可以启用node
来跑一下,是否能够访问到页面。
四、打包后文件浏览器端
1.浏览器端websocket
以上我们的服务端已经完成了,当监听到webpack
重新编译,服务端就发送最新的hahs
值,那么客户端也应该有对应的websocket
来响应。响应期间客户端代码又做了什么呢?
以下代码都会被webpack
进行打包,所以会出现在html
文件的引入文件中。
var currentHash;//当前hash
var lastHash;
const socket = window.io('/');
class EventEmitter {
constructor() {
this.events = {};
}
on(eventName, fn) {
this.events[eventName] = fn;
}
emit(eventName, ...args) {
this.events[eventName](...args);
}
}
var hotEmitter = new EventEmitter();
socket.on('hash', (hash) => {//监听到服务端重新编译发送hash
console.log(hash)
currentHash = hash;
})
socket.on("ok", () => {//监听到服务端重新编译发送ok
console.log("ok");
reloadApp();
})
function reloadApp() {
hotEmitter.emit('webpackHotUpdate');//发出webpackHotUpdate消息
}
客户端websocket
注册和服务端一样注册了两个事件:
hash
:webpack
重新编辑打包后的新hash
值 。ok
: 进行reloadApp
热更新检查 。
热更新检查是调用了reloadApp
方法,期间还调用了一层发布订阅模式 EventEmitter
,利用EventEmitter
派发一个webpackHotUpdate
事件进行检查。
2.webpackHotUpdate
首先你可以对比下,配置热更新和不配置时bundle.js
的区别。内存中看不到?因为webpack-dev-server
中使用的是内存文件系统memory-fs
,所以我们在dist
目录下看不到我们打包的文件,我这里给它改成了fs-extra
,这样就能看到了。
- 配置热更新的
- 没有配置热更新的
对比我们发现配置热更新的module
中多了hot
,我这里也写了一个hotCreateModule
function hotCreateModule() {//热创建模块
let hot = {
_acceptDependencies: {},
accept(deps, callBack) {
deps.forEach(dep => {
hot._acceptDependencies[dep] = callBack;
})
},
check: hotCheck
}
return hot;
}
现在我们知道module.hot.check
方法从哪里来了吧!其实这些都是HotModuleReplacementPlugin
插件的功能,它给我们打包出来的代码添了不少。你也可以直接在浏览器Sources
下阅读这些代码,也方便调试。
hotEmitter.on("webpackHotUpdate", () => {
if (!lastHash) {//如果没有 就是第一次渲染
lastHash = currentHash;
return;
}
console.log(lastHash, currentHash);
module.hot.check();//module.hot.check方法检查
})
3.hotCheck
function hotCheck() {//热更新检查
hotDownloadManifest().then(update => {//下载热更新标识文件
let chunkIds = Object.keys(update.c);
chunkIds.forEach(chunkId => {//遍历发送改变的模块
hotDownloadUpdateChunk(chunkId);
})
lastHash = currentHash;
}).catch(() => {
window.location.reload();
})
}
function hotDownloadManifest() {//下载更新文件
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
let url = `${lastHash}.hot-update.json`;//通过ajax去下载热更新标识文件 json
xhr.open('get', url);
xhr.responseType = "json";
xhr.onload = function () {
resolve(xhr.response);
}
xhr.send();
})
}
- 利用上一次保存的
hash
值,调用hotDownloadManifest
发送xxx/hash.hot-update.json
的ajax
请求。 - 保存下次热更新的
hash
标识。
function hotDownloadUpdateChunk(chunkId) {//将新代码通过script加载到html
let script = document.createElement('script');
script.src = `${chunkId}.${lastHash}.hot-update.js`;
document.head.appendChild(script);
}
- 这里通过
jsonp
的方式发送xxx/hash.hot-update.js
请求,获取最新的代码
这里可以看下里面的代码长什么样:
可以看到里面是我改动最新的代码的源代码,是可以直接执行的,内部调用了一个webpackHotUpdate
函数,所以我们来看下这个方法:
window.webpackHotUpdate = function (chunkId, moreModules) {//chunk名字,更新代码的模块
hotAddUpdateChunk(chunkId, moreModules)
}
let hotUpdate = {};
function hotAddUpdateChunk(chunkId, moreModules) {
for (let moduleId in moreModules) {
modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];//更新新模块
}
hotApply();
}
webpack
在打包后会将代码整合成一个个module
的形式
上面代码就是将图中eval
(第6
行)代码给替换了,替换成我们最新的可执行代码。
4.hotApply
热更新的核心逻辑就在hotApply
方法了。
function hotApply() {
for (let moduleId in hotUpdate) {
let oldModule = installedModules[moduleId];//从缓存中找到老模块
let { parents, children } = oldModule;//获取老模块的父模块数组和子模块数组
let module = installedModules[moduleId] = {//模块缓存替换
i: moduleId,
l: false,
exports: {},
parents: parents,
children: children,
hot: hotCreateModule()
}
modules[moduleId].call(module.exports, module, module.exports, hostCreateRequire(moduleId));//执行模块
module.l = true;//该模块已经执行
oldModule.parents.forEach(parentModule => {//遍历所有的父亲模块执行回调
let cb = parentModule.hot._acceptDependencies[moduleId];
cb && cb();
})
}
}
参考链接
本文中的代码都是手动自己写出来的,和源码比较一下精简了不少,但是浓缩的都是精华,也可以帮助大家更好的阅读源码😊😊😊😊😊😊。