13.1 · difficulty 4/4 · 16 min read
Source Walkthrough: `unplugin-auto-import`
The auto-import plugin used in virtually every Vue project — how does it actually work? How does resolveId intercept, how does transform inject imports, and why does it use unplugin instead of vite-plugin?
Feature Overview
unplugin-auto-import lets you write code like this:
// No import needed!
const count = ref(0)
const doubled = computed(() => count.value * 2)The plugin automatically injects at the top of the file:
import { ref, computed } from "vue"unplugin: A Cross-Bundler Plugin Framework
auto-import uses the unplugin framework, which lets a single codebase run on Vite / Webpack / Rollup / esbuild:
// Source structure (simplified)
import { createUnplugin } from "unplugin"
const AutoImport = createUnplugin((options) => {
return {
name: "unplugin-auto-import",
transform(code, id) {
/* ... */
},
// ...
}
})
// Export versions for each bundler
export const vitePlugin = AutoImport.vite
export const webpackPlugin = AutoImport.webpack
export const rollupPlugin = AutoImport.rollupunplugin internally adapts this unified hook format to each bundler's API.
Core Logic: Static Import Detection
// Simplified core transform implementation
function transform(code: string, id: string): string | null {
// 1. Skip files that don't need processing
if (!filterFile(id)) return null
// 2. Parse the code to find which identifiers have no import
const usedIdentifiers = scanUsedIdentifiers(code)
const existingImports = parseExistingImports(code)
// 3. Find the imports that need to be injected automatically
const toInject: Record<string, string[]> = {}
for (const identifier of usedIdentifiers) {
if (existingImports.has(identifier)) continue // already imported
const source = lookupSource(identifier, options.imports)
if (!source) continue // not in the configured preset
toInject[source] ??= []
toInject[source].push(identifier)
}
if (Object.keys(toInject).length === 0) return null
// 4. Generate import statements
const importStatements = Object.entries(toInject)
.map(([source, names]) => `import { ${names.join(", ")} } from "${source}"`)
.join("\n")
return `${importStatements}\n${code}`
}Implementing Identifier Scanning
Identifier scanning is the key challenge: how do you know whether ref is actually used, or just happens to be a variable name?
Simple implementation (regex):
function scanUsedIdentifiers(code: string): Set<string> {
const identifiers = new Set<string>()
const IDENTIFIER_RE = /\b([a-zA-Z_$][a-zA-Z0-9_$]*)\b/g
let match: RegExpExecArray | null
while ((match = IDENTIFIER_RE.exec(code)) !== null) {
identifiers.add(match[1])
}
return identifiers
}More accurate implementation (AST): unplugin-auto-import actually uses an AST to precisely detect identifier usage, rather than simple regex.
Automatic .d.ts Generation
The plugin generates global type declarations in buildEnd:
buildEnd() {
if (!options.dts) return
const declarations = generateDtsContent(resolvedImports)
writeFileSync(resolve(options.dts), declarations)
},
function generateDtsContent(imports: ImportMap): string {
const lines = ["// Auto-generated, do not edit manually"]
lines.push("export {}")
lines.push("declare global {")
for (const [identifier, source] of imports) {
lines.push(
` const ${identifier}: typeof import("${source}")["${identifier}"]`
)
}
lines.push("}")
return lines.join("\n")
}ESLint Integration
To prevent ESLint from reporting "no-undef" errors, the plugin can generate an ESLint config:
// auto-import.d.ts can include ESLint globalsSource Reading Order
Do not start by jumping directly into the transform function. Read the source in this order:
- Entry exports: see how
createUnpluginexports Vite, Rollup, Webpack, and other adapter versions. - Option resolution: inspect how presets, dirs, imports, dts, eslintrc, include, and exclude are normalized.
- Scanner: learn how the plugin finds undeclared identifiers and avoids mistaking property names, local variables, or type names for global APIs.
- Injector: inspect how import statements are inserted in the right location while preserving shebangs, comments, and sourcemaps.
- Declaration generation: see how
.d.tsand ESLint globals are produced from the final import map. - Cache and watcher logic: check how directory scanning, config changes, and type file updates avoid full recomputation on every transform.
The important part of the source is not "add one import line". It is the index, filter, cache, and type-experience system built around that line.
Call Flow
One file transform roughly follows this flow:
user saves file
-> Vite calls transform(code, id)
-> include/exclude decides whether to process it
-> AST/lexical scanner collects candidate identifiers
-> existing imports, local declarations, and type-only positions are excluded
-> import map resolves identifiers to source packages
-> MagicString injects imports
-> code + sourcemap are returned
-> used identifier cache is updated
-> buildEnd / watcher refreshes d.tsThis flow explains why auto-import cannot be implemented mainly with resolveId. resolveId decides how a module path resolves. Auto-import needs to answer "which imports are missing from this source file?", and that requires seeing the full source code. The core belongs in transform.
Edge Cases And Pitfalls
Production implementations usually need ASTs, scope analysis, and MagicString. ASTs answer "is this identifier really a free variable?", scope analysis answers "was it already declared in this file?", and MagicString keeps sourcemaps usable after import insertion.
Self-check
- Why does
unplugin-auto-importchoose to usetransformrather than theloadhook to inject imports? - What are the problems with scanning identifiers via regex? Why do production plugins use an AST?
- If a project manually imports
ref, will the plugin inject it again? How is this prevented? - Why is
.d.tsdeclaration file generation placed inbuildEndrather thangenerateBundle?
// Implement a simplified auto-import plugin:
// Support the { "vue": ["ref", "computed", "watch"] } config format
// No AST needed — regex is fine
import type { Plugin } from "vite"
interface AutoImportConfig {
imports: Record<string, string[]>
}
export function simpleAutoImport(config: AutoImportConfig): Plugin {
// Build a reverse lookup table: identifier → source
const lookup = new Map<string, string>()
for (const [source, identifiers] of Object.entries(config.imports)) {
for (const id of identifiers) {
lookup.set(id, source)
}
}
return {
name: "simple-auto-import",
transform(code, id) {
if (!id.endsWith(".ts") && !id.endsWith(".tsx")) return null
// TODO:
// 1. Check existing imports (avoid duplicates)
// 2. Scan the code for identifiers that appear
// 3. Find the imports that need to be injected
// 4. Inject import statements at the top of the code
return null
},
}
}