HMR是什么
使用场景
配置使用HMR
配置webpack
解析webpack打包后的文件内容
配置HMR
HMR原理
debug服务端源码
服务端简易实现
服务端调试阶段
debug客户端源码
客户端简易实现
客户端调试阶段
问题
总结
招聘
HMR
即Hot Module Replacement
是指当你对代码修改并保存后,webpack
将会对代码进行重新打包,并将改动的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,去实现局部更新页面而非整体刷新页面。接下来将从使用到实现一版简易功能带领大家深入浅出HMR
。
文章首发于@careteen/webpack-hmr,转载请注明来源即可。
scenario
如上图所示,一个注册页面包含用户名
、密码
、邮箱
三个必填输入框,以及一个提交
按钮,当你在调试邮箱
模块改动了代码时,没做任何处理情况下是会刷新整个页面,频繁的改动代码会浪费你大量时间去重新填写内容。预期是保留用户名
、密码
的输入内容,而只替换邮箱
这一模块。这一诉求就需要借助webpack-dev-server
的热模块更新功能。
相对于live reload
整体刷新页面的方案,HMR
的优点在于可以保存应用的状态,提高开发效率。
首先借助webpack
搭建项目
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
webpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development', // 开发模式不压缩代码,方便调试
entry: './src/index.js', // 入口文件
output: {
path: path.join(__dirname, 'dist'),
filename: 'main.js'
},
devServer: {
contentBase: path.join(__dirname, 'dist')
},
plugins: [
new htmlWebpackPlugin({
template: './src/index.html',
filename: 'index.html'
})
]
}
src/index.html
模板文件<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Webpack Hot Module Replacement</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
src/index.js
入口文件编写简单逻辑var root = document.getElementById('root')
function render () {
root.innerHTML = require('./content.js')
}
render()
src/content.js
导出字符供index渲染页面var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
package.json
"scripts": {
"dev": "webpack-dev-server",
"build": "webpack"
}
npm run dev
即可启动项目npm run build
打包生成静态资源到dist
目录接下来先分析下dist
目录中的文件
dist目录结构
.
├── index.html
└── main.js
index.html
内容如下<!-- ... -->
<div id="root"></div>
<script type="text/javascript" src="main.js"></script></body>
<!-- ... -->
使用html-webpack-plugin
插件将入口文件及其依赖通过script
标签引入
main.js
内容去掉注释和无关内容进行分析(function (modules) { // webpackBootstrap
// ...
})
({
"./src/content.js":
(function (module, exports) {
eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
}),
"./src/index.js": (function (module, exports, __webpack_require__) {
eval("var root = document.getElementById('root')\nfunction render () {\n root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
})
});
可见webpack打包后会产出一个自执行函数,其参数为一个对象
"./src/content.js": (function (module, exports) {
eval("...")
}
键为入口文件或依赖文件相对于根目录的相对路径,值则是一个函数,其中使用eval
执行文件的内容字符。
commonjs
规范(function (modules) {
// 模块缓存
var installedModules = {};
function __webpack_require__(moduleId) {
// 判断是否有缓存
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 没有缓存则创建一个模块对象并将其放入缓存
var module = installedModules[moduleId] = {
i: moduleId,
l: false, // 是否已加载
exports: {}
};
// 执行模块函数
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 将状态置为已加载
module.l = true;
// 返回模块对象
return module.exports;
}
// ...
// 加载入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
如果对上面
commonjs
规范感兴趣可以前往我的另一篇文章手摸手带你实现commonjs规范
给出上面代码主要是先对webpack的产出文件混个眼熟,不要惧怕。其实任何一个不管多复杂的事物都是由更小更简单的东西组成,剖开它认识它爱上它。
接下来配置并感受一下热更新带来的便捷开发
webpack.config.js
配置
// ...
devServer: {
hot: true
}
// ...
./src/index.js
配置
// ...
if (module.hot) {
module.hot.accept(['./content.js'], () => {
render()
})
}
当更改./content.js
的内容并保存时,可以看到页面没有刷新,但是内容已经被替换了。
这对提高开发效率意义重大。接下来将一层层剖开它,认识它的实现原理。
core
如上图所示,右侧Server
端使用webpack-dev-server
去启动本地服务,内部实现主要使用了webpack
、express
、websocket
。
使用express
启动本地服务,当浏览器访问资源时对此做响应。
服务端和客户端使用websocket
实现长连接
webpack
监听源文件的变化,即当开发者保存文件时触发webpack
的重新编译。
每次编译都会生成hash值
、已改动模块的json文件
、已改动模块代码的js文件
编译完成后通过socket
向客户端推送当前编译的hash戳
客户端的websocket
监听到有文件改动推送过来的hash戳
,会和上一次对比
一致则走缓存
不一致则通过ajax
和jsonp
向服务端获取最新资源
使用内存文件系统
去替换有修改的内容实现局部刷新
上图先只看个大概,下面将从服务端和客户端两个方面进行详细分析
core
现在也只需要关注上图的右侧服务端部分,左侧可以暂时忽略。下面步骤主要是debug服务端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。
1 . 启动webpack-dev-server
服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173
2 . 创建webpack实例,源代码地址@webpack-dev-server/webpack-dev-server.js#L89
3 . 创建Server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L107
4 . 添加webpack的done事件回调,源代码地址@webpack-dev-server/Server.js#L122
a. 编译完成向客户端发送消息,源代码地址@webpack-dev-server/Server.js#L184
5 . 创建express应用app,源代码地址@webpack-dev-server/Server.js#L123
6 . 设置文件系统为内存文件系统,源代码地址@webpack-dev-middleware/fs.js#L115 7 . 添加webpack-dev-middleware中间件,源代码地址@webpack-dev-server/Server.js#L125 a. 中间件负责返回生成的文件,源代码地址@webpack-dev-middleware/middleware.js#L20
8 . 启动webpack编译,源代码地址@webpack-dev-middleware/index.js#L51
9 . 创建http服务器并启动服务,源代码地址@webpack-dev-server/Server.js#L135
10 . 使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745
11 . 创建socket服务器,源代码地址@webpack-dev-server/SockJSServer.js#L34
上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合到一个文件中。
先导入所有依赖
const path = require('path') // 解析文件路径
const express = require('express') // 启动本地服务
const mime = require('mime') // 获取文件类型 实现一个静态服务器
const webpack = require('webpack') // 读取配置文件进行打包
const MemoryFileSystem = require('memory-fs') // 使用内存文件系统更快,文件生成在内存中而非真实文件
const config = require('./webpack.config') // 获取webpack配置文件
const compiler = webpack(config)
compiler代表整个webpack编译任务,全局只有一个
class Server {
constructor(compiler) {
this.compiler = compiler
}
listen(port) {
this.server.listen(port, () => {
console.log(`服务器已经在${port}端口上启动了`)
})
}
}
let server = new Server(compiler)
server.listen(8000)
在后面是通过express来当启动服务的
constructor(compiler) {
let sockets = []
let lasthash
compiler.hooks.done.tap('webpack-dev-server', (stats) => {
lasthash = stats.hash
// 每当新一个编译完成后都会向客户端发送消息
sockets.forEach(socket => {
socket.emit('hash', stats.hash) // 先向客户端发送最新的hash值
socket.emit('ok') // 再向客户端发送一个ok
})
})
}
webpack
编译后提供提供了一系列钩子函数,以供插件能访问到它的各个生命周期节点,并对其打包内容做修改。compiler.hooks.done
则是插件能修改其内容的最后一个节点。
编译完成通过socket
向客户端发送消息,推送每次编译产生的hash
。另外如果是热更新的话,还会产出二个补丁文件,里面描述了从上一次结果到这一次结果都有哪些chunk和模块发生了变化。
使用let sockets = []
数组去存放当打开了多个Tab时每个Tab的socket实例
。
let app = new express()
let fs = new MemoryFileSystem()
使用MemoryFileSystem
将compiler
的产出文件打包到内存中。
function middleware(req, res, next) {
if (req.url === '/favicon.ico') {
return res.sendStatus(404)
}
// /index.html dist/index.html
let filename = path.join(config.output.path, req.url.slice(1))
let stat = fs.statSync(filename)
if (stat.isFile()) { // 判断是否存在这个文件,如果在的话直接把这个读出来发给浏览器
let content = fs.readFileSync(filename)
let contentType = mime.getType(filename)
res.setHeader('Content-Type', contentType)
res.statusCode = res.statusCode || 200
res.send(content)
} else {
return res.sendStatus(404)
}
}
app.use(middleware)
使用expres启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件。
compiler.watch({}, err => {
console.log('又一次编译任务成功完成了')
})
以监控的模式启动一次webpack编译,当编译成功之后执行回调
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
// ...
}
listen(port) {
this.server.listen(port, () => {
console.log(`服务器已经在${port}端口上启动了`)
})
}
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
let io = require('socket.io')(this.server)
io.on('connection', (socket) => {
sockets.push(socket)
socket.emit('hash', lastHash)
socket.emit('ok')
})
}
启动一个 websocket服务器,然后等待连接来到,连接到来之后存进sockets池
当有文件改动,webpack重新编译时,向客户端推送hash
和ok
两个事件
感兴趣的可以根据上面debug服务端源码所带的源码位置,并在浏览器的调试模式下设置断点查看每个阶段的值。
node dev-server.js
使用我们自己编译的dev-server.js
启动服务,可看到页面可以正常展示,但还没有实现热更新。
下面将调式客户端的源代码分析其实现流程。
core
现在也只需要关注上图的左侧客户端部分,右侧可以暂时忽略。下面步骤主要是debug客户端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。
debug客户端源码分析其详细思路
1 . webpack-dev-server/client端会监听到此hash消息,源代码地址@webpack-dev-server/index.js#L54
2 . 客户端收到ok的消息后会执行reloadApp方法进行更新,源代码地址index.js#L101
3 . 在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器,源代码地址reloadApp.js#L7
4 . 在webpack/hot/dev-server.js会监听webpackHotUpdate事件,源代码地址dev-server.js#L55
5 . 在check方法里会调用module.hot.check方法,源代码地址dev-server.js#L13
6 . HotModuleReplacement.runtime请求Manifest,源代码地址HotModuleReplacement.runtime.js#L180
7 . 它通过调用 JsonpMainTemplate.runtime的hotDownloadManifest方法,源代码地址JsonpMainTemplate.runtime.js#L23
8 . 调用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码,源代码地址JsonpMainTemplate.runtime.js#L14
9 . 补丁JS取回来后会调用JsonpMainTemplate.runtime.js的webpackHotUpdate方法,源代码地址JsonpMainTemplate.runtime.js#L8
10 . 然后会调用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法动态更新模块代码,源代码地址HotModuleReplacement.runtime.js#L222
11 . 然后调用hotApply方法进行热更新,源代码地址HotModuleReplacement.runtime.js#L257、HotModuleReplacement.runtime.js#L278
上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合成一个文件。
在开发客户端功能之前,需要在src/index.html
中引入socket.io
<script src="/socket.io/socket.io.js"></script>
下面连接socket并接受消息
let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
console.log('客户端连接成功')
}
let hotCurrentHash // lastHash 上一次 hash值
let currentHash // 这一次的hash值
socket.on('hash', (hash) => {
currentHash = hash
})
将服务端webpack每次编译所产生hash
进行缓存
socket.on('ok', () => {
reloadApp(true)
})
// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
if (hot) { // 如果hot为true 走热更新的逻辑
hotEmitter.emit('webpackHotUpdate')
} else { // 如果不支持热更新,则直接重新加载
window.location.reload()
}
}
在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器。
首先需要一个发布订阅去绑定事件并在合适的时机触发。
class Emitter {
constructor() {
this.listeners = {}
}
on(type, listener) {
this.listeners[type] = listener
}
emit(type) {
this.listeners[type] && this.listeners[type]()
}
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
if (!hotCurrentHash || hotCurrentHash == currentHash) {
return hotCurrentHash = currentHash
}
hotCheck()
})
会判断是否为第一次进入页面和代码是否有更新。
上面的发布订阅较为简单,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
function hotCheck() {
hotDownloadManifest().then(update => {
let chunkIds = Object.keys(update.c)
chunkIds.forEach(chunkId => {
hotDownloadUpdateChunk(chunkId)
})
})
}
上面也提到过webpack每次编译都会产生hash值
、已改动模块的json文件
、已改动模块代码的js文件
,
此时先使用ajax
请求Manifest
即服务器这一次编译相对于上一次编译改变了哪些module和chunk。
然后再通过jsonp
获取这些已改动的module和chunk的代码。
function hotDownloadManifest() {
return new Promise(function (resolve) {
let request = new XMLHttpRequest()
//hot-update.json文件里存放着从上一次编译到这一次编译 取到差异
let requestPath = '/' + hotCurrentHash + ".hot-update.json"
request.open('GET', requestPath, true)
request.onreadystatechange = function () {
if (request.readyState === 4) {
let update = JSON.parse(request.responseText)
resolve(update)
}
}
request.send()
})
}
function hotDownloadUpdateChunk(chunkId) {
let script = document.createElement('script')
script.charset = 'utf-8'
// /main.xxxx.hot-update.js
script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
document.head.appendChild(script)
}
这里解释下为什么使用JSONP
获取而不直接利用socket
获取最新代码?主要是因为JSONP
获取的代码可以直接执行。
当客户端把最新的代码拉到浏览之后
window.webpackHotUpdate = function (chunkId, moreModules) {
// 循环新拉来的模块
for (let moduleId in moreModules) {
// 从模块缓存中取到老的模块定义
let oldModule = __webpack_require__.c[moduleId]
// parents哪些模块引用这个模块 children这个模块引用了哪些模块
// parents=['./src/index.js']
let {
parents,
children
} = oldModule
// 更新缓存为最新代码 缓存进行更新
let module = __webpack_require__.c[moduleId] = {
i: moduleId,
l: false,
exports: {},
parents,
children,
hot: window.hotCreateModule(moduleId)
}
moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
module.l = true // 状态变为加载就是给module.exports 赋值了
parents.forEach(parent => {
// parents=['./src/index.js']
let parentModule = __webpack_require__.c[parent]
// _acceptedDependencies={'./src/title.js',render}
parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
})
hotCurrentHash = currentHash
}
}
实现我们可以在业务代码中定义需要热更新的模块以及回调函数,将其存放在hot._acceptedDependencies
中。
window.hotCreateModule = function () {
let hot = {
_acceptedDependencies: {},
dispose() {
// 销毁老的元素
},
accept: function (deps, callback) {
for (let i = 0; i < deps.length; i++) {
// hot._acceptedDependencies={'./title': render}
hot._acceptedDependencies[deps[i]] = callback
}
}
}
return hot
}
然后在webpackHotUpdate
中进行调用
parents.forEach(parent => {
// parents=['./src/index.js']
let parentModule = __webpack_require__.c[parent]
// _acceptedDependencies={'./src/title.js',render}
parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
})
最后调用hotApply方法进行热更新
经过上述实现了一个基本版的HMR,可更改代码保存的同时查看浏览器并非整体刷新,而是局部更新代码进而更新视图。在涉及到大量表单的需求时大大提高了开发效率。
感兴趣的可前往debug CommonJs规范了解其实现原理。
webpack主要借助了
tapable
这个库所提供的一系列同步/异步钩子函数贯穿整个生命周期。基于此我实现了一版简易的webpack,源码100+行,食用时伴着注释很容易消化,感兴趣的可前往看个思路。
上面也提到需要使用到发布订阅模式,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
因为通过socket通信获取的是一串字符串需要再做处理。而通过
JSONP
获取的代码可以直接执行。
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/u6W3mMXnwGCncNyqaj6-hg
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。