vite1.x 热更新(HMR)的实现原理

前言

将近一年前自己尝试阅读vite源码(2.x),虽然也有些收获但整体并没有到达我的预期,对于vite也是停留在一知半解的程度上。最近想重新开始学习vite,但回顾之前的学习历程,感觉不太想继续之前的方式,自己的水平有限,读起来太费劲,经常在不同的函数调用间迷失自己,最后草草收场。想起之前看文章很多人是看代码的最初实现版本的,于是也想尝试一下,选择阅读vite的最初版本分支1.x,效果是明显比之前好的,后续我觉得再阅读最新版本的代码的话是有很大帮助的。

阅读过程中发现关于热更新(HMR)这块逻辑略微复杂,想着记录下来,避免之后忘记。

HMR

之前对于HMR的了解大概是:webpack/vite会在启动后开启websocket服务用于浏览器端和服务端之间的通信,每当我们修改代码后服务端就会发送消息给浏览器端,浏览器端进行更新,对于具体过程是不太了解的

阅读分支

vite-v1

下文中的vite如无特殊说明均指的v1版本

前置了解

vite开发模式下会启动一个server以供开发者访问调试,具体实现中是启动了一个Koa服务,vite对被访问文件的处理都是以插件的形式进行的,HMR相关的主要有以下几个文件

vite
├─ src
│  ├─ client
│  │  ├─ client.ts   
│  ├─ hmrPayload.ts
│  └─ node
│     ├─ server
│     │  ├─ index.ts
│     │  ├─ serverPluginClient.ts
│     │  ├─ serverPluginCss.ts
│     │  ├─ serverPluginHmr.ts
│     │  ├─ serverPluginHtml.ts
│     │  ├─ serverPluginModuleRewrite.ts
│     │  ├─ serverPluginVue.ts

vite1.x 热更新(HMR)的实现原理_第1张图片

整体流程

首先按照vite官网命令起一个demo,npm run dev之后打开开发者工具,可以看到请求的大概过程是:

vite1.x 热更新(HMR)的实现原理_第2张图片

浏览器端

入口文件index.html的处理

第一个请求是访问index.html的,与源文件不同的是这里多了一行代码,浏览器就会请求client.js



这个处理是有htmlRewritePlugin插件完成的,代码如下(不过vite-v1中不是以src的方式引入的,而是import /vite/client)

export const clientPublicPath = `/vite/client`
const devInjectionCode = `\n\n`
...
injectScriptToHtml(html, devInjectionCode)
...

client.js

对于/vite/client的访问 是由clientPlugin插件处理的,主要是读取client/client.js文件并进行一些初始化后返回,具体实现如下:

// src/node/server/serverPluginClient.ts 
export const clientFilePath = path.resolve(__dirname, '../../client/client.js')
export const clientPublicPath = `/vite/client`
export const clientPlugin: ServerPlugin = ({ app, config }) => {
  const clientCode = fs
    .readFileSync(clientFilePath, 'utf-8')
    .replace(`__MODE__`, JSON.stringify(config.mode || 'development'))

  app.use(async (ctx, next) => {
    if (ctx.path === clientPublicPath) {
      // ...
      ctx.type = 'js'
      ctx.status = 200
      ctx.body = clientCode
    }
  })
}

client.js中主要做了以下三件事

  1. 启动websocket建立与服务端之前的连接
  2. 接受websocket信息并进行相应处理(处理细节在后面)
  3. 暴露出一个HMR Context,以供其他模块(文件)调用
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')

socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
  if (payload.type === 'multi') {
    payload.updates.forEach(handleMessage)
  } else {
    handleMessage(payload)
  }
})

export const createHotContext = (id: string) => {
   ...
  const hot = {
    accept(callback: HotCallback['fn'] = () => {}) {
      hot.acceptDeps(id, callback)
    },
    acceptDeps(
      deps: HotCallback['deps'],
      callback: HotCallback['fn'] = () => {}
    ) {
     ...
    },
   ...
  }

  return hot
}

其他模块(文件)HMR能力的注入

随便打开几个文件可以发现在某些文件中是由HMR相关代码注入的,比如App.vue
vite1.x 热更新(HMR)的实现原理_第3张图片

可以看到除了业务代码外在最开始引入了client.js并创建了一个App.vue的HMR模块,在结束的地方调用了一些HMR的方法,有了这些就可以完成App.vue的热更新了

服务端

服务端的处理主要是hmrPlugin moduleRewritePlugin插件和一些专门处理某类文件HMR的cssPlugin vuePlugin插件完成的。

hmrPlugin主要做了以下几件事

  1. 启动服务端的websocket
  2. 每当文件有变化的时候会向浏览器端发送信息
import chokidar from 'chokidar'  // `chokidar`是用来监听文件变化的
const watcher = chokidar.watch(root, {
  ignored: ['**/node_modules/**', '**/.git/**'],
  ignoreInitial: true,
  ...chokidarWatchOptions
}) as HMRWatcher
const wss = new WebSocket.Server({ noServer: true })
watcher.on('change', (file) => {
  if (!(file.endsWith('.vue') || isCSSRequest(file))) {
    //  vue文件和plain css文件在serverPluginVue 和 serverPluginCss文件中处理
    handleJSReload(file)
  }
})

// 这里把send方法直接放到watcher实例上了,便于有文件变化的话可以直接send消息
const send = (watcher.send = (payload: HMRPayload) => {
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(stringified)
    }
  })
})

cssPluginvuePlugin是分别用来处理.css文件和.vue文件的,里面包含了HMR相关的部分,比如,App.vue最下方的HMR逻辑的注入就是从vuePlugin写入的(这里其实我没找到import.meta.hot.accept相关的逻辑,只有hmrId注入,但在最新版plugin-vue插件中找到了相关逻辑,这里我就认为是在vue插件中注入的了)

// src/node/server/serverPluginVue.ts
...
  code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`
  code += `\ntypeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)`
  code += `\n__script.__file = ${JSON.stringify(filePath)}`
  code += `\nexport default __script`
...
// https://github.com/vitejs/vite/blob/7a6d4bc0d7fa614d3ac469ca35352a23aaef8232/packages/plugin-vue/src/main.ts#L115
// HMR
if (
  devServer &&
  devServer.config.server.hmr !== false &&
  !ssr &&
  !isProduction
) {
  ...
  output.push(
    `import.meta.hot.accept(mod => {`,
    `  if (!mod) return`,
    `  const { default: updated, _rerender_only } = mod`,
    `  if (_rerender_only) {`,
    `    __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)`,
    `  } else {`,
    `    __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)`,
    `  }`,
    `})`
  )
}

moduleRewritePlugin主要并不是来处理HMR的,只是在对请求的模块(文件)进行重写处理的过程中进行了文件依赖关系的分析和HMR逻辑的相关重写,支撑了HMR功能。App.vue模块上方HMR Context的注入就是在此插件中完成的。

const hasHMR = source.includes('import.meta.hot')
if (hasHMR) {
  rewriteFileWithHMR(root, source, importer, resolver, s)
}

function rewriteFileWithHMR() {
  ...
  s.prepend(
    `import { createHotContext } from "${clientPublicPath}"; ` +
      `import.meta.hot = createHotContext(${JSON.stringify(importer)}); `
  )
  ...
}

具体流程

通过以上插件的执行,浏览器端和服务端通信的websocket就有了,各个文件要进行HMR的预先处理也完成了,只要文件发生变化,服务端就通知客户端进行热更新,那么文件变化=>进行热更新的具体流程是啥呢?

前置了解-HMR设计的思路

hmrPlugin中对于HMR的设计思路进行了注释(如下图)
vite1.x 热更新(HMR)的实现原理_第4张图片

大意是指对文件进行HMR graph analysis, 只要是走到dead end,就发送full page reload,否则找到相应的hmr boundary(指的是本次hmr受到影响的文件),把要进行hmr的所有模块hmr Boundaries都发送给浏览器端。对应的代码实现就是上面提到的hmrPlugin里的handleJSReload(file)法,这里留个大概印象就行,先不用关心具体细节比如是dead end,什么是hmr boundary,下面都有涉及

初次访问某个文件

moduleRewritePlugin是最后执行的koa插件,接收到的文件已经全部被处理为了js文件,moduleRewritePlugin的主要作用就是

  1. 路径的重写,比如把对某些第三方包的请求路径改为预购建后的路径
  2. 记录模块之前的依赖关系
  3. 加入HMR逻辑并track HMR boundary accept whitelists(这一段不知道该怎么翻译)

这里的2、3都是使用es-module-lexer将文件转化为ast然后再进行解析得到的。
其中模块之间的依赖关系被放在了以下两个变量中

// moduleRewritePlugin / function rewriteImports
export const importerMap: HMRStateMap = new Map()
export const importeeMap: HMRStateMap = new Map() 
// 例如,a模块有有一句`import {x} from 'b'`
// 那么importeeMap里就会加一条  {key: a, value:['b']}
// importerMap里就会加一条{ key: b, value: ['a']},两者的key 、value是相反的关系
// 每个文件都这么记录下来就能获取到所有文件的依赖关系

加入HMR逻辑是指上面提到的检测到vuePlugin注入了一段import.meta.hot.accept后在文件头部注入的HMR Context,track HMR boundary accept whitelists(这一段不知道该怎么翻译)是指下面这两个变量

// fucntion rewriteFileWithHMR
export const hmrAcceptanceMap: HMRStateMap = new Map() 
export const hmrDeclineSet = new Set() 

// 当发现文件中有调用Hmr方法,比如 import.hot.meta.accept 或者其他方法时,就会开始记录
// 比如 a 模块中有  import.meta.hot.accept(['./b','./c'],callback)
// hmrAcceptanceMap里就会加一个  {key: a, value: ['./b', './c']}
// 这里简单说下accept是单个模块,如`import.meta.hot.accept('./foo', () => {})`
// `import.meta.hot.accept() OR import.meta.hot.accept(() => {})`会把当前模块加进去
// accepts是接受多个模块
// decline的话会加到hmrDeclineSet里

有了以上这些变量后就能够支持HMR graph analysis

监听文件变化

// src/node/server/serverPluginHmr.ts
  watcher.on('change', (file) => {
    if (!(file.endsWith('.vue') || isCSSRequest(file))) {
      // everything except plain .css are considered HMR dependencies.
      // plain css has its own HMR logic in ./serverPluginCss.ts.
      handleJSReload(file)
    }
  })

可以看到当监听到文件变化后,会有两大类的处理

  1. 一般性的处理 ,执行handleJSReload
  2. 特殊处理,对于.vue .css文件需要在其对应的插件中处理

这里我们先看对一般性文件handleJSReload的处理

HMR graph analysis

这里的实现也就对应着上面提到的HMR设计思路

fn handleJSReload里表明 HMR graph analysis分析的结果就两种,要么是dead end,发送

send({ type: 'full-reload', path: publicPath })

要么就是把找到的多个hmr boundary发送出去

fn walkImportChain是来判断到底是dead end还是存在hmr boundary的,

  1. 当前文件调用了import.meta.hot.decline(),那么一定是dead end
  2. 当前文件自己就存在自己的hmrAcceptanceMap里, 那么自己就是hmr boundary
  3. 如果(被当前文件使用的文件)是.vue文件或者(被当前文件使用的文件)的hmrAcceptanceMap里包括当前文件或者自己,那么被(当前文件使用的文件)就是hmr boundary
  4. 如果非以上情况,那么就递归的判断 被(被当前文件使用的文件)使用的文件,一直往上,直到结束
//  在importer 的hmrAcceptanceMap里
function isHmrAccepted(importer: string, dep: string): boolean {
  const deps = hmrAcceptanceMap.get(importer)
  return deps ? deps.has(dep) : false
}
const handleJSReload = (watcher.handleJSReload = (
  filePath: string,
  timestamp: number = Date.now()
) => {
  const publicPath = resolver.fileToRequest(filePath)
  const importers = importerMap.get(publicPath) //  获取被publicPath使用的模块
  if (importers || isHmrAccepted(publicPath, publicPath)) {
    const hmrBoundaries = new Set()
    const dirtyFiles = new Set() //  记录被影响了的文件
    dirtyFiles.add(publicPath)

    const hasDeadEnd = walkImportChain(
      publicPath,
      importers || new Set(),
      hmrBoundaries,
      dirtyFiles
    )

    // record dirty files - this is used when HMR requests coming in with
    // timestamp to determine what files need to be force re-fetched
    hmrDirtyFilesMap.set(String(timestamp), dirtyFiles)

    const relativeFile = '/' + slash(path.relative(root, filePath))
    if (hasDeadEnd) {
      send({
        type: 'full-reload',
        path: publicPath
      })
      console.log(chalk.green(`[vite] `) + `page reloaded.`)
    } else {
      const boundaries = [...hmrBoundaries]
      send({
        type: 'multi',
        updates: boundaries.map((boundary) => {
          return {
            type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',
            path: boundary,
            changeSrcPath: publicPath,
            timestamp
          }
        })
      })
    }
  } else {
    debugHmr(`no importers for ${publicPath}.`)
  }
})
function walkImportChain(
  importee: string,
  importers: Set,
  hmrBoundaries: Set,
  dirtyFiles: Set,
  currentChain: string[] = []
): boolean {
  if (hmrDeclineSet.has(importee)) {
    // 文件调用了import.meta.hot.decline
    return true
  }

  if (isHmrAccepted(importee, importee)) {
    //  自己就自己的hmrAcceptanceMap里的话,直接返回了
    hmrBoundaries.add(importee)
    dirtyFiles.add(importee)
    return false
  }

  for (const importer of importers) {
    if (
      importer.endsWith('.vue') ||
      // explicitly accepted by this importer
      isHmrAccepted(importer, importee) ||
      // importer is a self accepting module
      isHmrAccepted(importer, importer)
    ) {
      // vue boundaries are considered dirty for the reload
      if (importer.endsWith('.vue')) {
        dirtyFiles.add(importer)
      }
      hmrBoundaries.add(importer)
      currentChain.forEach((file) => dirtyFiles.add(file))
    } else {
      const parentImpoters = importerMap.get(importer) //  获取被importer(被当前importee使用的模块)使用的模块
      if (!parentImpoters) {
        //  dead end
        return true
      } else if (!currentChain.includes(importer)) {
        if (
          walkImportChain(
            importer,
            parentImpoters,
            hmrBoundaries,
            dirtyFiles,
            currentChain.concat(importer)
          )
        ) {
          return true
        }
      }
    }
  }
  return false
}

消息类型

send的消息类型定义在 src/hmrPayload.ts里,针对每种type,浏览器端都会有不同的相应,在src/client/client.ts

export type HMRPayload =
  | ConnectedPayload
  | UpdatePayload
  | FullReloadPayload
  | StyleRemovePayload
  | SWBustCachePayload
  | CustomPayload
  | MultiUpdatePayload

interface ConnectedPayload {
  type: 'connected'
}

export interface UpdatePayload {
  type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'
  path: string
  changeSrcPath: string
  timestamp: number
}

interface StyleRemovePayload {
  type: 'style-remove'
  path: string
  id: string
}

interface FullReloadPayload {
  type: 'full-reload'
  path: string
}

interface SWBustCachePayload {
  type: 'sw-bust-cache'
  path: string
}

interface CustomPayload {
  type: 'custom'
  id: string
  customData: any
}

export interface MultiUpdatePayload {
  type: 'multi'
  updates: UpdatePayload[]
}

浏览器端响应

clients.js除了前面描述的一些功能外,还定义了一些变量和方法用于处理HMR相关的逻辑

const hotModulesMap = new Map() // 记录HMR模块相关的信息
初次访问

比如App.vue中增加了如下的HMR逻辑

import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.vue"); // 注册一个HMR模块
_sfc_main.__hmrId = "7a7a37b1";
import.meta.hot.accept((mod)=>{
    if (!mod)
        return;
    const {default: updated, _rerender_only} = mod;
    if (_rerender_only) {
        __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
    } else {
        __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
    }
}
);

import.meta.hot.accept执行后,hotModulesMap里会增加一条记录

key: '/src/App.vue'
values:  {
  id: '/src/App.vue',
  callbacks: [
    {
      deps: '/src/App.vue',
      fn: callback
    },
    {
      deps: 'xxxxxx/xxxx.vue',
      fn: callback
    }
  ]
}
响应HMR

服务端发送的消息类型有很多,每种类型都有对应的方法,比如full-reload会触发页面刷新等等,这里我们主要看下js-update的时候

switch (payload.type) {
  ...
  case 'vue-rerender':
    const templatePath = `${path}?type=template`
    import(`${templatePath}&t=${timestamp}`).then((m) => {
      __VUE_HMR_RUNTIME__.rerender(path, m.render)
      console.log(`[vite] ${path} template updated.`)
    })
    break
  case 'style-remove':
    removeStyle(payload.id)
    break
  case 'js-update':
    queueUpdate(updateModule(path, changeSrcPath, timestamp))
    break
  case 'full-reload':
    if (path.endsWith('.html')) {
      const pagePath = location.pathname
      if (
        pagePath === path ||
        (pagePath.endsWith('/') && pagePath + 'index.html' === path)
      ) {
        location.reload()
      }
      return
    } else {
      location.reload()
    }
}

服务端把hmr有变化的文件目录都发送了过来,fn updateModule里就是把hotModulesMap里这些文件里注册的所有callback(deps,callback )都拿出来,并重新请求各个depsimport deps, fn queueUpdate就是在这么deps重新加载后执行之前对应的callback,到这里一次HMR就完成了

总结

HMR消息类型有多种,以下是多个hmr boundary 类型为js-update时的一次更新流程图
vite1.x 热更新(HMR)的实现原理_第5张图片

End

  1. 本次源码阅读的结论主要是通过阅读源码和百度一些资料得到的,没有经过断点一步一步调试,所以可能会存在理解有偏差的地方,有任何问题都欢迎大家一起讨论
  2. 阅读过程中建了一个分支,有随手加的一些注释,有需要可以看下
  3. 自己阅读源码的记录会统一放在这里,包括single-spa rollup qiankun ...
  4. 感谢阅读!

你可能感兴趣的:(vite,打包工具,前端,vite,hmr)