← 返回文章列表

unplugin-auto-import 的实现原理

前端
  • # javascript
  • # typescript
  • # vite
  • # unplugin
!

这篇文章还在编辑中,内容可能会继续补充、调整或重写。

1很多人第一次用 unplugin-auto-import 时,直觉上会把它理解成「把 VueReact 或工具函数挂到全局」。比如配置了:

AutoImport({
  imports: ['vue'],
})

然后代码里就可以直接写:

const count = ref(0)
const doubled = computed(() => count.value * 2)

看起来像 refcomputed 变成了全局变量。但这不是它真正做的事情。

unplugin-auto-import 的核心模型是:在构建阶段扫描源码里使用到的标识符,然后把对应的 import 语句注入到文件中。最终交给 ViteRollupWebpackesbuild 的代码,仍然是标准 ESM 或 CommonJS。它不污染运行时全局对象,也不改变 JavaScript 的作用域规则。

这篇文章基于 unplugin/unplugin-auto-import 当前 main 分支源码整理,重点看它如何把「不用手写 import」变成一次可靠的源码转换。


先看整体分工

unplugin-auto-import 这个包本身其实很薄。它的工作可以拆成三层:

构建工具适配层:unplugin

auto-import 插件胶水层:unplugin-auto-import

导入分析与注入引擎:unimport

unplugin:一次编写,多端运行

项目使用 createUnplugin 定义插件。这样同一份插件逻辑可以导出给不同构建工具使用:

import AutoImport from 'unplugin-auto-import/vite'
import AutoImport from 'unplugin-auto-import/webpack'
import AutoImport from 'unplugin-auto-import/rollup'

这些入口不是多套实现,而是同一个核心插件通过 unplugin 适配到不同宿主。package.jsonexports 里也能看到它暴露了 ./vite./webpack./rollup./esbuild./astro 等入口。

这解决的是「插件如何接入构建工具」的问题。

unimport:真正的导入引擎

v0.8.0 开始,unplugin-auto-import 底层使用 unimport。官方 README 也明确说,unimport 是更底层的工具,Nuxt 的 auto import 也使用它;unplugin-auto-import 更像是一个面向构建工具和用户配置的包装层。

这意味着核心能力不在 unplugin-auto-import 里重复实现,而是交给 unimport

  • 维护可自动导入的 import map。
  • 扫描目录导出的 API。
  • 在源码里检测可注入的标识符。
  • 生成实际的 import 语句。
  • 生成 TypeScript declaration 文件。

unplugin-auto-import 做的是把用户配置、构建工具生命周期、resolver、.d.tsESLint/Biome 配置这些东西粘起来。


插件生命周期:什么时候做什么

核心插件定义在 src/core/unplugin.ts。它大致注册了几个关键 hook:

createContext(options)

buildStart      → 扫描 dirs 中的导出
transform       → 对匹配的源码注入 import
buildEnd        → 写入 auto-imports.d.ts / lint 配置等文件
Vite HMR        → 目录内文件变化时重新扫描
Vite config     → 可选地补充 optimizeDeps.include

transformIncludetransform.filter.id 用来限制处理范围。默认会处理常见的源码文件,例如 .js.ts.jsx.tsx.vue.astro.svelte,并排除 node_modules.git。这些扩展名覆盖了 VueAstroSvelte 等常见文件形态。

这一步很重要。auto import 必须在很多文件上运行,如果每个依赖和产物都扫一遍,构建性能会很难看。所以它一开始就用 include/exclude 把范围收窄。


createContext:把配置变成一个可运行上下文

真正的核心在 src/core/ctx.tscreateContext

它做的第一件事是整理用户配置:

  • imports:预设或自定义导入表。
  • dirs:从本地目录扫描导出。
  • resolvers:按标识符动态解析 import 来源。
  • dts:是否生成 auto-imports.d.ts
  • eslintrc / biomelintrc:是否生成 lint 工具能识别的全局配置。
  • vueTemplate / vueDirectives:是否处理 Vue template 里的使用。
  • include / exclude:哪些文件需要 transform。

然后它创建一个 unimport 实例:

const unimport = createUnimport({
  imports: [],
  presets: options.packagePresets,
  dirs,
  injectAtEnd,
  parser: options.parser,
  addons: {
    addons: [
      resolversAddon(resolvers),
      declarationAddon,
    ],
    vueDirectives,
    vueTemplate,
  },
})

这里有个细节:createUnimport 一开始传入的 imports 是空数组。随后 createContext 会异步执行 flattenImports(options.imports),把用户写的各种配置形式统一成 unimport 能理解的 Import[],再通过 replaceImports 放进内部上下文。

例如下面这几类配置最终都会变成统一结构。示例里用到的第三方库包括 VueUseaxiosVue Router

AutoImport({
  imports: [
    'vue',
    {
      '@vueuse/core': ['useMouse', ['useFetch', 'useMyFetch']],
      axios: [['default', 'axios']],
    },
    {
      from: 'vue-router',
      imports: ['RouteLocationRaw'],
      type: true,
    },
  ],
})

可以把 flattenImports 理解成「把人类友好的配置语法,编译成机器好处理的 import map」。


transform:从标识符到 import 语句

一次源码转换的入口很短:

async function transform(code: string, id: string) {
  await importsPromise
  const s = new MagicString(code)
  await unimport.injectImports(s, id)

  if (!s.hasChanged()) {
    return writeConfigFilesThrottled()
  }

  return {
    code: s.toString(),
    map: s.generateMap({ source: id, includeContent: true, hires: true }),
  }
}

这个流程里有几个关键点。

1. 等待 import map 初始化

importsPromise 必须先完成。否则源码里出现 ref 时,插件还不知道 ref 应该来自 vue,自然也就无法注入。

目录扫描产生的导入属于另一类动态 import map。buildStart 会调用 scanDirs(),把 dirs 下扫描到的导出放进 unimport 的 dynamic imports 里。

所以 import map 来源其实有三种:

来源例子进入方式
预设'vue''react'flattenImports
手写配置{ '@vueuse/core': ['useMouse'] }flattenImports
目录扫描dirs: ['./composables']scanDirs()

resolver 可以看作第四种扩展能力:当遇到某个标识符时,让用户提供函数决定它应该从哪里导入。

2. 用 MagicString 修改源码

插件没有直接拼接字符串,而是使用 MagicString。原因很实际:构建工具需要 source map。直接改字符串虽然能得到新代码,但很难可靠地告诉浏览器、调试器和报错栈「这段新代码对应原始文件的哪里」。

MagicString 允许插件在源码前面、现有 import 后面,或者其他位置插入代码,同时生成 sourcemap。

3. injectImports 做真正的检测和注入

unimport.injectImports 内部大致会做这些事:

  1. 读取当前 import map。
  2. 分析源码里出现的标识符。
  3. 排除已经声明、已经导入、处于注释或字符串里的内容。
  4. 找到命中的 import 项。
  5. 按模块来源合并生成 import 语句。
  6. 把 import 插入源码。

例如源代码:

const count = ref(0)
const doubled = computed(() => count.value * 2)

配置里注册了 Vue 预设后,可以转换成:

import { computed, ref } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

如果是默认导入、命名别名、namespace import,unimport 也会按不同形式生成不同语句:

import axios from 'axios'
import { useFetch as useMyFetch } from '@vueuse/core'
import * as React from 'react'

这就是为什么它仍然是 tree-shakable 的:最终产物是普通 import,构建工具后续依旧可以按模块图继续分析。


注入位置:为什么有 injectAtEnd

unplugin-auto-import 默认 injectAtEnd: true,含义是把自动注入的 import 放到已有 import 之后,而不是一定插到文件最顶部。

这背后有两个考虑:

第一,文件可能有 shebang:

#!/usr/bin/env node
console.log(version)

import 不能插到 shebang 前面。

第二,放在已有 import 后面通常更符合读代码时的直觉,也能减少和用户手写 import 的顺序冲突。

底层 unimport 在插入时会寻找静态 import 的位置。如果配置要求放到末尾,就会尽量插在已有 import 区域之后;否则会放到文件开头附近。


类型声明:让 TypeScript 相信这些变量存在

构建阶段注入 import 只能解决运行时代码问题,不能自动解决编辑器和 TypeScript 的问题。

在源码被 transform 之前,TypeScript 看到的是:

const count = ref(0)

如果没有额外信息,ref 就是一个未定义变量。所以插件默认在本地安装了 TypeScript 时生成 auto-imports.d.ts

生成的声明大致是:

export {}

declare global {
  const ref: typeof import('vue').ref
  const computed: typeof import('vue').computed
}

这份文件不是给浏览器执行的,而是给 TypeScript 语言服务看的。它告诉编辑器:这些名字在类型层面可以被当作全局常量理解,它们的类型来自对应模块。

这也解释了两个常见问题:

  • 如果 auto-imports.d.tstsconfig.json 排除了,编辑器可能继续报 Cannot find name
  • 如果你关掉 dts,构建可能仍然成功,但类型提示和跳转会变差。

dtsMode: 'append' 还会尽量保留已有声明,再把当前生成的声明合并进去。源码里通过解析原有 declare global 块,把旧声明和新声明合并后排序写回。


ESLint 和 Biome:为什么还要生成 lint 配置

TypeScript 能理解 auto-imports.d.ts,但 ESLintno-undef 这类规则不一定会读 TypeScript 类型系统。

所以插件提供了:

AutoImport({
  eslintrc: {
    enabled: true,
  },
})

这会生成类似 .eslintrc-auto-import.json 的文件,把自动导入的标识符声明成 lint 层面的全局变量。Biome 也有对应的 biomelintrc 配置生成能力。

不过官方 README 也提醒:如果使用 TypeScript,通常更推荐直接关闭 ESLintno-undef,因为 TypeScript 已经会检查未定义标识符。


目录扫描:从 composables 自动导入

除了包预设,dirs 是另一个常用能力:

AutoImport({
  dirs: ['./src/composables'],
})

buildStart 时,插件会调用 scanDirs()。这个函数委托 unimport.scanImportsFromDir() 扫描目录导出,并把结果标记为来自目录扫描。

如果目录下有:

// src/composables/useCounter.ts
export function useCounter() {}

之后代码里直接写:

const counter = useCounter()

插件就能注入:

import { useCounter } from './src/composables/useCounter'

Vite 开发服务器里,插件还会处理热更新:如果 dirs 匹配的文件发生变化,且变化的不是插件自己生成的配置文件,就重新扫描目录。这样新增一个 composable 后,不需要重启 dev server 才能被识别。


Vite optimizeDeps:顺手补上预构建

Vite 有依赖预构建机制。unplugin-auto-import 在 Vite 的 config hook 里会读取当前 import map,把来自包名的导入加入 optimizeDeps.include

它会过滤掉几类东西:

  • 相对路径或绝对路径。
  • 用户已经放进 optimizeDeps.exclude 的包。
  • 本地不存在的包。

这不是 auto import 的核心能力,但能减少开发服务器首次遇到这些自动导入依赖时的预构建抖动。


它解决了什么,又没有解决什么

理解实现后,对这个工具的边界会清楚很多。

它解决的是「重复 import 样板代码」:

  • VueReactVueUse 这类稳定 API 可以少写很多 import。
  • 项目里的 composables、hooks、utils 可以按目录约定自动发现。
  • TypeScriptESLintBiome 可以通过生成文件补齐认知。

但它没有让变量真的变成运行时全局变量。最终代码仍然依赖构建步骤。如果某个文件没有经过插件 transform,就不会凭空获得导入。

它也不是语言服务器的自动导入。语言服务器通常是在你写代码时补一行 import;unplugin-auto-import 是在构建时改写模块。两者看起来都叫 auto import,但发生时机和心智模型完全不同。

还有一个取舍:隐式导入会降低局部可读性。打开一个文件时,你看不到 refuseMouseuseCounter 来自哪里,需要依赖配置、生成的 .d.ts 或编辑器跳转。因此它更适合导入「稳定、基础、团队共识强」的 API,不太适合把所有业务函数都藏起来。


把流程串起来

最后用一次完整构建串一下:

1. 用户配置 AutoImport({ imports, dirs, resolvers, dts })
2. createContext 规范化配置,创建 unimport 实例
3. flattenImports 把预设和手写配置变成 Import[]
4. buildStart 扫描 dirs,把目录导出加入 dynamic imports
5. transform 处理匹配文件
6. unimport 检测源码中的标识符并注入 import
7. MagicString 返回新代码和 sourcemap
8. buildEnd / throttle 写入 auto-imports.d.ts、lint 配置、dump 文件
9. Vite 开发时,目录变化触发重新扫描

所以 unplugin-auto-import 的设计并不神秘:它用 unplugin 解决跨构建工具接入,用 unimport 解决导入分析和代码注入,自己负责把配置、生命周期和工具链副产物组织起来。

真正值得记住的只有一句话:auto import 不是运行时魔法,而是构建期把隐式依赖还原成显式 import 的源码转换。

参考