第五章:Vite 插件开发指南

第五章:Vite 插件开发指南

5.1 插件系统概述

Vite 的插件系统兼容 Rollup 插件 API,并扩展了 Vite 特有的钩子。

插件的作用

  • 转换代码(TypeScript、JSX)
  • 处理特殊文件(Vue、Svelte)
  • 注入代码
  • 修改构建输出
  • 集成第三方工具

5.2 插件基础结构

最简单的插件

javascript 复制代码
// my-plugin.js
export default function myPlugin() {
  return {
    name: 'my-plugin',  // 必需,用于错误和警告
    
    // 钩子函数
    transform(code, id) {
      // 转换代码
      return code
    }
  }
}

在项目中引入

javascript 复制代码
// vite.config.js
import myPlugin from './my-plugin.js'

export default {
  plugins: [myPlugin()]
}

5.3 插件钩子

通用钩子(Rollup 兼容)

buildStart
javascript 复制代码
buildStart(options) {
  console.log('构建开始')
}
resolveId
javascript 复制代码
resolveId(source, importer) {
  if (source === 'virtual-module') {
    return '\0virtual-module'  // 返回虚拟模块 ID
  }
  return null  // 交给下一个插件
}
load
javascript 复制代码
load(id) {
  if (id === '\0virtual-module') {
    return 'export default "Hello from virtual module"'
  }
  return null
}
transform
javascript 复制代码
transform(code, id) {
  if (id.endsWith('.custom')) {
    // 转换自定义文件
    return {
      code: `export default ${JSON.stringify(code)}`,
      map: null  // source map
    }
  }
}
generateBundle
javascript 复制代码
generateBundle(options, bundle) {
  // 修改或添加输出文件
  bundle['custom.js'] = {
    code: 'console.log("custom");',
    type: 'asset'
  }
}

Vite 特有钩子

config
javascript 复制代码
config(config, env) {
  // 修改配置
  return {
    resolve: {
      alias: {
        '@': '/src'
      }
    }
  }
}
configResolved
javascript 复制代码
configResolved(config) {
  // 保存最终配置
  this.config = config
}
configureServer
javascript 复制代码
configureServer(server) {
  // 添加自定义中间件
  server.middlewares.use((req, res, next) => {
    console.log(`${req.method} ${req.url}`)
    next()
  })
  
  // 监听 WebSocket
  server.ws.on('connection', () => {
    console.log('客户端连接')
  })
}
transformIndexHtml
javascript 复制代码
transformIndexHtml(html) {
  // 修改 index.html
  return html.replace(
    '<head>',
    '<head><link rel="preconnect" href="https://fonts.googleapis.com">'
  )
}
handleHotUpdate
javascript 复制代码
handleHotUpdate({ file, server, modules }) {
  // 自定义 HMR 行为
  if (file.endsWith('.custom')) {
    // 重新加载整个页面
    server.ws.send({
      type: 'full-reload'
    })
    return []
  }
  return modules
}

5.4 实战示例

示例 1:Markdown 文件转组件

这是一个完整的插件开发案例,演示如何将 Markdown 文件转换为 Vue 组件。

javascript 复制代码
// vite-plugin-md-to-vue.js
import { readFileSync } from 'fs'
import { parse } from 'marked'  // 安装: npm install marked

// 生成 Vue 组件代码
function generateVueComponent(content, meta = {}) {
  const html = parse(content)
  
  return `
<template>
  <div class="markdown-content">
    <h1 v-if="title">{{ title }}</h1>
    <div v-html="renderedHtml"></div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const title = ${JSON.stringify(meta.title || '')}
const renderedHtml = ref(${JSON.stringify(html)})
</script>

<style scoped>
.markdown-content {
  max-width: 800px;
  margin: 0 auto;
}
.markdown-content :deep(h1) {
  font-size: 2rem;
  margin-bottom: 1rem;
}
.markdown-content :deep(p) {
  line-height: 1.6;
  margin-bottom: 1rem;
}
</style>
  `.trim()
}

// 解析 Markdown 元数据
function parseFrontmatter(content) {
  const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
  const match = content.match(frontmatterRegex);
  
  if (!match) return { content, meta: {} };
  
  const meta = {};
  match[1].split('\n').forEach(line => {
    const [key, ...values] = line.split(':');
    if (key && values.length) {
      meta[key.trim()] = values.join(':').trim();
    }
  });
  
  return {
    content: content.replace(frontmatterRegex, ''),
    meta
  };
}

export default function mdToVuePlugin(options = {}) {
  const { include = /\.md$/, exclude } = options;
  
  return {
    name: 'vite-plugin-md-to-vue',
    
    transform(code, id) {
      // 检查是否需要处理
      if (!include.test(id)) return;
      if (exclude && exclude.test(id)) return;
      
      try {
        // 解析 frontmatter
        const { content, meta } = parseFrontmatter(code);
        
        // 生成 Vue 组件
        const componentCode = generateVueComponent(content, meta);
        
        return {
          code: componentCode,
          map: null
        };
      } catch (error) {
        this.error(`Markdown 转换失败: ${error.message}`);
      }
    }
  };
}

使用插件:

javascript 复制代码
// vite.config.js
import mdToVue from './vite-plugin-md-to-vue.js'

export default {
  plugins: [
    mdToVue({
      include: /\.md$/,
      // exclude: /README\.md$/  // 排除某些文件
    })
  ]
}

// 现在可以直接导入 Markdown 文件
import AboutPage from './pages/about.md'

示例 2:自动导入组件

这个插件实现类似 unplugin-vue-components 的功能,自动按需导入组件。

javascript 复制代码
// vite-plugin-auto-import.js
import { readdirSync, statSync } from 'fs'
import { resolve, relative } from 'path'

// 扫描组件目录
function scanComponents(componentsDir) {
  const components = {}
  
  function scan(dir, prefix = '') {
    const files = readdirSync(dir)
    
    files.forEach(file => {
      const fullPath = resolve(dir, file)
      const stat = statSync(fullPath)
      
      if (stat.isDirectory()) {
        scan(fullPath, `${prefix}${file}/`)
      } else if (file.endsWith('.vue')) {
        const name = prefix + file.replace('.vue', '')
        components[name] = fullPath
      }
    })
  }
  
  scan(componentsDir)
  return components
}

export default function autoImportComponents(options = {}) {
  const { dirs = ['src/components'], prefix = '' } = options
  let components = {}
  let viteConfig
  
  return {
    name: 'vite-plugin-auto-import',
    
    configResolved(config) {
      viteConfig = config
      // 扫描组件
      dirs.forEach(dir => {
        const fullPath = resolve(viteConfig.root, dir)
        components = { ...components, ...scanComponents(fullPath) }
      })
    },
    
    transform(code, id) {
      // 只处理 Vue 文件
      if (!id.endsWith('.vue')) return
      
      let imports = []
      let transformedCode = code
      
      // 查找未导入的组件使用
      const componentRegex = /<([A-Z][a-zA-Z0-9]*)/g
      let match
      
      while ((match = componentRegex.exec(code)) !== null) {
        const componentName = match[1]
        
        // 检查是否已导入
        const isImported = code.includes(`import ${componentName}`) ||
                          code.includes(`import { ${componentName} }`)
        
        // 检查组件是否存在
        if (!isImported && components[componentName]) {
          const importPath = relative(resolve(id, '..'), components[componentName])
          imports.push(`import ${componentName} from '${importPath}'`)
        }
      }
      
      if (imports.length > 0) {
        transformedCode = imports.join('\n') + '\n' + code
      }
      
      return { code: transformedCode, map: null }
    }
  }
}

示例 3:开发时 Mock API

javascript 复制代码
// vite-plugin-mock.js
import { createServer } from 'http'

export default function mockPlugin(mocks = {}) {
  return {
    name: 'vite-plugin-mock',
    
    configureServer(server) {
      // 创建中间件拦截 API 请求
      server.middlewares.use(async (req, res, next) => {
        const url = req.url
        
        // 查找匹配的 Mock
        for (const [pattern, handler] of Object.entries(mocks)) {
          if (url.match(new RegExp(pattern))) {
            const response = await handler(req, res)
            res.setHeader('Content-Type', 'application/json')
            res.end(JSON.stringify(response))
            return
          }
        }
        
        next()
      })
      
      // 热更新 Mock 配置
      server.ws.on('mock-update', () => {
        console.log('Mock 配置已更新')
        // 通知客户端刷新
        server.ws.send({ type: 'full-reload' })
      })
    }
  }
}

// 使用示例
// vite.config.js
import mockPlugin from './vite-plugin-mock.js'

export default {
  plugins: [
    mockPlugin({
      '^/api/users$': async (req) => {
        return {
          code: 0,
          data: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
        }
      },
      '^/api/user/(\\d+)$': async (req) => {
        const id = req.url.match(/^\/api\/user\/(\d+)$/)[1]
        return {
          code: 0,
          data: { id: parseInt(id), name: `User ${id}` }
        }
      }
    })
  ]
}

示例 4:构建产物分析

javascript 复制代码
// vite-plugin-bundle-report.js
import { writeFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'

export default function bundleReport(options = {}) {
  const { filename = 'bundle-report.html', open = true } = options
  let bundleStats = {}
  
  return {
    name: 'vite-plugin-bundle-report',
    
    generateBundle(outputOptions, bundle) {
      // 收集构建统计
      Object.keys(bundle).forEach(key => {
        const chunk = bundle[key]
        if (chunk.type === 'chunk') {
          bundleStats[key] = {
            size: chunk.code.length,
            modules: chunk.modules ? Object.keys(chunk.modules).length : 0,
            imports: chunk.imports || []
          }
        }
      })
    },
    
    buildEnd() {
      // 生成报告 HTML
      const html = `
        <!DOCTYPE html>
        <html>
        <head>
          <title>Bundle Report</title>
          <style>
            body { font-family: monospace; padding: 20px; }
            table { border-collapse: collapse; width: 100%; }
            th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
            th { background-color: #f2f2f2; }
            .size { font-weight: bold; }
          </style>
        </head>
        <body>
          <h1>Bundle Report</h1>
          <table>
            <tr><th>File</th><th>Size (bytes)</th><th>Modules</th><th>Imports</th></tr>
            ${Object.entries(bundleStats).map(([name, stats]) => `
              <tr>
                <td>${name}</td>
                <td class="size">${stats.size.toLocaleString()}</td>
                <td>${stats.modules}</td>
                <td>${stats.imports.join(', ')}</td>
              </tr>
            `).join('')}
          </table>
          <p>Total size: ${Object.values(bundleStats).reduce((sum, s) => sum + s.size, 0).toLocaleString()} bytes</p>
        </body>
        </html>
      `
      
      const outputPath = resolve(process.cwd(), filename)
      writeFileSync(outputPath, html)
      console.log(`Bundle report generated: ${outputPath}`)
      
      if (open) {
        const { exec } = require('child_process')
        exec(`open ${outputPath}`)
      }
    }
  }
}

5.5 插件调试技巧

使用 inspect 插件

bash 复制代码
npm install -D vite-plugin-inspect
javascript 复制代码
import Inspect from 'vite-plugin-inspect'

export default {
  plugins: [
    Inspect()  // 访问 http://localhost:5173/__inspect/
  ]
}

日志调试

javascript 复制代码
import { createLogger } from 'vite'

const logger = createLogger()

export default function debugPlugin() {
  return {
    name: 'debug-plugin',
    
    transform(code, id) {
      logger.info(`处理文件: ${id}`)
      
      // 条件日志
      if (process.env.DEBUG === 'plugin') {
        console.log('代码预览:', code.slice(0, 100))
      }
      
      return code
    }
  }
}

使用 Node.js 调试器

bash 复制代码
# 启动调试
node --inspect-brk node_modules/vite/bin/vite.js

# 在 Chrome 中打开 chrome://inspect

断点调试

javascript 复制代码
// 在插件中添加 debugger
transform(code, id) {
  debugger  // 代码执行会在此处暂停
  // 检查 id 和 code
  return code
}

5.6 Rollup 钩子与 Vite 钩子映射

Rollup 钩子 Vite 对应 说明
buildStart ✅ 完全支持 构建开始
resolveId ✅ 完全支持 解析模块 ID
load ✅ 完全支持 加载模块
transform ✅ 完全支持 转换模块内容
moduleParsed ⚠️ 部分支持 模块解析完成
buildEnd ✅ 完全支持 构建结束
generateBundle ✅ 完全支持 生成产物
writeBundle ✅ 完全支持 写入产物
closeBundle ✅ 完全支持 关闭构建
config 🔄 Vite 特有 修改配置
configResolved 🔄 Vite 特有 配置已解析
configureServer 🔄 Vite 特有 配置开发服务器
transformIndexHtml 🔄 Vite 特有 转换 index.html
handleHotUpdate 🔄 Vite 特有 处理热更新

钩子执行顺序

复制代码
config
configResolved
configureServer
buildStart (开发: 启动服务器)
resolveId
load
transform
moduleParsed
buildEnd (开发: 每次文件变化)
closeBundle (开发: 服务器关闭)

5.7 插件开发最佳实践

1. 命名规范

javascript 复制代码
// 官方插件命名
@vitejs/plugin-vue

// 社区插件命名
vite-plugin-[name]

// 作用域插件
@scope/vite-plugin-[name]

2. 错误处理

javascript 复制代码
transform(code, id) {
  try {
    // 转换逻辑
  } catch (error) {
    // 使用 this.error 抛出 Vite 可识别的错误
    this.error(`转换失败: ${error.message}`)
    // 或返回错误对象
    return { code: '', map: null, error: error.message }
  }
}

3. 性能优化

javascript 复制代码
// 使用缓存
const cache = new Map()

transform(code, id) {
  // 快速过滤,避免不必要的处理
  if (!id.endsWith('.custom')) return
  
  const cacheKey = `${id}:${code.length}`
  if (cache.has(cacheKey)) return cache.get(cacheKey)
  
  const result = expensiveTransform(code)
  cache.set(cacheKey, result)
  return result
}

4. 类型定义

typescript 复制代码
// plugin.d.ts
import { Plugin } from 'vite'

export interface MyPluginOptions {
  /** 是否启用 */
  enable?: boolean
  /** 包含的文件模式 */
  include?: RegExp
}

export default function myPlugin(options?: MyPluginOptions): Plugin

5. 虚拟模块处理

javascript 复制代码
// 虚拟模块需要加 \0 前缀避免被其他插件处理
const virtualModuleId = 'virtual:my-module'
const resolvedVirtualModuleId = '\0' + virtualModuleId

resolveId(id) {
  if (id === virtualModuleId) {
    return resolvedVirtualModuleId
  }
}

load(id) {
  if (id === resolvedVirtualModuleId) {
    return `export const version = '${pkg.version}'`
  }
}

6. 热更新支持

javascript 复制代码
handleHotUpdate({ file, server, modules }) {
  // 自定义 HMR 逻辑
  if (file.endsWith('.custom')) {
    // 通知客户端重新加载
    server.ws.send({
      type: 'full-reload',
      path: '*'
    })
    return []
  }
  return modules
}

5.8 插件发布

package.json 配置

json 复制代码
{
  "name": "vite-plugin-my-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "peerDependencies": {
    "vite": "^4.0.0 || ^5.0.0"
  },
  "keywords": ["vite", "vite-plugin"],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/username/vite-plugin-my-plugin.git"
  }
}

构建配置

javascript 复制代码
// build.config.js
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: ['src/index'],
  declaration: true,
  clean: true,
  rollup: {
    emitCJS: true
  }
})

本章小结

Vite 插件系统提供了强大的扩展能力:

  • 兼容 Rollup 插件,生态丰富
  • Vite 特有钩子增强开发体验
  • 可以处理文件、注入代码、修改配置
  • 通过插件实现各种自定义需求

掌握了插件开发,就能为项目定制专属工具链。

相关推荐
xiaotao1314 小时前
第八章:实战项目案例
前端·vue.js·vite·前端打包
Irene19911 天前
TypeScript baseUrl 弃用解决(附:怎么在 Vite 中配置 resolve.alias)
typescript·vite·baseurl
辻戋1 天前
从零手写mini-vite
webpack·vite·esbuild
还是大剑师兰特3 天前
vitejs/plugin-legacy 作用与使用方法
vite·大剑师
xiaotao1314 天前
Vite 工作原理深度解析
vite·前端打包
xiaotao1314 天前
Vite 概述与核心概念
vite·前端打包
米丘5 天前
从 HTTP 到 WebSocket:深入 Vite HMR 的网络层原理
http·node.js·vite
蜡台6 天前
Vue 打包优化
前端·javascript·vue.js·vite·vue-cli
大家的林语冰7 天前
《前端周刊》尤大官宣 Vite 8 稳定版首发!npm 新官网?React 官网更新。focusgroup 新功能!
前端·javascript·vite