vite-mastery

13.4 · difficulty 4/4 · 20 min read

Project 10: Build a Simplified Auto-Import Plugin

Apply everything you've learned about the plugin system — implement a real, working auto-import plugin that covers all the major hooks: transform / resolveId / load / generateBundle / configureServer.

Vite 8.1Stable

Feature Goal

ts
// vite.config.ts
autoImport({
  imports: {
    vue: ["ref", "computed", "reactive", "watch"],
    "vue-router": ["useRouter", "useRoute"],
  },
  dts: "auto-imports.d.ts",
})

// Application code: no imports needed
const count = ref(0)
const router = useRouter()

Full Implementation

Core Data Structure

ts
import type { Plugin } from "vite"
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { resolve } from "node:path"

interface AutoImportOptions {
  imports: Record<string, string[]>
  dts?: string | false
}

// Build a reverse lookup table: identifier → source
function buildLookup(imports: Record<string, string[]>): Map<string, string> {
  const lookup = new Map<string, string>()
  for (const [source, identifiers] of Object.entries(imports)) {
    for (const id of identifiers) {
      lookup.set(id, source)
    }
  }
  return lookup
}

transform hook: Core Injection Logic

ts
// Detect existing imports to avoid duplicate injection
function parseExistingImports(code: string): Set<string> {
  const existing = new Set<string>()
  const importRe = /import\s*\{([^}]+)\}\s*from\s*["'][^"']+["']/g
  let match: RegExpExecArray | null
  while ((match = importRe.exec(code)) !== null) {
    for (const name of match[1].split(",")) {
      existing.add(name.trim().split(" as ")[0].trim())
    }
  }
  return existing
}

function transform(code: string, id: string, lookup: Map<string, string>): { code: string; map: null } | null {
  if (!id.match(/\.(ts|tsx|js|jsx|vue)$/)) return null
  if (id.includes("node_modules")) return null
  if (!code) return null

  const existing = parseExistingImports(code)
  const toInject = new Map<string, Set<string>>()

  // Scan the code for identifiers
  // Note: this is a simplified regex version; production should use an AST
  const identifierRe = /(?<![.'"\\w])\\b([a-zA-Z_$][a-zA-Z0-9_$]*)\\b/g
  let match: RegExpExecArray | null
  while ((match = identifierRe.exec(code)) !== null) {
    const identifier = match[1]
    if (existing.has(identifier)) continue // already manually imported
    const source = lookup.get(identifier)
    if (!source) continue

    const set = toInject.get(source) ?? new Set()
    set.add(identifier)
    toInject.set(source, set)
  }

  if (toInject.size === 0) return null

  const importLines = [...toInject.entries()]
    .map(([source, names]) => `import { ${[...names].join(", ")} } from "${source}"`)
    .join("\n")

  return { code: `${importLines}\n${code}`, map: null }
}

generateBundle hook: Generate .d.ts

ts
function generateDts(lookup: Map<string, string>, dtsPath: string): void {
  const lines = [
    "// Auto-generated by vite-plugin-auto-import, do not edit manually",
    "// eslint-disable-next-line",
    "export {}",
    "",
    "declare global {",
  ]

  for (const [identifier, source] of lookup) {
    lines.push(`  const ${identifier}: typeof import("${source}")["${identifier}"]`)
  }

  lines.push("}")
  writeFileSync(resolve(dtsPath), lines.join("\n"))
}

Complete Plugin

ts
export function autoImport(options: AutoImportOptions): Plugin {
  const { dts = "auto-imports.d.ts" } = options
  const lookup = buildLookup(options.imports)

  return {
    name: "vite-plugin-auto-import",

    transform(code, id) {
      return transform(code, id, lookup)
    },

    buildEnd() {
      if (!dts) return
      generateDts(lookup, dts as string)
    },
  }
}

Running the Project

bash
cd examples/plugin-auto-import
pnpm install
pnpm dev

Use Vue APIs like ref and computed directly in src/main.ts without any imports.

Check the generated auto-imports.d.ts to confirm that the type declarations are correct.

Advanced: Incremental Processing

For large projects, scanning all APIs on every file is too slow. An incremental strategy:

ts
// Track which APIs each file actually uses
const usedMap = new Map<string, Set<string>>()

transform(code, id) {
  const result = transformWithTracking(code, id, lookup, usedMap)
  return result
},

buildEnd() {
  if (!dts) return
  // Only generate declarations for APIs that are actually used
  const usedIdentifiers = new Map<string, string>()
  for (const identifiers of usedMap.values()) {
    for (const id of identifiers) {
      usedIdentifiers.set(id, lookup.get(id)!)
    }
  }
  generateDts(usedIdentifiers, dts as string)
},

Self-check

  1. Why inject imports in transform rather than load?
  2. Why does parseExistingImports check for existing imports? What goes wrong if you skip this check?
  3. What is the difference between generating .d.ts in buildEnd versus generateBundle?
  4. Does this implementation work with Vue SFCs (<script setup>)? Why or why not?
ts
// Add the following features to this auto-import plugin:
// 1. Support custom preset functions:
//    imports: { from: "my-utils", as: { myFn: "utilFn" } }
//    Effect: inject myFn as an alias for utilFn

// 2. Support glob-based ignore rules:
//    ignore: ["**/node_modules/**", "**/*.spec.ts"]

// 3. Add a dev-mode-only configureServer hook so you can visit
//    http://localhost:5173/__auto-import to see all detected API usage

export function enhancedAutoImport(options: {
  imports: Record<string, string[]>
  aliases?: Record<string, string>
  ignore?: string[]
  dts?: string
}): Plugin {
  // TODO
}