Explique Vite en términos simples: implementación de HMR de nivel de milisegundos basada en ESM

En el capítulo anterior: "API de HMR y sus principios" , presentamos el uso de la API de HMR en Vite y también presentamos el modo de actualización basado en el límite de HMR (límite de HMR), es decir, cuando un módulo cambia, Vite encontrará automáticamente y actualice el límite, y luego actualice el módulo de límite, como se muestra en la figura a continuación.

inserte la descripción de la imagen aquí

Entonces, dentro de Vite, ¿cómo ubica el servidor el módulo de límite HMR y cómo acepta el cliente la actualización y carga el contenido del módulo más reciente? A continuación, profundicemos en la implementación subyacente de Vite y clasifiquemos los diversos puntos de implementación de HMR, para tener una comprensión más profunda de los principios de implementación de HMR de Vite.

1. Gráfico de dependencia del módulo

Con el fin de facilitar la gestión de dependencias entre módulos, Vite creó la estructura de datos del gráfico de dependencia de módulos en Dev Server, es decir, la clase ModuleGraph. Haga clic para ver el código fuente de implementación. El juicio del módulo de límite HMR en Vite será realizada por esta clase.

A continuación, veamos el proceso de creación de esta estructura gráfica a partir de las siguientes dimensiones. La creación de un gráfico de dependencia se divide principalmente en tres pasos:

  • Inicializar la instancia del gráfico de dependencia
  • Crear un nodo de gráfico de dependencia
  • Vincular las dependencias de cada nodo de módulo

Primero, Vite inicializará la instancia de ModuleGraph de la siguiente manera cuando se inicie el servidor de desarrollo:

// pacakges/vite/src/node/server/index.ts
const moduleGraph: ModuleGraph = new ModuleGraph((url) =>
  container.resolveId(url)
);

A continuación, veamos en detalle la implementación de la clase ModuleGraph. Se definen varios mapas para registrar la información del módulo:

// 由原始请求 url 到模块节点的映射,如 /src/index.tsx
urlToModuleMap = new Map<string, ModuleNode>()
// 由模块 id 到模块节点的映射,其中 id 与原始请求 url,为经过 resolveId 钩子解析后的结果
idToModuleMap = new Map<string, ModuleNode>()
// 由文件到模块节点的映射,由于单文件可能包含多个模块,如 .vue 文件,因此 Map 的 value 值为一个集合
fileToModulesMap = new Map<string, Set<ModuleNode>>()

El objeto ModuleNode representa la información específica del nodo del módulo, podemos echar un vistazo a su estructura de datos:

class ModuleNode {
  // 原始请求 url
  url: string
  // 文件绝对路径 + query
  id: string | null = null
  // 文件绝对路径
  file: string | null = null
  type: 'js' | 'css'
  info?: ModuleInfo
  // resolveId 钩子返回结果中的元数据
  meta?: Record<string, any>
  // 该模块的引用方
  importers = new Set<ModuleNode>()
  // 该模块所依赖的模块
  importedModules = new Set<ModuleNode>()
  // 接受更新的模块
  acceptedHmrDeps = new Set<ModuleNode>()
  // 是否为`接受自身模块`的更新
  isSelfAccepting = false
  // 经过 transform 钩子后的编译结果
  transformResult: TransformResult | null = null
  // SSR 过程中经过 transform 钩子后的编译结果
  ssrTransformResult: TransformResult | null = null
  // SSR 过程中的模块信息
  ssrModule: Record<string, any> | null = null
  // 上一次热更新的时间戳
  lastHMRTimestamp = 0


  constructor(url: string) {
    this.url = url
    this.type = isDirectCSSRequest(url) ? 'css' : 'js'
  }
}

ModuleNode contiene mucha información. Debe centrarse en los importadores y los módulos importados. Estas dos piezas de información representan respectivamente qué módulos hacen referencia al módulo actual y de qué módulos depende. Son la base para construir todo el gráfico de dependencia del módulo.

Entonces, ¿cuándo creó Vite el nodo ModuleNode? Podemos verificarlo en el middleware de transformación en Vite Dev Server:

// packages/vite/src/node/server/middlewares/transform.ts
// 核心转换逻辑
const result = await transformRequest(url, server, {
  html: req.headers.accept?.includes('text/html')
})

Se puede ver que la lógica principal del middleware transform es llamar al método transformRequest Echemos un vistazo más de cerca a la implementación del código central de este método.

// packages/vite/src/node/server/transformRequest.ts
// 从 ModuleGraph 查找模块节点信息
const module = await server.moduleGraph.getModuleByUrl(url)
// 如果有则命中缓存
const cached =
  module && (ssr ? module.ssrTransformResult : module.transformResult)
if (cached) {
  return cached
}
// 否则调用 PluginContainer 的 resolveId 和 load 方法对进行模块加载
const id = (await pluginContainer.resolveId(url))?.id || url
const loadResult = await pluginContainer.load(id, { ssr })
// 然后通过调用 ensureEntryFromUrl 方法创建 ModuleNode
const mod = await moduleGraph.ensureEntryFromUrl(url)

A continuación, continuamos viendo cómo el método sureEntryFromUrl crea un nuevo nodo ModuleNode.

async ensureEntryFromUrl(rawUrl: string): Promise<ModuleNode> {
  // 实质是调用各个插件的 resolveId 钩子得到路径信息
  const [url, resolvedId, meta] = await this.resolveUrl(rawUrl)
  let mod = this.urlToModuleMap.get(url)
  if (!mod) {
    // 如果没有缓存,就创建新的 ModuleNode 对象
    // 并记录到 urlToModuleMap、idToModuleMap、fileToModulesMap 这三张表中
    mod = new ModuleNode(url)
    if (meta) mod.meta = meta
    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)
  }
  return mod
}

Ahora debe comprender cómo se crea cada nodo ModuleNode en el gráfico de dependencia del módulo. Luego, ¿cuándo se vinculan las dependencias de cada nodo? También podríamos centrarnos en el complemento vite:import-analysis En el gancho de transformación de este complemento, se analizará la declaración de importación en el código del módulo y se obtendrá la siguiente información:

  • importUrls: una colección de direcciones URL de módulos dependientes del módulo actual.
  • acceptUrls: una colección de direcciones URL de módulos dependientes declaradas por import.meta.hot.accept en el módulo actual.
  • isSelfAccepting: analiza el uso de import.meta.hot.accept y marca si es un tipo que acepta la actualización automática.

A continuación, ingrese el enlace de enlace de dependencia del módulo principal, el código principal es el siguiente:

// 引用方模块
const importerModule = moduleGraph.getModuleById(importer)
await moduleGraph.updateModuleInfo(
  importerModule,
  importedUrls,
  normalizedAcceptedUrls,
  isSelfAccepting
)

Se puede ver que la lógica de las dependencias vinculantes se implementa principalmente mediante el método updateModuleInfo del objeto ModuleGraph. El código central es el siguiente:

async updateModuleInfo(
  mod: ModuleNode,
  importedModules: Set<string | ModuleNode>,
  acceptedModules: Set<string | ModuleNode>,
  isSelfAccepting: boolean
) {
  mod.isSelfAccepting = isSelfAccepting
  mod.importedModules = new Set()
  // 绑定节点依赖关系
  for (const imported of importedModules) {
    const dep =
      typeof imported === 'string'
        ? await this.ensureEntryFromUrl(imported)
        : imported
    dep.importers.add(mod)
    mod.importedModules.add(dep)
  }


  // 更新 acceptHmrDeps 信息
  const deps = (mod.acceptedHmrDeps = new Set())
  for (const accepted of acceptedModules) {
    const dep =
      typeof accepted === 'string'
        ? await this.ensureEntryFromUrl(accepted)
        : accepted
    deps.add(dep)
  }
}

Hasta ahora, las dependencias entre módulos se han enlazado con éxito. A medida que el gancho de transformación de vite:import-analysis procese más y más módulos, se registrarán las dependencias entre todos los módulos y se completará la información de todo el gráfico de dependencia.

2. Recopilar módulos de actualización

A continuación, echemos un vistazo a cómo el servidor Vite recopila módulos de actualización de acuerdo con esta estructura gráfica. Primero, Vite creará un nuevo detector de archivos a través de chokidar cuando se inicie el servicio:

// 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)
})

Luego, presentamos la lógica de HMR en el lado del servidor en términos de modificación de archivos, adición de archivos y eliminación de archivos.

2.1 Modificar archivos

Cuando se modifica un archivo en el código comercial, Vite primero llamará a onFileChange de moduleGraph para borrar el caché del nodo correspondiente en el gráfico del módulo. El código central es el siguiente:

class ModuleGraph {
  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
  }
}

Luego ingrese oficialmente a la etapa de recopilación y actualización de HMR. La lógica principal está en la función handleHMRUpdate. El código simplificado es el siguiente:

// packages/vite/src/node/server/hmr.ts
export async function handleHMRUpdate(
  file: string,
  server: ViteDevServer
): Promise<any> {
  const { ws, config, moduleGraph } = server
  const shortFile = getShortName(file, config.root)


  // 1. 配置文件/环境变量声明文件变化,直接重启服务
  // 代码省略


  // 2. 客户端注入的文件(vite/dist/client/client.mjs)更改
  // 给客户端发送 full-reload 信号,使之刷新页面
  if (file.startsWith(normalizedClientDir)) {
    ws.send({
      type: 'full-reload',
      path: '*'
    })
    return
  }
  // 3. 普通文件变动
  // 获取需要更新的模块
  const mods = moduleGraph.getModulesByFile(file)
  const timestamp = Date.now()
  // 初始化 HMR 上下文对象
  const hmrContext: HmrContext = {
    file,
    timestamp,
    modules: mods ? [...mods] : [],
    read: () => readModifiedFile(file),
    server
  }
  // 依次执行插件的 handleHotUpdate 钩子,拿到插件处理后的 HMR 模块
  for (const plugin of config.plugins) {
    if (plugin.handleHotUpdate) {
      const filteredModules = await plugin.handleHotUpdate(hmrContext)
      if (filteredModules) {
        hmrContext.modules = filteredModules
      }
    }
  }
  // updateModules——核心处理逻辑
  updateModules(shortFile, hmrContext.modules, timestamp, server)
}

Se puede ver a partir de esto que Vite tiene diferentes estrategias de actualización en caliente para diferentes tipos de archivos:

  • Para los cambios en los archivos de configuración y los archivos de declaración de variables de entorno, Vite reiniciará directamente el servidor.
  • Para cambios en el archivo inyectado por el cliente (vite/dist/client/client.mjs), Vite enviará una señal de recarga completa al cliente para actualizar la página.
  • Para los cambios de archivos ordinarios, Vite primero obtendrá los módulos que deben actualizarse en caliente, y luego buscará los límites de actualización en caliente para estos módulos a su vez, y luego pasará la información de actualización del módulo al cliente.

Entre ellos, la lógica de la búsqueda de límites de actualización en caliente para archivos ordinarios se concentra principalmente en la función updateModules.Echemos un vistazo a la lógica de implementación específica:

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
    }>()
    // 调用 propagateUpdate 函数,收集热更新边界
    const hasDeadEnd = propagateUpdate(mod, boundaries)
    // 返回值为 true 表示需要刷新页面,否则局部热更新即可
    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
      }))
    )
  }
  // 如果被打上 full-reload 标识,则让客户端强制刷新页面
  if (needFullReload) {
    ws.send({
      type: 'full-reload'
    })
  } else {
    config.logger.info(
      updates
        .map(({ path }) => chalk.green(`hmr update `) + chalk.dim(path))
        .join('\n'),
      { clear: true, timestamp: true }
    )
    ws.send({
      type: 'update',
      updates
    })
  }
}


// 热更新边界收集
function propagateUpdate(
  node: ModuleNode,
  boundaries: Set<{
    boundary: ModuleNode
    acceptedVia: ModuleNode
  }>,
  currentChain: ModuleNode[] = [node]
): boolean {
   // 接受自身模块更新
   if (node.isSelfAccepting) {
    boundaries.add({
      boundary: node,
      acceptedVia: node
    })
    return false
  }
  // 入口模块
  if (!node.importers.size) {
    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)) {
      // 出现循环依赖,需要强制刷新页面
      return true
    }
    // 递归向更上层的引用方寻找热更新边界
    if (propagateUpdate(importer, boundaries, subChain)) {
      return true
    }
  }
  return false
}

Se puede ver que una vez que se completa la recopilación de información del límite de actualización activa, el servidor enviará la información al cliente para completar la actualización parcial del módulo.

2.2 Agregar y eliminar archivos

Para agregar y eliminar archivos, Vite también escucha los eventos correspondientes a través de chokidar, el código es el siguiente:

watcher.on('add', (file) => {
  handleFileAddUnlink(normalizePath(file), server)
})


watcher.on('unlink', (file) => {
  handleFileAddUnlink(normalizePath(file), server, true)
})

A continuación, echemos un vistazo a la lógica de handleFileAddUnlink. El código simplificado es el siguiente:

export async function handleFileAddUnlink(
  file: string,
  server: ViteDevServer,
  isUnlink = false
): Promise<void> {
  const modules = [...(server.moduleGraph.getModulesByFile(file) ?? [])]


  if (modules.length > 0) {
    updateModules(
      getShortName(file, server.config.root),
      modules,
      Date.now(),
      server
    )
  }
}

No es difícil encontrar que esta función también llama a updateModules para completar la búsqueda de los límites de actualización en caliente del módulo y el impulso de la información de actualización, y updateModules se analizó anteriormente, por lo que no se explicará aquí.

2.3 Actualizaciones de distribución de clientes

Sabemos que el servidor monitoreará los cambios del archivo, luego calculará la información de actualización en caliente correspondiente y pasará la información de actualización al cliente a través de WebSocket.Específicamente, los siguientes datos serán enviados al cliente:

{
  type: "update",
  update: [
    {
      // 更新类型,也可能是 `css-update`
      type: "js-update",
      // 更新时间戳
      timestamp: 1650702020986,
      // 热更模块路径
      path: "/src/main.ts",
      // 接受的子模块路径
      acceptedPath: "/src/render.ts"
    }
  ]
}
// 或者 full-reload 信号
{
  type: "full-reload"
}

Entonces, ¿cómo acepta el cliente esta información y actualiza el módulo? De la sección anterior, sabemos que Vite inyectará un script del lado del cliente en el HTML de forma predeterminada durante la fase de desarrollo:

<script type="module" src="/@vite/client"></script>

Después de iniciar cualquier proyecto de Vite, podemos ver el contenido del script específico en el navegador:

A partir de él, puede encontrar que el cliente WebSocket se crea en el script del cliente y establece una conexión bidireccional con el servidor WebSocket en Vite Dev Server ( haga clic para ver la implementación ).

const socketProtocol = null || (location.protocol === 'https:' ? 'wss' : 'ws');
const socketHost = `${null || location.hostname}:${"3000"}`;
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr');

Luego escuchará el evento del mensaje de la instancia del socket y recibirá la información de actualización del servidor:

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

A continuación, centrémonos en la función handleMessage:

async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
    case 'connected':
      console.log(`[vite] connected.`)
      // 心跳检测
      setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
      break
    case 'update':
      payload.updates.forEach((update) => {
        if (update.type === 'js-update') {
          queueUpdate(fetchUpdate(update))
        } else {
          // css-update
          // 省略实现
          console.log(`[vite] css hot updated: ${path}`)
        }
      })
      break
    case 'full-reload':
      // 刷新页面
      location.reload()
    // 省略其它消息类型
  }
}

Entre ellos, nos centramos en la lógica de actualización de js, es decir, el código de queueUpdate:

queueUpdate(fetchUpdate(update))

Echemos un vistazo a la implementación específica de las dos funciones queueUpdate y fetchUpdate:

let pending = false
let queued: Promise<(() => void) | undefined>[] = []


// 批量任务处理,不与具体的热更新行为挂钩,主要起任务调度作用
async function queueUpdate(p: Promise<(() => void) | undefined>) {
  queued.push(p)
  if (!pending) {
    pending = true
    await Promise.resolve()
    pending = false
    const loading = [...queued]
    queued = []
    ;(await Promise.all(loading)).forEach((fn) => fn && fn())
  }
}


// 派发热更新的主要逻辑
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
  // 后文会介绍 hotModuleMap 的作用,你暂且不用纠结实现,可以理解为 HMR 边界模块相关的信息
  const mod = hotModulesMap.get(path)
  const moduleMap = new Map()
  const isSelfUpdate = path === acceptedPath


  // 1. 整理需要更新的模块集合
  const modulesToUpdate = new Set<string>()
  if (isSelfUpdate) {
    // 接受自身更新
    modulesToUpdate.add(path)
  } else {
    // 接受子模块更新
    for (const { deps } of mod.callbacks) {
      deps.forEach((dep) => {
        if (acceptedPath === dep) {
          modulesToUpdate.add(dep)
        }
      })
    }
  }
  // 2. 整理需要执行的更新回调函数
  // 注: mod.callbacks 为 import.meta.hot.accept 中绑定的更新回调函数,后文会介绍
  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
    return deps.some((dep) => modulesToUpdate.has(dep))
  })
  // 3. 对将要更新的模块进行失活操作,并通过动态 import 拉取最新的模块信息
  await Promise.all(
    Array.from(modulesToUpdate).map(async (dep) => {
      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) +
            `?import&t=${timestamp}${query ? `&${query}` : ''}`
        )
        moduleMap.set(dep, newMod)
      } catch (e) {
        warnFailedFetch(e, dep)
      }
    })
  )
  // 4. 返回一个函数,用来执行所有的更新回调
  return () => {
    for (const { deps, fn } of qualifiedCallbacks) {
      fn(deps.map((dep) => moduleMap.get(dep)))
    }
    const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
    console.log(`[vite] hot updated: ${loggedPath}`)
  }
}

Para los módulos de límite de actualización en caliente, necesitamos obtener esta información en el lado del cliente:

  • Módulos aceptados por el módulo límite
  • El módulo aceptado activa la devolución de llamada actualizada

Sabemos que en el complemento vite:import-analysis, algunos códigos de herramientas se inyectarán en módulos que contienen lógica de actualización activa, como se muestra en la siguiente figura:

createHotContext también es una función de utilidad en el script del cliente, echemos un vistazo a su implementación principal:

const hotModulesMap = new Map<string, HotModule>()


export const createHotContext = (ownerPath: string) => {
  // 将当前模块的接收模块信息和更新回调注册到 hotModulesMap
  function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
    const mod: HotModule = hotModulesMap.get(ownerPath) || {
      id: ownerPath,
      callbacks: []
    }
    mod.callbacks.push({
      deps,
      fn: callback
    })
    hotModulesMap.set(ownerPath, mod)
  }
  return {
    // import.meta.hot.accept
    accept(deps: any, callback?: any) {
      if (typeof deps === 'function' || !deps) {
        acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
      } else if (typeof deps === 'string') {
        acceptDeps([deps], ([mod]) => callback && callback(mod))
      } else if (Array.isArray(deps)) {
        acceptDeps(deps, callback)
      } else {
        throw new Error(`invalid hot.accept() usage.`)
      }
    },
    // import.meta.hot.dispose
    // import.meta.hot.invalidate
    // 省略更多方法的实现
  }
}

Por lo tanto, el código de herramienta inyectado por Vite en cada módulo de límite de actualización activa tiene dos funciones principales:

  • Inyectar la implementación del objeto import.meta.hot;
  • Registre los módulos aceptados por el módulo actual y la función de devolución de llamada de actualización en la tabla hotModulesMap;

La función fetchUpdate mencionada anteriormente usa hotModuleMap para obtener la información relevante del módulo de límite. Después de que el módulo aceptado cambia, extrae el contenido del módulo más reciente a través de la importación dinámica y luego devuelve la devolución de llamada de actualización, de modo que la función de programación queueUpdate ejecuta la devolución de llamada de actualización. Esto completa el proceso de distribución de actualizaciones. En este punto, el proceso de HMR ha terminado.

3. Resumen

Resumamos los secretos de la compilación de milisegundos HMR en Vite:

Primero, para administrar la relación entre los módulos de manera más conveniente, Vite creó la estructura de datos del gráfico de dependencia del módulo.Durante el proceso de HMR, el servidor buscará el módulo de límite de HMR de acuerdo con este gráfico.

En segundo lugar, la actualización de HMR se completa con la cooperación del cliente y el servidor, y los dos transmiten datos a través de WebSocket. En el lado del servidor, Vite determina el límite de la actualización activa al buscar el gráfico de dependencia del módulo y pasa la información de actualización parcial al cliente. Después de recibir la información de actualización activa, el cliente solicitará y cargará el contenido del módulo más reciente. a través de la importación dinámica y ejecutar la devolución de llamada para la actualización de envío, es decir, la función de devolución de llamada definida en import.meta.hot.accept, para completar el proceso completo de actualización en caliente.

Supongo que te gusta

Origin blog.csdn.net/xiangzhihong8/article/details/131779804
Recomendado
Clasificación