乐趣区

关于前端:HMR系列二整体模块更新的流程

在上一篇中咱们次要是理解了 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.ts
import 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.ts
if (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))});`
  )
}

总结

退出移动版