阿里云优惠活动,点击链接进行购买: 一年仅需96.9元即可以购买服务器~
腾讯云优惠活动, 点击链接进行购买一年仅需99元
腾讯云限时开团活动, 点击链接进行购买一年仅需95元
热更新是已经是我们开发中必备的东西了,能够提高我们的开发效率。能够让浏览器不刷新的情况下进行更新内容。其实他的主要原理和 webpack 的 HMR 差不多。但是每个打包器都单独写了不同的接口,使得热更新模块无法共享,为此,snowpack 写了一个关于 ES Module hmr 的规范,https://github.com/pikapkg/esm-hmr 。
主要分为 client 和 server 端。
我们先从入口开始讲起。

我将 index.js复制了出来。
import * as __SNOWPACK_HMR__ from "/__snowpack__/hmr.js";
import.meta.hot = __SNOWPACK_HMR__.createHotContext(import.meta.url);
import __SNOWPACK_ENV__ from "/__snowpack__/env.js";
import.meta.env = __SNOWPACK_ENV__;
import { createApp } from "/web_modules/vue.js";
import App from "./App.js";
const app = createApp(App);
app.mount("#app");
// Hot Module Replacement (HMR) - Remove this snippet to remove HMR.
// Learn more: https://www.snowpack.dev/#hot-module-replacement
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => {
app.unmount();
});
}
我们可以看到import * as __SNOWPACK_HMR__ from '/__snowpack__/hmr.js';从第一句就可以看到引入了 hmr 模块,并且将 import.meta.url 作为 导入模块的一个方法参数传入。
那么这个 import.meta.url 是什么呢 ?
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import.meta
我们来看看 MDN 上的例子来解释。
import.meta是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象。它包含了这个模块的信息,比如说这个模块的 URL。
示例
这里有一个 my-module.mjs模块
<script type="module" src="my-module.mjs"></script>
你可以通过 import.meta 对象获取这个模块的元数据信息.
console.log(import.meta); // { url: "file:///home/user/my-module.mjs" }
通过以上例子我们知道 import.meta.url就是当前加载 js 的完整 url。
我们当前的 url 为 http://localhost:8080/,对应的 index.js 的 import.meta.url 就是 http://localhost:8080/_dist_/index.js 。
接着往下看,我们就看到使用了__SNOWPACK_HMR__.createHotContext 赋值给的 import.meta.hot对象。
那么我们先来看看 这个 createHotContext 方法都干了什么?
https://github.com/pikapkg/snowpack/blob/f1fd2cb181c1fe6ce2f158159a6fc3191e7456b4/snowpack/assets/hmr.js#L83-L94
// hmr.js 83-94
export function createHotContext(fullUrl) {
const id = new URL(fullUrl).pathname;
const existing = REGISTERED_MODULES[id];
if (existing) {
existing.lock();
runModuleDispose(id);
return existing;
}
const state = new HotModuleState(id);
REGISTERED_MODULES[id] = state;
return state;
}
首先是将传入的import.meta.url 构造了 URL 对象并获取了 pathname,以http://localhost:8080/_dist_/index.js为例,就是获取到了 _dist/_index.js,然后到一个名叫 REGISTERED_MODULES 模块中来查找该模块是否注册,如果注册的话,就会运行一段逻辑。我们目前是未注册的情况,以后再来讲解注册的情况。可以看到如果为注册就到了是 HotModuleState 这个类实例化的过程。然后将它注册到 REGISTERED_MODULES,并返回实例化的对象。
继续往下看 HotModuleState 。
class HotModuleState {
constructor(id) {
this.data = {};
this.isLocked = false;
this.isDeclined = false;
this.isAccepted = false;
this.acceptCallbacks = [];
this.disposeCallbacks = [];
this.id = id;
}
lock() {
this.isLocked = true;
}
dispose(callback) {
this.disposeCallbacks.push(callback);
}
invalidate() {
reload();
}
decline() {
this.isDeclined = true;
}
accept(_deps, callback = true) {
if (this.isLocked) {
return;
}
if (!this.isAccepted) {
sendSocketMessage({ id: this.id, type: "hotAccept" });
this.isAccepted = true;
}
if (!Array.isArray(_deps)) {
callback = _deps || callback;
_deps = [];
}
if (callback === true) {
callback = () => {};
}
const deps = _deps.map((dep) => {
const ext = dep.split(".").pop();
if (!ext) {
dep += ".js";
} else if (ext !== "js") {
dep += ".proxy.js";
}
return new URL(dep, `${window.location.origin}${this.id}`).pathname;
});
this.acceptCallbacks.push({
deps,
callback,
});
}
}
就这样暴露出了这么一个实例化的对象。
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => {
app.unmount();
});
}
看到,如果 hmr ,注册成功的话,会先调用 hmr 模块的 accept 方法,然后再调用 dispose 方法,而 dispose 方法中的回调是 vue 实例销毁的方法。那我们就来分析一下 accept 和 dispose方法到底干了什么。
accept(_deps, callback = true) {
if (this.isLocked) {
return;
}
if (!this.isAccepted) {
sendSocketMessage({id: this.id, type: 'hotAccept'});
this.isAccepted = true;
}
if (!Array.isArray(_deps)) {
callback = _deps || callback;
_deps = [];
}
if (callback === true) {
callback = () => {};
}
const deps = _deps.map((dep) => {
const ext = dep.split('.').pop();
if (!ext) {
dep += '.js';
} else if (ext !== 'js') {
dep += '.proxy.js';
}
return new URL(dep, `${window.location.origin}${this.id}`).pathname;
});
this.acceptCallbacks.push({
deps,
callback,
});
}
我们的例子中是直接调用了 accept,并且未传入任何的参数,因此看到最后的this.acceptCallbacks.push({deps,callback,}); 此处应该为this.acceptCallbacks.push({[],()=>{},}); 。
再来看看dispose。
dispose(callback) {
this.disposeCallbacks.push(callback);
}
这个方法更加简单,就是将我们注册的app.unmoun()的回调函数加入了一个callback队列 。
以上就是初始化的所有,看起来并没有什么特别之处。接下来我们来讲一讲,如果是模块热更新后,第二次导入会发生什么? 我们继续回过头来看 createHotContext 模块。
export function createHotContext(fullUrl) {
...
if (existing) {
existing.lock();
runModuleDispose(id);
return existing;
}
...
}
主要逻辑为runModuleDispose(id);,我们接着来看~
async function runModuleDispose(id) {
const state = REGISTERED_MODULES[id];
if (!state) {
return false;
}
if (state.isDeclined) {
return false;
}
const disposeCallbacks = state.disposeCallbacks;
state.disposeCallbacks = [];
state.data = {};
disposeCallbacks.map((callback) => callback());
return true;
}
state 即为上面所说的 HotModuleState 实例化对象,获取 disposeCallbacks 队列,这个就是我们第一次初始化的时候,将回调函数塞入的队列。disposeCallbacks.map((callback) => callback());最后将队列中的回调函数依次执行。
上面描述的有点抽象,下面用途来描述一下主要的流程。

之后文件热更新,不断循环 5 - 10。
上面我们就讲完了前端部分的热更新,但是我们还没有讲 第一次文件加载以及后面文件改动后的第二次文件加载进行串联,这个就需要后端来进行串联。前端与后端建立 websocket 通信,后端通知前端去加载对应的文件。那么下面我们来讲讲这个桥梁部分。
那我们来看看后端 HMR 模块
https://github.com/pikapkg/snowpack/blob/f1fd2cb181c1fe6ce2f158159a6fc3191e7456b4/snowpack/src/hmr-server-engine.ts
包含空行一共 139 行代码。
import WebSocket from 'ws';
import type http from 'http';
import type http2 from 'http2';
interface Dependency {
dependents: Set<string>;
dependencies: Set<string>;
isHmrEnabled: boolean;
isHmrAccepted: boolean;
needsReplacement: boolean;
needsReplacementCount: number;
}
const DEFAULT_PORT = 12321;
export class EsmHmrEngine {
clients: Set<WebSocket> = new Set();
dependencyTree = new Map<string, Dependency>();
wsUrl = `ws://localhost:${DEFAULT_PORT}`;
constructor(options: {server?: http.Server | http2.Http2Server} = {}) {
// 构造函数
const wss = options.server
? new WebSocket.Server({noServer: true})
: new WebSocket.Server({port: DEFAULT_PORT});
if (options.server) {
options.server.on('upgrade', (req, socket, head) => {
// Only handle upgrades to ESM-HMR requests, ignore others.
if (req.headers['sec-websocket-protocol'] !== 'esm-hmr') {
return;
}
wss.handleUpgrade(req, socket, head, (client) => {
wss.emit('connection', client, req);
});
});
}
// 监听连接
wss.on('connection', (client) => {
this.connectClient(client);
this.registerListener(client);
});
}
// 注册消息事件
registerListener(client: WebSocket) {
client.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message.type === 'hotAccept') {
const entry = this.getEntry(message.id, true) as Dependency;
entry.isHmrAccepted = true;
entry.isHmrEnabled = true;
}
});
}
createEntry(sourceUrl: string) {
const newEntry: Dependency = {
dependencies: new Set(),
dependents: new Set(),
needsReplacement: false,
needsReplacementCount: 0,
isHmrEnabled: false,
isHmrAccepted: false,
};
this.dependencyTree.set(sourceUrl, newEntry);
return newEntry;
}
getEntry(sourceUrl: string, createIfNotFound = false) {
const result = this.dependencyTree.get(sourceUrl);
if (result) {
return result;
}
if (createIfNotFound) {
return this.createEntry(sourceUrl);
}
return null;
}
// 依赖关系处理
setEntry(sourceUrl: string, imports: string[], isHmrEnabled = false) {
const result = this.getEntry(sourceUrl, true)!;
const outdatedDependencies = new Set(result.dependencies);
result.isHmrEnabled = isHmrEnabled;
for (const importUrl of imports) {
this.addRelationship(sourceUrl, importUrl);
outdatedDependencies.delete(importUrl);
}
for (const importUrl of outdatedDependencies) {
this.removeRelationship(sourceUrl, importUrl);
}
}
removeRelationship(sourceUrl: string, importUrl: string) {
let importResult = this.getEntry(importUrl);
importResult && importResult.dependents.delete(sourceUrl);
const sourceResult = this.getEntry(sourceUrl);
sourceResult && sourceResult.dependencies.delete(importUrl);
}
addRelationship(sourceUrl: string, importUrl: string) {
if (importUrl !== sourceUrl) {
let importResult = this.getEntry(importUrl, true)!;
importResult.dependents.add(sourceUrl);
const sourceResult = this.getEntry(sourceUrl, true)!;
sourceResult.dependencies.add(importUrl);
}
}
markEntryForReplacement(entry: Dependency, state: boolean) {
if (state) {
entry.needsReplacementCount++;
} else {
entry.needsReplacementCount--;
}
entry.needsReplacement = !!entry.needsReplacementCount;
}
broadcastMessage(data: object) {
this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
} else {
this.disconnectClient(client);
}
});
}
// 将连接加入连接池
connectClient(client: WebSocket) {
this.clients.add(client);
}
disconnectClient(client: WebSocket) {
client.terminate();
this.clients.delete(client);
}
disconnectAllClients() {
for (const client of this.clients) {
this.disconnectClient(client);
}
}
}
以上代码我就详细讲一讲,我把以上主要分为三个部分,第一部分为初始化阶段的连接池,第二部分为消息注册部分,第三部分是依赖处理部分。
初始化阶段
wss.on('connection', (client) => {
this.connectClient(client);
this.registerListener(client);
});
...
connectClient(client: WebSocket) {
this.clients.add(client);
}
...
这里我们可以看到,连接的时候是将 client 放入了一个队列,这个是为啥呢?是由于当我们本地开发的时候,会开启多个 tab 页面,或者多个浏览器启动同一个页面,我们的服务都会监听到,当本地代码有改动的时候,所有页面都会自动热更新。怎么样?贴心吧,这个是一个常见的操作,特别是在我们多端设备登录的时候,例如电脑版微信和客户端版本,我们在双端都能收到最新的消息,也就是利用了这个特性。
消息注册部分
...
registerListener(client: WebSocket) {
client.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message.type === 'hotAccept') {
const entry = this.getEntry(message.id, true) as Dependency;
entry.isHmrAccepted = true;
entry.isHmrEnabled = true;
}
});
}
...
为什么我把这部分单独拎出来讲呢?这部分其实很简单,就是注册了一个messages 事件,等待客户端发送信息给服务端,但是这里也涉及到了一个部分 message.type === 'hotAccept' 当这个条件成立的时候,会去影响到我们的第三个部分,这里我们只需要注意一下就行,后面会详细讲到。
entry.isHmrAccepted = true;
entry.isHmrEnabled = true;
这两个是特殊标志。
依赖处理部分
这个部分也是热更新最重要的部分,决定着热更新的更新范围。你可能会比较懵,热更新又设计依赖收集了呢?不急先听我道来。
例如我们有以下结构
./
├── index.js
└── src
├── a.js
└── b.js
index.js 引入a.js ,a.js 引入b.js 。
这个时候我们修改 b.js ,如果我们不依赖分析,那么我们会一股脑儿去刷新 index.js ,因为只要index.js重新载入 ,那么我们 a.js 和 b.js 也会被重新导入,从而达到 b.js 也更新的效果。
而我们有时候期望更新点达到最小化,b.js 更新,我不期望整个应用去更新。我们只需要找到 b.js 从属的 a.js 更新就好了。
以下用图形化来解释,b.js 更新冒泡到 index.js 是我们所不期望。

而b.js 的修改冒泡到 a.js 是我们所期望的。

而b.js 冒泡到 a.js是我们所期望的。
以上我们讲清楚了,为什么需要这个第三部分。
那么我们来讲讲这个第三部分依赖关系是怎么样的。
由于这个依赖关系的调用是在构建阶段,因此我们这边不会去讲构建阶段相关信息。我们就单从这几个函数来进行推测模拟。我们可以将createEntry,getEntry,setEntry,removeRelationship,addRelationship这些依赖相关的函数都搜一下,最终我们会发现源头来源于 setEntry,因此我们就可以把这个函数当做入口。
setEntry(sourceUrl: string, imports: string[], isHmrEnabled = false) {
const result = this.getEntry(sourceUrl, true)!;
const outdatedDependencies = new Set(result.dependencies);
result.isHmrEnabled = isHmrEnabled;
for (const importUrl of imports) {
this.addRelationship(sourceUrl, importUrl);
outdatedDependencies.delete(importUrl);
}
for (const importUrl of outdatedDependencies) {
this.removeRelationship(sourceUrl, importUrl);
}
}
为了方便理解,我们会用例子带着来理解,还记得一开始的例子吗。将上述代码经过解析就是如下模样。
sourceUrl: /_dist_/index.js
imports: [ '/__snowpack__/hmr.js',
'/__snowpack__/env.js',
'/web_modules/vue.js',
'/_dist_/App.js' ]
isHmrEnabled: true
这样理解起来是不是非常的容易呢?源码为什么难懂,只是因为源码代码量太大,我们没办法一下记住那么多个变量以及流程。如果我们肉眼直接能看出某些变化,那源码阅读就很简单了。好了我们接着讲。
首先是通过 this.getEntry 去获取当前路径资源的依赖实例,如果没有找到则会去创建一个实例。依赖实例初始化长这个样子。
const newEntry: Dependency = {
dependencies: new Set(),
dependents: new Set(),
needsReplacement: false,
needsReplacementCount: 0,
isHmrEnabled: false,
isHmrAccepted: false,
};
接下来就会会遍历 imports 建立依赖关系。
这个时候我们会发现有这么个步骤
const outdatedDependencies = new Set(result.dependencies);
result.isHmrEnabled = isHmrEnabled;
for (const importUrl of imports) {
this.addRelationship(sourceUrl, importUrl);
outdatedDependencies.delete(importUrl);
}
for (const importUrl of outdatedDependencies) {
this.removeRelationship(sourceUrl, importUrl);
}
这个是在做什么呢。这里主要是讲老的依赖进行删除,然后增加新的依赖,类似于一个简版 diff。

例如我们的 index.js 旧的依赖的是
[ '/__snowpack__/hmr.js',
'/__snowpack__/env.js',
'/web_modules/vue.js',
'/_dist_/App.js' ]
新的依赖为
[ '/__snowpack__/env.js',
'/web_modules/vue.js',
'/_dist_/Demo.js' ]
那么就会加入 '/_dist_/Demo.js' 删减 '/__snowpack__/hmr.js'、'/_dist_/App.js'。
我们已经和前端建立了联系,那么,什么时候去发送消息呢?这个时候我们就要来看看,我们的文件监听模块。
https://github.com/pikapkg/snowpack/blob/f1fd2cb181c1fe6ce2f158159a6fc3191e7456b4/snowpack/src/commands/dev.ts#L901
const chokidar = await import('chokidar');作者主要用来了 chokidar 来进行监听本地文件的改动。这个库能够高性能地去使用文件监听。

像 VS Code、gulp、karma、pm2、webpack 监听文件改动也是使用了这个库。
async function onWatchEvent(fileLoc) {
logger.info(colors.cyan("File changed..."));
handleHmrUpdate(fileLoc);
inMemoryBuildCache.delete(fileLoc);
filesBeingDeleted.add(fileLoc);
await cacache.rm.entry(BUILD_CACHE, fileLoc);
filesBeingDeleted.delete(fileLoc);
}
const watcher = chokidar.watch(
mountedDirectories.map(([dirDisk]) => dirDisk),
{
ignored: config.exclude,
persistent: true,
ignoreInitial: true,
disableGlobbing: false,
}
);
watcher.on("add", (fileLoc) => onWatchEvent(fileLoc));
watcher.on("change", (fileLoc) => onWatchEvent(fileLoc));
watcher.on("unlink", (fileLoc) => onWatchEvent(fileLoc));
如果文件改动了、增加、删除,都会触发 onWatchEvent 这个事件。
async function onWatchEvent(fileLoc) {
logger.info(colors.cyan("File changed..."));
handleHmrUpdate(fileLoc);
inMemoryBuildCache.delete(fileLoc);
filesBeingDeleted.add(fileLoc);
await cacache.rm.entry(BUILD_CACHE, fileLoc);
filesBeingDeleted.delete(fileLoc);
}
我们先来看看handleHmrUpdate 这个函数。
function handleHmrUpdate(fileLoc: string) {
if (isLiveReloadPaused) {
return;
}
let updateUrl = getUrlFromFile(mountedDirectories, fileLoc, config);
if (!updateUrl) {
return;
}
// Append ".proxy.js" to Non-JS files to match their registered URL in the client app.
if (!updateUrl.endsWith(".js")) {
updateUrl += ".proxy.js";
}
// Check if a virtual file exists in the resource cache (ex: CSS from a Svelte file)
// If it does, mark it for HMR replacement but DONT trigger a separate HMR update event.
// This is because a virtual resource doesn't actually exist on disk, so we need the main
// resource (the JS) to load first. Only after that happens will the CSS exist.
// 检查资源缓存中是否存在一个虚拟文件(例如:来自Svelte文件的CSS),如果存在,将其标记为HMR替换,但不要触发一个单独的HMR更新事件。资源(JS)先加载。只有在这之后,CSS才会存在。
// 为什么呢?因为资源只有在请求 .vue 这样的文件找到后,才会进行构建生成新的 css。
const virtualCssFileUrl = updateUrl.replace(/.js$/, ".css");
const virtualNode = hmrEngine.getEntry(`${virtualCssFileUrl}.proxy.js`);
if (virtualNode) {
hmrEngine.markEntryForReplacement(virtualNode, true);
}
// If the changed file exists on the page, trigger a new HMR update.
if (hmrEngine.getEntry(updateUrl)) {
updateOrBubblejs(updateUrl, new Set());
return;
}
if (inMemoryBuildCache.has(fileLoc)) {
hmrEngine.broadcastMessage({ type: "reload" });
return;
}
}
其中又涉及到 getUrlFromFile ,这个函数的作用是,将本地的文件,映射到网络的 url 。例如 本地 src/App.vue 映射到 _dist_/App.js。然后接下来步骤是尝试看看当前路径是否为虚拟文件(所谓虚拟文件就是不实际存在,是一种 hack 的形式,例如 ES Modules 无法直接导入 css 等文件)。如果是虚拟文件的话,作一个标记,等下载虚拟文件的承载实体请求到的时候,再进行更新。如果不是上述情况,那么继续运行下面的步骤,如果在热更新模块中能找到当前的构建 url,则调用 updateOrBubblejs 。
这个函数就和它的字面意思一样,会往上冒泡,如果当前模块没有注册热更新模块,那么该模块会往上找他的父级,如果父级找不到就会找爷爷级。那么为什么会有这样的冒泡呢?原因我们在说热更新依赖的时候说了,最小化就近更新原则。如果找不到的话会调用 hmrEngine.broadcastMessage({type: 'reload'});, 就是刷新整个页面。handleHmrUpdate 函数最后也是如此,也会调用 hmrEngine.broadcastMessage({type: 'reload'});
最后清空当前文件的缓存,能够让下次请求的时候重新构建,而不是寻找缓存模块。
inMemoryBuildCache.delete(fileLoc);
filesBeingDeleted.add(fileLoc);
await cacache.rm.entry(BUILD_CACHE, fileLoc);
filesBeingDeleted.delete(fileLoc);
至此热更新的所有模块都讲完了。
前端 -> 后端 -> 文件改动 -> 后端监听 -> 通知前端