2
本文基于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内容的替换,将对应的tagtypesrc添加到指定位置中

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为:

截屏2023-04-08 02.46.32.png

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/clientindex.html<script>后,我们会运行@vite/client代码,然后我们会执行什么逻辑呢?

建立客户端的WebSocket,添加常见的事件: openmessageclose
当文件发生改变时,会触发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.jsIndex.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()方法,进行当前ownerPathcallbacks收集

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()isConfigisConfigDependencyisEnv代表什么意思?
  • isConfig代表更改的文件是configFile配置文件
  • isConfigDependency代表更改的文件是configFile配置文件的依赖文件
  • isEnv代表更改的文件是.env.xxx文件,当vite.config.js中配置InlineConfig.envFile=false时,会禁用.env文件

如果是上面三种条件中的文件发生改变,则直接重启本地服务器

全量更新的条件是什么?
  • (仅限开发)客户端本身不能热更新,满足client/client.mjs就是全量更新的条件需要全量更新
  • 如果没有模块需要更新,并且变化的是.html文件,需要全量更新

当不满足上面两种条件时,有对应的模块变化时,触发updateModules()逻辑

5.5.2 寻找热更新边界

注:acceptedHmrExportsvite 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/7309https://github.com/vitejs/vite/pull/7324中,我们可以发现acceptedHmrExportsimportedBindings的相关源码提交记录讨论

源码的提交记录是feat(hmr): experimental.hmrPartialAccept (#7324)

Reactson.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 => { ... })
}

defaultBar发生改变时,会触发上面注册的(newModule)=>{开始更新逻辑}方法的执行

importedBindings变量解析
acceptedHmrExportsimportedBindings配套使用!

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数据结构

然后我们会解析出namespacedImportdefaultImportnamedImports等数据,然后往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进行赋值

acceptedHmrExportsacceptedHmrDeps的数据在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.isSelfAcceptingtrue,代表它有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.isSelfAcceptingfalse,继续执行下面的条件判断
  • importer.acceptedHmrDeps.has(node),即parent有注入accept("A")监听import {A} from "xxx"的值,不继续向上找热更新的边界(停止propagateUpdate()再次执行)
  • node.acceptedHmrExportstrue时,直接将当前node加入到热更新边界中

    • 已经监听所有export出去的值,则不继续向上找热更新的边界(停止propagateUpdate()再次执行)
    • 如果没有监听所有export出去的值,则继续向上找热更新的边界propagateUpdate(importer, boundaries)
  • node.acceptedHmrExportsfalse时,继续向上找热更新的边界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最终的数据结构为:

截屏2023-04-11 09.32.35.png
其中有两个变量需要注意下:pathacceptedPath

  • path: 取的是boundary.url
  • acceptedPath: 取的是acceptedVia.url

在寻找热更新边界propagateUpdate()时,如下面代码所示,我们知道

  • node.isSelfAccepting: pathacceptedPath都为node
  • node.acceptedHmrExports: pathacceptedPath都为node
  • importer.acceptedHmrDeps.has(node): pathimporteracceptedPathnode
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.typejs-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: pathacceptedPath都为node
  • node.acceptedHmrExports: pathacceptedPath都为node
  • importer.acceptedHmrDeps.has(node): pathimporteracceptedPathnode

还有可能触发向上找热更新的边界propagateUpdate(importer, boundaries),此时pathimporteracceptedPathimporter

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()fetchedModuleconst {default}=fetchedModule也就是请求文件的内容(文件进行export default的内容)


当一个文件使用hot.accept("依赖a的路径")或者hot.accept(["依赖a的路径","依赖b的路径"])时,参数会作为deps存入到mod.callbacks

在寻找热更新边界propagateUpdate()时,我们知道

  • node.isSelfAccepting: pathacceptedPath都为node
  • node.acceptedHmrExports: pathacceptedPath都为node
  • importer.acceptedHmrDeps.has(node): pathimporteracceptedPathnode

还有可能触发向上找热更新的边界propagateUpdate(importer, boundaries),此时pathimporteracceptedPathimporter

如下面代码所示,当我们通过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)pathimporteracceptedPathnode

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: pathacceptedPath都为node
  • node.acceptedHmrExports: pathacceptedPath都为node
  • importer.acceptedHmrDeps.has(node): pathimporteracceptedPathnode

还有可能触发向上找热更新的边界propagateUpdate(importer, boundaries),此时pathimporteracceptedPathimporter

这里的node代表当前文件的路径!importer代表import当前node文件的那个文件的路径!

从上面的分析中,import.meta.hot.accept(xxx)则可以设置pathimporteracceptedPathnode,即可以在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"])的值,但仅仅是为了寻找热更新边界时使用

只有pathacceptedPath都为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 预构建原理

  1. 遍历所有的文件,搜集所有裸模块的请求,然后将所有裸模块的请求作为esbuild打包的入口文件,将所有裸模块缓存打包到.vite/deps文件夹下,在打包过程中,会将commonjs转化为esmodule的形式,本质是使用一个export default包裹着commonjs的代码,同时利用esbuild的打包能力,将多个内置请求合并为一个请求,防止大量请求引起浏览器端的网络堵塞,使页面加载变得非常缓慢
  2. 在浏览器请求链接时改写所有裸模块的路径指向.vite/deps
  3. 如果想要重新执行预构建,使用--force参数或者直接删除node_modeuls/.vite/deps是比较快捷的方式,或者改变一些配置的值可以触发重新预构建

6.2 热更新原理

  1. 使用websocket建立客户端和服务端
  2. 服务端会监听文件变化,然后通过一系列逻辑判断,得出热更新的文件范围,此时的热更新边界的判断依赖于transform文件内容时的分析,每一个文件都具有一个对象数据ModuleNode
  3. 客户端接收服务端的热更新文件范围相关的路径后,进行客户端中热更新代码的调用

6.3 vite与webpack的区别

webpack是先解析依赖,打包构建,形成bundle后再启动开发服务器,当我们修改bundle的其中一个子模块时,我们需要对这个bundle重新打包然后触发热更新,项目越大越复杂,启动时间就越长
vite的核心原理是利用esmodule进行按需加载,先启动开发服务器,然后再进行import模块,无法进行整体依赖的解析和构建打包,同时使用esbuild快速的打包速度进行不会轻易改变node_modules依赖的预构建,提升速度,当文件发生改变时,也会发送对应的文件数据到客户端,进行该文件以及相关文件的热更新,不用重新构建和重新打包,项目越大提升效果越明显

6.4 vite对比webpack优缺点

vite优点

  1. 快速的服务器启动: 当冷启动开发服务器时,基于打包器的方式启动必须优先抓取并构建你的整个应用,然后才能提供服务,Vite 以 原生 ESM 方式提供源码,让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码,然后根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理
  2. 反应快速的热更新: 基于打包器启动时,重建整个包的效率很低,并且更新速度会随着应用体积增长而直线下降,在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活(大多数时候只是模块本身),使得无论应用大小如何,HMR 始终能保持快速更新

vite缺点

  1. 首屏加载较慢: 需要大量http请求和源文件转化操作,首次加载还需要花费时间进行预构建

    虽然vite已经改进预构建不会影响本地服务的启动和运行,但是一些预构建的库,比如react.js,还是得等待预构建完成后才能加载react.js,然后进行整体渲染
  2. 懒加载较慢: 和首屏加载一样,动态加载的文件仍然需要转化操作,可能会存在大量的http请求(多个业务依赖文件)
  3. 开发服务器和生产环境构建之间输出和行为可能不一致: 开发环境使用esbuild,生产环境使用rollup,有一些插件(比如commonjs的转化)需要区分开发环境和生产环境

6.5 vite如何处理Typescript、SCSS等语言

  • vite:css插件: 调用预处理器依赖库进行转化处理
  • vite:esbuild插件: .ts.tsx转化.js,用来代替传统的tsc转化功能
Vite 使用 esbuild 将 TypeScript 转译到 JavaScript,约是 tsc 速度的 20~30 倍

参考文章

  1. Vite源码分析,是时候弄清楚Vite的原理了
  2. Vite原理及源码解析
  3. Vite原理分析

工程化文章

  1. 「Webpack5源码」热更新HRM流程浅析
  2. 「Webpack5源码」make阶段(流程图)分析
  3. 「Webpack5源码」enhanced-resolve路径解析库源码分析
  4. 「vite4源码」dev模式整体流程浅析(一)
  5. 「vite4源码」dev模式整体流程浅析(二)

白边
209 声望37 粉丝

源码爱好者,已经完成vue2和vue3的源码解析+webpack5整体流程源码+vite4开发环境核心流程源码+koa2源码