The principle of HMR revealed by Vite technology (Part 1)

Get into the habit of writing together! This is the 8th day of my participation in the "Nuggets Daily New Plan · April Update Challenge", click to view the details of the event .

Hello everyone, I'm Xiaoyu, a code farmer. In the previous section, we learned the client API of HMR, and we have a clear understanding of the common hot update receiving mechanism, hot update invalidation, and multi-instance variable cache. In this section, we will first explore the implementation principle of HMR from the node side.

When we modify a line of code in vscode (or other code editors), the file change will be triggered, and then it will be monitored by the file monitor instance on the Vite server (in the CreateServer section of Vue Technology Reveal, we know that the service creates a file monitor through chokidar Instance) to get the file change to trigger the change event:

// 文件改变时触发事件
watcher.on('change', async (file) => {
  // 规范化文件路径,将\\替换成/
  file = normalizePath(file)
  
  // ...
})

// 添加文件事件
watcher.on('add', (file) => {
  handleFileAddUnlink(normalizePath(file), server)
})

// 删除文件事件
watcher.on('unlink', (file) => {
  handleFileAddUnlink(normalizePath(file), server, true)
})
复制代码

When a file is added to the current directory, the add event is triggered; when a file is deleted in the current directory, the unlink event is triggered; when we modify the code, the change event is triggered. So we file = normalizePath(file)hit a breakpoint and start debugging this section.

By convention, we first prepare an example, create a Vite project with a vanillar template, and then create bar.js and foo.js files, the code is as follows:

// bar.js
export const name = 'bar.js'

// foo.js
import { name } from './bar'

export function sayName () {
  console.log(name);
  return name
}

if (import.meta.hot) {
  import.meta.hot.accept('./bar.js')
}

// main.js
import './style.css'
import { sayName } from './foo'

sayName()

if (import.meta.hot) {
  import.meta.hot.accept()
}
复制代码

main.js refers to foo.js and style.css, and foo.js refers to bar.js. The dependency diagram of the module is as follows:

After modifying the bar.js file, trigger the watcher's change event:

// 文件改变时触发事件
watcher.on('change', async (file) => {
  // 规范化文件路径,将\\替换成/
  file = normalizePath(file)
  if (file.endsWith('/package.json')) {
    return invalidatePackageData(packageCache, file)
  }
  // invalidate module graph cache on file change
  moduleGraph.onFileChange(file)
  if (serverConfig.hmr !== false) {
    try {
      await handleHMRUpdate(file, server)
    } catch (err) {
      ws.send({
        type: 'error',
        err: prepareError(err)
      })
    }
  }
})
复制代码

Get the file path file in the callback to normalizePath, and then call moduleGraph.onFileChange(file):

/**
   * 文件修改的事件
   */
onFileChange(file: string): void {
  // 根据文件获取模块信息
  const mods = this.getModulesByFile(file)
  if (mods) {
    const seen = new Set<ModuleNode>()
    mods.forEach((mod) => {
      this.invalidateModule(mod, seen)
    })
  }
}

/**
 * 处理失效的模块
 */
invalidateModule(mod: ModuleNode, seen: Set<ModuleNode> = new Set()): void {
  mod.info = undefined
  mod.transformResult = null
  mod.ssrTransformResult = null
  // ...
}
复制代码

For the bar.js file, the mods information is as follows:

所有模块循环调用 invalidateModule,就是将文件对应模块的 info、transformResult、ssrTransformResult 都置为 null;至于为什么要循环,因为一个文件对应的不止一个模块,比如 vue 的 SFC,一个 vue 文件会对应多个模块。

模块信息处理完了之后,就会开始执行热更 await handleHMRUpdate(file, server)

export async function handleHMRUpdate(
  file: string,
  server: ViteDevServer
): Promise<any> {
  const { ws, config, moduleGraph } = server
  // 获取简短文件名,对于本例子就是 bar.js
  const shortFile = getShortName(file, config.root)

  // 配置文件修改,比如 vite.config.ts
  const isConfig = file === config.configFile
  // 配置文件的依赖
  const isConfigDependency = config.configFileDependencies.some(
    (name) => file === path.resolve(name)
  )
  // 环境变量文件
  const isEnv =
    config.inlineConfig.envFile !== false &&
    (file === '.env' || file.startsWith('.env.'))

  // 如果是配置文件修改了,直接重启服务
  if (isConfig || isConfigDependency || isEnv) {
    // auto restart server
    try {
      await server.restart()
    } catch (e) {
      config.logger.error(colors.red(e))
    }
    return
  }

  // vite 的 client 修改了,全量刷新 -> 刷新页面
  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: HmrContext = {
    // 文件
    file,
    // 时间戳
    timestamp,
    // 受更改文件影响的模块数组
    modules: mods ? [...mods] : [],
    // 这是一个异步读函数,它返回文件的内容。之所以这样做,是因为在某些系统上,文件更改的回调函数可能会在编辑器完成文件更新之前过快地触发
    // 并 fs.readFile 直接会返回空内容。传入的 read 函数规范了这种行为。
    read: () => readModifiedFile(file),
    // 整个服务对象
    server
  }

  // 遍历插件,调用 handleHotUpdate 钩子
  for (const plugin of config.plugins) {
    if (plugin.handleHotUpdate) {
      const filteredModules = await plugin.handleHotUpdate(hmrContext)

      // 受更改文件影响的模块数组
      if (filteredModules) {
        hmrContext.modules = filteredModules
      }
    }
  }

  // 文件修改没有影响其他模块
  if (!hmrContext.modules.length) {
    // 是 html 的话,直接刷新页面
    if (file.endsWith('.html')) {
      ws.send({
        type: 'full-reload',
        path: config.server.middlewareMode
          ? '*'
          : '/' + normalizePath(path.relative(config.root, file))
      })
    }
    return
  }

  // 核心,执行模块更新
  updateModules(shortFile, hmrContext.modules, timestamp, server)
}
复制代码

handleHMRUpdate 主要处理了:

  1. 如果修改的是 vite.config.ts 或它的依赖文件,亦或者是环境变量的定义文件,都直接重启服务;
  2. 如果修改的是 vite 自带的 client 脚本,就刷新页面;
  3. 如果上述两种情况都不是,就定义 hmrContext 对象, 定义包含了 file 当前文件路径、timestamp 当前时间戳、modules 文件映射的模块、read 函数读取该文件内容、server 整个服务器对象;有了 hmrContext 之后,依次调用插件的 handleHotUpdate 钩子,钩子可以返回热更需要关联的模块,具体可以查看官方 HMR API 。如果没有关联的模块,并且修改的是 html 文件,发送 full-reload 进行页面刷新;前面几个条件都不满足的话,就调用 updateModules 。
/**
 * 更新模块
 * @param {string} file 文件路径
 * @param {ModuleNode[]} modules 影响的模块
 * @param {number} timestamp 当前时间的时间戳
 * @poram {ViteDevServer} server 服务对象
 */
function updateModules(
  file: string,
  modules: ModuleNode[],
  timestamp: number,
  { config, ws }: ViteDevServer
) {
  // 更新的列表
  const updates: Update[] = []

  // 失效模块
  const invalidatedModules = new Set<ModuleNode>()
  // 页面刷新符号
  let needFullReload = false

  for (const mod of modules) {
    invalidate(mod, timestamp, invalidatedModules)
    // 如果需要重新刷新,不再去计算边界
    if (needFullReload) {
      continue
    }

    const boundaries = new Set<{
      boundary: ModuleNode
      acceptedVia: ModuleNode
    }>()
    // 死路标志
    const hasDeadEnd = propagateUpdate(mod, boundaries)
    // 死路的话直接刷新页面
    if (hasDeadEnd) {
      needFullReload = true
      continue
    }

    // 否则的话,遍历全部边界,触发模块更新
    updates.push(
      ...[...boundaries].map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update` as Update['type'],
        timestamp,
        path: boundary.url,
        acceptedPath: acceptedVia.url
      }))
    )
  }

  if (needFullReload) {
    ws.send({
      type: 'full-reload'
    })
  } else {
    // ...
    // 触发全部模块的更新
    ws.send({
      type: 'update',
      updates
    })
  }
}
复制代码

上述代码遍历 modules,调用 invalidate 更新模块和引用者(importers)的信息,声明 HMR 边界(“接受” 热更新的模块),调用 propagateUpdate 判断模块之前是否存在“死路”,如果存在“死路”就直接发起 full-reload 命令刷新页面,否则发起 update 命令执行指定模块(updates)的更新。客户端接收命令的处理方式我们放在下篇去分析。

invalidate

上述流程有两个细节我们略过了,现在先来看看 invalidate 的处理:

/**
 * 处理失效模块
 * @param {ModuleNode} mod 模块节点
 * @param {number} timestamp 当前时间
 * @param {Set<ModuleNode>} seen
 */
function invalidate(mod: ModuleNode, timestamp: number, seen: Set<ModuleNode>) {
  if (seen.has(mod)) {
    return
  }
  seen.add(mod)
  mod.lastHMRTimestamp = timestamp
  // 置空一系列信息
  mod.transformResult = null
  mod.ssrModule = null
  mod.ssrTransformResult = null
  // 遍历依赖者,如果热更新的模块中不存在该模块
  mod.importers.forEach((importer) => {
    // 当前模块热更的依赖不包含当前模块,accept 的参数,例子中 foo 是 bar 的引用者,这里的判断是 true;
    // 如果不存在也就是 accept 的参数是空时就清空引用者的信息
    if (!importer.acceptedHmrDeps.has(mod)) {
      invalidate(importer, timestamp, seen)
    }
  })
}
复制代码

invalidate 函数更新了模块的最后热更时间,并将代码转换(transformResult、ssrTransformResult)置空,最后遍历模块的引用者(importers,也可叫作前置依赖,具体指哪些模块引用了该模块)。importer.acceptedHmrDeps 获取到的是模块中 import.meta.hot.accept 的 dep(s) 参数,对于本文的例子而言,mod 就是我们修改的文件 bar.js 指向的模块,importers 指的是 foo.js,所以 importer.acceptedHmrDeps 就是代码 import.meta.hot.accept('./bar.js') 中的 dep 参数代表的模块集合,即 './bar.js' 文件指向的模块,所以经过 invalidate 处理之后的结果如下:

因为引用者 foo.js 接受 bar.js 模块的更新, 所以 importer.acceptedHmrDeps.has(mod) 返回的是 true,取反后就不会执行内部的 invalidate。所以上述结果中 importers 中的 foo.js 模块 transformResult 结果没有置空。

propagateUpdate

接下来再来看看 propagateUpdate 是如何判断“死路”和生成 HMR 边界。

/**
 * 更新冒泡
 * @param {ModuleNode} node 当前更新的模块
 * @param {Set<{ boundary: ModuleNode acceptedVia: ModuleNode }>} boundaries 边界
 * @param {ModuleNode[]} currentChain
 * @returns {boolean} 是否死路
 */
function propagateUpdate(
  node: ModuleNode,
  boundaries: Set<{
    boundary: ModuleNode
    acceptedVia: ModuleNode
  }>,
  currentChain: ModuleNode[] = [node]
): boolean /* hasDeadEnd */ {
  // 如果模块自我“接受”,加入到边界数组中
  if (node.isSelfAccepting) {
    boundaries.add({
      boundary: node,
      acceptedVia: node
    })

    // additionally check for CSS importers, since a PostCSS plugin like
    // Tailwind JIT may register any file as a dependency to a CSS file.
    // 将 css 相关的资源引入全部加到 boundaries
    for (const importer of node.importers) {
      if (isCSSRequest(importer.url) && !currentChain.includes(importer)) {
        propagateUpdate(importer, boundaries, currentChain.concat(importer))
      }
    }

    return false
  }

  // 没有依赖
  if (!node.importers.size) {
    return true
  }

  // #3716, #3913
  // For a non-CSS file, if all of its importers are CSS files (registered via
  // PostCSS plugins) it should be considered a dead end and force full reload.
  if (
    !isCSSRequest(node.url) &&
    [...node.importers].every((i) => isCSSRequest(i.url))
  ) {
    return true
  }

  // 遍历当前模块的依赖
  for (const importer of node.importers) {
    const subChain = currentChain.concat(importer)
    if (importer.acceptedHmrDeps.has(node)) {
      boundaries.add({
        boundary: importer,
        acceptedVia: node
      })
      continue
    }

    // 循环引用直接刷新
    if (currentChain.includes(importer)) {
      // circular deps is considered dead end
      return true
    }

    if (propagateUpdate(importer, boundaries, subChain)) {
      return true
    }
  }
  return false
}
复制代码

进来就看到一个陌生的玩意——isSelfAccepting(自我“接受”)。自我“接受”的模块指的是那些定义了 import.meta.hot.accept() 或者import.meta.hot.accept(() => {}) 函数的模块,注意!accept 没有传依赖参数!比如例子中的 main.js 就是热更自我“接受”的。

For this type of module, it should first be added to boundaries. Next is the processing of css, all recursive additions of css to the boundaries for the module referrer. The more important logic in the follow-up is to traverse the module referrers and splicing the HMR chain. If the referee "accepts", it will be added to the boundary array boundaries, otherwise it will be judged whether there is a circular reference, and if so, it is a "dead end"; finally Repeat the above process recursively for the referrer.

Summarize

Look back at the picture at the beginning of the article:

After studying this section, we know what steps 1, 2, 3, and 4 do specifically:

  1. When we modify a line of code on vscode, it will trigger file changes;

  2. After the file information (modification time, content) is changed, the change event of the watcher instance on the Vite Server will be triggered;

  3. Vite Server does a lot of things to modify files, as shown in the following figure:

  1. Finally, the server sends the file-related information that needs to be updated to the socket client through the socket service;

In the next article, we will see how the socket client receives the information to modify the file to trigger the real update.

Guess you like

Origin juejin.im/post/7084206981463932964
Recommended