unplugin-auto-import 的实现原理
- # javascript
- # typescript
- # vite
- # unplugin
这篇文章还在编辑中,内容可能会继续补充、调整或重写。
1很多人第一次用 unplugin-auto-import 时,直觉上会把它理解成「把 Vue、React 或工具函数挂到全局」。比如配置了:
AutoImport({
imports: ['vue'],
})
然后代码里就可以直接写:
const count = ref(0)
const doubled = computed(() => count.value * 2)
看起来像 ref 和 computed 变成了全局变量。但这不是它真正做的事情。
unplugin-auto-import 的核心模型是:在构建阶段扫描源码里使用到的标识符,然后把对应的 import 语句注入到文件中。最终交给 Vite、Rollup、Webpack 或 esbuild 的代码,仍然是标准 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.json 的 exports 里也能看到它暴露了 ./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.ts、ESLint/Biome 配置这些东西粘起来。
插件生命周期:什么时候做什么
核心插件定义在 src/core/unplugin.ts。它大致注册了几个关键 hook:
createContext(options)
↓
buildStart → 扫描 dirs 中的导出
transform → 对匹配的源码注入 import
buildEnd → 写入 auto-imports.d.ts / lint 配置等文件
Vite HMR → 目录内文件变化时重新扫描
Vite config → 可选地补充 optimizeDeps.include
transformInclude 和 transform.filter.id 用来限制处理范围。默认会处理常见的源码文件,例如 .js、.ts、.jsx、.tsx、.vue、.astro、.svelte,并排除 node_modules 和 .git。这些扩展名覆盖了 Vue、Astro 和 Svelte 等常见文件形态。
这一步很重要。auto import 必须在很多文件上运行,如果每个依赖和产物都扫一遍,构建性能会很难看。所以它一开始就用 include/exclude 把范围收窄。
createContext:把配置变成一个可运行上下文
真正的核心在 src/core/ctx.ts 的 createContext。
它做的第一件事是整理用户配置:
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 放进内部上下文。
例如下面这几类配置最终都会变成统一结构。示例里用到的第三方库包括 VueUse、axios 和 Vue 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 内部大致会做这些事:
- 读取当前 import map。
- 分析源码里出现的标识符。
- 排除已经声明、已经导入、处于注释或字符串里的内容。
- 找到命中的 import 项。
- 按模块来源合并生成 import 语句。
- 把 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.ts被tsconfig.json排除了,编辑器可能继续报Cannot find name。 - 如果你关掉
dts,构建可能仍然成功,但类型提示和跳转会变差。
dtsMode: 'append' 还会尽量保留已有声明,再把当前生成的声明合并进去。源码里通过解析原有 declare global 块,把旧声明和新声明合并后排序写回。
ESLint 和 Biome:为什么还要生成 lint 配置
TypeScript 能理解 auto-imports.d.ts,但 ESLint 的 no-undef 这类规则不一定会读 TypeScript 类型系统。
所以插件提供了:
AutoImport({
eslintrc: {
enabled: true,
},
})
这会生成类似 .eslintrc-auto-import.json 的文件,把自动导入的标识符声明成 lint 层面的全局变量。Biome 也有对应的 biomelintrc 配置生成能力。
不过官方 README 也提醒:如果使用 TypeScript,通常更推荐直接关闭 ESLint 的 no-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 样板代码」:
- Vue、React、VueUse 这类稳定 API 可以少写很多 import。
- 项目里的 composables、hooks、utils 可以按目录约定自动发现。
- TypeScript、ESLint、Biome 可以通过生成文件补齐认知。
但它没有让变量真的变成运行时全局变量。最终代码仍然依赖构建步骤。如果某个文件没有经过插件 transform,就不会凭空获得导入。
它也不是语言服务器的自动导入。语言服务器通常是在你写代码时补一行 import;unplugin-auto-import 是在构建时改写模块。两者看起来都叫 auto import,但发生时机和心智模型完全不同。
还有一个取舍:隐式导入会降低局部可读性。打开一个文件时,你看不到 ref、useMouse、useCounter 来自哪里,需要依赖配置、生成的 .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 的源码转换。