上一篇Vite入门从手写一个乞丐版的Vite开始(上)咱们曾经胜利的将页面渲染进去了,这一篇咱们来简略的实现一下热更新的性能。
所谓热更新就是批改了文件,不必刷新页面,页面的某个局部就自动更新了,听着仿佛挺简略的,然而要实现一个很欠缺的热更新还是很简单的,要思考的状况很多,所以本文只会实现一个最根底的热更新成果。
创立WebSocket连贯
浏览器显然是不晓得文件有没有批改的,所以须要后端进行推送,咱们先来建设一个WebSocket
连贯。
// app.jsconst 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.jsconst clientPublicPath = "/client.js";app.use(async function (req, res, next) { // 提供html页面 if (req.url === "/index.html") { let html = readFile("index.html"); const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"</script>\n`; html = html.replace(/<head>/, `$&${devInjectionCode}`); send(res, html, "html"); }})
通过import
的形式引入,所以咱们须要拦挡一下这个申请:
// app.jsapp.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.jsconst 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.jsconst 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.jsapp.use(async function (req, res, next) { // 提供html页面 if (req.url === "/index.html") { let html = readFile("index.html"); // 查找模块依赖图 const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm; const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/; // 找出script标签 html = html.replace(scriptRE, (matched, openTag) => { const srcAttr = openTag.match(srcRE); if (srcAttr) { // 创立script到html的依赖关系 const importee = removeQuery(srcAttr[1] || srcAttr[2]); ensureMapEntry(importerMap, importee).add(removeQuery(req.url)); } return matched; }); // 注入client.js // ... }})
接下来咱们须要别离批改js
的拦挡办法,注册依赖关系;批改Vue
单文件的拦挡办法,注册js
局部的依赖关系,因为上一篇文章里咱们曾经把转换裸导入的逻辑都提取成一个公共函数parseBareImport
了,所以咱们只有批改这个函数就能够了:
// 解决裸导入// 减少了importer入参,req.urlconst parseBareImport = async (js, importer) => { await init; let parseResult = parseEsModule(js); let s = new MagicString(js); importer = removeQuery(importer);// ++ parseResult[0].forEach((item) => { let url = ""; if (item.n[0] !== "." && item.n[0] !== "/") { url = `/@module/${item.n}?import`; } else { url = `${item.n}?import`; } s.overwrite(item.s, item.e, url); // 注册importer模块所以依赖的模块到它的映射关系 ensureMapEntry(importerMap, removeQuery(url)).add(importer);// ++ }); return s.toString();};
再来减少一下后面提到的去除不再依赖的关系的逻辑:
// 解决裸导入const parseBareImport = async (js, importer) => { // ... importer = removeQuery(importer); // 上一次的依赖汇合 const prevImportees = importeeMap.get(importer);// ++ // 这一次的依赖汇合 const currentImportees = new Set();// ++ importeeMap.set(importer, currentImportees);// ++ parseResult[0].forEach((item) => { // ... let importee = removeQuery(url);// ++ // url -> 依赖 currentImportees.add(importee);// ++ // 依赖 -> url ensureMapEntry(importerMap, importee).add(importer); }); // 删除不再依赖的关系++ if (prevImportees) { prevImportees.forEach((importee) => { if (!currentImportees.has(importee)) { // importer不再依赖importee,所以要从importee的依赖汇合中删除importer const importers = importerMap.get(importee); if (importers) { importers.delete(importer); } } }); } return s.toString();};
Vue单文件的热更新
先来实现一下Vue
单文件的热更新,先监听一下Vue
单文件的扭转事件:
// app.js// 监听文件扭转watcher.on("change", (file) => { if (file.endsWith(".vue")) { handleVueReload(file); }});
如果批改的文件是以.vue
结尾,那么就进行解决,怎么解决呢,Vue
单文件会解析成js
、template
、style
三局部,咱们把解析数据缓存起来,当文件批改了当前会再次进行解析,而后别离和上一次的解析后果进行比拟,判断单文件的哪局部发生变化了,最初给浏览器发送不同的事件,由前端页面来进行不同的解决,缓存咱们应用lru-cache:
// app.jsconst LRUCache = require("lru-cache");// 缓存Vue单文件的解析后果const vueCache = new LRUCache({ max: 65535,});
而后批改一下Vue
单文件的拦挡办法,减少缓存:
// app.jsapp.use(async function (req, res, next) { if (/\.vue\??[^.]*$/.test(req.url)) { // ... // vue单文件 let descriptor = null; // 如果存在缓存则间接应用缓存 let cached = vueCache.get(removeQuery(req.url)); if (cached) { descriptor = cached; } else { // 否则进行解析,并且将解析后果进行缓存 descriptor = parseVue(vue).descriptor; vueCache.set(removeQuery(req.url), descriptor); } // ... }})
而后就来到handleVueReload
办法了:
// 解决Vue单文件的热更新const handleVueReload = (file) => { file = filePathToUrl(file);};// 解决文件门路到urlconst filePathToUrl = (file) => { return file.replace(/\\/g, "/").replace(/^\.\.\/test/g, "");};
咱们先转换了一下文件门路,因为监听到的是本地门路,和申请的url
是不一样的:
const handleVueReload = (file) => { file = filePathToUrl(file); // 获取上一次的解析后果 const prevDescriptor = vueCache.get(file); // 从缓存中删除上一次的解析后果 vueCache.del(file); if (!prevDescriptor) { return; } // 解析 let vue = readFile(file); descriptor = parseVue(vue).descriptor; vueCache.set(file, descriptor);};
接着获取了一下缓存数据,而后进行了这一次的解析,并更新缓存,接下来就要判断哪一部分产生了扭转。
热更新template
咱们先来看一下比较简单的模板热更新:
const handleVueReload = (file) => { // ... // 查看哪局部产生了扭转 const sendRerender = () => { sendMsg({ type: "vue-rerender", path: file, }); }; // template扭转了发送rerender事件 if (!isEqualBlock(descriptor.template, prevDescriptor.template)) { return sendRerender(); }}// 判断Vue单文件解析后的两个局部是否雷同function isEqualBlock(a, b) { if (!a && !b) return true; if (!a || !b) return false; if (a.src && b.src && a.src === b.src) return true; if (a.content !== b.content) return false; const keysA = Object.keys(a.attrs); const keysB = Object.keys(b.attrs); if (keysA.length !== keysB.length) { return false; } return keysA.every((key) => a.attrs[key] === b.attrs[key]);}
逻辑很简略,当template
局部产生扭转后向浏览器发送一个rerender
事件,带上批改模块的url
。
当初咱们来批改一下HelloWorld.vue
的template
看看:
能够看到曾经胜利收到了音讯。
接下来须要批改一下client.js
文件,减少收到vue-rerender
音讯后的解决逻辑。
文件更新了,浏览器必定须要申请一下更新的文件,Vite
应用的是import()
办法,然而这个办法js
自身是没有的,另外笔者没有找到是哪里注入的,所以加载模块的逻辑只能本人来简略实现一下:
// client.js// 回调idlet callbackId = 0;// 记录回调const callbackMap = new Map();// 模块导入后调用的全局办法window.onModuleCallback = (id, module) => { document.body.removeChild(document.getElementById("moduleLoad")); // 执行回调 let callback = callbackMap.get(id); if (callback) { callback(module); }};// 加载模块const loadModule = ({ url, callback }) => { // 保留回调 let id = callbackId++; callbackMap.set(id, callback); // 创立一个模块类型的script let script = document.createElement("script"); script.type = "module"; script.id = "moduleLoad"; script.innerHTML = ` import * as module from '${url}' window.onModuleCallback(${id}, module) `; document.body.appendChild(script);};
因为要加载的都是ES
模块,间接申请是不行的,所以创立一个type
为module
的script
标签,来让浏览器加载,这样申请都不必本人发,只有把想方法获取到模块的导出就行了,这个也很简略,创立一个全局函数即可,这个很像jsonp
的原理。
接下来就能够解决vue-rerender
音讯了:
// app.jssocket.addEventListener("message", async ({ data }) => { const payload = JSON.parse(data); handleMessage(payload);});const handleMessage = (payload) => { switch (payload.type) { case "vue-rerender": loadModule({ url: payload.path + "?type=template&t=" + Date.now(), callback: (module) => { window.__VUE_HMR_RUNTIME__.rerender(payload.path, module.render); }, }); break; }};
就这么简略,咱们来批改一下HelloWorld.vue
文件的模板来看看:
能够看到没有刷新页面,然而更新了,接下来具体解释一下原理。
因为咱们批改的是模板局部,所以申请的url
为payload.path + "?type=template
,这个源于上一篇文章里咱们申请Vue
单文件的模板局部是这么设计的,为什么要加个工夫戳呢,因为不加的话浏览器认为这个模块曾经加载过了,是不会从新申请的。
模板局部的申请后果如下:
导出了一个render
函数,这个其实就是HelloWorld.vue
组件的渲染函数,所以咱们通过module.render
来获取这个函数。
__VUE_HMR_RUNTIME__.rerender
这个函数是哪里来的呢,其实来自于Vue
,Vue
非生产环境的源码会提供一个__VUE_HMR_RUNTIME__
对象,顾名思义就是用于热更新的,有三个办法:
rerender
就是其中一个:
function rerender(id, newRender) { const record = map.get(id); if (!record) return; Array.from(record).forEach(instance => { if (newRender) { instance.render = newRender;// 1 } instance.renderCache = []; isHmrUpdating = true; instance.update();// 2 isHmrUpdating = false; });}
外围代码就是下面的1、2
两行,间接用新的渲染函数笼罩组件旧的渲染函数,而后触发组件更新就达到了热更新的成果。
另外要解释一下其中波及到的id
,须要热更新的组件会被增加到map
里,那怎么判断一个组件是不是须要热更新呢,也很简略,给它增加一个属性即可:
在mountComponent
办法里会判断组件是否存在__hmrId
属性,存在则认为是须要进行热更新的,那么就增加到map
里,注册办法如下:
这个__hmrId
属性须要咱们手动增加,所以须要批改一下之前拦挡Vue
单文件的办法:
// app.jsapp.use(async function (req, res, next) { if (/\.vue\??[^.]*$/.test(req.url)) { // vue单文件 // ... // 增加热更新标记 code += `\n__script.__hmrId = ${JSON.stringify(removeQuery(req.url))}`;// ++ // 导出 code += `\nexport default __script`; // ... }})
热更新js
趁热打铁,接下来看一下Vue
单文件中的js
局部产生了批改怎么进行热更新。
根本套路是一样的,查看两次的js
局部是否产生了批改了,批改了则向浏览器发送热更新音讯:
// app.jsconst handleVueReload = (file) => { const sendReload = () => { sendMsg({ type: "vue-reload", path: file, }); }; // js局部产生了扭转发送reload事件 if (!isEqualBlock(descriptor.script, prevDescriptor.script)) { return sendReload(); }}
js
局部产生扭转了就发送一个vue-reload
音讯,接下来批改client.js
减少对这个音讯的解决逻辑:
// client.jsconst handleMessage = (payload) => { switch (payload.type) { case "vue-reload": loadModule({ url: payload.path + "?t=" + Date.now(), callback: (module) => { window.__VUE_HMR_RUNTIME__.reload(payload.path, module.default); }, }); break; }}
和模板热更新很相似,只不过是调用reload
办法,这个办法会略微简单一点:
function reload(id, newComp) { const record = map.get(id); if (!record) return; Array.from(record).forEach(instance => { const comp = instance.type; if (!hmrDirtyComponents.has(comp)) { // 更新原组件 extend(comp, newComp); for (const key in comp) { if (!(key in newComp)) { delete comp[key]; } } // 标记为脏组件,在虚构DOM树patch的时候会间接替换 hmrDirtyComponents.add(comp); // 从新加载后勾销标记组件 queuePostFlushCb(() => { hmrDirtyComponents.delete(comp); }); } if (instance.parent) { // 强制父实例从新渲染 queueJob(instance.parent.update); } else if (instance.appContext.reload) { // 通过createApp()装载的根实例具备reload办法 instance.appContext.reload(); } else if (typeof window !== 'undefined') { window.location.reload(); } });}
通过正文应该能大略看进去它的原理,通过强制父实例从新渲染、调用根实例的reload
办法、通过标记为脏组件等等形式来从新渲染组件达到更新的成果。
style热更新
款式更新的状况比拟多,除了批改款式自身,还有作用域批改了、应用到了CSS
变量等状况,简略起见,咱们只思考批改了款式自身。
依据上一篇的介绍,Vue
单文件中的款式也是通过js
类型发送到浏览器,而后动态创建style
标签插入到页面,所以咱们须要能删除之前增加的标签,这就须要给增加的style
标签减少一个id
了,批改一下上一篇文章里咱们编写的insertStyle
办法:
// app.js// css to jsconst cssToJs = (css, id) => { return ` const insertStyle = (css) => { // 删除之前的标签++ if ('${id}') { let oldEl = document.getElementById('${id}') if (oldEl) document.head.removeChild(oldEl) } let el = document.createElement('style') el.setAttribute('type', 'text/css') el.id = '${id}' // ++ el.innerHTML = css document.head.appendChild(el) } insertStyle(\`${css}\`) export default insertStyle `;};
给style
标签减少一个id
,而后增加之前先删除之前的标签,接下来须要别离批改一下css
的拦挡逻辑减少removeQuery(req.url)
作为id
;以及Vue
单文件的style
局部的拦挡申请,减少removeQuery(req.url) + '-' + index
作为id
,要加上index
是因为一个Vue
单文件里可能有多个style
标签。
接下来持续批改handleVueReload
办法:
// app.jsconst handleVueReload = (file) => { // ... // style局部产生了扭转 const prevStyles = prevDescriptor.styles || [] const nextStyles = descriptor.styles || [] nextStyles.forEach((_, i) => { if (!prevStyles[i] || !isEqualBlock(prevStyles[i], nextStyles[i])) { sendMsg({ type: 'style-update', path: `${file}?import&type=style&index=${i}`, }) } })}
遍历新的款式数据,依据之前的进行比照,如果某个款式块之前没有或者不一样那就发送style-update
事件,留神url
须要带上import
及type=style
参数,这是上一篇里咱们规定的。
client.js
也要配套批改一下:
// client.jsconst handleMessage = (payload) => { switch (payload.type) { case "style-update": loadModule({ url: payload.path + "&t=" + Date.now(), }); break; }}
很简略,加上工夫戳从新加载一下款式文件即可。
不过还有个小问题,比方原来有两个style
块,咱们删掉了一个,目前页面上还是存在的,比方一开始存在两个style
块:
删掉第二个style
块,也就是设置背景色彩的那个:
能够看到还是存在,咱们是通过索引来增加的,所以更新后有多少个款式块,就会从头笼罩之前曾经存在的多少个款式块,最初多进去的是不会被删除的,所以须要手动删除不再须要的标签:
// app.jsconst handleVueReload = (file) => { // ... // 删除曾经被删掉的款式块 prevStyles.slice(nextStyles.length).forEach((_, i) => { sendMsg({ type: 'style-remove', path: file, id: `${file}-${i + nextStyles.length}` }) })}
发送一个style-remove
事件,告诉页面删除不再须要的标签:
// client.jsconst handleMessage = (payload) => { switch (payload.type) { case "style-remove": document.head.removeChild(document.getElementById(payload.id)); break; }}
能够看到被胜利删掉了。
一般js文件的热更新
最初咱们来看一下非Vue
单文件,一般js
文件更新后要怎么解决。
减少一个解决js
热更新的函数:
// app.js// 监听文件扭转watcher.on("change", (file) => { if (file.endsWith(".vue")) { handleVueReload(file); } else if (file.endsWith(".js")) {// ++ handleJsReload(file);// ++ }});
一般js
热更新就须要用到后面的依赖图数据了,如果监听到某个js
文件批改了,先判断它是否在依赖图中,不是的话就不必管,是的话就递归获取所有依赖它的模块,因为所有模块的最上层依赖必定是index.html
,如果只是简略的获取所有依赖模块再更新,那么每次都相当于要刷新整个页面了,所以咱们规定如果查看到某个依赖是Vue
单文件,那么就代表反对热更新,否则就相当于走到死胡同,须要刷新整个页面。
// 解决js文件的热更新const handleJsReload = (file) => { file = filePathToUrl(file); // 因为构建依赖图的时候有些是以相对路径援用的,而监听获取到的都是绝对路径,所以略微兼容一下 let importers = getImporters(file); // 遍历间接依赖 if (importers && importers.size > 0) { // 须要进行热更新的模块 const hmrBoundaries = new Set(); // 递归依赖图获取要更新的模块 const hasDeadEnd = walkImportChain(importers, hmrBoundaries); const boundaries = [...hmrBoundaries]; // 无奈热更新,刷新整个页面 if (hasDeadEnd) { sendMsg({ type: "full-reload", }); } else { // 能够热更新 sendMsg({ type: "multi",// 可能有多个模块,所以发送一个multi类型的音讯 updates: boundaries.map((boundary) => { return { type: "vue-reload", path: boundary, }; }), }); } }};// 获取模块的间接依赖模块const getImporters = (file) => { let importers = importerMap.get(file); if (!importers || importers.size <= 0) { importers = importerMap.get("." + file); } return importers;};
递归获取批改的js
文件的依赖模块,判断是否反对热更新,反对则发送热更新事件,否则发送刷新整个页面事件,因为可能同时要更新多个模块,所以通过type=multi
来标识。
看一下递归的办法walkImportChain
:
// 递归遍历依赖图const walkImportChain = (importers, hmrBoundaries, currentChain = []) => { for (const importer of importers) { if (importer.endsWith(".vue")) { // 依赖是Vue单文件那么反对热更新,增加到热更新模块汇合里 hmrBoundaries.add(importer); } else { // 获取依赖模块的再下层用来模块 let parentImpoters = getImporters(importer); if (!parentImpoters || parentImpoters.size <= 0) { // 如果没有下层依赖了,那么代表走到死胡同了 return true; } else if (!currentChain.includes(importer)) { // 通过currentChain来存储曾经遍历过的模块 // 递归再下层的依赖 if ( walkImportChain( parentImpoters, hmrBoundaries, currentChain.concat(importer) ) ) { return true; } } } } return false;};
逻辑很简略,就是递归遇到Vue
单文件就进行,否则持续遍历,直到顶端,代表走到死胡同。
最初再来批改一下client.js
:
// client.jssocket.addEventListener("message", async ({ data }) => { const payload = JSON.parse(data); // 同时须要更新多个模块 if (payload.type === "multi") {// ++ payload.updates.forEach(handleMessage);// ++ } else { handleMessage(payload); }});
如果音讯类型是multi
,那么就遍历updates
列表顺次调用解决办法:
// client.jsconst handleMessage = (payload) => { switch (payload.type) { case "full-reload": location.reload(); break; }}
vue-rerender
事件之前曾经有了,所以只须要减少一个刷新整个页面的办法即可。
测试一下,App.vue
外面引入一个test.js
文件:
// App.vue<script>import test from "./test.js";export default { data() { return { text: "", }; }, mounted() { this.text = test(); },};</script><template> <div> <p>{{ text }}</p> </div></template>
test.js
又引入了test2.js
:
// test.jsimport test2 from "./test2.js";export default function () { let a = test2(); let b = "我是测试1"; return a + " --- " + b;}// test2.jsexport 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
及热更新有一个根底的意识,其中必定有不合理或谬误之处,欢送指出~
示例代码在:https://github.com/wanglin2/vite-demo。