前端开发··2 阅读·预计 11 分钟

Vite 插件开发与构建生命周期:从 Hook 时序到产物干预的工程化实践

Vite 的插件系统继承自 Rollup 并做了增强。理解 Hook 的触发时机和返回值的副作用,是写出可控、可复用插件的基础。本文围绕几个工程化常见场景展开。

一、插件骨架与 Hook 分类

// vite-plugin-demo.js
export default function demo(options = {}) {
  return {
    name: 'vite-plugin-demo', // 必填,调试与错误定位
    enforce: 'pre',           // 'pre' | 'post' | 默认
    apply: 'build',           // 'build' | 'serve' | 函数

    // Vite 专属
    config(config, env) {},
    configResolved(resolved) {},
    configureServer(server) {},
    transformIndexHtml(html) {},
    handleHotUpdate(ctx) {},

    // Rollup 通用
    resolveId(source, importer) {},
    load(id) {},
    transform(code, id) {},
    buildStart() {},
    generateBundle(opts, bundle) {},
    closeBundle() {}
  }
}

Hook 分两类:Vite 专属只在 dev/构建配置阶段触发;Rollup 通用在 dev 与 build 中都会跑,但 dev 下是按需触发(请求驱动),build 下是图遍历驱动。

二、enforce 与执行顺序

插件执行顺序:Alias → enforce: 'pre' → 默认 → Vite 核心 → enforce: 'post' → Vite 后置

反例:写一个语法转换插件没设置 enforce: 'pre',结果在 Vite 内置 esbuild 转换之后才执行,源码已经被改写,正则匹配失败。

// ❌ 错误:默认顺序,处理时机滞后
export default () => ({
  name: 'my-transform',
  transform(code, id) {
    if (!id.endsWith('.ts')) return
    return code.replace(/__VERSION__/g, '1.0.0')
  }
})

// ✅ 正确:pre 提前,源码替换
export default () => ({
  name: 'my-transform',
  enforce: 'pre',
  transform(code, id) {
    if (!/\.[jt]sx?$/.test(id)) return
    return { code: code.replace(/__VERSION__/g, '1.0.0'), map: null }
  }
})

三、虚拟模块:替代全局注入

业务里常见用 define 注入常量,但当注入对象较大时会全量替换字符串,污染产物。改用虚拟模块更干净。

export default function virtualEnv(env) {
  const virtualId = 'virtual:app-env'
  const resolvedId = '\0' + virtualId // \0 前缀避免被其他插件处理

  return {
    name: 'virtual-env',
    resolveId(id) {
      if (id === virtualId) return resolvedId
    },
    load(id) {
      if (id === resolvedId) {
        return `export default ${JSON.stringify(env)}`
      }
    }
  }
}

// 使用
// import env from 'virtual:app-env'
// console.log(env.API_BASE)

配套 TS 声明:

// env.d.ts
declare module 'virtual:app-env' {
  const env: { API_BASE: string; VERSION: string }
  export default env
}

四、configureServer:注入开发中间件

开发期想 mock 接口,不必引第三方库:

export default function mock() {
  return {
    name: 'dev-mock',
    apply: 'serve',
    configureServer(server) {
      server.middlewares.use('/api/user', (req, res) => {
        res.setHeader('Content-Type', 'application/json')
        res.end(JSON.stringify({ id: 1, name: 'Tom' }))
      })
    }
  }
}

注意:configureServer 返回函数可获得 post hook,在内置中间件之后执行——通常用于兜底 404 处理。

configureServer(server) {
  return () => {
    server.middlewares.use((req, res, next) => {
      // 所有内置中间件之后
      next()
    })
  }
}

五、handleHotUpdate:自定义 HMR 边界

默认 HMR 对未知文件会触发整页刷新。处理自定义 DSL 时需手动控制:

handleHotUpdate(ctx) {
  if (!ctx.file.endsWith('.my')) return
  // 只让引用该模块的页面热更新,不刷新
  ctx.server.ws.send({
    type: 'custom',
    event: 'my-dsl-update',
    data: { path: ctx.file }
  })
  return [] // 返回空数组阻止默认行为
}

客户端监听:

if (import.meta.hot) {
  import.meta.hot.on('my-dsl-update', ({ path }) => {
    reloadDSL(path)
  })
}

六、generateBundle:产物干预

构建结束前可以读写所有产物。例如生成资源清单:

export default function manifest() {
  return {
    name: 'asset-manifest',
    apply: 'build',
    generateBundle(_opts, bundle) {
      const map = {}
      for (const [fileName, chunk] of Object.entries(bundle)) {
        if (chunk.type === 'chunk' && chunk.isEntry) {
          map[chunk.name] = fileName
        }
      }
      this.emitFile({
        type: 'asset',
        fileName: 'manifest.json',
        source: JSON.stringify(map, null, 2)
      })
    }
  }
}

反例:在 closeBundle 里通过 fs.writeFileSync 写文件——会绕过 Vite 的输出目录配置,且不会进入 bundle 对象,下游插件无法处理。优先用 emitFile

七、apply 与环境隔离

Mock、可视化分析、Source Map 上传等不应该污染另一端:

export default defineConfig({
  plugins: [
    mock(),                  // 内部已 apply: 'serve'
    visualizer({ open: false }), // 仅 build
    {
      ...sentryUpload(),
      apply(_cfg, { command, mode }) {
        return command === 'build' && mode === 'production'
      }
    }
  ]
})

小结

  • 涉及源码改写优先 enforce: 'pre',分析类放 'post'
  • 常量、配置走虚拟模块而非 define 全量替换
  • 开发态用 configureServer,HMR 自定义返回 [] 阻断默认刷新
  • 构建产物干预用 generateBundle + emitFile,避免直接写盘
  • apply 严格隔离 dev/build 插件

把 Hook 时序吃透,插件就从“能跑”进化为“可控”。

0 评论

评论区

登录 后参与评论