从零到一,带你彻底搞懂 vite 中的 HMR 原理(源码分析)

8,040 阅读7分钟

前言

不知不觉间,距离尤大大当初在微博宣布「vite」的出现到现在,已经过了 2 个月多。

当时,「vite」只是支持对 .vue 文件的即时编译和 importrewrite,相应地「Plugin」也没有几个。并且,最初在「GitHub」上「vite」的 slogan 是这样的:

—— No-bundle Dev Server for Vue 3 Single-File Components.

可以看到,起初介绍「vite」是一个不需要打包的开发阶段的服务器。但是,现在,这句 slogan 已经不在了。并且,它不仅仅是一个开发阶段的服务器这么简单。相应地也实现了很多「Feature」,例如:Web Assembly、JSX 、CSS Pre-processors、Dev Server Proxy 等等。

有兴趣了解这些「Feature」的同学,可以移步GitHub自行阅读

回到正题,作为一名「Vue」爱好者,我同样对「vite」充满了好奇。所以,本次文章,我会先浅析 webpack-dev-server的「HMR」,然后再循序渐进地讲解「vite」在「HMR」这个过程做了什么。

Webpack 的 HMR 过程

提及「HMR」,不可避免地是会想起现在我们家喻户晓webpack-dev-server 中的「HMR」。所以,我们先来了解一番webpack-dev-server的「HMR」。

首先,我们先对「HMR」建立一个基础的认知。「HMR」 全称即 Hot Module Replacement。相比较「live load」,它具有以下优点:

  • 可以实现局部更新,避免多余的资源请求,提高开发效率
  • 在更新的时候可以保存应用原有状态
  • 在代码修改和页面更新方面,实现所见即所得

而在 webpack-dev-server 中实现「HMR」的核心就是 HotModuleReplacementPlugin ,它是「Webpack」内置的「Plugin」。在我们平常开发中,之所以改一个文件,例如 .vue 文件,会触发「HMR」,是因为在 vue-loader 中已经内置了使用 HotModuleReplacementPlugin 的逻辑。它看起来会是这样

  1. Helloworld.vue
<template>
  <div>hello world</div>
</template>
<script lang="ts">
  import { Vue, Component } from 'vue-property-decorator'
  @Component
  export default class Helloworld extends Vue() {}
</script>
  1. main.js(手动实现「HMR」效果)
import Vue from 'vue'
import HelloWorld from '_c/HelloWorld'

if (module.hot) {
  module.hot.accept('_c/HelloWorld', ()=>{
    // 拉取更新过的 HelloWorld.vue 文件
  })
}

new Vue({
  el: '#app',
  template: '<HelloWorld/>'
  component: { HelloWorld }
})

那么,这个就是 webpack-dev-server 实现「HMR」的本质吗?显然不是,上面说的只是,如果你要通过 webpack-dev-server 实现「HMR」,你可以这么写来实现。

如果究其底层实现,是有两个关键的点

1.与本地服务器建立「socket」连接,注册 hashok 两个事件,发生文件修改时,给客户端推送 hash 事件。客户端根据 hash 事件中返回的参数来拉取更新后的文件。

2.HotModuleReplacementPlugin 会在文件修改后,生成两个文件,用于被客户端拉取使用。例如:

hash.hot-update.json

{
 "c": {
  "chunkname": true
 },
 "h": "d69324ef62c3872485a2"
}

chunkname.d69324ef62c3872485a2.hot-update.js,这里的 chunkname 即上面 c 中对于 key

webpackHotUpdate("main",{
   "./src/test.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval(....)
  })
})

当然,在这之前还会涉及到对原模块代码的注入,让它具备拉取文件的能力。而这其中实现的细节就不去扣了,要不然有点喧兵夺主的感觉。

有兴趣的同学可以去看看这篇文章一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时

基于 native ES Module 的 devServer

基于 native ES Module 的 devServer 是「vite」实现「HMR」的重要一环。总体来说,它会做这么两件事:

  • 初始化本地服务器
  • 加载并执行对应的 Plugin,例如 sourceMapPluginmoduleRewritePluginhtmlRewritePlugin 等等。

所以,本质上,devServer 做的最重要的一件事就是加载执行 Plugin。目前,「vite」总共具备了 11Plugin

这里大致列举几点 Plugin 会做:

  • 拦截请求,处理「ES Module」语法相关的代码,转化为浏览器可识别的「ES Module」语法,例如第三方模块的 import 转化为 /@module/vue.js
  • .ts.vue 进行即时的编译以及 sassless 的预编译
  • 建立模块间的导入导出关系,即 importeeMap和客户端建立 socket 连接,用于实现「HMR」

这里就列举 devServer 几个常见的 Plugin 需要做的事,至于其他像 wasmPluginwebWorkerPlugin 之类的 Plugin 会做些什么,有兴趣的同学可以自行去了解。

然后,我们再从代码地角度看看它是怎么实现我们上述所说的:

1.首先,我们执行 vite 命令.实际上是运行 cli.js 这个文件,这里我摘取了其中核心的逻辑:

(async () => {
  const { help, h, mode, m, version, v } = argv
  ...
  const envMode = mode || m || defaultMode
  const options = await resolveOptions(envMode)
  // 开发环境下,我们会命中 runServer
  if (!options.command || options.command === 'serve') {
    runServe(options)
  } else if (options.command === 'build') {
    runBuild(options)
  } else if (options.command === 'optimize') {
    runOptimize(options)
  } else {
    console.error(chalk.red(`unknown command: ${options.command}`))
    process.exit(1)
  }
})()
async function runServe(options: UserConfig) {
  // 在 createServer() 的时候会对 HRM、serverConfig 之类的进行初始化
  const server = require('./server').createServer(options)
  ...
}

可以看到,在自执行函数中,我们会命中 runServer() 的逻辑,而它的核心是调用 server.js 文件中的 createServer()

createServer 方法:

export function createServer(config: ServerConfig): Server {
  const {
    ...,
    enableEsbuild = true
  } = config

  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  const watcher = chokidar.watch(root, {
    ignored: [/\bnode_modules\b/, /\b\.git\b/]
  }) as HMRWatcher
  const resolver = createResolver(root, resolvers, alias)

  const context: ServerPluginContext = {
    ...
    watcher
    ...
  }

  app.use((ctx, next) => {
    Object.assign(ctx, context)
    ctx.read = cachedRead.bind(null, ctx)
    return next()
  })

  const resolvedPlugins = [
    ...,
    moduleRewritePlugin,
    hmrPlugin,
    ...
  ]
  // 核心逻辑执行 hmrPlugin
  resolvedPlugins.forEach((m) => m && m(context))

  const listen = server.listen.bind(server)
  server.listen = (async (port: number, ...args: any[]) => {
    ...
    }) as any

  return server
}

createServer 方法做了这么几件事:

  • 创建一个 koa 实例
  • 创建监听除了 node_modules 之外的文件的 watcher,并传入 context
  • context 上下文传入并调用每一个 Plugin

到这里,「vite」的 devServer 的创建过程就已经完成。那么,接下来我们去领略一番属于「vite」的「HMR」过程!

vite 的 HMR 过程

在「vite」中「HMR」的实现是以 serverPluginHmr 这个 Plugin 为核心实现。这里我们以 .vue 文件的修改触发的「HMR」为例,这个过程会涉及三个 PluginserverPluginHtmlserverPluginHmrserverPluginVue,这个过程看起来会是这样:

serverPluginHtml

从前面的流程图可以看到,首先是 serverPluginHtml 这个 Plugin 向 index.html 中注入了获取 hmr 模块的代码:

export const htmlRewritePlugin: ServerPlugin = ({
  root,
  app,
  watcher,
  resolver,
  config
}) => {
  const devInjectionCode =
    `\n<script type="module">\n` +
    `import "${hmrClientPublicPath}"\n` +
    `window.process = { env: { NODE_ENV: ${JSON.stringify(
      config.mode || 'development'
    )} }}\n` +
    `</script>\n`

  const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
  const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/

  async function rewriteHtml(importer: string, html: string) {
    ...
    html = html!.replace(scriptRE, (matched, openTag, script) => {
      ...
      return injectScriptToHtml(html, devInjectionCode)
  }

  app.use(async (ctx, next) => {
    await next()
    ...

    if (ctx.response.is('html') && ctx.body) {
      const importer = ctx.path
      const html = await readBody(ctx.body)
      if (rewriteHtmlPluginCache.has(html)) {
        ...
      } else {
        if (!html) return
        // 在这里给 index.html 文件注入代码块
        ctx.body = await rewriteHtml(importer, html)
        rewriteHtmlPluginCache.set(html, ctx.body)
      }
      return
    }
  })

}

所以,当我们访问一个 「vite」 启动的项目的时候,我们会在「network」中看到服务器返回给我们的 index.html 中的代码会多了这么一段代码:

<script type="module">
import "/vite/hmr"
window.process = { env: { NODE_ENV: "development" }}
</script>

而这一段代码,也是确保我们后续正常触发「HMR」的关键点。因为,在这里浏览器会向服务器发送请求获取 vite/hmr 模块,然后,在 serverPluginHmr 中会拦截 ctx.path===‘/vite/hmr’ 的请求,建立 socket 连接。那么,接下来我们看看 serverPluginHmr 是进行这些过程的。

serverPluginHmr

上面我们说了 serverPluginHmr 它会劫持导入 /vite/hmr 的请求,然后返回 client.js 文件。所以,我们分点来细致化地分析这个过程:

1.读取 cliten.js 文件,劫持导入 /vite/hmr 的请求

 export const hmrClientFilePath = path.resolve(
   __dirname,
   '../../client/client.js'
 )
 export const hmrClientPublicPath = `/vite/hmr`
 const hmrClient = fs
    .readFileSync(hmrClientFilePath, 'utf-8')
    .replace(`__SW_ENABLED__`, String(!!config.serviceWorker))

  app.use(async (ctx, next) => {
    if (ctx.path === hmrClientPublicPath) {
      ctx.type = 'js'
      ctx.status = 200
      ctx.body = hmrClient.replace(`__PORT__`, ctx.port.toString())
    } else {
      ...
    }
  })

这里通过 readFileSync() 读取 client.js 文件,然后分别 replace 读取到的文件内容(字符串),一个是用于判断是否支持 serviceWorker,另一个用于建立 socket 连接时的端口设置。

2.定义 send 方法,并赋值给 watcher.send,用于其他 Plugin 在热更新时向浏览器推送更新信息:

const send = (watcher.send = (payload: HMRPayload) => {
    const stringified = JSON.stringify(payload, null, 2)
    debugHmr(`update: ${stringified}`)

    wss.clients.forEach((client) => {
      // OPEN 表示已经建立连接
      if (client.readyState === WebSocket.OPEN) {
        client.send(stringified)
      }
    })
  })

3.client.js,它会做这两件事:

  • 建立和服务器的 socket 连接,监听 message 事件,拿到服务器推送的 data,例如我们只修改 .vue 文件,它的 data 类型定义会是这样:
export interface UpdatePayload {
  type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'
  path: string
  changeSrcPath: string
  timestamp: number
}
  • 对不同的 data.type 执行不同的逻辑,目前存在 type 有:vue-reloadvue-rerenderstyle-updatestyle-removejs-updatecustomfull-reload。本次我们只分析 vue-rerender 的逻辑,其实现的核心代码如下:
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
const socketUrl = `${socketProtocol}://${location.hostname}:${__PORT__}`
const socket = new WebSocket(socketUrl, 'vite-hmr')
// 监听 message 事件,拿到服务端推送的 data
socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
  if (payload.type === 'multi') {
    payload.updates.forEach(handleMessage)
  } else {
    // 通常情况下会命中这个逻辑
    handleMessage(payload)
  }
})
async function handleMessage(payload: HMRPayload) {
  const { path, changeSrcPath, timestamp } = payload as UpdatePayload
  ...
  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
    ...
  }
}

其实,这个过程还用到了 serviceWorker 做一些处理,但是看了下提交记录是 wip,所以这里就没有分析这个逻辑,有兴趣的同学可以自己了解。

serverPluginVue

前面,我们讲了 serverPluginHtmlserverPluginHmr 在 HMR 过程会做的一些前期准备。然后,我们这次分析的修改 .vue 文件触发的「HMR」逻辑,它的开始是在 serverPluginVue

首先,它会解析 .vue 文件,做一些 compiler 处理,然后通过 watcher 监听 .vue 文件的修改:

watcher.on('change', (file) => {
  if (file.endsWith('.vue')) {
    handleVueReload(file)
  }
})

可以看到在 change 事件的回调中调用了 handleVueReload(),针对我们这个 case,它会是这样:

const handleVueReload = (watcher.handleVueReload = async (
    filePath: string,
    timestamp: number = Date.now(),
    content?: string
  ) => {
    const publicPath = resolver.fileToRequest(filePath)
    const cacheEntry = vueCache.get(filePath)
    const { send } = watcher
    ...
    let needRerender = false
    ...
    if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
      needRerender = true
    }
    ...
    if (needRerender) {
      send({
        type: 'vue-rerender',
        path: publicPath,
        changeSrcPath: publicPath,
        timestamp
      })
    }
  })

handleVueReload() 它会针对 .vue 文件中,不同情况走不同的逻辑。这里,我们只是修改了 .vue 文件,给它加了一行代码,那么此时就会命中 isEqualBlock()false 的逻辑,所以 needRerendertrue,最终通过 send() 方法向浏览器推送 typevue-rerender 以及携带修改的文件路径的信息。然后,我们前面 client.js 中监听 message 的地方就会拿到对应的 data,再通过 import 发起获取该模块的请求。

小结

到这里,整个「vite」实现「HMR」的逻辑已经分析结束了。当然,这次只是针对 .vue 文件的修改来分析整个 HMR 的逻辑,相应地还有 .js.css 的文件的修改触发的 HMR 的逻辑,但是,可以说的是只要理解这个过程是如何进行的,那么每一个 case 的分析,也只是依葫芦画瓢

并且,其实在「HMR」的过程中还有一些辅助变量和概念,例如 hrmBoundariesimport chainchild importer 等等,它们都是用于帮助更好地进行 HMR 处理。所以,这里我就没有提及这些。有兴趣的同学可以自己去看源码中的讲解这些辅助变量和概念的意义

写在最后

其实,在当初尤大大发微博的时候,我就想着写一篇关于「vite」源码分析的文章。这两个月,我也经历一些起起伏伏,所以,一直到现在才开始交上这份答卷。但是,现在再看「vite」源码,已经不是仅仅 devServer 这么简单了,所以这次只分析了「HMR」这个点的实现,其他方面后续有时间应该会继续写其他方面,例如 rewritebundlecompiler 等等。最后,文章中可能会存在表述不对或不好的地方,欢迎各位同学提 Issue。

写作不易,如果你觉得有收获的话,可以帅气三连击!!!