从零读 Vite 4.0 源码(四)———— vite 热重载

模块热替换

模块热替换是指在运行时实现代码的动态更新,无需刷新整个页面或重新加载整个应用。 vite 也为我们提供了开箱即用的模块热替换功能,本节主要是介绍 vite 模块热替换的实现原理。

HMR API

vite 就是利用这套 ESM 的 HMR API 来实现模块热替换的。所以我们先来了解一下 HMR API。 注意:手动 HMR API 主要用于框架和工具作者。作为最终用户,HMR 可能已经在特定于框架的启动器模板中为你处理过了。

Vite 通过特殊的 import.meta.hot 对象暴露手动 HMR API。 请大家先仔细阅读官网:https://cn.vitejs.dev/guide/api-hmr.html 先来学习 HMR API的使用。

客户端与服务端之间的通信

服务端监听到文件变化后,需要向客户端发送 HMR 消息,客户端收到消息后,需要通知对应的模块进行热更新。 因此我们需要在服务端和客户端之间建立通信。在 vite 中,我们使用 socket.io 来建立通信。

先来看一下服务端通信部分的实现:

  import { createServer as createHttpsServer } from 'node:https'
  import { WebSocketServer } from 'ws'

  const wsHttpServer = createHttpsServer(httpsOptions)

  const wss = new WebSocketServer({ server: wsHttpServer })

  wss.on('connection', (socket) => {
    socket.on('message', (raw) => {
      let parsed: any
      try {
        parsed = JSON.parse(String(raw))
      } catch {}
     
      // 触发对应的事件监听
      listeners.forEach((listener) => listener(parsed.data, client))
    })

    socket.send(JSON.stringify({ type: 'connected' }))
  })

每当文件发生变化的时候,服务器发送 message 给客户端(浏览器),通知其热更:

watcher.on('change', async (file) => {
  // 生成热更信息,以下仅是示例,具体逻辑在下文详述 
  ws.send({
    type: 'update',
    updates: [{
      type: 'js-update',
      timestamp: '106989894',
      path: file,
      explicitImportRequired: undefined,
      acceptedPath: file,
    }],
  })
})

客户端(这里指浏览器)接收服务端的信息:

const socket = new WebSocket(`wss://localhost:5173/`, 'vite-hmr');

socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data);
  switch (payload.type) {
    case 'connected':
      console.debug(`[vite] connected.`)
    case 'update':
      await Promise.all(
        // 处理服务端发送的热更事件
        payload.updates.map(async (update) => {
          if (update.type === 'js-update') {
            // queueUpdate 的作用是:当同一个 src 文件内容多次变化时,
            // 会触发多个 hotUpdate 事件,需要保证执行的顺序

            // fetchUpdate 的作用是获取更新后的模块,之后会详细说明实现
            return queueUpdate(fetchUpdate(update))
          }
        })
      )
    case 'full-reload':
      location.reload()
  }
})

在了解了服务端和客户端如何通信后,我们可以再来继续考虑,vite 处理热重载的思路会是什么样的?

服务端能够监听到文件的变化,同时可以得到各个模块的依赖关系(根据文件中声明的 import),因此是可以计算出来需要更新的模块有哪些。

客户端是无法独立计算出变更模块内容,因此需要依赖服务端发送过来的信息,来真正执行模块的更新。

基于以上的思路,我们需要搞明白的事情有以下几点:

  • 服务器是如何生成各个模块的依赖关系的
  • 服务端监听到文件变化后,是如何生成对应的热更信息的
  • 客户端如何执行模块的更新

我们基于 js 文件的热更场景,来说明下具体实现:

服务器生成模块的依赖图

vite 的模块依赖分析图是基于 moduleGraph 这个类实现的。具体包括:

  • 生成并维护各个模块本身的信息
  • 生成各个模块的依赖关系

代码一览:

class ModuleGraph {
  // 重要,生成初始模块信息信息,在 updateModuleInfo 中处理依赖时调用
  async ensureEntryFromUrl() {}
  // 重要,根据模块依赖信息更新依赖图,在 importAnalysisPlugin 中调用(详见下文)
  async updateModuleInfo() {}

  // 工具函数,根据 Url,id, file 获取模块信息
  async getModuleByUrl() {}
  getModuleById() {}
  getModulesByFile() {}
  
  // 清除当前变化的文件依赖的所有模块的缓存,在 watcher 的 change 事件被触发时调用
  onFileChange() {}

  // 文件发生变化时,清空缓存的模块信息
  invalidateModule() {}
}

模块依赖关系的生成是基于 importAnalysisPlugin 这个插件来实现的, 除此之外,该插件还负责格式化源代码中的路径信息,并插入import.meta.hot 的实现代码。

// es-module-lexer 是一款 JS 模块语法词法分析器,性能非常好
import { init, parse as parseImports } from 'es-module-lexer'
import MagicString from 'magic-string'

export function importAnalysisPlugin(config) {
  return {
    name: 'vite:import-analysis',
    async transform(source, importer, options) {
      await init
      let [imports, exports] = parseImports(source)

      const importerModule = moduleGraph.getModuleById(importer)
      // 收集依赖 的 Url 路径
      const importedUrls = new Set()

      // 为了方便理解,仅展示静态 import 的处理
      await Promise.all(
        imports.map(async (importSpecifier, index) => {
          // eg: 'import { name } from 'mod'
          const {
            // slice(s, e) === 'mod'
            s: start,
            e: end,
            // slice(ss, se) === 'import { name } from 'mod''
            ss: expStart,
            se: expEnd,
            d: dynamicIndex,
            // 'mod'
            n: specifier,
            a: assertIndex,
          } = importSpecifier

          if (specifier) {
            const [url, resolvedId] = await normalizeUrl(specifier, start);
            importedUrls.add(url);

            const str = () => s || (s = new MagicString(source))

            // 注入 import.meta.hot 实现代码
            // createHotContext 具体的实现见下文
            str().prepend(
              `import { createHotContext as __vite__createHotContext } from "/@vite/client";` +
                `import.meta.hot = __vite__createHotContext(${importerModule.url});`,
            )
          }
        })
      )

      // 格式化路径,并重写 source
      for (const { url, start, end } of imports) {
        const [normalized] = await moduleGraph.resolveUrl(url);
        str().overwrite(start, end, JSON.stringify(normalized), {
          contentOnly: true,
        });
      }

      // 更新 module graph 的模块信息,增加了依赖的模块信息
      await moduleGraph.updateModuleInfo(
        // 当前模块的信息
        importerModule,
        // 依赖的 Url 路径
        importedUrls,
        importedBindings,
        // 格式化后的依赖的模块路径
        normalizedAcceptedUrls,
        // 是否依赖了其他模块
        isSelfAccepting,
      )

      // 返回修改后的文件内容和sourcemap
      return {
        code: s.toString(),
        map:s.generateMap({ hires: 'boundary', source: id })
      }
    }
  }
}

服务端监听变化的文件并生成热更信息

前面提到过,服务端有一个 watcher 用来监听文件的变化,那么如何针对变化的文件生成对应的热更信息呢? 我们来具体看下实现:

watcher.on('change', async (file) => {
  // 清除当前变化的文件依赖的所有模块的缓存
  moduleGraph.onFileChange(file)

  await onHMRUpdate(file, false)
})

onHMRUpdate 中核心的逻辑都在 handleHMRUpdate 这个函数中实现,而这个函数也是生成热重载信息的关键。 这里官网提出了一个概念: boundary, 我们先来看一下 boundary 的生成逻辑:

在文章的开头我们提到了 import.meta.hot.accept 语法,官网对 boundary 的定义是:

A module that "accepts" hot updates is considered an HMR boundary.

举例说明 boundary 是什么:

示例一:

// foo.js
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      // newModule is undefined when SyntaxError happened
      console.log('updated: count is now ', newModule.count)
    }
  })
}

// 在这个例子中, boundary 是模块本身(foo)

示例二

// main.js
import { foo } from './foo.js'

foo()

if (import.meta.hot) {
  import.meta.hot.accept('./foo.js', (newFoo) => {
    // the callback receives the updated './foo.js' module
    newFoo?.foo()
  })
}

// 在这个例子中, boundary 是当前模块(main)

示例三

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

foo()
bar()

if (import.meta.hot) {
  import.meta.hot.accept(['./foo.js', './bar.js'], (newFoo, newBar) => {
    newFoo?.foo()
    newBar?.bar()
  })
}

propagateUpdate 函数负责生成 boundary, 我们来看下具体实现:

function propagateUpdate(
  node,
  traversedModules,
  boundaries,
  currentChain = [node]
) {
  // 避免循环引用导致的死循环,因此用 traversedModule 作为缓存
  if (traversedModules.has(node)) {
    return false
  }
  traversedModules.add(node)

  // 示例一的情况:
  if (node.isSelfAccepting) {
    // boundary 是模块本身
    // accept 的是模块本身
    boundaries.push({ boundary: node, acceptedVia: node });

    return false;
  }

  // importers 是 依赖了 node 的模块合集
  // 在示例二中,若 node 是 foo, 则其 importers 是 ['main']
  for (const importer of node.importers) {
    // importer.acceptedHmrDeps 是指会触发热替换的模块
    // 若 mod.importer.acceptedHmrDeps 不包含 mod (模块自身)时
    // 表示该模块在发生变化时不会触发热模块替换。
    // 继续上述例子, 则 acceptedHmrDeps 的值是 ['foo']
    if (importer.acceptedHmrDeps.has(node)) {
      // 继续上述例子, boundary 是 main
      // acceptedVia 是 foo
      boundaries.push({ boundary: importer, acceptedVia: node })
      continue
    }

    // 如果发现循环引用,则不进一步处理,直接刷新页面处理
    if (currentChain.includes(importer)) {
      // circular deps is considered dead end
      return true
    }

    // 如果没有找到当前 node 模块的 boundary, 则递归向上继续寻找
    const subChain = currentChain.concat(importer)
    if (propagateUpdate(importer, traversedModules, boundaries, subChain)) {
      return true
    }
  }

  return false;
}

接着我们继续查看 handleHMRUpdate 函数中,对生成的 boundary 信息的进一步处理:

async function handleHMRUpdate(file) {
  const mods = moduleGraph.getModulesByFile(file);

  const updates = [];
  for (const mod of mods) {
    const boundaries = []
     const traversedModules = new Set()

    // 关键: 注意 boundaries 数组
    const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
    if (hasDeadEnd) {
      needFullReload = true;
      continue;
    }


    // 根据上一步收集的 boundaries 数组,生成热更信息
    updates.push(
      ...boundaries.map(({ boundary, acceptedVia }) => ({
        type: `js-update` as const,
        timestamp: Date.now(),
        path: boundary.url,
        explicitImportRequired: false,
        acceptedPath: acceptedVia.url,
      })),
    )

    if (needFullReload) {
      ws.send({
        type: 'full-reload',
      })
    }

    ws.send({
      type: 'update',
      updates,
    })

  }
}

客户端如何执行模块的更新

在上面的步骤中,有一步是向每个模块中注入一个函数 createHotContext, 用来实现 import.meta.hot API,这个函数就是在客户端上执行的。

我们先来看一下这几个 import.meta.hot API 是如何实现的。

const hotModulesMap = new Map()
// ownerPath: 模块对应的 url 
function createHotContext(ownerPath) {
  // hotModulesMap 是一个全局的对象,用来缓存每次需要热替换的模块
  const mod = hotModulesMap.get(ownerPath)
  if (mod) {
    // 当模块需要热更时,需要清除旧的上下文
    mod.callbacks = []
  }

  const hot = {
    // 先只说明 accept 的实现方法
    // 其他 API 的实现可参考:vite/packages/vite/src/client/client.ts

    accept(deps, callback) {
      if (typeof deps === 'function' || !deps) {
        // 为示例一的情况,第一个参数是函数,表示对当前模块进行热替换
        // self-accept: hot.accept(() => {})
        acceptDeps([ownerPath], ([mod]) => deps?.(mod))
      } else if (typeof deps === 'string') {
        // 为示例二的情况
        acceptDeps([deps], ([mod]) => callback?.(mod))
      } else if (Array.isArray(deps)) {
        // 为示例三的情况
        acceptDeps(deps, callback)
      } else {
        throw new Error(`invalid hot.accept() usage.`)
      }
    },
  }

  // 核心函数 acceptDeps 的实现:
  function acceptDeps(deps, callback) {
    // 初始化 mod
    const mod = hotModulesMap.get(ownerPath) || {
      id: ownerPath,
      callbacks: [],
    }

    // 将热更相关信息存到 callbacks 对象中
     mod.callbacks.push({
      // deps 是需要热更的模块路径
      deps,
      // 热更的回调函数
      fn: callback,
    })

    // 将当前模块的路径(类似 boundary) 和 需要热更的模块(类似 acceptUrl)缓存在hotModulesMap中
    // 待收到热更事件时执行热更回调。
    hotModulesMap.set(ownerPath, mod)
  }
}

从以上代码可以看到,当加载用户代码对应的模块,执行 import.meta.hot API 时,会将所有热更信息缓存到对象中,待模块变更后,进行热更模块替换。

那么,接下来,我们来看下当模块变更,客户端收到服务端的热更事件后,是如何处理的呢? 具体的代码在 vite/packages/vite/src/client/client.ts 中,这部分代码最终会在客户端上执行。

我们来看一下在客户端上,接受到事件后都做了哪些处理:

socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data)

  switch (payload.type) {
    case 'update':
      await Promise.all(payload.updates.map(async update => {
        if (update.type === 'js-update') {
            // queueUpdate 的作用是:当同一个 src 文件内容多次变化时,
            // 会触发多个 hotUpdate 事件,需要保证执行的顺序

            // fetchUpdate 的作用是获取更新后的模块
            return queueUpdate(fetchUpdate(update))
        }
      }))
  }
})

核心是 fetchUpdate 函数来对服务端传来的数据进行处理。

function fetchUpdate({
  // 对应 boundary.url
  path,
  // 对应 acceptedVia.url
  acceptedPath,
  // 服务端开始处理热重载的时间
  timestamp,
  // js下,此值为 undefined
  explicitImportRequired,
}) {
  // 对应上文示例一的情况
  const isSelfUpdate = path === acceptedPath
  let fetchedModule = undefined

  // 当前的 boundary 可能定义了多个热重载回调 (触发了多次 import.meta.hot)
  // 筛选出包含 acceptUrl 的回调函数
  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
    deps.includes(acceptedPath),
  )

  if (isSelfUpdate || qualifiedCallbacks.length > 0) {
    const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
    // 直接 re-import 最新的模块
    // ?? 模块何时生成的
    fetchedModule = await import(
      // 路径简单处理:
      `${acceptedPathWithoutQuery}t=${timestamp}${query ? `&${query}` : ''}`
    )
  }

  const mod = hotModulesMap.get(path)

  // 执行对应的热更回调,返回函数主要是便于 queueUpdate 函数处理
  return () => {
    for (const { deps, fn } of qualifiedCallbacks) {
      // 调用示例:
      // fn([newModule])
      // fn([newModule, undefined])
      fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
    }
  }
}

至此,热重载的过程就结束了,可以看到,整个热重载的过程,是由服务端先通过 watcher 监听文件变化,并根据依赖图计算出 boundary 和 acceptViaUrl, 将其发送给客户端,由客户端来执行对应的热更回调函数,从而实现了热重载。

在上述代码中,我们看到有这么一段:

fetchedModule = await import(
  // 路径简单处理:
  `${acceptedPathWithoutQuery}t=${timestamp}${query ? `&${query}` : ''}`
)

当客户端执行 import 时,会向服务器发送请求,那么服务器是如何处理这个请求及 query, 来获得最新的模块内容呢,我们可以来看一下服务器这部分的实现:

对请求的响应是在一个中间件内实现的:

return async function viteTransformMiddleware(req, res, next) {
  const ifNoneMatch = req.headers['if-none-match']
  if (ifNoneMatch &&
    (await moduleGraph.getModuleByUrl(url, false))?.transformResult?.etag === ifNoneMatch
  ) {
    // 客户端每次发送请求,会带上一个条件,如 if-none-match 头部字段,包含之前收到的 Etag 值,
    // 根据该值来校验是否使用缓存,如资源未发生变化,则直接返回 304
    res.statusCode = 304
    return res.end()
  }

  // resolve, load and transform using the plugin container
  // 在转换的时候,会去掉时间戳,再来处理路径
  const result = await transformRequest(url);

  // 设置缓存相关请求头
  res.setHeader('Etag', result.etag);
  // 不要直接使用缓存,而是每次请求时都与服务器进行验证。
  res.setHeader('Cache-Control', 'no-cache')
  res.statusCode = 200

  // 将转换后的内容返回
  res.end(result.code)
}

疑问

  • 是哪个插件加载的文件,如何处理的 query
  • ?import 的用处

vite

1275 Words

2023-09-25 11:24 +0000