HMR
前置知识
要想理解webpack中HMR的原理,你首先应该对以下知识点有一定的了解
- 了解webpack的基本知识以及webpack中require的实现 juejin.cn/post/690377…
- tapable(webpack的核心库)
- 有一定的node基础 (了解express 或者 koa)
- 了解websocket
刷新
- 基于浏览器的刷新,不保留页面状态,就是简单粗暴,直接
window.location.reload()
。 - 另一种是基于
WDS (Webpack-dev-server)
的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。
HMR
作为一个Webpack
内置的功能,可以通过HotModuleReplacementPlugin
或--hot
开启。那么,HMR
到底是怎么实现热更新的呢?下面让我们来了解一下吧!
什么是HMR
Hot Module Replacement(以下简称 HMR)是 webpack 发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。(注意不是浏览器刷新)。
webpack中使用HMR
只需要在webpack.config.js 加如下两句代码就可以实现热更新
- devServer: {hot:true}
- plugins:[new webpack.HotModuleReplacementPlugin()]
module.exports = {
mode: 'development',
entry: './src/index.js',
devtool: 'source-map',
output: {
filename: 'main.js',
path: path.join(__dirname,'dist')
},
devServer: {
hot:true
},
plugins:[
new HtmlWebpackPlugin(),
new webpack.HotModuleReplacementPlugin()
]
}
当发生热更新的时候我们在浏览器中看看发生了哪些变化
当第一次webpack打包完成之后,会生成一个hash发送到客户端。 当模块发生变化的时候。webpack回再次生成一个hash值。发送到客户端。客户端在发生热模块替换的时候,会拿到之前旧的hash值。去下载文件。从而实现热更新
浏览器根据hash会拿到一个json文件。从而知道哪些模块发生了变化
根据hash去下载相应变化的模块。通知代码进行更新
webpack中HMR的实现
在webpack中,HRM的实现原理如下图,可能现在看觉得有些复杂,接下来我们会根据图中的步骤一步步的去实现
- 首先我们会创建一个webpack实例(得到compiler对象有一些列关于webpack的方法和属性)
- 创建一个server服务器。
- 服务器创建成功后,去修改config中entry的配置。动态为entry中增加两个文件(webpack-dev-server/client.index.js 和 webpack/hot/index.js. 这两个文件的作用后续会讲到)
- 监听webapck的打包完成事件。(当后续文件发生改变的时候。能去触发模块更新)
- 以watch的模块,去监听文件的变化,当文件发生变化的时候,会去触发webpack的打包。输出到文件系统。这个时候就会触发 第四步
- 通过静态文件服务器将文件托管起来,这样我们在浏览器中可以访问文件
- 创建一个websocket服务器。当第四步完成的时候,也就是webpack打包完成之后,会生成相应的hash。 websocket会将hash推送到客户端
- 客户端通过websocket接受到hash的时候。就会去检查文件是否更新
- 当文件更新的时候。就会去下载相应的json文件。json文件里面会记录哪些文件会去更新,以及最新的hash
- 下载发生变化的文件,然后浏览器删掉缓存的文件,将最新的文件内容替换进去。最后执行accept去调用文件。这样我们的热更新就完成了 接下来我们会根据以上步骤,一步步去实现HRM
创建webpack实例和创建server
在package.json文件中。添加一下命令
"scripts": {
"dev": "node webpack-dev-server"
},
webpack-dev-server/index.js
const webpack = require('webpack')
const config = require('../webpack.config.js')
const complier = webpack(config) // 创建webpack的实例
const Server = require('./lib/server/index')
const server = new Server(complier) // 创建server服务器
server.listen(9090,'localhost',()=>{
console.log('服务已经启动')
})
webpack-dev-server/lib/server/index.js
const express = require('express')
const http = require('http')
const fs = require('fs-extra')
const path = require('path')
class Server {
constructor(compiler) {
this.compiler = compiler // 拿到webpack的实例
this.setupApp()
this.createServer()
}
setupApp() {
this.app =new express() // 创建express服务(主要是利用express的中间件功能)
}
createServer() {
this.server = http.createServer(this.app) // 创建http服务器。
}
listen(port,host,callback){
this.server.listen(port,host,callback)
}
}
module.exports = Server
这样我们就可以执行npm run dev ,访问9090端口,虽然我们现在什么文件也访问不了。
修改webpack入口文件的配置
在创建server的时候,去修改webpack的配置文件
class Server {
constructor(compiler) {
this.compiler = compiler // 拿到webpack的实例
updateCompiler(compiler)
}
}
lib/utils/updateCompiler.js文件
const path = require('path')
function updateCompiler(compiler) {
const config = compiler.options
config.entry = {
main:[
path.resolve(__dirname,'../../client/index.js'),
path.resolve(__dirname,'../../../webpack/hot/devServer.js'),
config.entry
]
}
}
module.exports = updateCompiler
主要是为了往入口注入了两个文件。用于热更新。后续会详细讲解这两个文件
监听webpack打包完成
主要是为了监听webpack打包完成的事件。每次webpack打包完成之后,就会触发这个事件,从而拿到最新的hash值,发送到客户端
class Server {
constructor(compiler) {
this.compiler = compiler
updateCompiler(compiler)
this.setupHooks()
}
setupApp() {
this.app =new express()
}
createServer() {
this.server = http.createServer(this.app)
}
setupHooks() {
let { compiler } = this
// 用开监听webpack打包完成。拿到相应的hash。从而去向客户端emit对应的hash
compiler.hooks.done.tap('webpack-dev-server',(stats)=>{
this.currentHash = stats.hash
this.clientSocketList.forEach(socket=>{
socket.emit('hash',this.currentHash)
socket.emit('ok')
})
})
}
webpack-dev-middleWare
这部分的主要逻辑是以watch模式为了打包我们的文件。 输出到我们的内存文件系统。实现一个静态文件服务器,使我们通过浏览器能够访问打包后的文件。
class Server {
constructor(compiler) {
this.compiler = compiler
updateCompiler(compiler)
this.setupApp()
this.currentHash;
this.clientSocketList = []
this.setupHooks()
this.setupDevMiddleWare()
this.routes()
this.createServer()
}
setupApp() {
this.app =new express()
}
createServer() {
this.server = http.createServer(this.app)
}
setupHooks() {
let { compiler } = this
compiler.hooks.done.tap('webpack-dev-server',(stats)=>{
this.currentHash = stats.hash
this.clientSocketList.forEach(socket=>{
socket.emit('hash',this.currentHash)
socket.emit('ok')
})
})
}
setupDevMiddleWare() {
this.middleWare = this.webpackDevMiddleware()
}
routes() {
let { compiler } = this
let config = compiler.options
// express的中间件。
this.app.use(this.middleWare(config.output.path))
}
webpackDevMiddleware() {
let { compiler } = this
// 当文件发生改变的时候,webpackjiu会重新编译
compiler.watch({},()=>{
console.log("监听模式")
})
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 filePath = path.join(staticDir,url)
console.log(filePath)
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)
}
} catch (error) {
return res.sendStatus(404)
}
}
}
}
listen(port,host,callback){
this.server.listen(port,host,callback)
}
}
module.exports = Server
创建websocket服务
这里创建websocket服务,是为了当webpack重新编译之后,将最新生成的hash值,发送到客户端。客户端通过hash去拉取相应的最新代码
class Server {
constructor(compiler) {
this.createServer()
}
createSocketServer() {
const io = socketIo(this.server) //实例化socket服务
io.on('connection',(socket)=>{
console.log('新客户端链接上')
this.clientSocketList.push(socket) // 维护socket
socket.emit('hash',this.currentHash) // 向客户端发送最新的hash值
socket.emit('ok')
socket.on('disconnect',()=>{
// 断开连接之后,就会删除相应的socket
let index = this.clientSocketList.indexOf(socket)
this.clientSocketList.splice(index,1)
})
})
}
}
走到这一步为们在服务端的代码就算基本完成。接下来我们去实现客户端的代码
客户端相关逻辑
在这里,我们首先创建两个文件 ./src/index.js
let input = document.createElement('input')
document.body.appendChild(input)
let div = document.createElement('div')
document.body.appendChild(div)
let render = () => {
let title = require('./title.js')
div.innerHTML = title
}
render()
if(module.hot){
module.hot.accept(['./title.js'],render)
}
./src/title.js
module.exports = 'title'
在经过webpack打包之后,对webpack打包之后的代码进行相关简化。我们可以得到代码。 想知道详细怎么实现的。可以参考这篇文章juejin.cn/post/690377…
(function (modules) {
let installedModules = {}
function __webpack_require__(modulesId) {
if (installedModules[modulesId]) {
return installedModules[modulesId]
}
let module = installedModules[modulesId] = {
i: modulesId,
l: false,
exports: {},
}
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.l = true
return module.exports
}
return __webpack_require__('./src/index.js')
})(
{
"./src/index.js": function (module, exports, __webpack_require__) {
__webpack_require__("webpack/hot/devServer.js")
__webpack_require__("webpack-dev-server/client/index.js")
let input = document.createElement('input')
document.body.appendChild(input)
let div = document.createElement('div')
document.body.appendChild(div)
let render = () => {
let title = __webpack_require__(/*! ./title.js */ "./src/title.js")
div.innerHTML = title
}
render()
if (true) {
module.hot.accept([/*! ./title.js */ "./src/title.js"], render)
}
},
"./src/title.js": function (module, exports) {
module.exports = 'title'
},
"webpack-dev-server/client/index.js": function (module, exports) {
},
"webpack/hot/devServer.js": function (module, exports) {
}
}
)
接下来我们实现webpack-dev-server/client/index.js
client/index.js
客户端主要是监听websocket事件,当有最新的hash生成的时候。触发相应的事件
let currentHash;
let lastHash;
class EventEmitter {
constructor() {
this.events = {}
}
on(eventName, fn) {
this.events[eventName] = fn
}
emit(eventName, ...args) {
this.events[eventName](...args)
}
};
let hotEmitter = new EventEmitter();
// EventEmitter 是自己实现了一个发布订阅者模式,这里你可以用一些第三方库来代替
const socket = window.io('/')
socket.on('hash', (hash) => {
currentHash = hash
})
//监听ok事件
socket.on('ok', () => {
console.log('ok')
reloadApp()
})
function reloadApp() {
// 发送一个事件。告诉发生了热更新
hotEmitter.emit('webpackHotUpdate')
}
这样我们client/index.js的逻辑就完成了
webpack/hot/devServer.js
当hash发生了变化。我们就会去调用module.hot.check()功能
hotEmitter.on('webpackHotUpdate', () => {
// console.log('check')
// 判断hash是否发生了变化
if(!lastHash) {
lastHash = currentHash
return
}
// hash发生了变化,就去调用热模块更新功能
module.hot.check()
})
check 和 accept
要想真正实现热模块更新,我们就必须维护模块之前的父子关系。当子模块发生变化的时候。我们就会通知父模块的accept方法,重新去加载子模块。所以接下来我们会对上面webpack打包的文件,进行相应的修改,让模块之间有相应的父子关系.并且实现模块的check和accept方法
function __webpack_require__(modulesId) {
if (installedModules[modulesId]) {
return installedModules[modulesId]
}
let module = installedModules[modulesId] = {
i: modulesId,
l: false,
exports: {},
hot:hotCreateModule(), // 实现模块的check方法
parents: [], // 用户维护模块之前的父子关系
children: []
}
modules[modulesId].call(module.exports, module, module.exports, hotCreateRequire(modulesId))
module.l = true
return module.exports
}
__webpack_require__.c = installedModules
return hotCreateRequire('./src/index.js')('./src/index.js')
// 主要实现模块的父子关系
function hotCreateRequire(parentsModuleId) {
let parentModule = installedModules[parentsModuleId]
// 说明是顶级模块。没有服模块
if (!parentModule) return __webpack_require__;
// 有父模块
let hotRequire = function (childModuleId) {
__webpack_require__(childModuleId)
let childModule = installedModules[childModuleId]
childModule.parents.push(parentModule)
parentModule.children.push(childModule)
// console.log(childModule)
return childModule.exports
}
return hotRequire
}
// 这里主要是为了实现模块的hot.accept方法以及check方法
function hotCreateModule() {
let hot = {
_acceptDependencies: {},
// 收集相关的依赖
accept(deps,callback) {
deps.forEach(dep => hot._acceptDependencies[dep]=callback);
},
check: hotcheck
}
return hot
}
这样我们就成功维护了模块之前的父子关系,方便后面我们进行热更新
hotcheck
当上面hash发生变化的时候,就会调用module.hot.check()方法。这里就是执行到我们的hotcheck
我们根据hash的变化。通过hash去寻找对应的json文件。利用ajax去下载文件。下载成功之后。我们去检查哪些文件发生了变化。去下载这些变化模块的最新代码
// 通过ajax去下载对应的json 文件
function hotDownloadManifest() {
return new Promise((resolve,reject)=>{
let xhr =new XMLHttpRequest()
let url = `${lastHash}.hot-update.json`
xhr.open('get',url)
xhr.responseType = 'json'
xhr.onload = function() {
resolve(xhr.response)
}
xhr.send()
})
}
function hotcheck() {
hotDownloadManifest().then(update=>{
let chunkIds = Object.keys(update.c)
chunkIds.forEach(chunkId=>{
hotDownloadUpdateChunk(chunkId)
})
lastHash = currentHash
}).catch(()=>{
window.location.reload()
})
}
hotDownloadUpdateChunk
拿到了变化的模块,去下载最新变化模块的代码
function hotDownloadUpdateChunk(chunkId) {
let script = document.createElement('script')
script.src = `${chunkId}.${lastHash}.hot-update.js`
document.head.appendChild(script)
}
webpackHotUpdate
当浏览器拿到了最新的代码。就会去执行webpackHotUpdate 方法 这里主要拿到最新的模块。记录下最新的模块代码
window.webpackHotUpdate = function(chunkId,moreModules) {
HotUpdateChunk(chunkId,moreModules)
}
let hotUpdate = {}
function HotUpdateChunk(chunkId,moreModules) {
for(moduleId in moreModules) {
modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId]
}
hotApply()
}
hotApply
拿到之前的旧模块。删除之前的旧模块。从而去渲染最新生成的模块。去执行对应的回调函数。这样我们就实现了热模块替换
function hotApply() {
for(moduleId in hotUpdate) {
let oldModule = installedModules[moduleId]
delete installedModules[moduleId]
// 拿到旧模块的父模块。去执行对应的回调
oldModule.parents.forEach(parentModule=>{
let cb = parentModule.hot._acceptDependencies[moduleId]
cb&&cb()
})
}
}
全部代码可以访问 gitee.com/yujun96/web…