本文基于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
的插入(插入到<head>
或者插入到<body>
),然后返回改造后的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进行数据的组装,判断是要插入<head>还是插入<body>
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;
}
// 处理<script>标签,添加时间戳?t=xxx,以及触发预加载preTransformRequest()
// 处理<style>标签,添加到styleUrl数组中
// 处理其它attrs标签
});
await Promise.all(styleUrl.map(async ({ start, end, code }, index) => {
const url = `${proxyModulePath}?html-proxy&direct&index=${index}.css`;
// 处理缓存
const mod = await moduleGraph.ensureEntryFromUrl(url, false);
ensureWatchedFile(watcher, mod.file, config.root);
// 转化style数据,触发vite:css插件进行transform()
const result = await server.pluginContainer.transform(code, mod.id);
// 重写s字符串
s.overwrite(start, end, result?.code || '');
}));
html = s.toString();
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: path$o.posix.join(base, "/@vite/client"),
},
injectTo: 'head-prepend',
},
],
};
};
而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 < imports.length; index++) {
const { s: start, e: end, ss: expStart, se: expEnd, d: dynamicIndex,
n: specifier, a: assertIndex, } = imports[index];
// resolvedId="/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/node_modules/.vite/deps/vue.js?v=da0b3f8b"
// url="/node_modules/.vite/deps/vue.js?v=da0b3f8b"
const [url, resolvedId] = await normalizeUrl(specifier, start);
if (!isDynamicImport) {
// for pre-transforming
staticImportedUrls.add({ url: hmrUrl, id: resolvedId });
}
}
if (hasHMR && !ssr) {
// inject hot context
str().prepend(`import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` +
`import.meta.hot = __vite__createHotContext(${JSON.stringify(normalizeHmrUrl(importerModule.url))});`);
}
if (config.server.preTransformRequests && staticImportedUrls.size) {
staticImportedUrls.forEach(({ url }) => {
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) {
//...处理<script>、<style>、<template>,然后放入到output中
const output = [
scriptCode,
templateCode,
stylesCode,
customBlocksCode
];
if (devServer && devServer.config.server.hmr !== false && !ssr && !isProduction) {
output.push(`_sfc_main.__hmrId = ${JSON.stringify(descriptor.id)}`);
output.push(
`typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)`
);
if (prevDescriptor && isOnlyTemplateChanged(prevDescriptor, descriptor)) {
output.push(`export const _rerender_only = true`);
}
output.push(
`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)`,
` }`,
`})`
);
}
//...
let resolvedCode = output.join("\n");
return {
code: resolvedCode
};
}
比如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
的<script>
后,我们会运行@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 < imports.length; index++) {
const { s: start, e: end, ss: expStart, se: expEnd, d: dynamicIndex,
n: specifier, a: assertIndex, } = imports[index];
const rawUrl = source.slice(start, end);
// check import.meta usage
if (rawUrl === 'import.meta') {
const prop = source.slice(end, end + 4);
if (prop === '.hot') {
hasHMR = true;
const endHot = end + 4 + (source[end + 4] === '?' ? 1 : 0);
if (source.slice(endHot, endHot + 7) === '.accept') {
// further analyze accepted modules
if (source.slice(endHot, endHot + 14) === '.acceptExports') {
lexAcceptedHmrExports(source, source.indexOf('(', endHot + 14) + 1, acceptedExports);
isPartiallySelfAccepting = true;
}else if (lexAcceptedHmrDeps(source, source.indexOf('(', endHot + 7) + 1, acceptedUrls)) {
isSelfAccepting = true;
}
}
}
else if (prop === '.env') {
hasEnv = true;
}
continue;
}
}
}
acceptedHmrExports和importedBindings变量产生缘由分析
为什么需要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 = () => <div>foo</div>
export const bar = () => 123
在理想情况下,如果我们改变bar
这个值,那么son.jsx
应该触发热更新重新加载!但是parent.jsx
不应该热更新重新加载,因为它所使用的Foo
并没有发生改变
// parent.jsx
import { Foo } from './Foo.js'
export const Bar = () => <Foo />
因此需要一个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 = () => <Foo />
如下面代码所示,我们会往extractImportedBindings()
传入
imports[index]
:import { Foo } from './Foo.js'
importedBindings
: 空的Map
数据结构
然后我们会解析出namespacedImport
、defaultImport
、namedImports
等数据,然后往importedBindings
添加对应的字符串,为:
bindings.add('*')
bindings.add('default')
bindings.add(name)
:import
的属性名称,比如"Foo"
本质就是解析出import的类型,比如有的是import->export default,有的是import->export const name=""
if (enablePartialAccept && importedBindings) {
extractImportedBindings(
resolvedId,
source,
imports[index],
importedBindings
)
}
async function extractImportedBindings(
id: string,
source: string,
importSpec: ImportSpecifier,
importedBindings: Map<string, Set<string>>
) {
let bindings = importedBindings.get(id)
if (!bindings) {
bindings = new Set < string > ()
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 < imports.length; index++) {
// check import.meta usage
if (rawUrl === 'import.meta') {
const prop = source.slice(end, end + 4);
if (prop === '.hot') {
hasHMR = true;
const endHot = end + 4 + (source[end + 4] === '?' ? 1 : 0);
if (source.slice(endHot, endHot + 7) === '.accept') {
// further analyze accepted modules
if (source.slice(endHot, endHot + 14) === '.acceptExports') {
lexAcceptedHmrExports(source, source.indexOf('(', endHot + 14) + 1, acceptedExports);
isPartiallySelfAccepting = true;
} else if (lexAcceptedHmrDeps(source, source.indexOf('(', endHot + 7) + 1, acceptedUrls)) {
isSelfAccepting = true;
}
}
}
continue;
}
}
for (const { url, start, end } of acceptedUrls) {
const [normalized] = await moduleGraph.resolveUrl(toAbsoluteUrl(markExplicitImport(url)), ssr);
normalizedAcceptedUrls.add(normalized);
}
await moduleGraph.updateModuleInfo(importerModule, importedUrls, importedBindings, normalizedAcceptedUrls,
isPartiallySelfAccepting ? acceptedExports : null, isSelfAccepting, ssr);
}
function lexAcceptedHmrDeps(code, start, urls) {
function addDep(index) {
urls.add({
url: currentDep,
start: index - currentDep.length - 1,
end: index + 1,
});
currentDep = '';
}
//...解析code,调用addDep()
}
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;
}
分析完上面4个变量,现在我们可以进行总结
isSelfAccepting
: 代表该文件是否具有热更新hot.accept代码,因为如果不写热更新代码,那么这个文件发生变化,是无法被处理的!这个值会根据文件类型的不同而采取不同的逻辑判断importedBindings
: 代表该文件中import了什么依赖,以及import使用的是具名函数还是default还是*acceptedHmrDeps
: 代表该文件中import.meta.hot.accept(["a", "b"])的内容,比如"a"和"b"acceptedHmrExports
: 代表该文件中import.meta.hot.acceptExports(["a", "b"])的内容,比如"a"和"b"现在我们就可以进行
propagateUpdate()
的详细分析
propagateUpdate()详细分析
下面代码为propagateUpdate()
的所有代码,我们可以分为4个部分进行分析
当propagateUpdate()
返回true
时,说明无法找到热更新边界,需要全量更新<br/>
当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)
的监听代码,并且包含当前文件node(如下面代码块所示), 那么热更新边界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);
那为什么满足areAllImportsAccepted
就触发continue
呢?
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
热更新
当B.js监听的acceptExports
的字段(test)跟A.js中import的字段(test1)不一样时,如下面代码所示,会触发向上寻找热更新边界
// 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热更新触发");
})
const test1 = "B432.js";
export {test, test1}
如果将B.js监听的acceptExports
的字段改为test1
,跟A.js中import的字段(test1)一样时,然后改变test1以及改变test,那么最终会发生什么呢?
<br/>答:最终只会触发当前文件B.js的热更新,不会触发向上寻找热更新边界(此时areAllImportsAccepted()=true,满足areAllImportsAccepted
触发continue
,不会触发向上寻找热更新边界)
从这个例子中我们就可以清晰明白acceptExports
的作用,我们可以监听部分export
变量,从而避免过多文件的无效热更新
那如果目前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"
的值,不继续向上找热更新的边界(停止propagateUpdate()再次执行)node.acceptedHmrExports
为true
时,直接将当前node
加入到热更新边界中- 已经监听所有
export
出去的值,则不继续向上找热更新的边界(停止propagateUpdate()再次执行) - 如果没有监听所有
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;
}
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;
}
// 使用<link href="路径?t=更新时间戳">加载文件
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文件注入代码
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)
}
})
当一个.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
中
在寻找热更新边界
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
如下面代码所示,当我们通过hotModulesMap.get(path)
拿到关联的mod
,此时的mod
对应的path
文件就是代码import.meta.hot.accept
或者import.meta.hot.acceptExports
所在的文件
async function fetchUpdate({ path, acceptedPath, timestamp, explicitImportRequired, }) {
//根据路径拿到之前收集的依赖更新对象
const mod = hotModulesMap.get(path);
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => deps.includes(acceptedPath));
// 根据路径重新请求该文件数据
//...
}
然后通过deps.includes(acceptedPath)
进行注册回调的筛选,如果hot.accept
有显式注册deps
,就会根据deps
去筛选
如果hot.accept
没有显式注册deps
,那么此时deps
=[ownerPath]
,即deps
=[path]
比如B.js有一个export test,A.js中进行hot.accept("test"),那么此时
importer.acceptedHmrDeps.has(node)
:path
为importer
,acceptedPath
为node
deps就是test的路径,path就是A.js的路径,属性test的路径必定包含当前文件
node
的路径最终结果就是找到A.js中注册的
hot.accept("test")
进行执行
// @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"
也就是说虽然acceptedHmrExports能够拿到
import.meta.hot.acceptExports(["a", "b"])
的值,但仅仅是为了寻找热更新边界时使用只有
path
和acceptedPath
都为node
才会触发实际import.meta.hot.acceptExports(["a", "b"], fn)
的fn
执行
name: 'vite:import-analysis',
async transform(source, importer, options) {
for (let index = 0; index < imports.length; index++) {
// check import.meta usage
if (rawUrl === 'import.meta') {
const prop = source.slice(end, end + 4);
if (prop === '.hot') {
hasHMR = true;
const endHot = end + 4 + (source[end + 4] === '?' ? 1 : 0);
if (source.slice(endHot, endHot + 7) === '.accept') {
// further analyze accepted modules
if (source.slice(endHot, endHot + 14) === '.acceptExports') {
lexAcceptedHmrExports(source, source.indexOf('(', endHot + 14) + 1, acceptedExports);
isPartiallySelfAccepting = true;
} else if (lexAcceptedHmrDeps(source, source.indexOf('(', endHot + 7) + 1, acceptedUrls)) {
isSelfAccepting = true;
}
}
}
continue;
}
}
}
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 倍
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。