vite HMR原理

690 阅读6分钟

前言

在前端项目开发中,尤其是像webpack、vite等这种需要打包构建的项目在开发时,HMR对我们的开发提供了极大的便利,接下来让我们看下vite中的HMR具体是怎么实现的。

vite的HMR其中的一部分需要在自己的代码中调用vite提供的hot.accept()函数来实现,一般情况下我们在用 reactvue 开发时官方提供的插件会帮我门解决这件事,经过插件处理会自动注入需要调用的代码,本文基于vite、vue3、tsx的开发模式来分析vite在开发vue时是如何HMR的。

client.ts 的注入

在我们所写的项目中并未设计HMR相关处理逻辑,或者相关包的引入,可是当从ws发送消息时需要浏览器具有处理这些消息的能力,因此关于在浏览器中的HMR处理逻辑其实是vite自动帮我们实现的,具体做法是在index.html中注入了/@vite/client的请求链接,因此能自动加载这段逻辑: 【源码】

const devHtmlHook: IndexHtmlTransformHook = async (
  html,
  { path: htmlPath, filename, server, originalUrl }
) => {
  const { config, moduleGraph } = server!
  const base = config.base || '/'

  const s = new MagicString(html)
  let inlineModuleIndex = -1
  const filePath = cleanUrl(htmlPath)
  ...

  html = s.toString()

  return {
    html,
    tags: [
      {
        tag: 'script',
        attrs: {
          type: 'module',
          src: path.posix.join(base, CLIENT_PUBLIC_PATH)  // /@vite/client
        },
        injectTo: 'head-prepend'
      }
    ]
  }
}

从以上代码可以看出这个方法会返回需要要添加client文件的信息(注入为止、路径等)并根据这些信息对请求的html进行transform,下图为index.html转化前后的对比:

index.html 转换前后对比

hot.accept对HMR模块的收集

先看一下hot.accept相关的源码

const hot: ViteHotContext = {
...
    accept(deps?: any, callback?: any) {
        if (typeof deps === 'function' || !deps) {
            // self-accept: hot.accept(() => {})
            acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
        } else if (typeof deps === 'string') {
            // explicit deps
            acceptDeps([deps], ([mod]) => callback && callback(mod))  // callback的参数下面会说到
        } else if (Array.isArray(deps)) {
            acceptDeps(deps, callback)
        } else {
            throw new Error(`invalid hot.accept() usage.`)
        }
},
...
}

从源码中不难看出accept会对参数进行整理并最终调用acceptDeps,我们暂且分析最简单的情形:假如调用accept时只传入一个参数 callback,那么会把ownerPathcallback 当作参数传入,ownerPath是调用accept文件的路径,接下来看看acceptDeps做了什么:

// hotModulesMap 调用accept文件的集合
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)  // 可以理解为mod的收集,每个调用accept的地方都会经过这个给记录下来
}

其实accept的作用就是在自己代码内在浏览器端运行时调用accept对热更新的mod进行收集。

插件实现accept的调用

在我们自己的代码中需要手动调用accept函数,如果在每个文件中都写入accept,会让热更新的用法看起来很麻烦,而且这些HMR的代码在生产环境时也是没用的,所以需要需要vite来做这件事,但是不同的框架又有不同的热更新逻辑,一次在框架解析的vite插件中实现这个功能,用vue3和tsx开发时,插件 @vitejs/plugin-vue-jsx accept这段代码进行注入:

// plugin-vue-jsx 在vite的transform钩子上进行注册,在vite的tranform时调用 transform就是对代码进行转换,并输出转换后的代码
transform(code, id, opt) {
    ...
    const filter = createFilter(include || /\.[jt]sx$/, exclude)
    if (filter(id) || filter(filepath)) {  // 只处理 jsx tsx文件
        ...
        const result = babel.transformSync(code, {
            babelrc: false,
            ast: true,
            plugins,
            sourceMaps: needSourceMap,
            sourceFileName: id,
            configFile: false
        })  // 获取到通过babel处理后的结构 babel解析的AST语法树
        const hotComponents = []  // 存储需要HMR的组件
        for (const node of result.ast.program.body) {
            ...
            hotComponents.push({
                local: node.declaration.name,
                exported: 'default',
                id: hash(id + 'def
                ault')
            })
        } // 最终会根据解析的AST中各个node的条件进行筛选 并把有export 组件的模块添加到 hotComponents 中


         if (hotComponents.length) {
             ...
          if (needHmr && !ssr && !/\?vue&type=script/.test(id)) {
            let code = result.code
            let callbackCode = ``
            for (const { local, exported, id } of hotComponents) {
              code +=
                `\n${local}.__hmrId = "${id}"` +
                `\n__VUE_HMR_RUNTIME__.createRecord("${id}", ${local})`
              callbackCode += `\n__VUE_HMR_RUNTIME__.reload("${id}", __${exported})`
            }
            code += `\nimport.meta.hot.accept(({${hotComponents
              .map((c) => `${c.exported}: __${c.exported}`)
              .join(',')}}) => {${callbackCode}\n})`
              // 注入import.meta,hot.accep函数 
            result.code = code
          }

          if (ssr) {
            // ssr 相关逻辑
          }
        }
     }
}

import.meta.hot.accept函数注入后会在浏览器加载这个文件时进行调用,执行accept并对HMR模块进行收集,下图为@vitejs/plugin-vue-jsx插件transform前后的文件对比:

accept代码注入

用chokidar对文件的变化做监听

chokidar是一个而高效的文件监视库,vite在启动开发服务时,在createServer引用chokidar对项目文件具体监听:源码

export async function createServer(
    inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  ...
  // websocket相关
  ...

  const watcher = chokidar.watch(path.resolve(root), {
    ignored: [
      '**/node_modules/**',
      '**/.git/**',
      ...(Array.isArray(ignored) ? ignored : [ignored])
    ],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
    ...watchOptions
  }) as FSWatcher

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

在启动devServer时不启动一个http服务供前端界面展示,还会启动一个ws服务,根据 chokidar 监听到的文件变化时向客户端发送一些HMR信息,接下来我们分析一下上文中提到的 handleHMRUpdate 并以js文件更新为例,看发送的HMR消息具体如何:源码

export async function handleHMRUpdate(
  file: string,
  server: ViteDevServer
): Promise<any> {
  ...
  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] : [],
    read: () => readModifiedFile(file),
    server
  }
  // HMR钩子调用插件中 handleHotUpdate 函数
  for (const plugin of config.plugins) {
    if (plugin.handleHotUpdate) {
      const filteredModules = await plugin.handleHotUpdate(hmrContext)
      if (filteredModules) {
        hmrContext.modules = filteredModules
      }
    }
  }
  ...
  updateModules(shortFile, hmrContext.modules, timestamp, 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) {
      ...
      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
      }))
      )
    }
    ...  // websocket消息处理逻辑
}

在上面看到的 updateModules 会看到判断何时发送full reload消息,判断过程会分析引入依赖,这点需要单独拿出来看:

function propagateUpdate(
  node: ModuleNode,
  boundaries: Set<{
    boundary: ModuleNode
    acceptedVia: ModuleNode
  }>,
  currentChain: ModuleNode[] = [node]
): boolean /* hasDeadEnd */ {
  if (node.isSelfAccepting) {  // (js文件)如果该模块在accept函数调用时注册过 则直接加到边界数组中
  // 每个模块的 isSelfAccepting 属性判断是在 vite:import-analysis 插件的 transform 中做的,这里不做具体分析
    boundaries.add({
      boundary: node,
      acceptedVia: node
    })
    ...
    // CSS isSelfAccepting 相关逻辑处理

    return false
  }

  if (!node.importers.size) { // 没被调用 且没在accept函数中注册
    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.
  ... //css 处理相关

  for (const importer of node.importers) { // 找到当前mod的引用者,直到找到 引用者具有 accept 函数为止并返回false,否则一直递归,如果最终整个调用链上都没accept 那么返回true 即是 DeadEnd
    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
}

用ws向浏览器发送update消息

在开发环境createServer时会创建一个websocket连接,当chokidar监听到文件变化时会创建更新消息并把消息推入updates队列中并通过ws向客户端发送更新消息:

// 创建ws的过程
export async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  const ws = createWebSocketServer(httpServer, config, httpsOptions)
  // 创建ws连接 HMR更新通信基于此
}
// 基于ws向客户端发送update消息
function updateModules(
  file: string,
  modules: ModuleNode[],
  timestamp: number,
  { config, ws }: ViteDevServer
) {
  ...

  if (needFullReload) {
    config.logger.info(colors.green(`page reload `) + colors.dim(file), {
      clear: true,
      timestamp: true
    })
    ws.send({  // 发送 full-reload 消息
      type: 'full-reload'
    })
  } else {
    config.logger.info(
      updates
        .map(({ path }) => colors.green(`hmr update `) + colors.dim(path))
        .join('\n'),
      { clear: true, timestamp: true }
    )
    ws.send({  // 会将整个updates更新数组发送到浏览器
      type: 'update',
      updates
    })
  }
}

客户端处理接收的ws消息

在上文中我们已经讲过在第一次请求html时,会在html注入client.ts的请求链接,客户端处理HMR消息的逻辑也是存在在client.ts中:

socket.addEventListener('message', async ({ data }) => {
  handleMessage(JSON.parse(data))  // 监听ws消息,并交由handleMessage处理
})
async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
    case 'connected':
      ...
      break
    case 'update':
      notifyListeners('vite:beforeUpdate', payload)
      // if this is the first update and there's already an error overlay, it
      // means the page opened with existing server compile error and the whole
      // module script failed to load (since one of the nested imports is 500).
      // in this case a normal update won't work and a full reload is needed.
      if (isFirstUpdate && hasErrorOverlay()) {
        window.location.reload()
        return
      } else {
        clearErrorOverlay()
        isFirstUpdate = false
      }
      payload.updates.forEach((update) => {
        if (update.type === 'js-update') {
          queueUpdate(fetchUpdate(update))
        } else {
          // css-update
          // this is only sent when a css file referenced with <link> is updated
          const { path, timestamp } = update
          const searchUrl = cleanUrl(path)
          // can't use querySelector with `[href*=]` here since the link may be
          // using relative paths so we need to use link.href to grab the full
          // URL for the include check.
          const el = Array.from(
            document.querySelectorAll<HTMLLinkElement>('link')
          ).find((e) => cleanUrl(e.href).includes(searchUrl))
          if (el) {
            const newPath = `${base}${searchUrl.slice(1)}${
              searchUrl.includes('?') ? '&' : '?'
            }t=${timestamp}`
            el.href = new URL(newPath, el.href).href
          }
          console.log(`[vite] css hot updated: ${searchUrl}`)
        }
      })
      break
    case 'custom': {
      ...
      break
    }
    case 'full-reload':  // 直接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 ||
          (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)
        ) {
          location.reload()
        }
        return
      } else {
        location.reload()
      }
      break
    case 'prune':
      ...
      break
    case 'error': {
      ...
      break
    }
    default: {
      const check: never = payload
      return check
    }
  }
}

js update 的逻辑更新关键是调用 queueUpdate(fetchUpdate(update))fetchUpdate中值得一提的是如果在同一个ws消息中有多个相同的同一文件更新需求例如:

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "timestamp": 1650278428261,
            "path": "/src/App.tsx",
            "acceptedPath": "/src/App.tsx"
        },
        {
            "type": "js-update",
            "timestamp": 1650278428261,
            "path": "/src/App.tsx",
            "acceptedPath": "/src/App.tsx"
        }
    ]
}

两个更新相同,则根据浏览器机制这个文件只会发送一次异步模块调用的import在import之后会把结果放到一个Map中 key值为文件url唯一的,所以达到了去重的效果(后者覆盖前者),具体实现如下

async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
  const mod = hotModulesMap.get(path)
  if (!mod) {
    // In a code-splitting project,
    // it is common that the hot-updating module is not loaded yet.
    // https://github.com/vitejs/vite/issues/721
    return
  }

  const moduleMap = new Map()
  const isSelfUpdate = path === acceptedPath

  // make sure we only import each dep once
  const modulesToUpdate = new Set<string>()
  if (isSelfUpdate) {
    // self update - only update self
    modulesToUpdate.add(path)
  } else {
    // dep update
    for (const { deps } of mod.callbacks) {
      deps.forEach((dep) => {
        if (acceptedPath === dep) {
          modulesToUpdate.add(dep)
        }
      })
    }
  }

  // determine the qualified callbacks before we re-import the modules
  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
    return deps.some((dep) => modulesToUpdate.has(dep))
  })

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

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

queueUpdate配合fetchUpdate使用是为了确保来自同一url同时更新时按照异步加载的顺序来返回加载结果,中间调用了一个await Promise.resolve()通过js的事件循环机制控制,即在Promise.resolve()之前的主线程上 fetchUpdate返回的Promise有序地添加到queued这个队列中,最后通过

/**
 * buffer multiple hot updates triggered by the same src change
 * so that they are invoked in the same order they were sent.
 * (otherwise the order may be inconsistent because of the http request round trip)
 */
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())
  }
}

流程图

vite hmr 流程图.png