Vite
vite介绍
vite是一种新型前端构建工具,它提供两部分功能:
vite相较于传统的构建工具(例如webpack),最大的特点就是速度快,无论的冷启动还是热更新,速度都非常的快。
为什么vite那么快
vite的速度快体现在冷启动和热更新上。
预构建
vite将代码分为源码和依赖两部分,源码就是用户开发的源代码,而依赖通常是引入的第三方包。
vite会对依赖执行依赖预构建,vite使用了esbuild来实现依赖预构建,并且会将结果缓存到/node_modules/.vite/
目录下,只有一些特殊情况才会重新预构建(例如修改vite配置文件)。
预构建主要做了两件事,一是将CommonJs模块和UMD模块转换成ESM模块,二是重写裸模块地址。预构建可以加快页面的加载速度。
ESM
vite之所以要将CommonJs模块和UMD模块转换成ESM模块,这是因为vite自身的机制就是借助ESM来承担一部分打包工作。
首先在index.html中使用ESM,引入入口文件
<script type="module" src="/src/main.tsx"></script>
浏览器会识别ESM,自动加载main.tsx
文件,然后会将main.tsx
中import
的文件也加载了,这样就可以将所有依赖的文件都加载下来。
也就是说vite会通过ESM的形式提供源码,让浏览器承担了一部分打包工作,而vite只需要在这个过程中做一些处理工作,例如将非JavaScript文件内容转换成JavaScript能够理解的代码,转换导入文件的地址等。
采用这种方式很明显的一个好处是不需要vite自己去构建依赖关系,并且加载时只加载使用的依赖和源码,没有用到的依赖或源码不会被加载。
Http缓存
前面说过vite通过ESM加载文件,而在这个过程vite还借助了http缓存来实现加载优化。
前面说过vite会将应用中的模块分为两类:源码和依赖,对于依赖而言,基本上是不会变动的(例如你引入的组件库),如果每个更新时都处理成本会很高,因此vite会对依赖模块使用强缓存Cache-Control: max-age=31536000,immutable
。
对于源码而言,这部分可能是会经常改变的,因此会使用协商缓存cache-control: no-cache
。
冷启动
vite在开发环境下会启动一台node服务器来提供源码和进行其他操作,vite只会在浏览器请求源码时进行转换并按需提供源码,而对于不会使用到的源码则不会进行处理,而webpack则会将源码和依赖全部构建成bundle,因此启动时速度非常慢。
热更新
在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite会将被编辑的模块重新导入(import()
),并且还借助http缓存优化模块加载策略,依赖使用了强缓存不会被重新加载,而源码也只会对更改过的文件进行重新加载,使得无论应用大小如何,HMR 始终能保持快速更新。
Vite client原理分析
vite组成
vite由client和node两部分组成,分别是客户端和服务器端。
热更新
vite的热更新是依靠websocket来实现的。vite其实可以看做是一台静态资源服务器,它会监听项目源码文件的改动,如果有文件改动了,则通过ws通知客户端执行对应的回调,回调中会通过import()
+时间缀的方式重新加载改动后的代码文件。
/* /src/client/client.ts */
// 创建websocket连接
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
// 监听服务器消息,从而执行相应的回调
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data))
})
/* data的数据格式: {
"type": "update",
"updates": [
{
"type": "js-update",
"timestamp": 1650103650496,
"path": "/src/App.tsx",
"acceptedPath": "/src/App.tsx"
}
]
}
*/
type有以下几种值
full-reload 全更新,即刷新页面,浏览器当前所在的html文件被更改时执行
我们通常更关心update的处理。
async function handleMessage(payload: HMRPayload) {
// ...
switch (payload.type) {
case 'update':
notifyListeners('vite:beforeUpdate', payload)
/*
如果这是第一次更新,并且已经存在error overlay,则
表示打开的页面存在现有服务器编译错误,并且整个
模块脚本无法加载(因为其中一个模块导入为 500)。
在这种情况下,正常更新将不起作用,需要完全重新加载。
*/
if (isFirstUpdate && hasErrorOverlay()) {
window.location.reload()
return
} else {
clearErrorOverlay() // 清除错误覆盖,就是出现错误时的提示界面
isFirstUpdate = false
}
payload.updates.forEach((update) => {
// update type只有两种:css-update和js-update
// 其中css-update只针对被link标签引入的css
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {
const { path, timestamp } = update
const searchUrl = cleanUrl(path)
// 不能使用`[href*=]`,因为href可能使用了相对路径
// 这里需要使用link.href来获取完整路径
const el = Array.from(
document.querySelectorAll<HTMLLinkElement>('link')
).find((e) => cleanUrl(e.href).includes(searchUrl))
if (el) {
const newPath = `${base}${searchUrl.slice(1)}${
searchUrl.includes('?') ? '&' : '?'
}t=${timestamp}`
el.href = new URL(newPath, el.href).href
}
console.log(`[vite] css hot updated: ${searchUrl}`)
}
})
break
}
css-update只针对通过link引入的css文件,加入是通过import导入css文件,那么仍然是通过js-update来更新。
可以看到vite拿到了update信息后是先执行了fetchUpdate
函数,接着又执行了queueUpdate
函数,接下来我们看一下这两个函数做了什么。
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
// hotModulesMap保存了所有被加载的模块信息
// mod:{ id: 文件地址, callbacks: [{ deps: 依赖文件路径数组, fn: 定义的回调函数 }] }
const mod = hotModulesMap.get(path)
if (!mod) {
// In a code-splitting project,
// it is common that the hot-updating module is not loaded yet.
// https://github.com/vitejs/vite/issues/721
return
}
// 模块信息
const moduleMap = new Map()
// 是否是自身更新
const isSelfUpdate = path === acceptedPath
// 借助Set的去重特性,确保每个依赖只被导入一次
const modulesToUpdate = new Set<string>()
if (isSelfUpdate) {
// 如果是自身更新,则只需要将自身path添加即可
modulesToUpdate.add(path)
} else {
// 依赖更新时
for (const { deps } of mod.callbacks) {
deps.forEach((dep) => {
if (acceptedPath === dep) {
modulesToUpdate.add(dep)
}
})
}
}
// 在重新导入模块之前找出对应的回调
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
return deps.some((dep) => modulesToUpdate.has(dep))
})
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
const disposer = disposeMap.get(dep)
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
const newMod = await import(
/* @vite-ignore */
base +
path.slice(1) +
`?import&t=${timestamp}${query ? `&${query}` : ''}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
})
)
return () => {
// 执行回调
for (const { deps, fn } of qualifiedCallbacks) {
fn(deps.map((dep) => moduleMap.get(dep)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.log(`[vite] hot updated: ${loggedPath}`)
}
}
/**
使用队列来确保以与发送加载时相同的顺序来执行fn
*/
async function queueUpdate(p) {
queued.push(p);
if (!pending) {
pending = true;
await Promise.resolve();
pending = false;
const loading = [...queued];
queued = [];
(await Promise.all(loading)).forEach((fn) => fn && fn());
}
}
fetchUpdate
借助import()
实现了模块的重新导入,而queueUpdate
则是调用了回调函数来更新渲染,这里使用了队列结构,因为http响应模块文件的顺序不一定就是模块的请求顺序,所以需要用队列确保以与发送加载时相同的顺序来重新渲染。
接下来我们看看浏览器通过ESM请求模块时,vite对模块文件做了什么处理。
我们创建一个vue文件,并导入一些模块
import { ref } from "vue";
import { test } from "../utils/common";
刷新重新加载,可以在看到浏览器请求得到的文件是这样的:
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/components/HelloWorld.vue");
import {ref} from "/node_modules/.vite/deps/vue.js?v=4ffd74a8";
import {test} from "/src/utils/common.js";
// ...
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(),
_createElementBlock(_Fragment, null, [_createElementVNode("h1", null, _toDisplayString($props.msg), 1 /* TEXT */
), _hoisted_1, _hoisted_2, _createElementVNode("button", {
type: "button",
onClick: _cache[0] || (_cache[0] = $event=>($setup.count++))
}, "count is: " + _toDisplayString($setup.count), 1 /* TEXT */
), _hoisted_3], 64 /* STABLE_FRAGMENT */
))
}
_sfc_main.__hmrId = "469af010"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)
import.meta.hot.accept(({default: updated, _rerender_only})=>{
if (_rerender_only) {
__VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)
} else {
__VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)
}
}
)
可以看出
vite在每个模块中都添加了createHotContext
函数,并且将自身模块路径作为参数传入执行。
vite通过import.meta.hot.accept
导入了渲染该模块的方法
既然使用到了createHotContext
函数,那我们就来看一下这个函数的定义。
const hotModulesMap = new Map();
const disposeMap = new Map();
const pruneMap = new Map();
const dataMap = new Map();
const customListenersMap = new Map();
const ctxToListenersMap = new Map();
function createHotContext(ownerPath) {
if (!dataMap.has(ownerPath)) {
dataMap.set(ownerPath, {});
}
// 当文件更新时,创建一个新的上下文
// 清除旧的回调
const mod = hotModulesMap.get(ownerPath);
if (mod) {
mod.callbacks = [];
}
// 清除旧的自定义事件监听器
const staleListeners = ctxToListenersMap.get(ownerPath);
if (staleListeners) {
for (const [event, staleFns] of staleListeners) {
const listeners = customListenersMap.get(event);
if (listeners) {
customListenersMap.set(event, listeners.filter((l) => !staleFns.includes(l)));
}
}
}
const newListeners = new Map();
ctxToListenersMap.set(ownerPath, newListeners);
function acceptDeps(deps, callback = () => { }) {
const mod = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: []
};
mod.callbacks.push({
deps,
fn: callback
});
hotModulesMap.set(ownerPath, mod);
}
const hot = {
get data() {
return dataMap.get(ownerPath);
},
accept(deps, callback) {
if (typeof deps === 'function' || !deps) {
// self-accept: hot.accept(() => {})
console.log('deps',deps);
acceptDeps([ownerPath], ([mod]) => deps && deps(mod));
}
else if (typeof deps === 'string') {
// explicit deps
acceptDeps([deps], ([mod]) => callback && callback(mod));
}
else if (Array.isArray(deps)) {
acceptDeps(deps, callback);
}
else {
throw new Error(`invalid hot.accept() usage.`);
}
},
acceptDeps() {
throw new Error(`hot.acceptDeps() is deprecated. ` +
`Use hot.accept() with the same signature instead.`);
},
dispose(cb) {
disposeMap.set(ownerPath, cb);
},
// @ts-expect-error untyped
prune(cb) {
pruneMap.set(ownerPath, cb);
},
// TODO
// eslint-disable-next-line @typescript-eslint/no-empty-function
decline() { },
invalidate() {
// TODO should tell the server to re-perform hmr propagation
// from this module as root
location.reload();
},
// 监听自定义事件
on(event, cb) {
const addToMap = (map) => {
const existing = map.get(event) || [];
existing.push(cb);
map.set(event, existing);
};
addToMap(customListenersMap);
addToMap(newListeners);
},
// 发服务器发送自定义事件
send(event, data) {
messageBuffer.push(JSON.stringify({ type: 'custom', event, data }));
sendMessageBuffer();
}
};
return hot;
}
可以看到之前在fetchUpdate
中用到的hotModulesMap
是在这里定义的。createHotContext
函数返回了一个hot对象,事实上这个就是模块中使用到的import.meta.hot
对象,上面挂载了一些方法,其中比较重要的是accept
方法,它的作用是当模块更新时执行对应的回调,在vite服务器返回模块文件时其实就会执行这个方法,回调函数就是重新渲染该模块的方法,因此当前面执行重新import()
后,会调用模块的回调函数来渲染更新,此外accept也被暴露给开发者使用,开发者可以通过import.meta.hot.accept()
来传入自定义回调函数。
综上,vite的热更新流程大致流程如下
vite服务器在返回模块文件时会创建热更新上下文,并将该模块的渲染方法传入到回调中
服务器监听文件改动,如果有文件更新则将模块文件path、更新type等信息发给浏览器
浏览器接收到后重新import()
该模块文件,并执行重新渲染的回调方法