上一篇Vite入门从手写一个乞丐版的Vite开始(上)我们已经成功的将页面渲染出来了,这一篇我们来简单的实现一下热更新的功能。
所谓热更新就是修改了文件,不用刷新页面,页面的某个部分就自动更新了,听着似乎挺简单的,但是要实现一个很完善的热更新还是很复杂的,要考虑的情况很多,所以本文只会实现一个最基础的热更新效果。
创建WebSocket连接
浏览器显然是不知道文件有没有修改的,所以需要后端进行推送,我们先来建立一个WebSocket
连接。
// app.js
const server = http.createServer(app);
const WebSocket = require("ws");
// 创建WebSocket服务
const createWebSocket = () => {
// 创建一个服务实例
const wss = new WebSocket.Server({ noServer: true });// 不用额外创建http服务,直接使用我们自己创建的http服务
// 接收到http的协议升级请求
server.on("upgrade", (req, socket, head) => {
// 当子协议为vite-hmr时就处理http的升级请求
if (req.headers["sec-websocket-protocol"] === "vite-hmr") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
}
});
// 连接成功
wss.on("connection", (socket) => {
socket.send(JSON.stringify({ type: "connected" }));
});
// 发送消息方法
const sendMsg = (payload) => {
const stringified = JSON.stringify(payload, null, 2);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(stringified);
}
});
};
return {
wss,
sendMsg,
};
};
const { wss, sendMsg } = createWebSocket();
server.listen(3000);
WebSocket
和我们的服务共用一个http
请求,当接收到http
协议的升级请求后,判断子协议是否是vite-hmr
,是的话我们就把创建的WebSocket
实例连接上去,这个子协议是自己定义的,通过设置子协议,单个服务器可以实现多个WebSocket
连接,就可以根据不同的协议处理不同类型的事情,服务端的WebSocket
创建完成以后,客户端也需要创建,但是客户端是不会有这些代码的,所以需要我们手动注入,创建一个文件client.js
:
// client.js
// vite-hmr代表自定义的协议字符串
const socket = new WebSocket("ws://localhost:3000/", "vite-hmr");
socket.addEventListener("message", async ({ data }) => {
const payload = JSON.parse(data);
});
接下来我们把这个client.js
注入到html
文件,修改之前html
文件拦截的逻辑:
// app.js
const clientPublicPath = "/client.js";
app.use(async function (req, res, next) {
// 提供html页面
if (req.url === "/index.html") {
let html = readFile("index.html");
const devInjectionCode = `\n\n`;
html = html.replace(//, `$&${devInjectionCode}`);
send(res, html, "html");
}
})
通过import
的方式引入,所以我们需要拦截一下这个请求:
// app.js
app.use(async function (req, res, next) {
if (req.url === clientPublicPath) {
// 提供client.js
let js = fs.readFileSync(path.join(__dirname, "./client.js"), "utf-8");
send(res, js, "js");
}
})
可以看到已经连接成功。
监听文件改变
接下来我们要初始化一下对文件修改的监听,监听文件的改变使用chokidar:
// app.js
const chokidar = require(chokidar);
// 创建文件监听服务
const createFileWatcher = () => {
const watcher = chokidar.watch(basePath, {
ignored: [/node_modules/, /\.git/],
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 10,
},
});
return watcher;
};
const watcher = createFileWatcher();
watcher.on("change", (file) => {
// file文件修改了
})
构建导入依赖图
为什么要构建依赖图呢,很简单,比如一个模块改变了,仅仅更新它自己肯定还不够,依赖它的模块都需要修改才对,要做到这一点自然要能知道哪些模块依赖它才行。
// app.js
const importerMap = new Map();
const importeeMap = new Map();
// map : key -> set
// map : 模块 -> 依赖该模块的模块集合
const ensureMapEntry = (map, key) => {
let entry = map.get(key);
if (!entry) {
entry = new Set();
map.set(key, entry);
}
return entry;
};
需要用到的变量和函数就是上面几个,importerMap
用来存放模块
到依赖它的模块
之间的映射;importeeMap
用来存放模块
到该模块所依赖的模块
的映射,主要作用是用来删除不再依赖的模块,比如a
一开始依赖b
和c
,此时importerMap
里面存在b -> a
和c -> a
的映射关系,然后我修改了一下a
,删除了对c
的依赖,那么就需要从importerMap
里面也同时删除c -> a
的映射关系,这时就可以通过importeeMap
来获取到之前的a -> [b, c]
的依赖关系,跟此次的依赖关系a -> [b]
进行比对,就可以找出不再依赖的c
模块,然后在importerMap
里删除c -> a
的依赖关系。
接下来我们从index.html
页面开始构建依赖图,index.html
内容如下:
可以看到它依赖了main.js
,修改拦截html
的方法:
// app.js
app.use(async function (req, res, next) {
// 提供html页面
if (req.url === "/index.html") {
let html = readFile("index.html");
// 查找模块依赖图
const scriptRE = /(
{{ text }}
test.js
又引入了test2.js
:
// test.js
import test2 from "./test2.js";
export default function () {
let a = test2();
let b = "我是测试1";
return a + " --- " + b;
}
// test2.js
export default function () {
return '我是测试2'
}
接下来修改test2.js
测试效果:
可以看到重新发送了请求,但是页面并没有更新,这是为什么呢,其实还是缓存问题:
App.vue
导入的两个文件之前已经请求过了,所以浏览器会直接使用之前请求的结果,并不会重新发送请求,这要怎么解决呢,很简单,可以看到请求的App.vue
的url
是带了时间戳的,所以我们可以检查请求模块的url
是否存在时间戳,存在则把它依赖的所有模块路径也都带上时间戳,这样就会触发重新请求了,修改一下模块路径转换方法parseBareImport
:
// app.js
// 处理裸导入
const parseBareImport = async (js, importer) => {
// ...
// 检查模块url是否存在时间戳
let hast = checkQueryExist(importer, "t");// ++
// ...
parseResult[0].forEach((item) => {
let url = "";
if (item.n[0] !== "." && item.n[0] !== "/") {
url = `/@module/${item.n}?import${hast ? "&t=" + Date.now() : ""}`;// ++
} else {
url = `${item.n}?import${hast ? "&t=" + Date.now() : ""}`;// ++
}
// ...
})
// ...
}
再来测试一下:
可以看到成功更新了。最后我们再来测试运行刷新整个页面的情况,修改一下main.js
文件即可:
总结
本文参考Vite-1.0.0-rc.5
版本写了一个非常简单的Vite
,简化了非常多的细节,旨在对Vite
及热更新有一个基础的认识,其中肯定有不合理或错误之处,欢迎指出~