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.
Recommended Online Tools
- Vite Playground — Experience Vite build flow and plugin effects online
- Rollup REPL — Debug Rollup plugin compilation output online
- AST Explorer — Visualize code AST to aid transform development
- Bundlephobia — Analyze bundle size and evaluate optimization effects
- ToolsKu JSON Formatter — Format build artifact JSON online
Try these browser-local tools — no sign-up required →