Skip to content

自定义插件开发

学习如何从零开始编写自己的 Vite 插件,扩展构建能力。

基本信息

插件基础

什么是 Vite 插件

Vite 插件是一个对象,包含一组钩子函数,用于在构建过程的不同阶段执行自定义逻辑。Vite 插件扩展了 Rollup 插件接口,并添加了一些 Vite 特有的钩子。

插件结构

typescript
import type { Plugin } from 'vite'

export default function myPlugin(): Plugin {
  return {
    name: 'vite-plugin-my-plugin', // 必需,插件名称
    
    // Vite 特有钩子
    config(config, env) {
      // 修改 Vite 配置
    },
    
    configResolved(resolvedConfig) {
      // 配置解析完成后调用
    },
    
    configureServer(server) {
      // 配置开发服务器
    },
    
    handleHotUpdate(ctx) {
      // 处理热更新
    },
    
    // Rollup 通用钩子
    buildStart() {
      // 构建开始
    },
    
    resolveId(id) {
      // 解析模块 ID
    },
    
    load(id) {
      // 加载模块内容
    },
    
    transform(code, id) {
      // 转换模块代码
    },
    
    buildEnd() {
      // 构建结束
    }
  }
}

快速入门

示例 1:添加自定义虚拟模块

创建一个插件,提供虚拟模块供项目导入:

typescript
// plugins/virtual-module.ts
import type { Plugin } from 'vite'

export default function virtualModulePlugin(): Plugin {
  const virtualModuleId = 'virtual:my-module'
  // \0 是 Rollup 约定的前缀,用于标识虚拟模块(不对应实际文件)
  // 告诉 Rollup 不要尝试从文件系统加载这个模块
  const resolvedVirtualModuleId = '\0' + virtualModuleId

  return {
    name: 'vite-plugin-virtual-module',
    
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId
      }
    },
    
    load(id) {
      if (id === resolvedVirtualModuleId) {
        return `export const msg = "Hello from virtual module!"`
      }
    }
  }
}

使用插件:

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import virtualModule from './plugins/virtual-module'

export default defineConfig({
  plugins: [virtualModule()]
})

在代码中导入:

typescript
// src/main.ts
import { msg } from 'virtual:my-module'

console.log(msg) // "Hello from virtual module!"

示例 2:自动注入代码

创建一个插件,在每个 Vue 文件中自动注入代码:

typescript
// plugins/auto-inject.ts
import type { Plugin } from 'vite'

export default function autoInjectPlugin(): Plugin {
  return {
    name: 'vite-plugin-auto-inject',
    
    transform(code, id) {
      // 只处理 .vue 文件
      if (id.endsWith('.vue')) {
        // 在 script setup 后注入代码
        const injectedCode = `
// 自动注入的代码
console.log('Component loaded:', '${id}')
`
        // 查找 <script setup> 标签
        const scriptSetupRegex = /<script\s+setup[^>]*>/
        if (scriptSetupRegex.test(code)) {
          code = code.replace(scriptSetupRegex, (match) => {
            return match + injectedCode
          })
        }
        
        return {
          code,
          map: null // 如果需要 source map,这里返回 map 对象
        }
      }
    }
  }
}

示例 3:环境变量注入

创建一个插件,注入构建时间等环境信息:

typescript
// plugins/build-info.ts
import type { Plugin } from 'vite'

interface BuildInfoOptions {
  prefix?: string
}

export default function buildInfoPlugin(options: BuildInfoOptions = {}): Plugin {
  const { prefix = '__BUILD_' } = options
  
  return {
    name: 'vite-plugin-build-info',
    
    config() {
      const buildTime = new Date().toISOString()
      const buildVersion = process.env.npm_package_version || '1.0.0'
      
      return {
        define: {
          [`${prefix}TIME`]: JSON.stringify(buildTime),
          [`${prefix}VERSION`]: JSON.stringify(buildVersion)
        }
      }
    }
  }
}

使用:

typescript
// vite.config.ts
import buildInfo from './plugins/build-info'

export default defineConfig({
  plugins: [buildInfo({ prefix: '__APP_' })]
})
typescript
// 在代码中使用
declare const __APP_TIME: string
declare const __APP_VERSION: string

console.log('Build Time:', __APP_TIME)
console.log('Version:', __APP_VERSION)

常用钩子详解

Vite 特有钩子

config

修改 Vite 配置,在配置解析前调用:

typescript
config(config, { command, mode }) {
  if (command === 'serve') {
    // 开发环境配置
    return {
      server: {
        port: 3000
      }
    }
  } else {
    // 生产环境配置
    return {
      build: {
        minify: 'terser'
      }
    }
  }
}

configResolved

配置解析完成后调用,可以获取最终配置:

typescript
configResolved(resolvedConfig) {
  // 保存配置供其他钩子使用
  this.config = resolvedConfig
  console.log('Final config:', resolvedConfig)
}

configureServer

配置开发服务器,添加自定义中间件:

typescript
configureServer(server) {
  server.middlewares.use((req, res, next) => {
    if (req.url === '/api/custom') {
      res.end('Custom API response')
      return
    }
    next()
  })
}

transformIndexHtml

转换 index.html:

typescript
transformIndexHtml(html) {
  return html.replace(
    /<title>(.*?)<\/title>/,
    '<title>My Custom Title</title>'
  )
}

Rollup 通用钩子

resolveId

解析模块导入路径:

typescript
resolveId(source, importer) {
  if (source === 'my-lib') {
    // 返回自定义路径
    return '/path/to/my-lib.js'
  }
  return null // 让其他插件或默认解析器处理
}

load

加载模块内容:

typescript
load(id) {
  if (id.endsWith('.custom')) {
    // 读取并返回文件内容
    return fs.readFileSync(id, 'utf-8')
  }
}

transform

转换模块代码(最常用):

typescript
transform(code, id) {
  if (id.endsWith('.vue')) {
    // 转换代码
    const transformedCode = code.replace(/old/g, 'new')
    
    return {
      code: transformedCode,
      map: null // source map
    }
  }
}

实战案例

案例 1:Markdown 导入插件

支持直接导入 .md 文件为 Vue 组件:

点击查看完整代码
typescript
// plugins/markdown.ts
import type { Plugin } from 'vite'
import { marked } from 'marked'

export default function markdownPlugin(): Plugin {
  return {
    name: 'vite-plugin-markdown',
    
    // 声明处理的文件类型
    async transform(code, id) {
      if (!id.endsWith('.md')) return
      
      // 将 Markdown 转换为 HTML
      const html = await marked(code)
      
      // 生成 Vue 组件代码
      const component = `
<template>
  <div class="markdown-body" v-html="html"></div>
</template>

<script setup>
const html = ${JSON.stringify(html)}
</script>

<style scoped>
.markdown-body {
  padding: 20px;
  line-height: 1.6;
}

.markdown-body h1 {
  font-size: 2em;
  margin-bottom: 0.5em;
  border-bottom: 1px solid #eee;
}

.markdown-body h2 {
  font-size: 1.5em;
  margin-top: 1em;
}

.markdown-body code {
  background: #f5f5f5;
  padding: 2px 6px;
  border-radius: 3px;
}

.markdown-body pre {
  background: #f5f5f5;
  padding: 15px;
  border-radius: 5px;
  overflow-x: auto;
}
</style>
`
      
      return {
        code: component,
        map: null
      }
    }
  }
}

使用示例:

typescript
// vite.config.ts
import markdown from './plugins/markdown'

export default defineConfig({
  plugins: [vue(), markdown()]
})
vue
<script setup lang="ts">
import Readme from './README.md'
</script>

<template>
  <Readme />
</template>

案例 2:自动生成路由插件

扫描 pages 目录自动生成路由配置:

点击查看完整代码
typescript
// plugins/auto-routes.ts
import type { Plugin } from 'vite'
import fs from 'fs'
import path from 'path'

interface RouteConfig {
  path: string
  component: string
  name: string
}

export default function autoRoutesPlugin(): Plugin {
  const virtualModuleId = 'virtual:generated-routes'
  const resolvedVirtualModuleId = '\0' + virtualModuleId
  
  // 扫描 pages 目录生成路由
  function generateRoutes(pagesDir: string): RouteConfig[] {
    const routes: RouteConfig[] = []
    
    function scanDir(dir: string, basePath = '') {
      const files = fs.readdirSync(dir)
      
      files.forEach(file => {
        const filePath = path.join(dir, file)
        const stat = fs.statSync(filePath)
        
        if (stat.isDirectory()) {
          scanDir(filePath, `${basePath}/${file}`)
        } else if (file.endsWith('.vue')) {
          const name = file.replace('.vue', '')
          const routePath = name === 'index' 
            ? basePath || '/' 
            : `${basePath}/${name}`
          
          routes.push({
            path: routePath,
            component: filePath,
            name: routePath.replace(/\//g, '-').slice(1) || 'home'
          })
        }
      })
    }
    
    scanDir(pagesDir)
    return routes
  }
  
  return {
    name: 'vite-plugin-auto-routes',
    
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId
      }
    },
    
    load(id) {
      if (id === resolvedVirtualModuleId) {
        const pagesDir = path.resolve(process.cwd(), 'src/pages')
        const routes = generateRoutes(pagesDir)
        
        // 生成路由配置代码
        const code = `
export default [
  ${routes.map(route => `
  {
    path: '${route.path}',
    name: '${route.name}',
    component: () => import('${route.component}')
  }`).join(',\n  ')}
]
`
        return code
      }
    },
    
    // 监听文件变化,热更新路由
    handleHotUpdate({ file, server }) {
      if (file.includes('src/pages') && file.endsWith('.vue')) {
        const module = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
        if (module) {
          server.moduleGraph.invalidateModule(module)
          server.ws.send({
            type: 'full-reload'
          })
        }
      }
    }
  }
}

使用示例:

typescript
// vite.config.ts
import autoRoutes from './plugins/auto-routes'

export default defineConfig({
  plugins: [vue(), autoRoutes()]
})
typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import routes from 'virtual:generated-routes'

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

案例 3:移除 console 插件

生产环境自动移除所有 console 语句:

typescript
// plugins/remove-console.ts
import type { Plugin } from 'vite'
import MagicString from 'magic-string'

export default function removeConsolePlugin(): Plugin {
  return {
    name: 'vite-plugin-remove-console',
    
    transform(code, id) {
      // 只在生产环境处理
      if (process.env.NODE_ENV !== 'production') return
      
      // 只处理 .js, .ts, .vue 文件
      if (!/\.(vue|[jt]sx?)$/.test(id)) return
      
      // 检查是否包含 console
      if (!code.includes('console.')) return
      
      const s = new MagicString(code)
      
      // 移除 console.log, console.warn 等
      const consoleRegex = /console\.(log|warn|error|info|debug)\([^)]*\);?/g
      
      let match
      while ((match = consoleRegex.exec(code))) {
        s.remove(match.index, match.index + match[0].length)
      }
      
      return {
        code: s.toString(),
        map: s.generateMap({ hires: true })
      }
    }
  }
}

插件开发最佳实践

1. 命名规范

typescript
// ✅ 推荐:使用 vite-plugin- 前缀
export default function vitePluginMyPlugin(): Plugin {
  return {
    name: 'vite-plugin-my-plugin'
  }
}

// ❌ 不推荐
export default function myPlugin(): Plugin {
  return {
    name: 'my-plugin'
  }
}

2. 提供 TypeScript 类型

typescript
import type { Plugin } from 'vite'

export interface MyPluginOptions {
  include?: string | string[]
  exclude?: string | string[]
  customOption?: boolean
}

export default function myPlugin(options: MyPluginOptions = {}): Plugin {
  const {
    include = ['**/*.vue'],
    exclude = ['node_modules/**'],
    customOption = false
  } = options
  
  return {
    name: 'vite-plugin-my-plugin',
    // ...
  }
}

3. 错误处理

typescript
transform(code, id) {
  try {
    // 转换逻辑
    const result = transformCode(code)
    return result
  } catch (error) {
    // 提供友好的错误信息
    this.error({
      message: `Failed to transform ${id}`,
      cause: error
    })
  }
}

4. 性能优化

typescript
// ✅ 使用过滤器减少处理
const filter = createFilter(include, exclude)

transform(code, id) {
  if (!filter(id)) return null
  
  // 只处理匹配的文件
  return transformCode(code)
}

5. 支持热更新

typescript
handleHotUpdate({ file, server }) {
  if (file.endsWith('.custom')) {
    // 使相关模块失效
    const modules = server.moduleGraph.getModulesByFile(file)
    
    return modules ? [...modules] : []
  }
}

6. 添加调试日志

typescript
import debug from 'debug'

const log = debug('vite-plugin-my-plugin')

export default function myPlugin(): Plugin {
  return {
    name: 'vite-plugin-my-plugin',
    
    transform(code, id) {
      log('Transforming:', id)
      // ...
    }
  }
}

常用工具库

文件过滤

typescript
import { createFilter } from '@rollup/pluginutils'

const filter = createFilter(
  ['**/*.vue', '**/*.ts'],  // include
  ['node_modules/**']        // exclude
)

if (filter(id)) {
  // 处理文件
}

代码转换

typescript
import MagicString from 'magic-string'

const s = new MagicString(code)
s.replace('old', 'new')
s.prepend('// header\n')
s.append('\n// footer')

return {
  code: s.toString(),
  map: s.generateMap({ hires: true })
}

路径处理

typescript
import { normalizePath } from 'vite'
import path from 'path'

const normalizedPath = normalizePath(path.resolve(__dirname, './file.js'))

发布插件

1. 项目结构

vite-plugin-my-plugin/
├── src/
│   └── index.ts          # 插件源码
├── dist/
│   ├── index.js          # 编译后的 CJS
│   ├── index.mjs         # 编译后的 ESM
│   └── index.d.ts        # 类型声明
├── package.json
├── tsconfig.json
└── README.md

2. package.json 配置

json
{
  "name": "vite-plugin-my-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  },
  "keywords": [
    "vite",
    "vite-plugin"
  ],
  "peerDependencies": {
    "vite": "^5.0.0"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.0.0",
    "vite": "^5.0.0"
  }
}

3. 构建配置

使用 tsup 构建:

typescript
// tsup.config.ts
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],
  dts: true,
  clean: true,
  shims: true
})

4. 发布到 npm

bash
# 构建
pnpm build

# 发布
npm publish

调试技巧

1. 使用 debug 模块

bash
# 启动时开启调试
DEBUG=vite-plugin-my-plugin pnpm dev

2. 查看插件执行顺序

typescript
export default function myPlugin(): Plugin {
  return {
    name: 'vite-plugin-my-plugin',
    enforce: 'pre', // 'pre' | 'post' | undefined
    
    buildStart() {
      console.log('Plugin order:', this.meta.watchMode)
    }
  }
}

3. 检查模块图

typescript
configureServer(server) {
  server.middlewares.use((req, res, next) => {
    if (req.url === '/__inspect') {
      const modules = [...server.moduleGraph.idToModuleMap.entries()]
      res.end(JSON.stringify(modules, null, 2))
      return
    }
    next()
  })
}

学习资源

官方文档

优秀插件源码

工具库


总结

编写 Vite 插件的关键点:

  1. ✅ 理解插件钩子的执行时机和顺序
  2. ✅ 合理使用 transformloadresolveId 等核心钩子
  3. ✅ 提供清晰的类型定义和配置选项
  4. ✅ 注意性能优化,避免不必要的文件处理
  5. ✅ 支持热更新,提升开发体验
  6. ✅ 编写完善的文档和示例

通过自定义插件,你可以:

  • 🎯 扩展 Vite 的构建能力
  • 🎯 集成自定义工具和流程
  • 🎯 优化项目构建性能
  • 🎯 提升团队开发效率