Vite 热更新原理探索

一、概述

1. 背景

代码变更后查看更新的页面效果一直以来都是前端工程师的工作流程当中出现频率最高的环节

在前端界还没有大量工具与解决方案的时代,工程师们一度是通过手动/自动刷新页面的方式来解决应对这个开发环节

但随着互联网的发展,对前端产品的要求越来越高,一个项目里出现越来越多的模块,前端工程逐渐变得庞大,手动/自动刷新页面会很大程度上影响开发体验与效率

于是,HMR 诞生了

HMR(Hot Module Replacement)又称模块热替换,即当修改代码时,能够在不刷新页面的情况下,自动把页面中发生变化的模块,替换成新的模块,同时不影响其他模块的正常运作

接下来我们以 Vite 为例,介绍一下 HMR 的核心原理

二、Vite 的 HMR

1. Vite 的组成部分

在讲解原理之前我们首先了解下 Vite 的组成部分。在这里,为了方便理解,我们将 Vite 主要分为 Client 和 Server 两部分,分别对应我们平时业务需求中的前端&后端,这样可以按照我们平时业务开发中的前后端交互逻辑来理解整个流程。

1.1 Server

Vite 会在启动项目时通过 CreateServer 方法在本地创建一个开发服务器,用来运行项目。开发服务器的主要作用是将项目的文件内容翻译成浏览器可解析的代码(如ts、scss 文件被翻译成 js 最终被浏览器解析)、监听文件变化以及发送更新消息。

Server 主要包含了以下几个部分

  • Watcher:用来监听文件变化
  • Websocket:监听文件变化后向 Client 发送信息
  • PluginContainer:插件管理容器
  • ModuleGraph:存储模块信息

后面我们会分别对这几个部分进行讲解

1.2 Client

Vite 在开发阶段会主动向页面注入一段拉取客户端代码的脚本

检查元素的时候可以看到在 head 中通过 script 请求了 /@vite/client 这样一个文件(@vite/client是别名,为了和其他文件做区分),Server 收到请求后会调用处理别名的中间件插件,执行名称替换,最终替换成 vite/dist/client/client.mjs,也就是我们使用 Vite 时 node_module 中最终生成的文件

Vite 热更新原理探索

请求下来的其实就是一段 js 脚本,这段 js 代码运行在我们的页面中

相当于在我们运行的前端网页中插入了一个服务,它会和 Server 进行交互,帮助我们实现包括热更新在内的一系列行为

Vite 热更新原理探索

1.2.2 Vite 的 HMR API

我们来看下 Vite HMR 的核心 API,Client 中用到的 API 主要是挂载在 import.meta 上的热更新回调函数 accept 以及模块失活函数 dispose。

// importMeta.d.ts
interface ImportMeta {
  
  url: string
  
  readonly hot?: import('./hot').ViteHotContext // HMR 依赖 hot 属性
  
  readonly env: ImportMetaEnv
  
  glob: import('./importGlob').ImportGlobFunction

  globEager: import('./importGlob').ImportGlobEagerFunction

}


// hot.d.ts
export interface ViteHotContext {
  
  readonly data: any // 共享数据
  
  // 模块作为热更新边界,注册模块热更新(监听目标的模块文件更新)时的回调函数
  accept(): void
  
  accept(cb: (mod: ModuleNamespace | undefined) => void): void
  
  accept(dep: string, cb: (mod: ModuleNamespace | undefined) => void): void
  
  accept(
    deps: readonly string[],
    cb: (mods: Array<ModuleNamespace | undefined>) => void
  ): void
  
  dispose(cb: (data: any) => void): void // 注册模块更新or卸载时需要执行的回调函数
  
}

import.meta 对象是现代浏览器原生的一个内置对象。Vite 基于 import.meta 进行了封装,新增了只读的 hot 属性,并在 hot 上挂载了一系列参数和方法

从上面代码中可以看到accept有四个重载方法,同时它也是 Vite 实现 HMR 的关键 API

import.meta.hot.accept

accept 方法用于接受模块更新消息,并调用更新影响范围对应的回调函数,也就是通过accept注册的函数

“接受”热更新的模块被认为是“边界模块”

import.meta.hot.dispose

模块销毁时逻辑:用于注册在模块更新、旧模块销毁时的回调处理函数

2. Vite HMR 整体流程

首先我们了解下 Vite HMR的整体流程:

  • 用户修改文件后被 server 端的监听器监听到,监听器遍历文件对应的模块,计算出热更新边界
  • server 端通过 websocket 向 client 发送热更新信号
  • client 对旧模块进行失活,向 server 请求最新的模块资源
  • server 收到请求后将模块代码转换为 js,并将转换后的代码返回给 client
  • client 执行返回后的代码,调用更新函数更新页面内容

Vite 热更新原理探索

三、Vite HMR 的详细介绍

1. Server 创建开发服务器

1.1 翻译文件内容

对于浏览器不能处理的文件内容,像 ts、less、sass 这类文件,vite 会将他们转换成浏览器能够解析的 js 文件内容返回给浏览器。每个 import 都是一个 HTTP 请求,Vite 收到请求后会将对应的资源以浏览器可解析的方式返回,直到当前所需要的资源都加载完成。

Vite 热更新原理探索

1.2 创建监听器

为了实现热更新,Vite 需要监听项目文件的变化。这一步是通过 chokidar 来实现的。chokidar npm 官网

那为什么不用 Node.js 原生自带的监听器 watch 和 watchFile?

1.2.1 Node.js 原生监听器

根据 chokidar 的介绍,Node.js 的两个监听方法各有缺陷

Node.js 的 watch 缺陷:

  • 在 MacOS 上不报告文件名变化

  • 经常报告两次事件

  • 把多数事件通知为 rename

  • 没有便捷的方式递归监控文件树

Node.js 的 watchFile 缺陷:

  • 事件处理有大量问题

例如:文件被删除,然后恢复。触发了两次监听动作,但是这两次监听动作监听到的文件 prev(代表修改前的内容) 却是一样的。

  • 不提供便捷的递归监控文件树功能
  • 轮询监听,会导致 CPU 占用高

node 官方更推荐使用 fs.watch,fs.watch 内部针对各个系统调用了公共的原生 API,在 Linux 中使用 inofity,Windows 中使用 ReadDictoryChangesW,在 MacOS 中使用 FSEvents(但实现不够完善,尤其是针对MacOS系统的 API 实现,Node.js 官网也承认了 fs.watch 的各种缺陷)

fs.watchFile 一般在 fs.watch  不能满足需求时(例如在 MacOS 系统中)才被使用到,它通过轮询的方式监听文件变化,这种方式可能会导致 CPU 占用率过高,是一种不得已的方法

综上所述,Node.js 的文件监听系统并不够完善,两个方法都存在缺陷,欠缺通用性,因此 Vite 使用了 chokidar

1.2.2 chokidar

chokidar 是对 Node.js 文件监控能力进行封装的一个结果,它有效地解决了上述的一些问题,并且在针对项目文件监听方面提供了更方便的 API。

完善文件监听 API

Node.js 原生监听 API 提供的递归监控文件树能力不够完善,只支持 Windows 和 Mac OS 两种系统,而 chokidar 则基于此对更多系统提供了支持,可以在各个系统中使用。

监听优化(相比于 node.js 原生 API)

针对 Node.js 监听的诸多问题,chokidar 分别进行了优化

  • chokidar 同样针对系统进行区分,针对 Mac OS 系统使用原生 API FSEvents 重写了监听逻辑,弥补了 Nodejs 原生 API 的不足,避免了直接使用 Nodejs  API 带来的不报告事件问题。在 Window 和 Linux 中chokidar 则正常使用 Node.js 原生的 API。这种针对不同系统使用调用合适的 API 并优化使用方式的方法解决了在 MacOS 监听事件的问题。同时有效避免了由于 fs.watch 一系列问题不能满足需求而被迫使用 fs.watchFile 导致的 CPU 占用率过高问题

  • chokidar 也解决了在 fs.watch 的 change 事件调用两次的问题,解决方案是使用 _throttle 节流方法,让 30 毫秒内的 change 只执行一次

chokidar API

export class FSWatcher extends EventEmitter implements fs.FSWatcher {
  options: WatchOptions;

  constructor(options?: WatchOptions);

  add(paths: string | ReadonlyArray<string>): this;

  unwatch(paths: string | ReadonlyArray<string>): this;

  getWatched(): {
    [directory: string]: string[];
  };

  close(): Promise<void>;

  on(event: 'add'|'addDir'|'change', listener: (path: string, stats?: fs.Stats) => void): this;

  on(event: 'all', listener: (eventName: 'add'|'addDir'|'change'|'unlink'|'unlinkDir', path: string, stats?: fs.Stats) => void): this;

  on(event: 'error', listener: (error: Error) => void): this;

  on(event: 'raw', listener: (eventName: string, path: string, details: any) => void): this;

  on(event: 'ready', listener: () => void): this;

  on(event: 'unlink'|'unlinkDir', listener: (path: string) => void): this;

  on(event: string, listener: (...args: any[]) => void): this;
}

通过上述代码我们可以看到 chokidar 本质上是一个 EventEmitter,对 FSWatcher 进行了实现,因此在使用的时候也和 EventEmitter 的使用方法基本一致。

1.3 创建 Websocket 连接

Vite 会在创建开发服务器(Server)的过程中创建一个 Websocket 连接,用于和 Client 通信,Server 端会在监听到文件变化时遍历对应模块计算出对应的热更新边界,将热更新信号发送给客户端,由客户端进行热更新处理。

1.3.1 热更新边界

热更新边界也叫边界模块,指的是当文件被修改之后,Vite 会沿着依赖树一直往上寻找依赖关系,直到查找到最近的一个可以热更新的模块,这个最近的一个热更新模块叫热更新边界。

Vite 热更新原理探索

例如有一个 index.vue 组件依赖了一个 useState.ts 的文件,当 useState.ts 文件变化时,我们在 index.vue 中看到的实际效果并不会发生变化,因此 Vite 会沿着 useState.ts 向上寻找,找到一个可热更新的依赖 index.vue 对其进行热更新,这时候我们看到的页面才会发生变化

此外我们会发现在修改 main.js 文件时,页面并不会发生热更新而是进行了页面刷新,这是因为 main.js 是入口文件,往上找不到可以热更新的模块了,vite 不知道如何进行热更新,因此只能刷新页面

1.4 创建模块依赖图 Module Graph

Vite 是以模块为单位进行处理项目的,为了记录各个模块的关系,Vite使用了Module Graph(模块依赖图)结构,Module Graph 描述了各节点的依赖关系

Vite 在 CreateServer 中创建 Module Graph

// 初始化模块图
  const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
    container.resolveId(url, undefined, { ssr })
  )

1.4.1 Module Graph 的类型

export class ModuleGraph {
  // url 和模块的映射
  urlToModuleMap = new Map<string, ModuleNode>()
  // id 和模块的映射
  idToModuleMap = new Map<string, ModuleNode>()
  // 文件和模块的映射
  fileToModulesMap = new Map<string, Set<ModuleNode>>()
  // /@fs 的模块
  safeModulesPath = new Set<string>()

  constructor(
    private resolveId: (
      url: string,
      ssr: boolean
    ) => Promise<PartialResolvedId | null>
  ) {}

  /**
   * 文件修改事件
   */
  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
    invalidateSSRModule(mod, seen)
  }
  
  // 更新模块信息
  async updateModuleInfo()(
    mod: ModuleNode,
    importedModules: Set<string | ModuleNode>,
    acceptedModules: Set<string | ModuleNode>,
    isSelfAccepting: boolean,
    ssr?: boolean
  ): Promise<Set<ModuleNode> | undefined> {
    // ...
  }
}


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

  // ...
}

从代码中可以看出 ModuleGraph 主要由 urlToModuleMap、idToModuleMap、fileToModulesMap、safeModulesPath 四个映射关系属性以及其他方法组成

  • getModuleByUrl、getModuleById、getModulesByFile 分别是通过 url、id、file 获取模块的方法;

  • onFileChange 、invalidateModule 文件改变时的响应函数以及清除模块方法

  • updateModuleInfo 更新模块时触发,用作热更新

1.5 创建插件容器 PluginContainer

vite 的插件容器时基于 Rollup plugin container,提供了一些 hooks:

  • pluginContainer.watchChange: 每当受监控的文件发生更改时,都会通知插件, 执行对应处理
  • pluginContainer.transform: 每个 rollup plugin 提供 transform 方法,在这个钩子里执行是为了对不同文件代码进行转换操作,比如 plugin-vue,经过执行就将 vue 文件转换成新的格式代码。
export interface PluginContainer {
  options: InputOptions
  getModuleInfo(id: string): ModuleInfo | null
  buildStart(options: InputOptions): Promise<void>
  resolveId(
    id: string,
    importer?: string,
    options?: {
      attributes?: Record<string, string>
      custom?: CustomPluginOptions
      skip?: Set<Plugin>
      ssr?: boolean
      /**
       * @internal
       */
      scan?: boolean
      isEntry?: boolean
    },
  ): Promise<PartialResolvedId | null>
    
  transform(
    code: string,
    id: string,
    options?: {
      inMap?: SourceDescription['map']
      ssr?: boolean
    },
  ): Promise<{ code: string; map: SourceMap | { mappings: '' } | null }>
    
  watchChange(
    id: string,
    change: { event: 'create' | 'update' | 'delete' },
  ): Promise<void>
  close(): Promise<void>
}

2. Server 监听变化&推送更新

我们前面讲到 Vite 通过 chokidar 创建 watcher 对项目文件进行监听,并且 watch 本质上是一个 EventEmitter。因此它也会在内部监听器触发时发布事件,并提供相对应的订阅方法供我们调用。下面是三个比较关键的订阅事件:

2.1 change 事件

在 change 事件里主要进行了两步操作,分别是对旧模块进行失活,以及像 Client 发送更新信号

watcher.on('change', async (file) => {
    file = normalizePath(file)
    await container.watchChange(file, { event: 'update' })
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    await onHMRUpdate(file, false)
  })

watcher.on('add', (file) => onFileAddUnlink(file, false))
watcher.on('unlink', (file) => onFileAddUnlink(file, true))

2.1.1 模块失活

  • 调用插件容器的 watchChange 方法,通知插件容器文件发生变化,调用文件对应的更新 hooks,插件监测到修改的文件为 package.json 时则会清除缓存
  • 通过 invalidateModule 方法使模块失效

invalidateModule(
		mod: ModuleNode,
    seen: Set<ModuleNode> = new Set(),
    timestamp: number = Date.now(),
    isHmr: boolean = false,
    /** @internal */
    softInvalidate = false,
): void		{
  
  	// 软失效处理逻辑
    if (softInvalidate) {
      mod.invalidationState ??= mod.transformResult ?? 'HARD_INVALIDATED'
      mod.ssrInvalidationState ??= mod.ssrTransformResult ?? 'HARD_INVALIDATED'
    }
    // If hard invalidated, further soft invalidations have no effect until it's reset to `undefined`
    else {
      mod.invalidationState = 'HARD_INVALIDATED'
      mod.ssrInvalidationState = 'HARD_INVALIDATED'
    }
  
		if (isHmr) { // 这里 isHmr 为 false
      mod.lastHMRTimestamp = timestamp
    } else {
      mod.lastInvalidationTimestamp = timestamp // 保存失效时间戳,用于标记模块失效,后续是否需要重新翻译
    }
  
		mod.transformResult = null
    mod.ssrTransformResult = null
    mod.ssrModule = null
    mod.ssrError = null
		
		// 处理所有接受热更新的模块
		mod.importers.forEach((importer) => {
      if (!importer.acceptedHmrDeps.has(mod)) {
        // 模块软失效逻辑,防止调用链上的模块层层失效,Vite5 支持
        // 这边是判断导入模块是否soft invalidate 的方式
        // 导入模块中导入当前模块是静态导入方式。App.vue 中 import { foo } from './foo.ts', 此时App.vue就是soft invalidate状态
        // 当前模块已经是soft invalite了,那么其导入模块也是soft invalidate方式。App.vue是soft invalidate,导入App.vue的模块也是soft invalidte方式
        const shouldSoftInvalidateImporter =
          importer.staticImportedUrls?.has(mod.url) || softInvalidate
        this.invalidateModule(
          importer,
          seen,
          timestamp,
          isHmr,
          shouldSoftInvalidateImporter,
        )
      }
    })
}

2.1.2 调用 onHMRUpdate 方法

在热更新逻辑中有三种情况

变化文件 对应行为
Vite 配置文件/配置文件依赖/环境变量声明文件(.env) 重启 Server 加载最新配置数据
dist/client/client.mjs 客户端文件/ html 文件 通知客户端刷新页面
普通文件改动 通知客户端热更新

热更新方法主要流程

  • 获取模块依赖图中文件路径对应模块节点对象列表(mods),
  • 使用 invalidateModule 方法更新受修改影响的模块节点属性(清空 transformResult、更新 lastHMRTimestamp)

我们可以看到在前面模块失活的时候已经调用 invalidateModule 清空了一次翻译结果,在这里又调用了一次,主要区别是在这一次调用中更新了 时间戳 和 热更新标志,在前面失活的过程没有这两个参数的更新

  • 找出热更新边界模块,将边界模块信息整理成一个数组 updates
  • 开发服务器通过 WebSocket 通知客户端更新,并带上边界模块信息
// 刷新页面
ws.send({
  type: 'full-reload',
  path: '*',
})

// 热更新
ws.send({
  type: 'update',
  updates,
})

3. Client 接收更新信息

Server 发出更新信号后, Client 收到信号,根据更新类型作出对应的更新行为

2.1.1 full-reload

switch (payload.type) {
  case 'full-reload':
    notifyListeners('vite:beforeFullReload', payload);
    if (payload.path && payload.path.endsWith('.html')) {
      // 如果发生变更的是html文件,且正在被浏览器访问,则进行页面刷新
      const pagePath = decodeURI(location.pathname);
      const payloadPath = base + payload.path.slice(1);
      if (pagePath === payloadPath ||
          payload.path === '/index.html' ||
          (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)) {
        location.reload();
      }
      return;
    }
    else {
      location.reload();
    }
    break;
}

在 full-reload 刷新页面的事件中,对于发生变化的 html 文件,Vite 只对当前正在访问的页面进行刷新。

2.1.2 update

case 'update':
  console.debug('vite:beforeUpdate', payload)
  notifyListeners('vite:beforeUpdate', payload);
  await Promise.all(payload.updates.map(async (update) => {
    if (update.type === 'js-update') {
      // js-update逻辑
      return queueUpdate(fetchUpdate(update)); // 客户端JS模块热更新核心逻辑
    }
  notifyListeners('vite:afterUpdate', payload);
  break;
}

update.type 值为 js-update 时会根据服务器派发的 update 信息找到对应的边界模块的热更新回调并执行以完成最终的更新

4. Client 执行热更新方法

Client 拿到 Server 的 update 信息后,会做如下处理

  • 在热更新操作中通过 hotModulesMap 获取 HMR 边界模块相关信息
  • 构造合适的回调函数,回调函数接收模块,并在将来调用模块对应的更新信息(定义了接收容器)
  • 通过动态 import 拉取最新的模块信息
  • 最后通过队列的方式按照 Server 派发的热更新顺序执行热更新操作,防止因网络问题导致热更新顺序出错
  • async function fetchUpdate({
      path,
      acceptedPath,
      timestamp,
      explicitImportRequired
    }: Update) {
      // HMR 边界模块相关的信息
      const mod = hotModulesMap.get(path)
      if (!mod) {
        return
      }
    
      let fetchedModule: ModuleNamespace | undefined
      const isSelfUpdate = path === acceptedPath
    
      // 获取需要执行的更新回调,mod.callbacks 就是 import.meta.hot.accept 中绑定的更新回调函数
      const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
        deps.includes(acceptedPath)
      )
    
      if (isSelfUpdate || qualifiedCallbacks.length > 0) {
        // 将要不再需要的模块进行失活
        const disposer = disposeMap.get(acceptedPath)
        if (disposer) await disposer(dataMap.get(acceptedPath))
        
        // 通过动态 import 拉取最新的模块信息
        const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
        try {
          fetchedModule = await import(
            /* @vite-ignore */
            base +
              acceptedPathWithoutQuery.slice(1) +
              `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
                query ? `&${query}` : ''
              }`
          )
        } catch (e) {
          warnFailedFetch(e, acceptedPath)
        }
      }
      // return 一个函数执行所有的更新回调,这个函数在 queueUpdate 方法中执行
      return () => {
        for (const { deps, fn } of qualifiedCallbacks) {
          fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
        }
        const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
        console.debug(`[vite] hot updated: ${loggedPath}`)
      }
    }
    
    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())
      }
    }
    

5. Server 处理资源请求并返回

Server 收到 Client 的 import 请求之后,会先通过中间件对请求进行过滤,在这个过程中会调用 transformMiddleware 这一中间件。

中间件会首先判断缓存,如果命中了 e-tag 缓存,就进行重定向并直接返回结果,否则才继续调用函数对文件内容进行翻译,并添加缓存

Vite 虽然会在开发服务器启动后首屏的时候资源加载稍慢一些,但是后面再加载相同资源时非常快,这就是缓存带来的优势

/**
 * 文件转换中间件
 */
export function transformMiddleware(
  server: ViteDevServer
): Connect.NextHandleFunction {
  const {
    config: { root, logger, cacheDir },
    moduleGraph
  } = server
	
  // ...
  return async function viteTransformMiddleware(req, res, next) {
    // 如果请求不是GET、url在忽略列表中,直接到下一个中间件
    if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
      return next()
    }
		// ...

    try {
      // 省略sourcemap、publicDir的逻辑处理...
      
      // 如果是js、import查询、css、html-proxy
      if (
        isJSRequest(url) ||
        isImportRequest(url) ||
        isCSSRequest(url) ||
        isHTMLProxy(url)
      ) {
        // 去掉 import 的查询参数
        url = removeImportQuery(url)
        // 去除有效的 id 前缀。这是由 importAnalysis 插件在解析的不是有效浏览器导入说明符的 Id 之前添加的
        url = unwrapId(url)

        // 区分(普通的 css 请求)和导入
        if (
          isCSSRequest(url) &&
          !isDirectRequest(url) &&
          req.headers.accept?.includes('text/css')
        ) {
          url = injectQuery(url, 'direct')
        }

        // 二次加载,利用 etag 做协商缓存
        const ifNoneMatch = req.headers['if-none-match']
        if (
          ifNoneMatch &&
          (await moduleGraph.getModuleByUrl(url, false))?.transformResult
            ?.etag === ifNoneMatch
        ) {
          isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
          res.statusCode = 304
          return res.end()
        }

        // 使用插件容器解析、接在和转换
        const result = await transformRequest(url, server, {
          html: req.headers.accept?.includes('text/html')
        })
        if (result) {
          const type = isDirectCSSRequest(url) ? 'css' : 'js'
          const isDep =
            DEP_VERSION_RE.test(url) ||
            (cacheDirPrefix && url.startsWith(cacheDirPrefix))

          // 输出结果
          return send(req, res, result.code, type, {
            etag: result.etag,
            // allow browser to cache npm deps!
            cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
            headers: server.config.server.headers,
            map: result.map
          })
        }
      }
    } catch (e) {
      return next(e)
    }

    next()
  }
}

6. Client 接收资源更新视图(接 4 的27行代码)

Client 首先在返回的文件中拿到 createHotContext 方法中的热更新回调函数,并赋值给 import.meta.hot

Vite 热更新原理探索

function createHotContext(ownerPath) {
  if (!dataMap.has(ownerPath)) {
    dataMap.set(ownerPath, {})
  }
  // 文件更新时创建一个 context
  // 清空毁掉函数
  const mod = hotModulesMap.get(ownerPath)
  if (mod) {
    mod.callbacks = []
  }
  // 清空自定义监听器
  const staleListeners = ctxToListenersMap.get(ownerPath)
  if (staleListeners) {
    for (const [event, staleFns] of staleListeners) {
      const listeners = customListenersMap.get(event)
      if (listeners) {
        customListenersMap.set(
          event,
          listeners.filter((l) => !staleFns.includes(l))
        )
      }
    }
  }
  const newListeners: CustomListenersMap = new Map()
  ctxToListenersMap.set(ownerPath, newListeners)
  // 以当前模块文件路径作为key将所有通过accept注册的回调函数收集到map当中
  function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
    // ownerPath:调用accept的模块路径
    const mod: HotModule = hotModulesMap.get(ownerPath) || {
      id: ownerPath,
      callbacks: []
    }
    // deps:接收热更新依赖模块路径字符串数组
    // fn:热更新回调方法
    mod.callbacks.push({
      deps,
      fn: callback
    })
    // 设置到 hotModulesMap 中
    hotModulesMap.set(ownerPath, mod)
  }
  const hot = {
      get data() {
          return dataMap.get(ownerPath);
      },
      accept(deps, callback) {
          if (typeof deps === 'function' || !deps) {
            // self-accept: hot.accept(() => {})
            acceptDeps([this.ownerPath], ([mod]) => deps?.(mod))
          } else if (typeof deps === 'string') {
            // explicit deps
           	acceptDeps([deps], ([mod]) => callback?.(mod))
          } else if (Array.isArray(deps)) {
            acceptDeps(deps, callback)
          } else {
            throw new Error(`invalid hot.accept() usage.`)
          }
      },
  };
  return hot;
}

四、Vite 与 Webpack 的对比

通过上述流程我们不难发现 Vite 在开发阶段充分利用了 ESM 的特性,通过按需加载并利用浏览器缓存大大提升了 HMR 的速度,能够极大提升开发体验和效率。所以在这里我们提出一个问题,Vite 有这么好的开发体验,那与 Webpack 相比还有什么其他的优缺点呢?我们如何选择这两种工具?

1. 优劣对比

1.1 Webpack

1.1.1 优点

  • 强大的生态系统:Webpack拥有丰富的插件和加载器,可以处理各种类型的资源,提供了更多的灵活性和可扩展性。

  • 兼容性好:Webpack可以处理各种模块规范,包括CommonJS、AMD等,适用于更广泛的项目需求。

  • 成熟稳定:Webpack经过多年的发展和使用,已经成为前端开发中最常用的构建工具之一。

1.1.2 缺点

  • 较慢的冷启动和热更新:由于Webpack需要将所有模块打包成一个或多个bundle,因此在冷启动和热更新时相对较慢。

  • 配置复杂:Webpack的配置相对复杂,需要了解和配置多个概念,对于初学者来说可能有一定的学习曲线。

1.2 Vite

1.2.1 优点

快速的冷启动和热更新:Vite利用原生ES模块加载能力,在开发环境下能够实现更快的冷启动和热更新速度,提升开发效率。

按需加载:Vite只加载需要的模块,而不需要将所有代码打包成一个或多个 bundle,减少了不必要的网络请求和加载时间,并且能够充分利用浏览器缓存,提高开发效率。

开发体验好:Vite支持热模块替换(HMR)和快速的热更新,使得开发过程更加流畅

1.2.2 缺点

兼容性稍差:对于一些旧的浏览器或不支持ES模块的环境,需要进行额外的处理或使用转换工具。

打包需要使用额外工具:prod环境的构建,目前依赖 Rollup

1.3 结论

从上面的对比我们可以看出,Webpack 相比于 Vite 主要的优势是成熟稳定并且具有强大的生态系统,但是这些优点正在逐渐被 Vite 追赶上。

随着 Vite 使用量越来越大(github 上 Webpack 的 Star 是 63.9k,Vite 的 Star 是 62.5k),更多的人参与到 Vite 的生态建设中,它的生态系统也越来越完善,并且官方团队也一直在不断完善 Vite 自身的机制,一直在更新迭代(目前 Vite 已经发布到了最新的 5.0.11 版本)。

但是 Vite 所具有的优势却令 Webpack 望尘莫及,受自身 bundle 机制的影响,无法做到像 Vite 一样带来极致的开发体验。在这种发展趋势下,我们有理由相信 Vite 是更有潜力的,随着 Vite 的持续迭代,以及越来越多的浏览器对 ESM 提供支持,Vite 的适用性会越来越广泛。

五、总结

本文先介绍了 HMR 的起源,主要是为了方便地在代码变更后查看更新后的页面效果。常用的构建工具有 Vite 和 Webpack,本文从一次 HMR 发生的全流程角度介绍了 Vite 的 HMR 机制,主要为:监听文件变化(Server)、 派送更新(Server)、接收更新信息(Client)、重新请求资源(Client) 、翻译模块(Server)、更新视图代码(Client)。

最后,本文引申出了 Vite 与 Webpack 工具的对比,Vite 相比于 Webpack 具有更明显的优势,并且随着技术的发展和迭代,Vite 会有更大的适用性。

原文链接:https://juejin.cn/post/7351430820714266650 作者:Stardots

(0)
上一篇 2024年3月29日 下午4:05
下一篇 2024年3月29日 下午4:16

相关推荐

发表回复

登录后才能评论