Vite构建工具插件开发机制与自定义转译管线实战配置

Vite的插件系统基于Rollup插件接口扩展,在开发环境通过esbuild和原生ESM实现毫秒级热更新,生产构建则走Rollup打包流程。理解Vite插件Hook生命周期和虚拟模块机制,是开发自定义转译管线、集成非标资源处理的基础。本文从插件架构到实战开发,覆盖完整流程。

Vite插件架构与Hook生命周期

Vite插件是一个返回插件的函数,Hook分为Rollup通用Hook和Vite专用Hook:

// Vite插件基本结构
export default function myPlugin(options) {
  return {
    name: 'vite-plugin-my',

    // ===== Vite专用Hook =====

    // 配置解析前调用,可修改Vite配置
    config(config, { command }) {
      return {
        resolve: {
          alias: {
            '@': '/src'
          }
        }
      }
    },

    // 配置解析后调用,可读取最终配置
    configResolved(config) {
      console.log('Build target:', config.build.target)
    },

    // 自定义Dev Server中间件
    configureServer(server) {
      server.middlewares.use('/api/mock', (req, res) => {
        res.setHeader('Content-Type', 'application/json')
        res.end(JSON.stringify({ code: 200, data: [] }))
      })
    },

    // 转换index.html
    transformIndexHtml(html) {
      return html.replace(
        '',
        ''
      )
    },

    // 自定义热更新处理
    handleHotUpdate({ file, server }) {
      if (file.endsWith('.md')) {
        server.ws.send({ type: 'full-reload' })
      }
    },

    // ===== Rollup通用Hook =====

    // 解析模块ID,返回虚拟模块标记
    resolveId(source, importer) {
      if (source === 'virtual:my-module') {
        return source // 返回source表示已解析
      }
      return null
    },

    // 加载模块内容
    load(id) {
      if (id === 'virtual:my-module') {
        return `export default ${JSON.stringify(options.data)}`
      }
      return null
    },

    // 转译代码
    transform(code, id) {
      if (id.endsWith('.md')) {
        const html = markdownToHtml(code)
        return `export default ${JSON.stringify(html)}`
      }
      return null
    },

    // 生成产物后调用
    generateBundle(opts, bundle) {
      // 可修改或添加产物文件
    }
  }
}

Hook执行顺序:

开发环境:
  config -> configResolved -> configureServer ->
  [请求模块] resolveId -> load -> transform ->
  [文件变更] handleHotUpdate

生产构建:
  config -> configResolved ->
  [构建阶段] resolveId -> load -> transform ->
  [输出阶段] renderChunk -> generateBundle -> writeBundle -> closeBundle

自定义插件开发与虚拟模块导入

虚拟模块允许在代码中导入不存在的文件路径,通过插件动态生成内容:

// plugins/vite-plugin-virtual-routes.js
import fg from 'fast-glob'
import path from 'path'

export default function virtualRoutesPlugin(options = {}) {
  const { pagesDir = 'src/pages', extensions = ['vue', 'tsx'] } = options
  let moduleId = 'virtual:routes'
  const resolvedId = '\0' + moduleId

  return {
    name: 'vite-plugin-virtual-routes',
    enforce: 'pre', // 在其他插件之前执行

    resolveId(id) {
      if (id === moduleId) {
        return resolvedId // \0前缀标记为虚拟模块
      }
    },

    load(id) {
      if (id !== resolvedId) return null

      // 扫描页面目录生成路由配置
      const files = fg.sync(`${pagesDir}/**/*.{${extensions.join(',')}}`, {
        cwd: process.cwd()
      })

      const routes = files.map(file => {
        const ext = path.extname(file)
        const routePath = file
          .replace(pagesDir + '/', '')
          .replace(ext, '')
          .replace(/\/g, '/')
          .replace(/index$/, '')
          .replace(/\[(\w+)\]/, ':$1') // [id] -> :id

        const componentPath = file.replace(ext, '')

        return `  {
    path: '/${routePath}',
    name: '${routePath.replace(/\//g, '_') || 'index'}',
    component: () => import('/${componentPath}${ext}')
  }`
      })

      return `export default [
${routes.join(',\n')}
]`
    }
  }
}

在项目中使用:

// vite.config.js
import { defineConfig } from 'vite'
import virtualRoutes from './plugins/vite-plugin-virtual-routes'

export default defineConfig({
  plugins: [
    virtualRoutes({ pagesDir: 'src/pages' })
  ]
})

// src/router.js
import routes from 'virtual:routes'
// TypeScript类型声明(src/vite-env.d.ts)
// declare module 'virtual:routes' {
//   import type { RouteRecordRaw } from 'vue-router'
//   const routes: RouteRecordRaw[]
//   export default routes
// }

console.log(routes)
// [{ path: '/', name: 'index', component: [AsyncFunction] }, ...]

转译管线配置与自定义文件处理器

通过transform Hook可实现自定义文件格式转译。以下插件将YAML配置文件转译为ES模块:

// plugins/vite-plugin-yaml.js
import yaml from 'js-yaml'

export default function yamlPlugin(options = {}) {
  const { include = ['**/*.yaml', '**/*.yml'] } = options

  return {
    name: 'vite-plugin-yaml',
    enforce: 'pre',

    transform(code, id) {
      // 过滤非YAML文件
      const isYaml = include.some(pattern =>
        new RegExp(pattern.replace('**/', '.*?').replace('.', '\.')).test(id)
      )
      if (!isYaml) return null

      try {
        const data = yaml.load(code)
        return {
          code: `export default ${JSON.stringify(data)}`,
          map: null // 可选:返回source map
        }
      } catch (err) {
        this.error(`YAML parse error in ${id}: ${err.message}`)
      }
    }
  }
}

// 进阶版:支持YAML中嵌入JS表达式
export default function yamlPluginAdvanced() {
  return {
    name: 'vite-plugin-yaml-advanced',
    enforce: 'pre',

    transform(code, id) {
      if (!id.match(/\.(ya?ml)$/)) return null

      const data = yaml.load(code)

      // 将嵌套函数字符串转为实际导出
      const serialized = JSON.stringify(data, (key, value) => {
        if (typeof value === 'string' && value.startsWith('!function')) {
          return value.slice(1) // 去掉!前缀,标记为函数
        }
        return value
      })

      return {
        code: `export default ${serialized}`,
        map: null
      }
    }
  }
}

插件开发实战:Markdown转HTML组件

完整示例:将Markdown文件转译为Vue SFC组件,支持代码高亮和Front Matter:

// plugins/vite-plugin-md-to-component.js
import markdownIt from 'markdown-it'
import matter from 'gray-matter'
import { highlightPlugin } from './highlight'

export default function mdToComponentPlugin(options = {}) {
  const {
    markdownOptions = {},
    includeFrontMatter = true
  } = options

  const md = markdownIt({
    html: true,
    linkify: true,
    typographer: true,
    highlight: highlightPlugin
  })

  return {
    name: 'vite-plugin-md-to-component',
    enforce: 'pre',

    transform(code, id) {
      if (!id.endsWith('.md')) return null

      const { content, data: frontMatter } = includeFrontMatter
        ? matter(code)
        : { content: code, data: {} }

      const html = md.render(content)

      // 生成Vue SFC
      const sfc = `



`

      return {
        code: sfc,
        map: null
      }
    }
  }
}

React版本的Markdown组件插件:

// plugins/vite-plugin-md-to-react.js
export default function mdToReactPlugin() {
  const md = markdownIt({ html: true, linkify: true })

  return {
    name: 'vite-plugin-md-to-react',
    enforce: 'pre',

    transform(code, id) {
      if (!id.endsWith('.md')) return null

      const { content, data } = matter(code)
      const html = md.render(content)

      const component = `import React from 'react'

export const frontMatter = ${JSON.stringify(data)}

export default function MarkdownComponent() {
  return React.createElement('div', {
    className: 'markdown-body',
    dangerouslySetInnerHTML: { __html: ${JSON.stringify(html)} }
  })
}`

      return { code: component, map: null }
    }
  }
}

插件调试与单元测试方法

// 使用Vite插件容器测试插件
import { createServer } from 'vite'

async function testPlugin() {
  const server = await createServer({
    configFile: false,
    root: './test-fixture',
    plugins: [myPlugin()],
    logLevel: 'error'
  })

  // 加载虚拟模块验证输出
  const result = await server.ssrLoadModule('virtual:my-module')
  console.log('Module output:', result)

  await server.close()
}

// 单元测试(vitest)
import { describe, it, expect } from 'vitest'
import { transform } from '../plugins/my-plugin'

describe('vite-plugin-md-to-component', () => {
  it('should convert markdown to SFC', () => {
    const input = '# Hello\n\nThis is a test.'
    const result = transform(input, 'test.md')
    expect(result.code).toContain('

Hello

') expect(result.code).toContain('