在上一篇中咱们次要是理解了 HMR 的简略概念以及相干 API,也手动实现了文件的 HMR。
接下来咱们来梳理一下在 vite 中,当咱们对文件代码做出扭转时,整个 HMR 的流程是怎么的。

这里可能还会有疑难,就是咱们在上篇文章中都是手动对每个文件增加了import.meta.hot.accept()然而在咱们理论开发的我的项目中,其实咱们是没有在代码中手动增加热更新相干的代码的,然而他还是会进行 hmr,其实是插件帮咱们注入了 hmr 相干的操作。咱们在下篇文章中会解析插件(@vite/react-plugin)

hmr其实整体分为两个局部:

  1. 在服务端监听到模块改变,对模块进行相应的解决,将解决的后果发送给客户端(浏览器)进行热更新。
  2. 客户端收到服务端发送的信息,进行解决,解析出对应须要热更新的模块,从新import最新模块,实现hmr

整个过程中的通信都是通过websocket来实现的。

服务端

首先咱们启动服务之后,批改render.ts文件来触发hmr

chokidar 监听文件

vite中是通过chokidar来监听文件的

这里的moduleGraph其实是收集的各个模块间的信息。
也是hmr流程中比拟要害的信息,不过这里不开展讲。后续再看这一块
当初咱们只须要晓得这里寄存的是所有模块的依赖关系。比方a文件import什么文件,以及a文件被什么文件所依赖
// packages/vite/src/node/server/index.tsimport chokidar from 'chokidar'// 监听根目录下的文件const watcher = chokidar.watch(path.resolve(root))// 批改文件watcher.on('change', async (file) => {  file = normalizePath(file)  moduleGraph.onFileChange(file)  await handleHMRUpdate(file, server)})// 新增文件watcher.on('add', (file) => {  handleFileAddUnlink(normalizePath(file), server)})// 删除文件watcher.on('unlink', (file) => {  handleFileAddUnlink(normalizePath(file), server, true)})

handleHMRUpdate

当文件扭转之后(change)事件。会进入到handleHMRUpdate函数中。

async function handleHMRUpdate(file, server) {  // 1. 这一部分是对配置文件和环境变量相干的文件进行解决  const { ws, config, moduleGraph } = server  const shortFile = getShortName(file, config.root)  const fileName = path.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) {    // auto restart server    debugHmr(`[config change] ${colors.dim(shortFile)}`)    config.logger.info(colors.green(`${path.relative(process.cwd(), file)} changed, restarting server...`), { clear: true, timestamp: true })    try {      // 服务器重新启动      await server.restart()    } catch (e) {      config.logger.error(colors.red(e))    }    return  }  debugHmr(`[file change] ${colors.dim(shortFile)}`)  // 客户端注入的文件(vite/dist/client/client.mjs)更改,间接刷新页面  if (file.startsWith(normalizedClientDir)) {    ws.send({      type: 'full-reload',      path: '*',    })    return  }  // ====================================================  // 2. 对一般文件进行解决  const mods = moduleGraph.getModulesByFile(file)  const timestamp = Date.now()  // 初始化hmr的context(咱们在handleHotUpdate中拿的参数)  const hmrContext = {    file,    timestamp,    modules: mods ? [...mods] : [],    read: () => readModifiedFile(file),    server,  }  // 执行插件中 handleHotUpdate 中的钩子,失去须要更新的模块  for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {    const filteredModules = await hook(hmrContext)    if (filteredModules) {      hmrContext.modules = filteredModules    }  }  if (!hmrContext.modules.length) {    // html文件不能hmr,间接刷新页面    if (file.endsWith('.html')) {      config.logger.info(colors.green(`page reload `) + colors.dim(shortFile), {        clear: true,        timestamp: true,      })      ws.send({        type: 'full-reload',        path: config.server.middlewareMode ? '*' : '/' + normalizePath(path.relative(config.root, file)),      })    } else {      // loaded but not in the module graph, probably not js      debugHmr(`[no modules matched] ${colors.dim(shortFile)}`)    }    return  }  // 这里执行次要的模块更新逻辑  updateModules(shortFile, hmrContext.modules, timestamp, server)}

:::danger

总结:

  1. 对配置文件、环境变量相干的文件,会间接重启服务器
  2. 客户端注入的文件(vite/dist/client/client.mjs)更改,间接full-reload,刷新页面
  3. 之后会执行插件中的所有handleHotUpdate钩子,失去须要解决的模块
  4. 执行updateModules来进行模块更新(hmr的次要逻辑)

:::

updateModules

/** * file: 文件门路(`src/render.ts`) * module: 更新的模块汇合(具体长啥样看上面截图) module[] * timestamp: 工夫戳 * server: 服务端相干配置 */function updateModules(file, modules, timestamp, { config, ws }) {  // 更新模块的汇合  const updates = []  // 有效模块汇合(?)  const invalidatedModules = new Set()  // 是否须要刷新页面  let needFullReload = false  // 对更新的模块进行遍历  for (const mod of modules) {    // 检测模块是否有效(即不须要更新)    invalidate(mod, timestamp, invalidatedModules)    if (needFullReload) {      continue    }    const boundaries = new Set()    // 查找更新的边界    const hasDeadEnd = propagateUpdate(mod, boundaries)    // 如果为true就刷新页面    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),      }))    )  }  if (needFullReload) {    config.logger.info(colors.green(`page reload `) + colors.dim(file), {      clear: true,      timestamp: true,    })    ws.send({      type: 'full-reload',    })    return  }  if (updates.length === 0) {    debugHmr(colors.yellow(`no update happened `) + colors.dim(file))    return  }  // 打印信息  config.logger.info(updates.map(({ path }) => colors.green(`hmr update `) + colors.dim(path)).join('\n'), { clear: true, timestamp: true })  // updates:{  //     type: "js-update",  //     timestamp: 1665641766748,  //     path: "/src/main.ts",  //     explicitImportRequired: false,  //     acceptedPath: "/src/render.ts",  // }  ws.send({    type: 'update',    updates,  })}
modules:

propagateUpdate

这外面须要留神的是isSelfAccepting是否接管本身的更新。这里指的是文件中有import.meta.hot.accept()的模块,accept中不能有依赖的参数,比方accept('xxx',()=>{})

function propagateUpdate(node, boundaries, currentChain = [node]) {  // 是否接管本身的更新,如果接管就将自身这个模块放到热更新的边界汇合中去  if (node.isSelfAccepting) {    boundaries.add({      boundary: node,      acceptedVia: node,    })    // 如果该模块中援用了css,则将css全副退出到边界汇合中    for (const importer of node.importers) {      if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {        propagateUpdate(importer, boundaries, currentChain.concat(importer))      }    }    return false  }  if (node.acceptedHmrExports) {    boundaries.add({      boundary: node,      acceptedVia: node,    })  } else {    // 。。。  }  // 不承受本身更新的,查找援用它的模块是否接管更新  for (const importer of node.importers) {    const subChain = currentChain.concat(importer)    // importer如果将该模块列为acceptedHmrDeps,在将importer列入更新边界中    if (importer.acceptedHmrDeps.has(node)) {      boundaries.add({        boundary: importer,        acceptedVia: node,      })      continue    }    // 。。。    if (currentChain.includes(importer)) {      // 循环依赖间接终止,返回true刷新页面      return true    }    if (propagateUpdate(importer, boundaries, subChain)) {      return true    }  }  // 返回false进行hmr  return false}
这里有一个问题就是,如果咱们在render.ts中设置了acceptI(),那么main中的accept中即时依赖了render.ts,那么main模块也不会呈现在hmr的边界汇合中。
也就是一旦这个模块成为了isSelfAccepting,那么它更新的边界就是它自身,只有当该文件isSelfAccepting===false的时候才会去遍历他的前置依赖者(也就是 import 该模块的父级模块)是否依赖了该模块的更新(就像main中依赖了render一样)

invalidate

invalidate 函数次要做以下几件事:

  1. 更新了模块的最初热更工夫
  2. 并将代码转换相干的后果(transformResultssrTransformResult)置空
  3. 最初遍历模块的援用者(importers,也可叫作前置依赖,具体指哪些模块援用了该模块)
// 查看是否为有效模块,并且更新mod的信息function invalidate(mod, timestamp, seen) {  // 防止出现循环依赖  if (seen.has(mod)) {    return  }  seen.add(mod)  // 更新模块上次hmr的工夫  mod.lastHMRTimestamp = timestamp  // 置空一系列信息  mod.transformResult = null  mod.ssrModule = null  mod.ssrError = null  mod.ssrTransformResult = null  // 查看援用该模块的文件中是否接管该模块的hmr---accptedHmrDeps  mod.importers.forEach((importer) => {    // 如果援用该模块的文件的acceptedHmrDeps(可承受的更新依赖模块)中不蕴含本次文件变动    // 的模块,就证实该importer的不须要更新    // 就持续对该importer进行检测,清空前置依赖的一些援用,更新信息    if (!importer.acceptedHmrDeps.has(mod)) {      invalidate(importer, timestamp, seen)    }  })}// 其实在咱们的案例中来解释invalidate函数做了什么事:// 咱们是变动的render.ts,此时main是援用了render.ts的,所以这个importer就是main文件。// 那咱们在main的import.meta.hot.accept外面是依赖了render的,所以importer.acceptedHmrDeps// 就有render.ts,所以main是一个无效的更新模块,即须要hmr// 假如咱们的main不依赖render,那么importer.acceptedHmrDeps.has(mod)就是false,就会对main进行// invalidate,清空importer的援用信息更新mod对应的相干信息

:::danger
总结:
次要就是寻找模块更新的边界。
:::

到这里服务端的工作就实现了。
服务端会将信息发送给客户端,最终发送的信息大略是这样的:

{  "type": "js-update",  "timestamp": 1665641766748,  "path": "/src/main.ts",  "explicitImportRequired": false,  "acceptedPath": "/src/render.ts"}

客户端

当客户端接管到服务端发送过去的ws信息之后,也会进行相干的解决。
而客户端解决信息的代码,也是vite注入的,大略长这样:

这里大略就是创立了一个websocket服务器,而后监听一些事件。咱们这里重点关注的是handleMessage,这个函数会在服务端发送过去信息的时候触发,用来解决hmr的信息

handleMessage

async function handleMessage(payload) {  switch (payload.type) {    case 'connected':      console.debug(`[vite] connected.`)      sendMessageBuffer()      // ws心跳检测,保障ws服务处于连贯中      setInterval(() => {        if (socket.readyState === socket.OPEN) {          socket.send('{"type":"ping"}')        }      }, __HMR_TIMEOUT__)      break    case 'update':      // 触发vite插件中的对应名称的钩子      notifyListeners('vite:beforeUpdate', payload)      // 。。。      payload.updates.forEach((update) => {        // 对js文件进行解决        if (update.type === 'js-update') {          // 次要逻辑在这里!!!!          queueUpdate(fetchUpdate(update))        } else {          // css-update          // 。。。          console.debug(`[vite] css hot updated: ${searchUrl}`)        }      })      break    case 'custom': {      notifyListeners(payload.event, payload.data)      break    }    case 'full-reload':      notifyListeners('vite:beforeFullReload', payload)      if (payload.path && payload.path.endsWith('.html')) {        // if html file is edited, only reload the page if the browser is        // currently on that page.        const pagePath = decodeURI(location.pathname)        const payloadPath = base + payload.path.slice(1)        if (pagePath === payloadPath || payload.path === '/index.html' || (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)) {          location.reload()        }        return      } else {        location.reload()      }      break    case 'prune':      // 。。。。      break    case 'error': {      // 。。。      break    }    default: {      const check = payload      return check    }  }}

:::danger
总结:
对服务端发送来的不同类型的音讯进行解决。

  1. 对于不同类型可能须要触发不同的 vite 插件中的钩子。
  2. 咱们次要关注 queueUpdate(fetchUpdate(update))
    :::

queueUpdate

这个办法次要是进行更新工作的调度。保障触发程序。
参数p就是fetchUpdate(updatre)中的返回后果,咱们把留神里放到这个函数中去。

// 将由同一src门路更改触发的多个热更新放入队列中,以便依照发送顺序调用它们。// (否则,因为http申请往返,程序可能不统一)async function queueUpdate(p) {  queued.push(p)  // p:执行的就是上面的办法:  // () => {  //       for (const { deps, fn } of qualifiedCallbacks) {  //           fn(deps.map((dep) => moduleMap.get(dep)));  //       }  //   };  if (!pending) {    pending = true    await Promise.resolve()    pending = false    const loading = [...queued]    queued = []    ;(await Promise.all(loading)).forEach((fn) => fn && fn())  }}

fetchUpdate

/** * 这个参数就是最终服务端发送的update信息 **/async function fetchUpdate({ path, acceptedPath, timestamp, explicitImportRequired }) {  const mod = hotModulesMap.get(path)  // 获取到的mod的格局  // {  //   "id": "/src/render.ts",  //   "callbacks": [  //     {  //       "deps": [  //         "/src/render.ts"  //       ],  //       "fn": ([mod]) => deps && deps(mod)  //     }  //   ]  // }  if (!mod) {    return  }  const moduleMap = new Map()  const isSelfUpdate = path === acceptedPath  // 过滤出来对应的依赖门路上面的callback  const filtercb = ({ deps }) => deps.includes(acceptedPath)  const qualifiedCallbacks = mod.callbacks.filter(filtercb)  if (isSelfUpdate || qualifiedCallbacks.length > 0) {    const dep = acceptedPath    const disposer = disposeMap.get(dep)    if (disposer) await disposer(dataMap.get(dep))    // 获取门路的参数    const [path, query] = dep.split(`?`)    try {      // 从新导入文件获取更新之后的模块      const newMod = await import(        /* @vite-ignore */        base + path.slice(1) + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}`      )      moduleMap.set(dep, newMod)    } catch (e) {      warnFailedFetch(e, dep)    }  }  return () => {    for (const { deps, fn } of qualifiedCallbacks) {      //  moduleMap: { key: 'src/render',value: Module }      //  moduleMap.get(dep)获取到的是一个模块      //  fn([Module]) ===>  ([mod]) => deps && deps(mod)      fn(deps.map((dep) => moduleMap.get(dep)))    }    const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`    console.debug(`[vite] hot updated: ${loggedPath}`)  }}

:::danger
总结:

const newMod = await import(/* @vite-ignore */ `path + import&t=${timestamp}${query}`)

在从新导入模块后,会发送新的申请,来申请最新的模块内容

:::

最初的疑难

咱们在fetchUpdate中通过hotModulesMap.get获取到的mod,格局是这样的

    {      "id": "/src/render.ts",      "callbacks": [        {          "deps": [            "/src/render.ts"          ],          "fn": ([mod]) => deps && deps(mod)        }      ]    }

其实会有个疑难,这个数据里的 fn 是哪里来的?hotModulesMap又是哪来的?
这里咱们就须要持续往下看!

咱们先来看下咱们在更新模块内容之后,申请回来的文件长什么样?(咱们这里还是更改的render.ts)

import { createHotContext as __vite__createHotContext } from '/@vite/client'import.meta.hot = __vite__createHotContext('/src/render.ts')export const render = () => {  const app = document.querySelector('#app')  app.innerHTML = `    <h1>Helloss Vite12d</h1>    <p id="p">\u662F\u662Ffff\u6492\u53D1\u987A\u4E30\u662Fdfd\uFF01sooo\uFF1F</p>  `}export const other = () => {  const p = document.querySelector('#p')  p.innerHTML = `    <p>other</p>  `}if (import.meta.hot) {  import.meta.hot.data.count = 1  import.meta.hot.accept((mod) => mod?.render())}
vite:import-analysis 插件进行注入的

能够看到,在咱们文件的头部是被注入了createHotContext的,并且重写了咱们的import.meta.hot中的内容为createHotContext的返回值。

也就是说咱们在 21 行应用的 accept 办法是被createHotContext重写过的,那咱们就来看看createHotContext做了什么?

createHotContext

其实这个办法的次要工作就是:重写客户端的import.meta.hot中的一系列办法。

const hotModulesMap = new Map<string, HotModule>()// 简化后的代码/** * ownerPath: 以后文件的相对路径 "/src/render.ts" **/export function createHotContext(ownerPath: string): ViteHotContext {  const mod = hotModulesMap.get(ownerPath)  if (mod) {    mod.callbacks = []  }  // 2. 再来看这个函数  function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {    // 先查找缓存,如果没缓存,就新建模块相干信息    const mod: HotModule = hotModulesMap.get(ownerPath) || {      id: ownerPath,      callbacks: [],    }    // 批改模块的相干信息    mod.callbacks.push({      deps,      fn: callback,    })    // 将模块信息放入hotModuleMap中    hotModulesMap.set(ownerPath, mod)  }  // 1. 先来看最终的返回值,就是对上一篇中提到的hmr中的一些API进行重写  const hot: ViteHotContext = {    get data() {      return dataMap.get(ownerPath)    },    // 咱们重点关注这里,重写accept办法    accept(deps?: any, callback?: any) {      // 第一种状况就是,import.meta.accetpt(mod=>{}) 这样写      if (typeof deps === 'function' || !deps) {        // 咱们下面的问题,fn就是在这里注入的,这里的deps其实就是咱们传入的回调 mod=>{}        acceptDeps([ownerPath], ([mod]) => deps && deps(mod))      } else if (typeof deps === 'string') {        // 第二种状况是,import.meta.accetpt('xxx', mod=>{})        acceptDeps([deps], ([mod]) => callback && callback(mod))      } else if (Array.isArray(deps)) {        // 第三种状况是,import.meta.accetpt(['xxx','xxxx'], mod=>{})        acceptDeps(deps, callback)      } else {        throw new Error(`invalid hot.accept() usage.`)      }    },    // export names (first arg) are irrelevant on the client side, they're    // extracted in the server for propagation    acceptExports(_: string | readonly string[], callback?: any) {      acceptDeps([ownerPath], callback && (([mod]) => callback(mod)))    },    dispose(cb) {      disposeMap.set(ownerPath, cb)    },    // @ts-expect-error untyped    prune(cb: (data: any) => void) {      pruneMap.set(ownerPath, cb)    },    decline() {},    // tell the server to re-perform hmr propagation from this module as root    invalidate() {      notifyListeners('vite:invalidate', { path: ownerPath })      this.send('vite:invalidate', { path: ownerPath })    },    // custom events    on(event, cb) {      const addToMap = (map: Map<string, any[]>) => {        const existing = map.get(event) || []        existing.push(cb)        map.set(event, existing)      }      addToMap(customListenersMap)      addToMap(newListeners)    },    send(event, data) {      messageBuffer.push(JSON.stringify({ type: 'custom', event, data }))      sendMessageBuffer()    },  }  return hot}

对于下面这个的createHotContext函数咱们分为两局部来看:

  1. 先看返回值hot, 这里总结来说就是重写上一篇中咱们提到过的hmr相干的API。 咱们重点关注accept函数,这里对accept函数的三种不同应用形式进行解决:

    1. 第一种状况就是:import.meta.accetpt(mod=>{})
    2. 第二种状况是:import.meta.accetpt('xxx', mod=>{})
    3. 第三种状况是:import.meta.accetpt(['xxx','xxxx'], mod=>{})
  2. 而后再看在返回值里重写accept办法时,用到了一个新的办法acceptDeps。这个办法其实就是更新hotModulesMap信息的。

参数:

  1. deps: 第一种状况下,其实就是传入的自身的门路,其余状况下是在accpet中被动申明的依赖
  2. callback

    1. 第一种状况下,是从新注入的([mod]) => deps && deps(mod),这里的deps其实就是咱们传入的回调mod=>{}
    2. 第二种状况下,也是从新注入的([mod]) => callback && callback(mod),这里的callback是咱们在 accept 中传入的第二个参数import.meta.accetpt('xxx', mod=>{})
    3. 第三种状况就没怎么解决,将两个参数顺次传入就好了
    咱们下面的疑难 ❓:
    这个 fn 是哪里来的? 其实就是在从新 accpet 时
    acceptDeps([ownerPath], ([mod]) => deps && deps(mod))中注入的

而后咱们最初再来看一下在queueUpdat中调度工作最初执行的办法

// qualifiedCallbacks: {//       "deps": [//         "/src/render.ts"//       ],//       "fn": ([mod]) => deps && deps(mod)//     }for (const { deps, fn } of qualifiedCallbacks) {  fn(deps.map((dep) => moduleMap.get(dep)))}
  • fn: 就是咱们下面解析的acceptDeps中的第二个参数callback
  • moduleMap.get(dep): 获取到的是一个模块的信息Module
  • 所以最终fn([mod]) ===> ([mod]) => cb(mod)cb就是咱们在accept中传入的)

在哪里注入的 createContext

// vite/src/node/plugins/importAnalysis.tsif (hasHMR && !ssr) {  debugHmr(`${isSelfAccepting ? `[self-accepts]` : isPartiallySelfAccepting ? `[accepts-exports]` : acceptedUrls.size ? `[accepts-deps]` : `[detected api usage]`} ${prettyImporter}`)  // inject hot context  str().prepend(    `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` + `import.meta.hot = __vite__createHotContext(${JSON.stringify(normalizeHmrUrl(importerModule.url))});`  )}

总结