3.5 · difficulty 3/4 · 12 min read
Plugin Communication
How can multiple plugins share state? What is `this.meta`? What are the trade-offs between Vite's closure-sharing and virtual module bridge approaches?
Why plugins need to communicate
A single plugin handling a single responsibility is the ideal case, but real projects sometimes require multiple plugins to collaborate:
- Plugin A scans all route files and generates a route table
- Plugin B injects the route table into the
<head>of the HTML
Plugin B needs the data generated by Plugin A — that's plugin communication.
Another common scenario: one plugin handles development-time features while another handles build-time optimizations, and both need to share configuration or intermediate data.
Option 1: closure sharing (simplest)
The most direct approach is to wrap both plugins inside the same factory function, sharing a single closure variable:
import type { Plugin } from "vite"
export function routeSystemPlugins(): Plugin[] {
// Shared state: declared in the closure
const routes: { path: string; component: string }[] = []
return [
// Plugin A: scan routes
{
name: "vite-plugin-route-scanner",
enforce: "pre",
buildStart() {
// Scan and populate routes
routes.push({ path: "/", component: "src/pages/index.tsx" })
routes.push({ path: "/about", component: "src/pages/about.tsx" })
},
},
// Plugin B: inject the route table
{
name: "vite-plugin-route-injector",
// routes from the closure is visible to B
resolveId(id) {
if (id === "virtual:routes") return "\0virtual:routes"
return null
},
load(id) {
if (id !== "\0virtual:routes") return null
return `export default ${JSON.stringify(routes)}`
},
},
]
}Configure them together:
import { defineConfig } from "vite"
import { routeSystemPlugins } from "./plugins/route-system"
export default defineConfig({
plugins: [...routeSystemPlugins()],
})Pros: Simple and direct, zero overhead. Cons: Both plugins must be published together and cannot be reused independently.
Option 2: passing data through a virtual module
If the two plugins come from different npm packages, you can use a virtual module as a data bridge:
import type { Plugin } from "vite"
const BRIDGE_ID = "virtual:plugin-a-data"
let sharedData: Record<string, unknown> = {}
// Expose a function for Plugin B to call
export function setSharedData(data: Record<string, unknown>) {
sharedData = data
}
export function pluginA(): Plugin {
return {
name: "plugin-a",
buildStart() {
sharedData = { timestamp: Date.now(), items: ["a", "b", "c"] }
},
resolveId(id) {
if (id === BRIDGE_ID) return "\0" + BRIDGE_ID
return null
},
load(id) {
if (id !== "\0" + BRIDGE_ID) return null
return `export default ${JSON.stringify(sharedData)}`
},
}
}import type { Plugin } from "vite"
export function pluginB(): Plugin {
return {
name: "plugin-b",
// Plugin B retrieves data via import "virtual:plugin-a-data"
transform(code, id) {
if (!code.includes("virtual:plugin-a-data")) return null
// Let Vite continue processing this import
return null
},
}
}Option 3: this.meta — Rollup plugin metadata
Rollup (and Rolldown) provides the this.meta mechanism, which lets a plugin write and read metadata at the module level:
import type { Plugin } from "vite"
// Plugin A: attach metadata to a module
const pluginAMeta = {
name: "plugin-a",
buildStart() {
// this.meta cannot be used for cross-plugin communication
// this.meta is only the plugin's own metadata space
},
transform(code, id) {
// Attach metadata to this module
// Note: this.meta is private to this plugin for this module
this.meta.processedAt = Date.now()
return null
},
}Option 4: this.getModuleInfo() — reading other modules' information
In output-phase hooks like generateBundle, you can read detailed information about any module using this.getModuleInfo(id):
import type { Plugin } from "vite"
export function analyzePlugin(): Plugin {
return {
name: "vite-plugin-analyze",
apply: "build",
generateBundle(_, bundle) {
for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type !== "chunk") continue
// Get detailed information about each module in the chunk
for (const moduleId of Object.keys(chunk.modules)) {
const info = this.getModuleInfo(moduleId)
if (!info) continue
console.log({
id: info.id,
importers: info.importers,
isEntry: info.isEntry,
hasDefaultExport: info.hasDefaultExport,
// code contains the module's original source (null if tree-shaken)
})
}
}
},
}
}Practical pattern: config-sharing plugin suite
When a plugin suite needs multiple sub-plugins to share configuration:
import type { Plugin } from "vite"
interface SuiteOptions {
prefix: string
debug: boolean
}
function createSharedContext(options: SuiteOptions) {
// All sub-plugins share this context object
return {
prefix: options.prefix,
debug: options.debug,
cache: new Map<string, string>(),
}
}
export function pluginSuite(options: SuiteOptions): Plugin[] {
const ctx = createSharedContext(options)
return [transformPlugin(ctx), injectPlugin(ctx), reportPlugin(ctx)]
}
function transformPlugin(ctx: ReturnType<typeof createSharedContext>): Plugin {
return {
name: "suite:transform",
transform(code, id) {
const result = doTransform(code, ctx.prefix)
ctx.cache.set(id, result) // write to shared cache
return { code: result, map: null }
},
}
}
function injectPlugin(ctx: ReturnType<typeof createSharedContext>): Plugin {
return {
name: "suite:inject",
transform(code, id) {
const cached = ctx.cache.get(id) // read from shared cache
if (!cached) return null
return injectCode(cached)
},
}
}
function doTransform(code: string, prefix: string) {
return code
}
function injectCode(code: string) {
return null
}
function reportPlugin(ctx: ReturnType<typeof createSharedContext>): Plugin {
return {
name: "suite:report",
buildEnd() {
if (ctx.debug) {
console.log(`Processed ${ctx.cache.size} modules`)
}
},
}
}Self-check
- Can
this.metabe used to share data between different plugins? Why or why not? - When is closure sharing the right choice, and when is the virtual module bridge the better option?
- If Plugin A collects data in
buildStartand Plugin B uses that data intransform, is the execution order guaranteed? - In which hooks can
this.getModuleInfo(id)be used? Why is it not well-suited for use intransform?
// Implement two cooperating plugins:
// Plugin 1: scan all .md files and generate a file list
// Plugin 2: expose that list via the virtual module "virtual:md-files"
//
// Requirement: both plugins live in the same factory function and share data via closure
import type { Plugin } from "vite"
import { glob } from "node:fs/promises"
import { resolve } from "node:path"
export function mdListPlugins(): Plugin[] {
// TODO: declare shared state
const mdFiles: string[] = []
return [
// Plugin 1: scan .md files
{
name: "md-scanner",
async buildStart() {
// TODO: use glob to scan content/**/*.md
},
},
// Plugin 2: expose the virtual module
{
name: "md-virtual",
resolveId(id) {
// TODO
},
load(id) {
// TODO: return export default [...mdFiles]
},
},
]
}