Vue 3 Vite Plugin Development: 5 Core Patterns for Custom Build Optimization Plugins

前端工程

Build Optimization Pain Points: Why Custom Vite Plugins Matter

In 2026, Vite has become the standard build tool for Vue 3 projects, but out-of-the-box optimizations don't always meet business requirements. Bundled size bloat, slow cold starts, no path for custom code transforms, and a steep Rollup plugin API learning curve — these issues keep developers from tackling build optimization. Vite plugins extend the Rollup plugin system; mastering their core patterns lets you precisely solve performance bottlenecks in the build pipeline.

Pain Point Manifestation Impact
Large bundle size Un-tree-shaken libs, duplicate deps, uncompressed assets First paint > 3s
Slow builds Full transforms, no caching, synchronous processing CI/CD pipeline timeouts
Unmet custom needs Built-in plugins don't cover business scenarios Manual build hacks
Complex Rollup API Many hooks, unclear execution order, fuzzy enforce mechanism Frequent plugin conflicts

Core Concepts at a Glance

Concept Description
Vite Plugin Extended from Rollup plugin interface with Vite-specific hooks
Rollup Plugin Hook Build lifecycle hooks like resolveId, load, transform
transform Transform module source code, returning modified code and sourcemap
load Custom module loading logic, can return code content instead of default file read
resolveId Custom module resolution, intercepting import paths to virtual modules or real files
configureServer Configure Vite dev server, add middleware and custom request handling
buildStart Triggered when build starts, used for resource initialization and config reading
buildEnd Triggered when build ends, used for cleanup and report generation
HMR Hot Module Replacement, updating modules locally during development without full page reload

Deep Analysis of 5 Key Challenges

Challenge 1: Plugin Execution Order

Vite plugins are sorted by enforce (pre/post/default), executing in array order within the same group. When multiple plugins operate on the same file, incorrect ordering leads to unpredictable transform results.

Challenge 2: Dev/Production Environment Differences

Vite dev mode uses esbuild for TS/JSX, while production uses Rollup. The same plugin may behave differently in each mode — use apply: 'build' | 'serve' to control.

Challenge 3: HMR Compatibility

Custom transforms must return correct sourcemap, otherwise HMR positioning fails. Virtual modules need handleHotUpdate to manually manage update boundaries.

Challenge 4: Virtual Module Handling

Virtual modules (e.g., virtual:xxx) have no corresponding file. You must mark them with the \0 prefix in resolveId to prevent other plugins from processing, and return content in load.

Challenge 5: Plugin Debugging Difficulty

Plugin internal errors don't surface directly in the terminal. Incorrect transform return format causes silent failures. Manual console.log or vite --debug plugin is needed.


Pattern 1: Vite Plugin Structure and Hooks

import type { Plugin, ResolvedConfig } from 'vite'

interface BuildStatsPluginOptions {
  outputFileName?: string
  includeModules?: RegExp
}

export default function buildStatsPlugin(
  options: BuildStatsPluginOptions = {}
): Plugin {
  const outputFileName = options.outputFileName ?? 'build-stats.json'
  const includeModules = options.includeModules ?? /\.[jt]sx?$/
  let config: ResolvedConfig
  const moduleSizes = new Map<string, number>()

  return {
    name: 'vite-plugin-build-stats',
    enforce: 'post',
    apply: 'build',

    configResolved(resolvedConfig) {
      config = resolvedConfig
    },

    buildStart() {
      moduleSizes.clear()
      console.log(`[build-stats] Build started, mode: ${config.mode}`)
    },

    transform(code, id) {
      if (includeModules.test(id)) {
        moduleSizes.set(id, code.length)
      }
      return null
    },

    buildEnd(error) {
      if (error) {
        console.error(`[build-stats] Build failed: ${error.message}`)
        return
      }
      const totalSize = Array.from(moduleSizes.values()).reduce(
        (sum, size) => sum + size,
        0
      )
      console.log(
        `[build-stats] ${moduleSizes.size} modules, total ${(totalSize / 1024).toFixed(1)}KB`
      )
    },

    closeBundle() {
      const stats = Object.fromEntries(moduleSizes)
      const fs = require('fs')
      const path = require('path')
      fs.writeFileSync(
        path.join(config.build.outDir, outputFileName),
        JSON.stringify(stats, null, 2)
      )
    }
  }
}

Pattern 2: Custom Transform Code Transformation

import type { Plugin } from 'vite'
import { createFilter, type FilterPattern } from '@rollup/pluginutils'

interface RemoveConsolePluginOptions {
  include?: FilterPattern
  exclude?: FilterPattern
  removeMethods?: string[]
  retainExternal?: boolean
}

export default function removeConsolePlugin(
  options: RemoveConsolePluginOptions = {}
): Plugin {
  const {
    removeMethods = ['log', 'debug', 'info'],
    retainExternal = true
  } = options
  const filter = createFilter(options.include, options.exclude)
  const methodPattern = removeMethods.join('|')
  const consoleRegex = new RegExp(
    `console\\.(${methodPattern})\\s*\\([^)]*\\)\\s*;?`,
    'g'
  )

  return {
    name: 'vite-plugin-remove-console',
    enforce: 'post',
    apply: 'build',

    transform(code, id) {
      if (!filter(id)) return null
      const matches = code.match(consoleRegex)
      if (!matches || matches.length === 0) return null

      const newCode = code.replace(consoleRegex, '')
      const linesRemoved = matches.length
      console.log(
        `[remove-console] ${id}: removed ${linesRemoved} console calls`
      )

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

Pattern 3: Virtual Modules and resolveId

import type { Plugin } from 'vite'

const VIRTUAL_MODULE_ID = 'virtual:env-config'
const RESOLVED_MODULE_ID = '\0' + VIRTUAL_MODULE_ID

interface EnvConfigPluginOptions {
  publicVars: Record<string, string>
  secretVars?: Record<string, string>
}

export default function envConfigPlugin(
  options: EnvConfigPluginOptions
): Plugin {
  const { publicVars, secretVars = {} } = options
  let isProduction = false

  return {
    name: 'vite-plugin-env-config',
    enforce: 'pre',

    configResolved(config) {
      isProduction = config.command === 'build'
    },

    resolveId(source) {
      if (source === VIRTUAL_MODULE_ID) {
        return RESOLVED_MODULE_ID
      }
      return null
    },

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

      const allVars = isProduction
        ? { ...publicVars, ...secretVars }
        : publicVars

      const exports = Object.entries(allVars)
        .map(([key, value]) => `export const ${key} = ${JSON.stringify(value)}`)
        .join('\n')

      return `${exports}\nexport default { ${Object.keys(allVars).join(', ')} }`
    },

    handleHotUpdate({ file, server }) {
      if (file.endsWith('.env') || file.endsWith('.env.local')) {
        server.ws.send({
          type: 'full-reload',
          path: '*'
        })
      }
    }
  }
}

// Usage: import envConfig from 'virtual:env-config'

Pattern 4: Dev Server Middleware and HMR

import type { Plugin, ViteDevServer } from 'vite'

interface ApiMockPluginOptions {
  mockDir?: string
  prefix?: string
}

export default function apiMockPlugin(
  options: ApiMockPluginOptions = {}
): Plugin {
  const mockDir = options.mockDir ?? 'mocks'
  const prefix = options.prefix ?? '/api/'
  let server: ViteDevServer
  const mockModules = new Map<string, any>()

  return {
    name: 'vite-plugin-api-mock',
    apply: 'serve',
    enforce: 'pre',

    configureServer(devServer) {
      server = devServer

      devServer.middlewares.use(async (req, res, next) => {
        if (!req.url?.startsWith(prefix)) {
          return next()
        }

        const mockPath = req.url.replace(prefix, '').replace(/\?.*$/, '')
        const handler = mockModules.get(mockPath)

        if (handler) {
          res.setHeader('Content-Type', 'application/json')
          res.setHeader('Access-Control-Allow-Origin', '*')
          const body =
            typeof handler === 'function'
              ? await handler(req)
              : handler
          res.end(JSON.stringify(body))
          return
        }

        next()
      })
    },

    async buildStart() {
      const fs = require('fs')
      const path = require('path')
      const mockRoot = path.resolve(process.cwd(), mockDir)

      if (!fs.existsSync(mockRoot)) return

      const files = fs.readdirSync(mockRoot).filter((f: string) => f.endsWith('.json'))
      for (const file of files) {
        const name = file.replace('.json', '')
        const content = fs.readFileSync(path.join(mockRoot, file), 'utf-8')
        mockModules.set(name, JSON.parse(content))
      }
      console.log(`[api-mock] Loaded ${mockModules.size} mock endpoints`)
    },

    handleHotUpdate({ file }) {
      if (file.includes(mockDir)) {
        const name = file.split('/').pop()?.replace('.json', '')
        if (name) {
          const fs = require('fs')
          const content = fs.readFileSync(file, 'utf-8')
          mockModules.set(name, JSON.parse(content))
          console.log(`[api-mock] Hot updated: ${name}`)
        }
        return []
      }
    }
  }
}

Pattern 5: Production Build Optimization Plugin

import type { Plugin, RollupOutputAsset } from 'vite'
import { gzipSync } from 'zlib'

interface CompressPluginOptions {
  algorithm?: 'gzip' | 'brotli'
  threshold?: number
  deleteOriginal?: boolean
}

export default function compressPlugin(
  options: CompressPluginOptions = {}
): Plugin {
  const algorithm = options.algorithm ?? 'gzip'
  const threshold = options.threshold ?? 1024
  const deleteOriginal = options.deleteOriginal ?? false

  return {
    name: 'vite-plugin-compress',
    enforce: 'post',
    apply: 'build',

    generateBundle(_, bundle) {
      const extensions = ['.js', '.css', '.html', '.svg', '.json']

      for (const [fileName, file] of Object.entries(bundle)) {
        if (file.type !== 'chunk' && file.type !== 'asset') continue
        if (!extensions.some((ext) => fileName.endsWith(ext))) continue

        const source =
          file.type === 'chunk'
            ? Buffer.from(file.code, 'utf-8')
            : Buffer.from((file as RollupOutputAsset).source as string, 'utf-8')

        if (source.length < threshold) continue

        if (algorithm === 'gzip') {
          const compressed = gzipSync(source, { level: 9 })
          this.emitFile({
            type: 'asset',
            fileName: `${fileName}.gz`,
            source: compressed
          })
          console.log(
            `[compress] ${fileName}: ${(source.length / 1024).toFixed(1)}KB → ${(compressed.length / 1024).toFixed(1)}KB (${((1 - compressed.length / source.length) * 100).toFixed(1)}%)`
          )
        }

        if (deleteOriginal) {
          delete bundle[fileName]
        }
      }
    }
  }
}

Pitfall Guide

Scenario Wrong Approach Right Approach
Hook return value ❌ Not returning sourcemap from transform ✅ Return { code, map } or use MagicString to generate map
Virtual module ID ❌ Not adding \0 prefix to virtual module IDs ✅ resolveId returns \0+id to prevent other plugins from processing
Enforce order ❌ Not setting enforce, relying on default order ✅ Explicitly set enforce: 'pre' or 'post' to control timing
HMR handling ❌ Not implementing handleHotUpdate for virtual modules ✅ Implement handleHotUpdate returning affected modules or empty array to prevent reload
Apply condition ❌ Not restricting apply for dev-only plugins ✅ Set apply: 'serve' or 'build' to avoid unnecessary execution

Error Troubleshooting

Error Message Cause Solution
Plugin "${name}" returned invalid transform result Incorrect transform return format Ensure returning { code: string, map?: any } or null
Could not resolve "virtual:xxx" resolveId not handling virtual module correctly Match virtual ID in resolveId and return \0-prefixed ID
Sourcemap is likely to be incorrect Sourcemap mismatch after transform Use MagicString to generate correct sourcemap
HMR update failed: module not found handleHotUpdate returned non-existent module Verify returned module IDs exist in module graph
Plugin conflict: multiple plugins handle "${id}" Multiple plugins processing same module Use enforce or filter to scope plugin effects
Cannot read properties of undefined (reading 'ws') Accessing server object in non-serve mode Check apply config, only use configureServer in serve mode
Circular dependency: virtual:xxx Virtual module content imports itself Check load output code, remove self-references
Build timeout in plugin "${name}" Long synchronous operation in plugin Convert to async, use Worker or caching
ENOENT: no such file, open "virtual:xxx" Virtual module not returning content in load Ensure load hook matches resolvedId and returns code
Unexpected token after transform Transform output contains invalid JS syntax Check code replacement logic, ensure valid AST output

Advanced Optimization Tips

1. Plugin Caching and Incremental Builds

import type { Plugin } from 'vite'
import { createHash } from 'crypto'
import { readFileSync, writeFileSync, existsSync } from 'fs'

const cacheDir = 'node_modules/.vite-plugin-cache'

export function withCache(
  pluginName: string,
  transformFn: (code: string, id: string) => string | null
): Plugin {
  return {
    name: pluginName,
    transform(code, id) {
      const hash = createHash('md5').update(code).digest('hex').slice(0, 8)
      const cacheFile = `${cacheDir}/${pluginName}/${hash}.js`
      if (existsSync(cacheFile)) {
        return { code: readFileSync(cacheFile, 'utf-8'), map: null }
      }
      const result = transformFn(code, id)
      if (result) {
        writeFileSync(cacheFile, result)
        return { code: result, map: null }
      }
      return null
    }
  }
}

2. Plugin Composition Pipeline

import type { Plugin } from 'vite'

export function composePlugins(
  name: string,
  plugins: Array<Pick<Plugin, 'transform' | 'name'>>
): Plugin {
  return {
    name,
    async transform(code, id) {
      let result: string = code
      for (const plugin of plugins) {
        if (!plugin.transform) continue
        const output = await plugin.transform.call(this as any, result, id)
        if (output && typeof output === 'object') {
          result = output.code
        } else if (typeof output === 'string') {
          result = output
        }
      }
      return result === code ? null : { code: result, map: null }
    }
  }
}

3. Plugin Debugging Tool

import type { Plugin } from 'vite'

export function debugPlugin(options: { logTransforms?: boolean } = {}): Plugin {
  const transformTimings: Array<{ name: string; id: string; duration: number }> = []

  return {
    name: 'vite-plugin-debug',
    enforce: 'post',

    transform(code, id) {
      if (!options.logTransforms) return null
      const start = performance.now()
      const duration = performance.now() - start
      transformTimings.push({ name: 'debug', id, duration })
      return null
    },

    buildEnd() {
      const byPlugin = new Map<string, number>()
      for (const t of transformTimings) {
        byPlugin.set(t.name, (byPlugin.get(t.name) ?? 0) + t.duration)
      }
      console.table(Object.fromEntries(byPlugin))
    }
  }
}

Comparative Analysis

Dimension Vite Plugin Webpack Loader/Plugin Rollup Plugin esbuild Plugin
Learning curve Low (Rollup superset) High (Loader + Plugin dual API) Medium (pure Rollup API) Low (but limited features)
Hook richness High (Rollup + Vite-specific) Very high (full lifecycle) Medium (core hooks) Low (only onLoad/onResolve)
HMR support Native Requires webpack-dev-server None (pure bundler) None
Virtual modules \0 prefix convention resolve plugin \0 prefix convention namespace mechanism
Dev/build consistency Medium (esbuild vs Rollup diff) High (same Loader set) High (same API) High (same engine)
TypeScript Full type support Requires extra config Full type support Limited support
Ecosystem richness Fast growing Most mature Growing Limited
Build performance Fast (esbuild pre-bundle) Slow Medium Extremely fast

Summary and Outlook

The core of Vite plugin development lies in understanding the combination of the Rollup plugin system and Vite-specific extensions. The 5 core patterns — plugin structure and hooks, custom transform, virtual modules, dev server middleware with HMR, and production build optimization — cover the complete build pipeline from development to production.

In 2026, the Vite ecosystem is mature enough. Rolldown, the Rust-based Rollup replacement, is being integrated — the plugin API will remain compatible while underlying performance improves dramatically. Start with simple transform plugins, gradually master virtual modules and HMR mechanisms, and ultimately implement complete build optimization solutions.


Try these browser-local tools — no sign-up required →

#Vite插件开发#Vue3#Rollup插件#构建优化#自定义插件#2026#前端工程