本文基于
vite 4.3.0-beta.1
版本的源码进行分析
前言
在「vite4源码」dev模式整体流程浅析(一)的文章中,我们已经分析了预构建、请求拦截以及常见的插件源码,在本文中,我们将详细分析vite
开发模式下的热更新逻辑
5. 热更新HMR
5.1 服务器启动
启动热更新WebSocketServer服务,启动文件监控
-
createWebsocketServer()
启动websocket服务 - 使用
chokidar.watch()
监听文件变化
当文件变化时,最终触发handleHMRUpdate()
方法
async function createServer(inlineConfig = {}) {
const ws = createWebSocketServer(httpServer, config, httpsOptions);
const watcher = chokidar.watch(
[root, ...config.configFileDependencies, path$o.join(config.envDir, '.env*')],
resolvedWatchOptions);
watcher.on('change', async (file) => {
file = normalizePath$3(file);
if (file.endsWith('/package.json')) {
return invalidatePackageData(packageCache, file);
}
// invalidate module graph cache on file change
moduleGraph.onFileChange(file);
await onHMRUpdate(file, false);
});
}
const onHMRUpdate = async (file, configOnly) => {
if (serverConfig.hmr !== false) {
await handleHMRUpdate(file, server, configOnly);
}
};
5.2 服务器拦截浏览器请求然后注入代码
5.2.1 拦截index.html注入@vite/client.js
在初始化createServer()
中,先注册了中间件middlewares.use(indexHtmlMiddleware(server))
在浏览器加载初始化页面index.html
时,会触发indexHtmlMiddleware()
的viteIndexHtmlMiddleware()
对index.html
进行拦截:
- 先使用
fsp.readFile(filename)
读取index.html
文件内容 - 然后使用
transformIndexHtml()
,也就是createDevHtmlTransformFn()
重写index.html
文件内容 - 最终将重写完成的
index.html
文件返回给浏览器进行加载
async function createServer(inlineConfig = {}) {
const middlewares = connect();
const server = {
...
}
server.transformIndexHtml = createDevHtmlTransformFn(server);
if (config.appType === 'spa' || config.appType === 'mpa') {
middlewares.use(indexHtmlMiddleware(server));
}
return server;
}
function indexHtmlMiddleware(server) {
return async function viteIndexHtmlMiddleware(req, res, next) {
if (res.writableEnded) {
return next();
}
const url = req.url && cleanUrl(req.url);
if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') {
const filename = getHtmlFilename(url, server);
// 读取index.html文件
let html = await fsp.readFile(filename, 'utf-8');
// 改写index.html文件
html = await server.transformIndexHtml(url, html, req.originalUrl);
// 返回index.html文件
return send$1(req, res, html, 'html', {
headers: server.config.server.headers,
});
}
next();
};
}
改写index.html
的方法transformIndexHtml()
虽然逻辑非常简单,但是代码非常冗长,因此这里不会具体到每一个方法进行分析
核心逻辑为从resolveHtmlTransforms()
中拿到很多hooks
,然后使用applyHtmlTransforms()
遍历所有hook
,根据hook(html,ctx)
执行结果,进行数据在index.html
的插入(插入到或者插入到
),然后返回改造后的
index.html
function createDevHtmlTransformFn(server) {
const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(server.config.plugins);
return (url, html, originalUrl) => {
return applyHtmlTransforms(html, [
preImportMapHook(server.config),
...preHooks,
htmlEnvHook(server.config),
devHtmlHook,
...normalHooks,
...postHooks,
postImportMapHook(),
], {
path: url,
filename: getHtmlFilename(url, server),
server,
originalUrl,
});
};
}
async function applyHtmlTransforms(html, hooks, ctx) {
for (const hook of hooks) {
const res = await hook(html, ctx);
//...省略对res类型的判断逻辑
html = res.html || html;
tags = res.tags;
//..根据类型tags进行数据的组装,判断是要插入还是插入
html = injectToHead(html, headPrependTags, true);
html = injectToHead(html, headTags);
html = injectToBody(html, bodyPrependTags, true);
html = injectToBody(html, bodyTags);
}
return html;
}
我们经过调试知道,我们
inject
的内容是@vite/client
,那么是在哪个方法进行注入的呢?
在devHtmlHook()
这个hook
中,我们进行html
的处理,然后返回数据{html, tags}
其中返回的tags
数据中就包含了我们的/@vite/client
以及对应要插入的位置和一些属性,最终会触发上面分析的applyHtmlTransforms()
->injectToHead()
方法
const devHtmlHook = async (html, { path: htmlPath, filename, server, originalUrl }) => {
//...
await traverseHtml(html, filename, (node) => {
if (!nodeIsElement(node)) {
return;
}
// 处理
而injectToHead()
的具体代码如下所示,本质也是使用正则表达式进行index.html
内容的替换,将对应的tag
、type
、src
添加到指定位置中
function injectToHead(html, tags, prepend = false) {
if (tags.length === 0)
return html;
if (prepend) {
// inject as the first element of head
if (headPrependInjectRE.test(html)) {
return html.replace(headPrependInjectRE, (match, p1) => `${match}n${serializeTags(tags, incrementIndent(p1))}`);
}
}
else {
// inject before head close
if (headInjectRE.test(html)) {
// respect indentation of head tag
return html.replace(headInjectRE, (match, p1) => `${serializeTags(tags, incrementIndent(p1))}${match}`);
}
// try to inject before the body tag
if (bodyPrependInjectRE.test(html)) {
return html.replace(bodyPrependInjectRE, (match, p1) => `${serializeTags(tags, p1)}n${match}`);
}
}
// if no head tag is present, we prepend the tag for both prepend and append
return prependInjectFallback(html, tags);
}
function serializeTags(tags, indent = '') {
if (typeof tags === 'string') {
return tags;
}
else if (tags && tags.length) {
return tags.map((tag) => `${indent}${serializeTag(tag, indent)}n`).join('');
}
return '';
}
插入/@vite/client
后改造的index.html
为:
5.2.2 vite:import-analysis插件注入热更新代码
name: 'vite:import-analysis',
async transform(source, importer, options) {
let imports;
let exports;
[imports, exports] = parse$e(source);
for (let index = 0; index {
url = removeImportQuery(url);
transformRequest(url, server, { ssr }).catch((e) => {
});
});
}
}
比如Index.vue
示例代码中注入:
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/Index.vue");
5.2.3 vite:vue插件注入hot.accept热更新代码
对于每一个.vue
文件,都会走vite:vue
的插件解析,在对应的transform()
转化代码中,会注入对应的import.meta.hot.accept
热更新代码
name: 'vite:vue',
async transform(code, id, opt) {
//...
if (!query.vue) {
return transformMain(
code,
filename,
options,
this,
ssr,
customElementFilter(filename)
);
} else {
//...
}
}
async function transformMain(code, filename, options, pluginContext, ssr, asCustomElement) {
//...处理
比如Index.vue
文件就注入:
import.meta.hot.accept(mod => {
if (!mod) return
const { default: updated, _rerender_only } = mod
if (_rerender_only) {
__VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)
} else {
__VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)
}
})
5.3 @vite/client加载后的执行逻辑
当我们注入
@vite/client
到index.html
的后,我们会运行
@vite/client
代码,然后我们会执行什么逻辑呢?
建立客户端的WebSocket
,添加常见的事件: open
、message
、close
等
当文件发生改变时,会触发message
事件回调,然后触发handleMessage()
进行处理
socket = setupWebSocket(socketProtocol, socketHost, fallback);
function setupWebSocket(protocol, hostAndPath, onCloseWithoutOpen) {
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr');
let isOpened = false;
socket.addEventListener('open', () => {
isOpened = true;
}, { once: true });
// Listen for messages
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data));
});
return socket;
}
5.4 非index.html注入代码,执行局部热更新操作
而在5.2
步骤的分析中,我们知道除了在index.html
入口文件注入@vite/client
后,
我们还在其它文件注入了热更新代码,这些热更新代码主要为createHotContext()
和accept()
方法,如下所示,从@vite/client
获取暴露出来的接口,然后使用@vite/client
这些接口进行局部热更新操作
@vite/client
加载后有直接运行的代码,进行WebSocket
客户端的创建,同时也提供了一些外部可以使用的接口,可以在不同的文件,比如main.js
、Index.vue
中使用@vite/client
提供的外部接口进行局部热更新
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/Index.vue");
import.meta.hot.accept(mod => {
if (!mod) return
const { default: updated, _rerender_only } = mod
if (_rerender_only) {
__VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)
} else {
__VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)
}
})
从上面注入的代码可以知道,我们一开始会使用createHotContext()
,在createHotContext()
的源码中,我们使用目前文件路径作为key
,获取对应的hot
对象
从createHotContext()
获取hot
对象并且赋值给import.meta.hot
后,会进行import.meta.hot.accept()
的监听,最终触发时会执行acceptDeps()
方法,进行当前ownerPath
的callbacks
收集
那
accept()
收集的callbacks
什么时候会被触发呢?在下面5.6.1 fetchUpdate
将展开分析
function createHotContext(ownerPath) {
function acceptDeps(deps, callback = () => { }) {
const mod = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: [],
};
mod.callbacks.push({
deps,
fn: callback,
});
hotModulesMap.set(ownerPath, mod);
}
const hot = {
accept(deps, callback) {
if (typeof deps === 'function' || !deps) {
// self-accept: hot.accept(() => {})
acceptDeps([ownerPath], ([mod]) => deps === null || deps === void 0 ? void 0 : deps(mod));
} else if (typeof deps === 'string') {
// explicit deps
acceptDeps([deps], ([mod]) => callback === null || callback === void 0 ? void 0 : callback(mod));
} else if (Array.isArray(deps)) {
acceptDeps(deps, callback);
} else {
throw new Error(`invalid hot.accept() usage.`);
}
}
};
return hot;
}
5.5 文件改变,服务器处理逻辑
如果改变的文件是"package.json"
,触发invalidatePackageData()
,将"package.json"
缓存在packageCache
的数据进行删除,不会触发任何热更新逻辑
如果改变的不是"package.json"
,则会触发onHMRUpdate()
->handleHMRUpdate()
逻辑
function invalidatePackageData(packageCache, pkgPath) {
packageCache.delete(pkgPath);
const pkgDir = path$o.dirname(pkgPath);
packageCache.forEach((pkg, cacheKey) => {
if (pkg.dir === pkgDir) {
packageCache.delete(cacheKey);
}
});
}
watcher.on('change', async (file) => {
file = normalizePath$3(file);
if (file.endsWith('/package.json')) {
return invalidatePackageData(packageCache, file);
}
// invalidate module graph cache on file change
moduleGraph.onFileChange(file);
await onHMRUpdate(file, false);
});
const onHMRUpdate = async (file, configOnly) => {
if (serverConfig.hmr !== false) {
await handleHMRUpdate(file, server, configOnly);
}
};
5.5.1 重启服务|全量更新|局部热更新updateModules
async function handleHMRUpdate(file, server, configOnly) {
const { ws, config, moduleGraph } = server;
const shortFile = getShortName(file, config.root);
const fileName = path$o.basename(file);
const isConfig = file === config.configFile;
const isConfigDependency = config.configFileDependencies.some((name) => file === name);
const isEnv = config.inlineConfig.envFile !== false &&
(fileName === '.env' || fileName.startsWith('.env.'));
if (isConfig || isConfigDependency || isEnv) {
await server.restart();
return;
}
if (configOnly) {
return;
}
//normalizedClientDir="dist/client/client.mjs"
if (file.startsWith(normalizedClientDir)) {
ws.send({
type: 'full-reload',
path: '*',
});
return;
}
const mods = moduleGraph.getModulesByFile(file);
// check if any plugin wants to perform custom HMR handling
const timestamp = Date.now();
const hmrContext = {
file,
timestamp,
modules: mods ? [...mods] : [],
read: () => readModifiedFile(file),
server,
};
for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
const filteredModules = await hook(hmrContext);
if (filteredModules) {
hmrContext.modules = filteredModules;
}
}
if (!hmrContext.modules.length) {
// html file cannot be hot updated
if (file.endsWith('.html')) {
ws.send({
type: 'full-reload',
path: config.server.middlewareMode
? '*'
: '/' + normalizePath$3(path$o.relative(config.root, file)),
});
}
return;
}
updateModules(shortFile, hmrContext.modules, timestamp, server);
}
什么情况下需要
server.restart()
?isConfig
、isConfigDependency
、isEnv
代表什么意思?
-
isConfig
代表更改的文件是configFile
配置文件 -
isConfigDependency
代表更改的文件是configFile
配置文件的依赖文件 -
isEnv
代表更改的文件是.env.xxx
文件,当vite.config.js
中配置InlineConfig.envFile
=false
时,会禁用.env
文件
如果是上面三种条件中的文件发生改变,则直接重启本地服务器
全量更新的条件是什么?
- (仅限开发)客户端本身不能热更新,满足
client/client.mjs
就是全量更新的条件需要全量更新 - 如果没有模块需要更新,并且变化的是
.html
文件,需要全量更新
当不满足上面两种条件时,有对应的模块变化时,触发updateModules()
逻辑
5.5.2 寻找热更新边界
注:
acceptedHmrExports
在vite 4.3.0-beta.1
版本为试验性功能!必须手动配置才能启用!默认不启用!因此一般条件下可以忽略该逻辑产生的热更新!
updateModules()
的代码逻辑看起来是比较简单的
- 通过
propagateUpdate()
获取是否需要全量更新的标志位 - 同时通过
propagateUpdate()
将更新内容放入到boundaries
数据中 - 最终将
boundaries
塞入updates
数组中 -
ws.send
发送updates
数据到客户端进行热更新
但是问题来了,
propagateUpdate()
到底做了什么?什么情况下hasDeadEnd
=true
?什么情况下hasDeadEnd
=false
?
从热更新的角度来说,都会存在几个常见的问题:
- 什么类型文件默认开启了热更新?
- 是否存在不需要热更新的文件或者情况?
- 一个文件什么情况需要自己更新?
vite
是否有自动注入一些代码?指定某一个模块作为另一个模块热更新的依赖项?
function updateModules(file, modules, timestamp, { config, ws, moduleGraph }, afterInvalidation) {
const updates = [];
const invalidatedModules = new Set();
let needFullReload = false;
for (const mod of modules) {
moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true);
if (needFullReload) {
continue;
}
const boundaries = new Set();
const hasDeadEnd = propagateUpdate(mod, boundaries);
if (hasDeadEnd) {
needFullReload = true;
continue;
}
updates.push(...[...boundaries].map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update`,
timestamp,
path: normalizeHmrUrl(boundary.url),
explicitImportRequired: boundary.type === 'js'
? isExplicitImportRequired(acceptedVia.url)
: undefined,
acceptedPath: normalizeHmrUrl(acceptedVia.url),
})));
}
//...全量更新或者ws.send()
}
在进行
propagateUpdate()
分析之前,有几个比较特殊的变量,我们需要先分析下,才能更好理解propagateUpdate()
流程
isSelfAccepting解析
isSelfAccepting
是什么?isSelfAccepting=true
代表什么?
对于vite:css
的css文件来说,热更新判断条件如下面代码块所示:
- 不是
CSS modules
- 没有携带
inline
字段 - 没有携带
html-proxy
字段
const thisModule = moduleGraph.getModuleById(id);
if (thisModule) {
// CSS modules cannot self-accept since it exports values
const isSelfAccepting = !modules && !inlineRE.test(id) && !htmlProxyRE.test(id);
}
对于vite:import-analysis
,如果存在import.meta.hot.accept()
,那么isSelfAccepting
=true
name: 'vite:import-analysis',
async transform(source, importer, options) {
if (!imports.length && !this._addedImports) {
importerModule.isSelfAccepting = false;
return source;
}
for (let index = 0; index
产生acceptedHmrExports和importedBindings的原因
在https://github.com/vitejs/vite/discussions/7309 和 https://github.com/vitejs/vite/pull/7324中,我们可以发现
acceptedHmrExports
和importedBindings
的相关源码提交记录讨论
源码的提交记录是feat(hmr): experimental.hmrPartialAccept (#7324)
在React
的son.jsx
文件中,可能存在混合模式,比如下面的代码,export
一个组件和一个变量,但是在parent.jsx
中只使用Foo
这个组件
// son.jsx
export const Foo = () => foo
export const bar = () => 123
在理想情况下,如果我们改变bar
这个值,那么son.jsx
应该触发热更新重新加载!但是parent.jsx
不应该热更新重新加载,因为它所使用的Foo
并没有发生改变
// parent.jsx
import { Foo } from './Foo.js'
export const Bar = () =>
因此需要一个API,在原来的模式:
- 如果某个文件改变,无论什么内容,都会触发该文件的
accept(()=>{开始更新逻辑})
- 监听部分
import
依赖库,当import
依赖库发生更新时,会触发该文件的accept(()=>{开始更新逻辑})
还要增加一个监听export {xx}
对象触发的热更新,也就是:
export const Bar = ...
export const Baz = ...
export default ...
if (import.meta.hot) {
import.meta.hot.acceptExports(['default', 'Bar'], newModule => { ... })
}
当default
和Bar
发生改变时,会触发上面注册的(newModule)=>{开始更新逻辑}
方法的执行
importedBindings解析
acceptedHmrExports
和importedBindings
配套使用!
node.acceptedHmrExports
代表目前文件import.meta.hot.acceptExports
监听的模块,比如下面的['default', 'Bar']
export const Bar = ...
export const Baz = ...
export default ...
if (import.meta.hot) {
import.meta.hot.acceptExports(['default', 'Bar'], newModule => { ... })
}
importer.importedBindings
是在vite:import-analysis
中解析import
语句时,解析该语句是什么类型,然后添加到importedBindings
// parent.jsx
import { Foo } from './Foo.js'
export const Bar = () =>
如下面代码所示,我们会传入imports[index]
为import { Foo } from './Foo.js'
,importedBindings
是一个空的Map
数据结构
然后我们会解析出namespacedImport
、defaultImport
、namedImports
等数据,然后往importedBindings
添加对应的字符串,为:
bindings.add('*')
bindings.add('default')
-
bindings.add(name)
:import
的属性名称,比如"Foo"
if (enablePartialAccept && importedBindings) {
extractImportedBindings(
resolvedId,
source,
imports[index],
importedBindings
)
}
async function extractImportedBindings(
id: string,
source: string,
importSpec: ImportSpecifier,
importedBindings: Map>
) {
let bindings = importedBindings.get(id)
if (!bindings) {
bindings = new Set ()
importedBindings.set(id, bindings)
}
const isDynamic = importSpec.d > -1
const isMeta = importSpec.d === -2
if (isDynamic || isMeta) {
// this basically means the module will be impacted by any change in its dep
bindings.add('*')
return
}
const exp = source.slice(importSpec.ss, importSpec.se)
const [match0] = findStaticImports(exp)
if (!match0) {
return
}
const parsed = parseStaticImport(match0)
if (!parsed) {
return
}
if (parsed.namespacedImport) {
bindings.add('*')
}
if (parsed.defaultImport) {
bindings.add('default')
}
if (parsed.namedImports) {
for (const name of Object.keys(parsed.namedImports)) {
bindings.add(name)
}
}
}
acceptedHmrExports和acceptedHmrDeps解析
在vite:import-analysis
插件中,当我们分析文件的import.meta.hot.accept()
时,我们会进行解析source
name: 'vite:import-analysis',
async transform(source, importer, options) {
for (let index = 0; index
acceptedHmrDeps
通过调试可以知道,当我们使用import.meta.hot.accept(["a", "b"])
时,我们可以得到acceptedUrls
=[{url: "a"},{url: "b"}]
,然后触发updateModuleInfo()
传入normalizedAcceptedUrls
进行赋值
acceptedHmrExports
通过调试可以知道,当我们使用import.meta.hot.acceptExports(["a", "b"])
时,我们可以得到acceptedExports
=[{url: "a"},{url: "b"}]
,然后触发updateModuleInfo()
传入acceptedExports
进行赋值
acceptedHmrExports
和acceptedHmrDeps
的数据在updateModuleInfo()
方法中进行添加
- 在
updateModuleInfo()
中,通过字符串"a"
经过this.ensureEntryFromUrl(accepted)
拿到对应的ModuleNode
对象,存入到acceptedHmrDeps
中,即mod.acceptedHmrDeps.add(this.ensureEntryFromUrl(acceptedModules[i]))
-
mod.acceptedHmrExports
=acceptedExports
async updateModuleInfo(mod, importedModules, importedBindings, acceptedModules, acceptedExports, isSelfAccepting, ssr) {
// update accepted hmr deps
const deps = (mod.acceptedHmrDeps = new Set());
for (const accepted of acceptedModules) {
const dep = typeof accepted === 'string'
? await this.ensureEntryFromUrl(accepted, ssr)
: accepted;
deps.add(dep);
}
// update accepted hmr exports
mod.acceptedHmrExports = acceptedExports;
mod.importedBindings = importedBindings;
return noLongerImported;
}
async ensureEntryFromUrl(rawUrl, ssr, setIsSelfAccepting = true) {
const [url, resolvedId, meta] = await this.resolveUrl(rawUrl, ssr);
let mod = this.idToModuleMap.get(resolvedId);
if (!mod) {
mod = new ModuleNode(url, setIsSelfAccepting);
this.urlToModuleMap.set(url, mod);
mod.id = resolvedId;
this.idToModuleMap.set(resolvedId, mod);
const file = (mod.file = cleanUrl(resolvedId));
let fileMappedModules = this.fileToModulesMap.get(file);
if (!fileMappedModules) {
fileMappedModules = new Set();
this.fileToModulesMap.set(file, fileMappedModules);
}
fileMappedModules.add(mod);
} else if (!this.urlToModuleMap.has(url)) {
this.urlToModuleMap.set(url, mod);
}
return mod;
}
propagateUpdate()详细分析
下面代码为propagateUpdate()
的所有代码,我们可以分为4个部分进行分析
当propagateUpdate()
返回true
时,说明无法找到热更新边界,需要全量更新
当propagateUpdate()
返回false
时,说明已经找到热更新边界并且存放在boundaries
中
function propagateUpdate(node, boundaries, currentChain = [node]) {
if (node.id && node.isSelfAccepting === undefined) {
return false;
}
//==========第1部分============
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
for (const importer of node.importers) {
if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {
propagateUpdate(importer, boundaries, currentChain.concat(importer));
}
}
return false;
}
//==========第2部分============
if (node.acceptedHmrExports) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
} else {
// 没有文件import目前的node
if (!node.importers.size) {
return true;
}
// 当前node不是CSS类型,但是CSS文件import目前的node,那么直接全量更新
if (!isCSSRequest(node.url) &&
[...node.importers].every((i) => isCSSRequest(i.url))) {
return true;
}
}
//==========第3部分============
for (const importer of node.importers) {
const subChain = currentChain.concat(importer);
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
});
continue;
}
if (node.id && node.acceptedHmrExports && importer.importedBindings) {
const importedBindingsFromNode = importer.importedBindings.get(node.id);
if (importedBindingsFromNode &&
areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports)) {
continue;
}
}
// 递归调用直接全量更新
if (currentChain.includes(importer)) {
// circular deps is considered dead end
return true;
}
// 从node向上寻找,递归调用propagateUpdate收集boundaries
if (propagateUpdate(importer, boundaries, subChain)) {
return true;
}
}
return false;
}
第1部分 处理isSelfAccepting
node.isSelfAccepting
=true
一般发生在.vue
、.jsx
、.tsx
等响应式组件中,代表该文件变化时会触发里面注册的热更新回调方法,然后执行自定义的更新代码
function propagateUpdate(node, boundaries, currentChain = [node]) {
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
for (const importer of node.importers) {
if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {
propagateUpdate(importer, boundaries, currentChain.concat(importer));
}
}
return false;
}
//...
}
如果node.isSelfAccepting
为true
,代表它有accept()
方法,比如.vue
文件中会注入accept()
方法,这个时候只要将目前的node
加入到boundaries
同时还要判断node.importers
是不是CSS
请求链接,如果是的话,要继续向上寻找,再次出发propagateUpdate()
收集热更新边界boundaries
源码中注释:像
Tailwind JIT
这样的PostCSS
插件可能会将任何文件注册为CSS文件的依赖项,因此需要检测node.importers
是不是CSS
请求,本文对这方面不展开详细的分析,请参考其它文章进行了解
当isSelfAccepting
=true
,最终propagateUpdate()
返回false
,代表不用全量更新,热更新边界boundaries
加入当前的node
,结束其它条件语句的执行
第2部分 处理acceptedHmrExports
function propagateUpdate(node, boundaries, currentChain = [node]) {
//...第1部分
// 第2部分
if (node.acceptedHmrExports) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
} else {
// 没有文件import目前的node
if (!node.importers.size) {
return true;
}
// 当前node不是CSS类型,但是CSS文件import目前的node,那么直接全量更新
if (!isCSSRequest(node.url) &&
[...node.importers].every((i) => isCSSRequest(i.url))) {
return true;
}
}
//...
}
-
node.acceptedHmrExports
: 代表目前文件注入了import.meta.hot.acceptExports(xxx)
代码,热更新边界boundaries
加入当前的node
-
!node.importers.size
: 代表没有其它文件
import(引用)了目前的node文件
,直接全量更新 -
目前的node文件
不是CSS类型,但是其它CSS文件
import(引用)了目前的node文件
,直接全量更新
第3部分 遍历node.importers
function propagateUpdate(node, boundaries, currentChain = [node]) {
//...第一部分
//...第2部分
// 第3部分
for (const importer of node.importers) {
const subChain = currentChain.concat(importer);
// 逻辑1
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
});
continue;
}
// 逻辑2
if (node.id && node.acceptedHmrExports && importer.importedBindings) {
const importedBindingsFromNode = importer.importedBindings.get(node.id);
if (importedBindingsFromNode &&
areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports)) {
continue;
}
}
// 逻辑3
// 递归调用直接全量更新
if (currentChain.includes(importer)) {
// circular deps is considered dead end
return true;
}
// 从node向上寻找,递归调用propagateUpdate收集boundaries
if (propagateUpdate(importer, boundaries, subChain)) {
return true;
}
}
//...
}
function areAllImportsAccepted(importedBindings, acceptedExports) {
for (const binding of importedBindings) {
if (!acceptedExports.has(binding)) {
return false;
}
}
return true;
}
从上面的分析可以知道
-
acceptedHmrDeps
本质就是获取import.meta.hot.accept(xxx)
的监听模块 -
acceptedHmrExports
本质就是获取import.meta.hot.acceptExports(xxx)
的监听模块 -
importedBindings
代表目前文件中import
的文件的数据
第3部分的代码逻辑主要是遍历当前node.importer
,寻找是否需要加入热更新边界boundaries
的文件
逻辑1 处理acceptedHmrDeps
如果node.importers[i]
注入了import.meta.hot.accept(xxx)
的监听模块(如下面代码块所示), 那么热更新边界boundaries
加入当前的node.importers[i]
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
});
continue;
}
// B.js
export const test = "B.js";
// A.js
import {test} from "./B.js";
import.meta.hot.accept("B", (mod)=>{});
逻辑2 处理acceptedHmrExports&importedBindingsFromNode
如下面代码块所示,目前node
=B.js
,我们改变了B.js
的内容,触发了热更新
此时importedBindingsFromNode
=["test"]
,acceptedHmrExports
=["test"]
,触发continue
,不触发向上寻找热更新边界的逻辑
if (node.id && node.acceptedHmrExports && importer.importedBindings) {
const importedBindingsFromNode = importer.importedBindings.get(node.id);
if (importedBindingsFromNode &&
areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports)) {
continue;
}
}
function areAllImportsAccepted(importedBindings, acceptedExports) {
for (const binding of importedBindings) {
if (!acceptedExports.has(binding)) {
return false;
}
}
return true;
}
// B.js
const test = "B.js3";
import.meta.hot.acceptExports("test", (mod)=>{
console.error("B.js热更新触发");
})
const test1 = "B1.js";
export {test, test1}
// A.js
import {test} from "./B.js";
console.info("A.js", test);
export const AExport = "AExport";
那为什么满足
areAllImportsAccepted
就触发continue
呢?意思就是如果目前node
文件acceptExports
所有export出去的值,就可以不向上处理寻找热更新边界了?
在上面isSelfAccepting
的分析中,我们可以知道,acceptExports
代表import.meta.hot.acceptExports(xxx)
监听的模块数据
exports
代表该文件所exports
的数据,比如上面示例B.js
的["test", "test1"]
当acceptExports
监听的数据已经完全覆盖文件所exports
的数据时,会强行设置isSelfAccepting
=true
name: 'vite:import-analysis',
async transform(source, importer, options) {
// 当source存在hot.acceptExport字段时,isPartiallySelfAccepting=true
// 当source存在hot.accept字段时,isSelfAccepting=true
if (!isSelfAccepting &&
isPartiallySelfAccepting &&
acceptedExports.size >= exports.length &&
exports.every((e) => acceptedExports.has(e.n))) {
isSelfAccepting = true;
}
}
当isSelfAccepting
=true
时,当B.js
文件发生变化时,就会触发propagateUpdate()
的第1部分,热更新边界boundaries
加入当前的node
,然后直接return false
,停止向上处理寻找热更新边界,这样的逻辑也验证了我们上面的猜想,如果目前node
文件已经acceptExports
所有export出去的值,就可以不向上处理寻找热更新边界了
function propagateUpdate(node, boundaries, currentChain = [node]) {
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
for (const importer of node.importers) {
if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {
propagateUpdate(importer, boundaries, currentChain.concat(importer));
}
}
return false;
}
//...
}
逻辑3 继续向上找热更新的边界
如果存在循环递归的情况,直接返回true
,直接全量更新
// 递归调用直接全量更新
if (currentChain.includes(importer)) {
// circular deps is considered dead end
return true;
}
// 从node向上寻找,递归调用propagateUpdate收集boundaries
if (propagateUpdate(importer, boundaries, subChain)) {
return true;
}
propagateUpdate小结
什么情况下才需要向上找热更新的边界?
现在我们可以根据上面的分析进行总结:
-
node.isSelfAccepting
为false
,继续执行下面的条件判断 -
importer.acceptedHmrDeps.has(node)
,即parent
有注入accept("A")
监听import {A} from "xxx"
的值,不继续向上找热更新的边界 -
node.acceptedHmrExports
为true
时,直接将当前node
加入到热更新边界中- 已经监听所有
export
出去的值,则不继续向上找热更新的边界 - 如果没有监听所有
export
出去的值,则继续向上找热更新的边界propagateUpdate(importer, boundaries)
- 已经监听所有
-
node.acceptedHmrExports
为false
时,继续向上找热更新的边界propagateUpdate(importer, boundaries)
function propagateUpdate(node, boundaries, currentChain = [node]) {
if (node.id && node.isSelfAccepting === undefined) {
return false;
}
//==========第1部分============
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
for (const importer of node.importers) {
if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {
propagateUpdate(importer, boundaries, currentChain.concat(importer));
}
}
return false;
}
//==========第2部分============
if (node.acceptedHmrExports) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
} else {
// 没有文件import目前的node
if (!node.importers.size) {
return true;
}
// 当前node不是CSS类型,但是CSS文件import目前的node,那么直接全量更新
if (!isCSSRequest(node.url) &&
[...node.importers].every((i) => isCSSRequest(i.url))) {
return true;
}
}
//==========第3部分============
for (const importer of node.importers) {
const subChain = currentChain.concat(importer);
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
});
continue;
}
if (node.id && node.acceptedHmrExports && importer.importedBindings) {
const importedBindingsFromNode = importer.importedBindings.get(node.id);
if (importedBindingsFromNode &&
areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports)) {
continue;
}
}
// 递归调用直接全量更新
if (currentChain.includes(importer)) {
// circular deps is considered dead end
return true;
}
// 从node向上寻找,递归调用propagateUpdate收集boundaries
if (propagateUpdate(importer, boundaries, subChain)) {
return true;
}
}
return false;
}
acceptExports具体示例分析
通过具体实例明白
acceptExports
想要达到的效果
在B.js
中,我们监听了export
数据:test
- 当我们改变
test
变量时,比如从test
="B.js"
更改为test
="B111.js"
时,只会触发B.js
热更新,然后触发打印B.js热更新触发
- 当我们改变
test1
变量,由于B.js
中没有监听test1
变量,因此会触发B.js
热更新 + 向上寻找A.js
->向上寻找main.js
,最终找到main.js
,触发main.js
热更新
从这个例子中我们就可以清晰明白acceptExports
的作用,我们可以监听部分export
变量,从而避免过多文件的无效热更新
当监听的
acceptExports
的字段跟import的字段不一样时,会触发向上寻找热更新边界
当监听的acceptExports
的字段跟import的字段一样时,只会触发当前文件的热更新
// main.js
import {AExport} from "./simple/A.js";
import.meta.hot.acceptExports(["aa"]);
// A.js
import {test1} from "./B.js";
console.info("A.js", test1);
export const AExport = "AExport3";
// B.js
const test = "B.js";
import.meta.hot.acceptExports("test", (mod)=>{
console.error("B.js热更新触发");
})
// 当acceptExports覆盖了所有export数据时,会强行设置isSelfAccepting=true
const test1 = "B432.js";
export {test, test1}
5.5.3 全量更新或者发送热更新模块到客户端
function updateModules(file, modules, timestamp, { config, ws, moduleGraph }, afterInvalidation) {
for (const mod of modules) {
//...寻找热更新边界updates,如果找不到,则进行全量更新needFullReload=true
updates.push(...[...boundaries].map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update`,
timestamp,
path: normalizeHmrUrl(boundary.url),
explicitImportRequired: boundary.type === 'js'
? isExplicitImportRequired(acceptedVia.url)
: undefined,
acceptedPath: normalizeHmrUrl(acceptedVia.url),
})));
}
if (needFullReload) {
// 全量更新
ws.send({
type: 'full-reload',
});
return;
}
if (updates.length === 0) {
// 没有更新,不进行ws.send
return;
}
ws.send({
type: 'update',
updates,
});
}
updates
最终的数据结构为:
其中有两个变量需要注意下:path
和acceptedPath
-
path
: 取的是boundary.url
-
acceptedPath
: 取的是acceptedVia.url
在寻找热更新边界propagateUpdate()
时,如下面代码所示,我们知道
-
node.isSelfAccepting
:path
和acceptedPath
都为node
-
node.acceptedHmrExports
:path
和acceptedPath
都为node
-
importer.acceptedHmrDeps.has(node)
:path
为importer
,acceptedPath
为node
function propagateUpdate(node, boundaries, currentChain = [node]) {
//==========第1部分============
if (node.isSelfAccepting) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
}
//==========第2部分============
if (node.acceptedHmrExports) {
boundaries.add({
boundary: node,
acceptedVia: node,
});
}
//==========第3部分============
for (const importer of node.importers) {
const subChain = currentChain.concat(importer);
if (importer.acceptedHmrDeps.has(node)) {
boundaries.add({
boundary: importer,
acceptedVia: node,
});
continue;
}
//...
if (propagateUpdate(importer, boundaries, subChain)) {
return true;
}
}
return false;
}
5.6 文件改变,服务器->客户端触发热更新逻辑
我们从5.3
步骤后知道,当文件变化,服务器WebSocket
->客户端WebSocket
后,会触发handleMessage()
的执行
如果update.type
为js-update
,则触发fetchUpdate(update)
方法
如果update.type
不为js-update
,检测是否存在link
标签包含这个要更新模块的路径,如果存在,则重新加载该文件数据(加载新的link
,删除旧的link
)
Element: after()
表示插入新的元素到Elment
的后面Element: remove()
表示删除该元素
async function handleMessage(payload) {
switch (payload.type) {
case 'update':
notifyListeners('vite:beforeUpdate', payload);
await Promise.all(payload.updates.map(async (update) => {
if (update.type === 'js-update') {
return queueUpdate(fetchUpdate(update));
} else {
const el = Array.from(document.querySelectorAll('link')).find((e) => !outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl));
const newPath = `${base}${searchUrl.slice(1)}${searchUrl.includes('?') ? '&' : '?'}t=${timestamp}`;
if (!el) {
return;
}
// 使用加载文件
const newLinkTag = el.cloneNode();
newLinkTag.href = new URL(newPath, el.href).href;
const removeOldEl = () => {
el.remove();
console.debug(`[vite] css hot updated: ${searchUrl}`);
resolve();
};
newLinkTag.addEventListener('load', removeOldEl);
outdatedLinkTags.add(el);
el.after(newLinkTag);
}
}));
notifyListeners('vite:afterUpdate', payload);
break;
case 'full-reload':
notifyListeners('vite:beforeFullReload', payload);
//...
location.reload();
break;
}
}
5.6.1 fetchUpdate
在寻找热更新边界
propagateUpdate()
时,我们知道
node.isSelfAccepting
:path
和acceptedPath
都为node
node.acceptedHmrExports
:path
和acceptedPath
都为node
importer.acceptedHmrDeps.has(node)
:path
为importer
,acceptedPath
为node
还有可能触发向上找热更新的边界
propagateUpdate(importer, boundaries)
,此时path
为importer
,acceptedPath
为importer
async function fetchUpdate({ path, acceptedPath, timestamp, explicitImportRequired, }) {
//根据路径拿到之前收集的依赖更新对象
const mod = hotModulesMap.get(path);
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => deps.includes(acceptedPath));
// 根据路径重新请求该文件数据
fetchedModule = await import(
/* @vite-ignore */
base +
acceptedPathWithoutQuery.slice(1) +
`?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}`);
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
// 将新请求的数据,使用fn(fetchedModule)进行局部热更新
fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)));
}
};
}
如上面代码所示,在fetchUpdate()
中,我们会通过hotModulesMap.get(path)
拿到关联的mod
那么
hotModulesMap
的数据是在哪里初始化的呢?
在5.4
步骤的非index.html注入代码分析中,如下面的代码所示,我们知道会在文件中进行hot.accept()
的调用
- 当一个
.vue
文件使用hot.accept()
或者hot.accept(()=>{})
时,当监听的文件发生变化时下面代码中meta.hot.accept((mod)=>{})
的mod
就是上面fetchUpdate()
的fetchedModule
,const {default}=fetchedModule
也就是请求文件的export default
内容 - 当一个文件使用
hot.accept("a")
或者hot.accept(["a","b"])
时,参数会作为deps
存入到mod.callbacks
中
如上面代码所示,当我们通过hotModulesMap.get(path)
拿到关联的mod
,此时的mod
对应的path
文件所注册的import.meta.hot.accept
或者import.meta.hot.acceptExports
的回调
然后通过deps.includes(acceptedPath)
进行注册回调的筛选,如果hot.accept
有显式注册deps
,就会根据deps
去筛选
如果hot.accept
没有显式注册deps
,那么此时deps
=[ownerPath]
,即deps
=[path]
// .vue文件注入代码
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/Index.vue");
import.meta.hot.accept((mod) => {
if (!mod) return
const { default: updated, _rerender_only } = mod
if (_rerender_only) {
__VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)
} else {
__VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)
}
})
// @vite/client代码
function createHotContext(ownerPath) {
function acceptDeps(deps, callback = () => { }) {
const mod = hotModulesMap.get(ownerPath) || {
id: ownerPath,
callbacks: [],
};
mod.callbacks.push({
deps,
fn: callback,
});
hotModulesMap.set(ownerPath, mod);
}
const hot = {
accept(deps, callback) {
if (typeof deps === 'function' || !deps) {
// self-accept: hot.accept(() => {})
acceptDeps([ownerPath], ([mod]) => deps === null || deps === void 0 ? void 0 : deps(mod));
} else if (typeof deps === 'string') {
// explicit deps
acceptDeps([deps], ([mod]) => callback === null || callback === void 0 ? void 0 : callback(mod));
} else if (Array.isArray(deps)) {
acceptDeps(deps, callback);
} else {
throw new Error(`invalid hot.accept() usage.`);
}
},
acceptExports(_, callback) {
acceptDeps([ownerPath], ([mod]) => callback === null || callback === void 0 ? void 0 : callback(mod));
},
};
return hot;
}
那为什么
acceptExports
传入的第一个参数不使用呢?直接初始化为[ownerPath]
?
我们在上面的propagateUpdate()
的分析中,我们知道
-
node.isSelfAccepting
:path
和acceptedPath
都为node
-
node.acceptedHmrExports
:path
和acceptedPath
都为node
-
importer.acceptedHmrDeps.has(node)
:path
为importer
,acceptedPath
为node
还有可能触发向上找热更新的边界propagateUpdate(importer, boundaries)
,此时path
为importer
,acceptedPath
为importer
这里的
node
代表当前文件的路径!importer
代表import当前node
文件的那个文件的路径!
从上面的分析中,import.meta.hot.accept(xxx)
则可以设置path
为importer
,acceptedPath
为node
,即可以在import当前node
文件的那个文件中处理当前node
文件的热更新
而acceptedHmrExports
存在,即import.meta.hot.acceptExports(xxx)
存在时,它监听的都是当前node
文件的路径,只能在当前node
文件中处理当前node
文件的热更新,这跟监听export
部分数据触发热更新的初衷是符合的,因此acceptExports
传入的第一个参数不使用,直接初始化为当前node
的文件路径[ownerPath]
import.meta.hot.accept(xxx)
不仅仅可以监听export
还可以监听import
那为什么上面分析的
acceptedHmrExports
变量就代表import.meta.hot.acceptExports(["a", "b"])
所监听的值,即acceptedHmrExports
=[{url: "a"},{url: "b"}]
呢?
那是因为为了得到acceptedHmrExports
,是直接拿代码去正则表达式获取数据,而不是方法调用,如下面代码所示,是通过lexAcceptedHmrExports()
拿到acceptExports(["a", "b"])
的"a"
和"b"
name: 'vite:import-analysis',
async transform(source, importer, options) {
for (let index = 0; index
6. 总结
6.1 预构建原理
- 遍历所有的文件,搜集所有裸模块的请求,然后将所有裸模块的请求作为esbuild打包的入口文件,将所有裸模块缓存打包到
.vite/deps
文件夹下,在打包过程中,会将commonjs
转化为esmodule
的形式,本质是使用一个export default
包裹着commonjs
的代码,同时利用esbuild的打包能力,将多个内置请求合并为一个请求,防止大量请求引起浏览器端的网络堵塞,使页面加载变得非常缓慢 - 在浏览器请求链接时改写所有裸模块的路径指向
.vite/deps
- 如果想要重新执行预构建,使用
--force
参数或者直接删除node_modeuls/.vite/deps
是比较快捷的方式,或者改变一些配置的值可以触发重新预构建
6.2 热更新原理
- 使用
websocket
建立客户端和服务端 - 服务端会监听文件变化,然后通过一系列逻辑判断,得出热更新的文件范围,此时的热更新边界的判断依赖于
transform
文件内容时的分析,每一个文件都具有一个对象数据ModuleNode
- 客户端接收服务端的热更新文件范围相关的路径后,进行客户端中热更新代码的调用
6.3 vite与webpack的区别
webpack
是先解析依赖,打包构建,形成bundle
后再启动开发服务器,当我们修改bundle
的其中一个子模块时,我们需要对这个bundle
重新打包然后触发热更新,项目越大越复杂,启动时间就越长vite
的核心原理是利用esmodule
进行按需加载,先启动开发服务器,然后再进行import
模块,无法进行整体依赖的解析和构建打包,同时使用esbuild快速的打包速度进行不会轻易改变node_modules
依赖的预构建,提升速度,当文件发生改变时,也会发送对应的文件数据到客户端,进行该文件以及相关文件的热更新,不用重新构建和重新打包,项目越大提升效果越明显
6.4 vite对比webpack优缺点
vite优点
- 快速的服务器启动: 当冷启动开发服务器时,基于打包器的方式启动必须优先抓取并构建你的整个应用,然后才能提供服务,Vite 以 原生 ESM 方式提供源码,让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码,然后根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理
- 反应快速的热更新: 基于打包器启动时,重建整个包的效率很低,并且更新速度会随着应用体积增长而直线下降,在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活(大多数时候只是模块本身),使得无论应用大小如何,HMR 始终能保持快速更新
vite缺点
-
首屏加载较慢: 需要大量http请求和源文件转化操作,首次加载还需要花费时间进行预构建
虽然
vite
已经改进预构建不会影响本地服务的启动和运行,但是一些预构建的库,比如react.js
,还是得等待预构建完成后才能加载react.js
,然后进行整体渲染 - 懒加载较慢: 和首屏加载一样,动态加载的文件仍然需要转化操作,可能会存在大量的http请求(多个业务依赖文件)
- 开发服务器和生产环境构建之间输出和行为可能不一致: 开发环境使用
esbuild
,生产环境使用rollup
,有一些插件(比如commonjs
的转化)需要区分开发环境和生产环境
6.5 vite如何处理Typescript、SCSS等语言
-
vite:css
插件: 调用预处理器依赖库进行转化处理 -
vite:esbuild
插件:.ts
和.tsx
转化.js
,用来代替传统的tsc
转化功能
Vite 使用 esbuild 将 TypeScript 转译到 JavaScript,约是
tsc
速度的 20~30 倍
参考文章
- Vite源码分析,是时候弄清楚Vite的原理了
- Vite原理及源码解析
- Vite原理分析
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net