跳过正文
  1. 文章/

Vite Dev 模式下的插件钩子执行详解

·3164 字·7 分钟·
hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录

本文详细介绍 Vite 在 dev 模式(vite dev / vite serve)下,插件系统会触发哪些钩子、触发时机及执行顺序。

目录
#


1. Dev 模式与 Build 模式的核心差异
#

Vite dev 模式启动的是一个基于 原生 ESM 的开发服务器,而不是像 build 模式那样将整个项目打包成 bundle。

特性Dev 模式Build 模式
底层引擎原生 ESM + esbuild(依赖预构建)Rollup
模块处理方式按需(浏览器请求时)处理全量分析、打包
Rollup 完整管线不执行(无 chunk 生成)完整执行
HMR 相关钩子触发不触发
服务器相关钩子触发不触发
chunk 相关钩子不触发触发

2. 钩子执行总览
#

vite dev 启动
【启动阶段】
config          → 修改/合并用户配置
configResolved  → 读取最终配置(只读)
configureServer → 注册自定义中间件
options         → 修改 Rollup/Vite 内部选项
buildStart      → 服务器就绪,开始处理模块

  ↓(浏览器请求模块时,每个模块触发一次)

【模块请求阶段】
resolveId       → 解析模块路径
load            → 加载模块内容
transform       → 转换模块代码

  ↓(浏览器请求 HTML 时)

【HTML 处理阶段】
transformIndexHtml → 转换 index.html

  ↓(文件变更时)

【HMR 阶段】
hotUpdate / handleHotUpdate → 处理热更新

  ↓(执行 Ctrl+C 关闭服务器时)

【关闭阶段】
buildEnd        → 模块图销毁前
closeBundle     → 服务器完全关闭后

3. 服务器启动阶段
#

3.1 config
#

时机:读取并合并所有插件配置之前。

{
  name: 'my-plugin',
  config(userConfig, env) {
    // env.command === 'serve' 时为 dev 模式
    if (env.command === 'serve') {
      return {
        resolve: {
          alias: { '@': '/src' }
        }
      }
    }
  }
}
  • 可返回部分配置对象,Vite 会深度合并
  • 此时其他插件的配置尚未完全合并

3.2 configResolved
#

时机:所有插件的 config 钩子执行完毕、配置最终确定后。

{
  name: 'my-plugin',
  configResolved(resolvedConfig) {
    // 只读访问最终配置
    console.log('根目录:', resolvedConfig.root)
    console.log('模式:', resolvedConfig.command) // 'serve'
    console.log('插件列表:', resolvedConfig.plugins.map(p => p.name))
  }
}
  • 适合读取最终配置、存储供后续钩子使用
  • 不能修改配置(只读)

3.3 configureServer(Vite 专属)
#

时机:开发服务器创建完成后、内部中间件安装之前。

{
  name: 'my-plugin',
  configureServer(server) {
    // 在内部中间件之前添加自定义中间件
    server.middlewares.use('/api', (req, res) => {
      res.end('mock data')
    })

    // 返回函数则在内部中间件之后执行
    return () => {
      server.middlewares.use((req, res, next) => {
        // 后置中间件
        next()
      })
    }
  }
}
  • server 对象提供 watcherws(WebSocket)、moduleGraph
  • 只在 dev 模式触发,build 模式对应的是 configurePreviewServer

3.4 options
#

时机:Vite 初始化内部 Rollup 兼容选项时(服务器启动前)。

{
  name: 'my-plugin',
  options(rollupOptions) {
    // 可修改 input、external 等选项
    return {
      ...rollupOptions,
      external: ['lodash']
    }
  }
}
  • dev 模式下仅作为插件钩子执行,并不真正驱动 Rollup 打包

3.5 buildStart
#

时机:服务器完成初始化、准备好接收请求时。

{
  name: 'my-plugin',
  buildStart(options) {
    console.log('dev server 已就绪,开始监听模块请求')
    // 适合初始化插件内部状态
  }
}

4. 模块请求阶段(按需触发)
#

每当浏览器请求一个模块(.js.ts.vue.css 等)时,以下三个钩子按顺序触发。

4.1 resolveId
#

时机:解析 import 语句中的模块 ID 时。

{
  name: 'my-plugin',
  resolveId(source, importer, options) {
    // source: 导入路径,如 'lodash' 或 './utils'
    // importer: 发起导入的文件路径
    if (source === 'virtual:my-module') {
      return '\0virtual:my-module' // \0 前缀表示虚拟模块
    }
    return null // 返回 null 则交给下一个插件处理
  }
}
  • 返回解析后的绝对路径,或 null 跳过
  • 可用于创建虚拟模块

4.2 load
#

时机resolveId 确定路径后,读取模块内容时。

{
  name: 'my-plugin',
  load(id) {
    if (id === '\0virtual:my-module') {
      // 返回虚拟模块的代码
      return `export const msg = 'Hello from virtual module'`
    }
    return null // 返回 null 则由 Vite 默认读取文件内容
  }
}
  • 适合为虚拟模块提供内容,或拦截特定文件的加载

4.3 transform
#

时机:模块内容加载完成后,发送给浏览器之前。

{
  name: 'my-plugin',
  transform(code, id) {
    if (id.endsWith('.custom')) {
      const transformed = myCompiler(code)
      return {
        code: transformed,
        map: null // source map
      }
    }
    return null // 不处理则返回 null
  }
}
  • 每个模块每次请求都会执行(有缓存机制,未变更的模块不会重复 transform)
  • 是最常用的钩子,用于编译 .vue.tsx、自定义语言等

5. HTML 转换阶段
#

5.1 transformIndexHtml(Vite 专属)
#

时机:浏览器请求 index.html 时。

{
  name: 'my-plugin',
  transformIndexHtml(html) {
    // 简单字符串替换
    return html.replace('<title>App</title>', '<title>My App</title>')
  }
}

也可以返回标签注入描述对象:

{
  name: 'my-plugin',
  transformIndexHtml(html) {
    return {
      html,
      tags: [
        {
          tag: 'script',
          attrs: { src: '/analytics.js' },
          injectTo: 'body'
        }
      ]
    }
  }
}
  • 可注入 <script><link><meta> 等标签
  • 在 dev 和 build 模式下都会触发

6. HMR 热更新阶段
#

6.1 hotUpdate(Vite 5+ 推荐)
#

时机:检测到文件变更,即将向客户端发送 HMR 更新时。

{
  name: 'my-plugin',
  hotUpdate(ctx) {
    // ctx.file: 变更的文件路径
    // ctx.timestamp: 变更时间戳
    // ctx.modules: 受影响的模块数组
    // ctx.server: ViteDevServer 实例
    // ctx.read(): 读取文件新内容

    if (ctx.file.endsWith('.json')) {
      // 自定义 HMR 逻辑:向客户端发送自定义事件
      ctx.server.hot.send({
        type: 'custom',
        event: 'json-update',
        data: { file: ctx.file }
      })
      return [] // 返回空数组表示不需要默认 HMR 处理
    }
  }
}

6.2 handleHotUpdate(旧版,Vite 4 及以前)
#

{
  name: 'my-plugin',
  handleHotUpdate(ctx) {
    // 与 hotUpdate 类似,但参数结构略有不同
    // Vite 5 中已被 hotUpdate 取代
    if (ctx.file.endsWith('.custom')) {
      ctx.server.ws.send({ type: 'full-reload' })
      return []
    }
  }
}

注意:Vite 5 推荐使用 hotUpdatehandleHotUpdate 仍可用但已被标记为废弃。


7. 服务器重启阶段
#

注意buildEndcloseBundle 不会Ctrl+C 终止进程时触发——SIGINT 信号使进程直接退出,来不及执行异步清理钩子。

这两个钩子实际触发的时机是 Vite 主动调用 server.close() 优雅关闭旧实例时,最典型的场景是修改了 vite.config.* 配置文件,Vite 检测到变更后先关闭当前服务器(触发这两个钩子),再以新配置重新启动。

7.1 buildEnd
#

时机:服务器优雅关闭、模块图即将销毁时(配置文件变更触发重启时)。

{
  name: 'my-plugin',
  buildEnd(error) {
    if (error) {
      console.error('服务器异常关闭:', error)
    }
    // 清理插件内部状态,等待重启后重新初始化
  }
}

7.2 closeBundle
#

时机:旧服务器实例完全关闭后,Vite 将用新配置重新执行 buildStart 之前。

{
  name: 'my-plugin',
  closeBundle() {
    // 释放文件句柄、关闭数据库连接等
    console.log('旧 dev server 已关闭,即将以新配置重启')
  }
}

8. Dev 模式不会触发的钩子
#

以下钩子属于 Rollup 打包阶段的专属钩子,dev 模式下不会触发

钩子原因
moduleParsedRollup 解析 AST 时触发,dev 不完整运行 Rollup
renderStartchunk 渲染开始时触发,dev 无 chunk 生成
renderChunk每个 chunk 渲染时触发,dev 无 chunk
augmentChunkHash计算 chunk hash 时触发,dev 无 chunk
generateBundlebundle 生成时触发,dev 无 bundle
writeBundlebundle 写入磁盘时触发,dev 无写入
banner / footer / intro / outrochunk 头尾内容注入,dev 无 chunk

9. 钩子执行顺序示例
#

以一次完整的 dev server 启动 + 首次请求为例:

1. config            ← 所有插件按顺序执行
2. configResolved    ← 所有插件按顺序执行
3. configureServer   ← 所有插件按顺序执行
4. options           ← 所有插件按顺序执行
5. buildStart        ← 所有插件按顺序执行

[浏览器请求 index.html]
6. transformIndexHtml

[浏览器请求 main.ts]
7. resolveId('main.ts')
8. load('/path/to/main.ts')
9. transform(code, '/path/to/main.ts')

[浏览器请求 App.vue]
10. resolveId('App.vue')
11. load('/path/to/App.vue')
12. transform(code, '/path/to/App.vue')

[文件变更触发 HMR]
13. hotUpdate(ctx)

[修改 vite.config.* 触发服务器重启(注意:Ctrl+C 不会触发以下两个钩子)]
14. buildEnd
15. closeBundle

多个插件的钩子顺序
#

  • 默认顺序:按插件数组顺序依次执行
  • enforce: 'pre':插件钩子提前到普通插件之前执行
  • enforce: 'post':插件钩子推迟到普通插件之后执行
enforce: 'pre' 插件
Vite 内置插件
普通插件(无 enforce)
enforce: 'post' 插件

10. 实用插件示例
#

示例:Dev 模式下注入环境变量
#

import type { Plugin } from 'vite'

function envInjectPlugin(): Plugin {
  let isDev = false

  return {
    name: 'env-inject',

    config(_, { command }) {
      isDev = command === 'serve'
    },

    configResolved(config) {
      console.log(`[env-inject] 运行模式: ${config.command}`)
    },

    transform(code, id) {
      if (!isDev) return null
      if (!id.endsWith('.ts') && !id.endsWith('.js')) return null

      // 开发模式下注入调试信息
      return code.replace(
        '__DEV_FILE__',
        JSON.stringify(id.replace(process.cwd(), ''))
      )
    }
  }
}

示例:自定义 HMR 通知
#

import type { Plugin } from 'vite'

function hmrNotifyPlugin(): Plugin {
  return {
    name: 'hmr-notify',

    hotUpdate(ctx) {
      const affectedModules = ctx.modules.map(m => m.id)
      console.log(`[HMR] 文件变更: ${ctx.file}`)
      console.log(`[HMR] 影响模块: ${affectedModules.join(', ')}`)

      // 不阻断默认 HMR 流程
      return ctx.modules
    }
  }
}

11. 总结
#

阶段钩子触发次数Vite 专属
启动config1 次
启动configResolved1 次
启动configureServer1 次
启动options1 次否(Rollup 兼容)
启动buildStart1 次否(Rollup 兼容)
模块请求resolveId每模块 1 次否(Rollup 兼容)
模块请求load每模块 1 次否(Rollup 兼容)
模块请求transform每模块 1 次否(Rollup 兼容)
HTMLtransformIndexHtml每次请求 HTML
HMRhotUpdate每次文件变更是(Vite 5+)
HMRhandleHotUpdate每次文件变更是(已废弃)
重启buildEnd每次配置重启 1 次(Ctrl+C 不触发)否(Rollup 兼容)
重启closeBundle每次配置重启 1 次(Ctrl+C 不触发)否(Rollup 兼容)

Dev 模式下,插件与 Vite 的交互核心是:启动时配置服务器、按需拦截模块请求、响应文件变更触发 HMR。理解这条主线,就能灵活编写服务于开发体验的 Vite 插件。

相关文章