WSの小屋

Vite 插件开发实战:从零编写一个自定义插件

前言

Vite 的插件系统是其强大的核心所在。Vite 插件本质上是一个 Rollup 插件的超集——它扩展了 Rollup 的插件接口,增加了开发服务器和 HMR 相关的 hooks。理解 Vite 插件体系,不仅能让你定制化构建流程,还能深入理解 Vite 的内部工作机制。

本文将从 Vite 插件的 hooks 体系讲起,逐步实现两个实战插件:自动导入组件插件和 Markdown 转 Vue 组件插件。


一、Vite 插件 Hooks 体系全解

import { Plugin } from 'vite'

export function myPlugin(options = {}): Plugin {
  return {
    name: 'vite-plugin-my-plugin',

    // === Rollup 兼容 Hooks ===
    buildStart(options) { /* 构建开始前 */ },
    resolveId(source, importer) { /* 解析模块 ID */ },
    load(id) { /* 加载模块内容 */ },
    transform(code, id) { /* 转换模块代码 */ },
    buildEnd(error) { /* 构建结束 */ },
    generateBundle(options, bundle) { /* 生成产物 */ },

    // === Vite 独有 Hooks ===
    config(config, { mode }) { /* 配置 Vite */ },
    configResolved(resolvedConfig) { /* 最终配置 */ },
    configureServer(server) { /* 配置开发服务器 */ },
    handleHotUpdate(ctx) { /* 自定义 HMR 更新 */ },
    transformIndexHtml(html, ctx) { /* 转换 index.html */ },
  }
}

Hooks 分类

Hook 类型 执行时机 用途
config Vite 配置初始化 修改 Vite 配置
configResolved 配置解析完成 读取最终配置
configureServer Dev Server 创建时 添加中间件
buildStart 每次构建开始 初始化资源
resolveId 模块解析阶段 自定义模块解析
load 模块加载阶段 自定义模块内容
transform 模块转换阶段 转换模块代码
transformIndexHtml HTML 转换阶段 修改入口 HTML
handleHotUpdate HMR 更新时 自定义热更新
generateBundle 产物生成阶段 修改构建产物

二、enforce 执行顺序

export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    enforce: 'pre', // 'pre' | 'post' | undefined
  }
}

执行顺序:pre → Vite 核心插件 → 普通插件 → Vite 构建插件 → post


三、虚拟模块模式

export function virtualModulePlugin(): Plugin {
  const virtualModuleId = 'virtual:my-module'
  const resolvedVirtualModuleId = '\0' + virtualModuleId

  return {
    name: 'vite-plugin-virtual-module',
    enforce: 'pre',
    resolveId(id) {
      if (id === virtualModuleId) return resolvedVirtualModuleId
    },
    load(id) {
      if (id === resolvedVirtualModuleId) {
        const data = getSomeData()
        return `const data = ${JSON.stringify(data)}; export default data`
      }
    },
  }
}

虚拟模块 ID 以 \0 开头是 Vite/Rollup 的约定,表示虚拟模块不对应磁盘文件。


四、实战一:自动导入组件插件

import { Plugin, createFilter } from 'vite'
import fs from 'fs'
import path from 'path'
import { parse } from '@vue/compiler-sfc'

export function autoImportComponents(options = {}): Plugin {
  const { dirs = ['src/components'], extensions = ['.vue'] } = options
  const componentMap = new Map<string, string>()

  function scanComponents(dir: string) {
    if (!fs.existsSync(dir)) return
    const items = fs.readdirSync(dir, { withFileTypes: true })
    for (const item of items) {
      const fullPath = path.join(dir, item.name)
      if (item.isDirectory()) scanComponents(fullPath)
      else if (extensions.some(ext => item.name.endsWith(ext))) {
        const name = item.name.replace(/\.\w+$/, '')
        componentMap.set(name, fullPath)
      }
    }
  }

  return {
    name: 'vite-plugin-auto-import',
    enforce: 'pre',
    configResolved(config) {
      dirs.forEach(dir => scanComponents(path.resolve(config.root, dir)))
    },
    configureServer(server) {
      server.watcher.on('add', (file) => {
        const name = path.basename(file).replace(/\.\w+$/, '')
        componentMap.set(name, file)
      })
      server.watcher.on('unlink', (file) => {
        const name = path.basename(file).replace(/\.\w+$/, '')
        componentMap.delete(name)
      })
    },
    transform(code, id) {
      if (!id.endsWith('.vue')) return null
      const { descriptor } = parse(code)
      if (!descriptor.template) return null

      const tagRegex = /<([A-Z][a-zA-Z0-9]*)/g
      const usedComponents: string[] = []
      let match
      while ((match = tagRegex.exec(descriptor.template.content)) !== null) {
        if (componentMap.has(match[1]) && !usedComponents.includes(match[1])) {
          usedComponents.push(match[1])
        }
      }
      if (usedComponents.length === 0) return null

      const imports = usedComponents.map(name => {
        const filePath = componentMap.get(name)!
        const relativePath = path.relative(path.dirname(id), filePath)
        return `import ${name} from '${relativePath}'`
      }).join('\n')

      return code.replace('<script setup>', `<script setup>\n${imports}`)
    },
  }
}

五、实战二:Markdown 转 Vue 组件插件

import { Plugin } from 'vite'
import matter from 'gray-matter'
import MarkdownIt from 'markdown-it'

export function markdownPlugin(options = {}): Plugin {
  const md = new MarkdownIt({ html: true, linkify: true })

  return {
    name: 'vite-plugin-markdown',
    enforce: 'pre',
    resolveId(id) { if (id.endsWith('.md')) return id },
    transform(code, id) {
      if (!id.endsWith('.md')) return null
      const { content, data: frontmatter } = matter(code)
      const htmlContent = md.render(content)
      const escapedHtml = htmlContent.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')

      return {
        code: `<template><div class="markdown-body" v-html="renderedHtml"></div></template>
<script setup lang="ts">
import { ref } from 'vue'
const frontmatter = ${JSON.stringify(frontmatter)}
const renderedHtml = ref(\`${escapedHtml}\`)
defineExpose({ frontmatter })
</script>`,
        map: { version: 3, mappings: '', sources: [] },
      }
    },
    handleHotUpdate(ctx) {
      if (!ctx.file.endsWith('.md')) return
      ctx.server.moduleGraph.invalidateModule(
        ctx.server.moduleGraph.getModuleById(ctx.file)!
      )
      ctx.server.ws.send({ type: 'update', updates: [{
        type: 'js-update', path: ctx.file,
        acceptedPath: ctx.file, timestamp: Date.now(),
      }] })
      return []
    },
  }
}

六、HMR API 深度解析

handleHotUpdate

handleHotUpdate(ctx) {
  const { file, server, modules } = ctx
  // 场景 1: 返回空数组,阻止默认更新
  if (file.endsWith('.data')) {
    server.ws.send({ type: 'custom', event: 'data-updated' })
    return []
  }
  // 场景 2: 触发完整刷新
  if (file.endsWith('.config.js')) {
    server.ws.send({ type: 'full-reload' })
    return []
  }
}

客户端 HMR API

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => { /* 接受自身更新 */ })
  import.meta.hot.accept('./utils', (newUtils) => { utils = newUtils })
  import.meta.hot.dispose((data) => { clearInterval(timer); data.count = count })
  import.meta.hot.on('data-updated', (data) => { console.log(data) })
}

七、插件性能优化

缓存策略

transform(code, id) {
  const stat = fs.statSync(id)
  const cached = cache.get(id)
  if (cached && cached.mtime === stat.mtimeMs) {
    return { code: cached.code, map: null }
  }
  const transformedCode = doTransform(code)
  cache.set(id, { code: transformedCode, mtime: stat.mtimeMs })
  return { code: transformedCode, map: null }
}

延迟初始化

let parser: any = null
transform(code, id) {
  if (!parser) parser = require('heavy-parser') // 按需加载
}

八、最佳实践总结

实践 建议
命名规范 使用 vite-plugin-* 前缀
enforce 转换型插件用 pre,注入型用 post
虚拟模块 使用 \0 前缀标记
HMR 始终实现 handleHotUpdate 提升体验
缓存 使用文件 mtime 做缓存失效
类型安全 为 options 和返回值提供 TypeScript 类型
SSR 支持 通过 apply 属性区分环境
错误处理 捕获异常并提供有意义的错误信息

Vite 插件系统是一个设计精良的扩展机制。通过掌握 resolveId/load/transform 三大核心 hooks,配合 configureServerhandleHotUpdate,你可以实现几乎任何构建需求的插件。善用缓存、异步处理和 Worker 线程来保证插件性能。

Comments | 0条评论