vite-mastery

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?

Vite 8.1Stable

Feature Overview

unplugin-auto-import lets you write code like this:

ts
// No import needed!
const count = ref(0)
const doubled = computed(() => count.value * 2)

The plugin automatically injects at the top of the file:

ts
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:

ts
// 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.rollup

unplugin internally adapts this unified hook format to each bundler's API.

Core Logic: Static Import Detection

ts
// 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):

ts
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:

ts
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:

ts
// auto-import.d.ts can include ESLint globals

Source Reading Order

Do not start by jumping directly into the transform function. Read the source in this order:

  1. Entry exports: see how createUnplugin exports Vite, Rollup, Webpack, and other adapter versions.
  2. Option resolution: inspect how presets, dirs, imports, dts, eslintrc, include, and exclude are normalized.
  3. Scanner: learn how the plugin finds undeclared identifiers and avoids mistaking property names, local variables, or type names for global APIs.
  4. Injector: inspect how import statements are inserted in the right location while preserving shebangs, comments, and sourcemaps.
  5. Declaration generation: see how .d.ts and ESLint globals are produced from the final import map.
  6. 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:

text
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.ts

This 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

  1. Why does unplugin-auto-import choose to use transform rather than the load hook to inject imports?
  2. What are the problems with scanning identifiers via regex? Why do production plugins use an AST?
  3. If a project manually imports ref, will the plugin inject it again? How is this prevented?
  4. Why is .d.ts declaration file generation placed in buildEnd rather than generateBundle?
ts
// 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
    },
  }
}