1. 当手写import列表膨胀到失控,Vite用文件系统扫描给出了答案
先交代一下背景。我之前维护的中后台项目里有大量页面组件,分散在src/views下的几十个二级目录里。新增一个页面,要手动改路由表、组件注册表、菜单映射表,少则三四行,多则十几行。最难受的是某次重构删掉了一个模块目录,却漏掉了一处import,构建直接报模块不存在的错误。那会儿我就在想:能不能让工具自己去扫目录,扫到什么文件就自动生成对应的模块引用?如果刚接触 Vite,你可能觉得import.meta.glob只是个“批量导入”的小语法糖,实际用过之后会发现,它是 Vite 提供的文件系统查询能力,能直接在编译阶段把磁盘上的目录结构变成 JavaScript 模块映射。
const modules = import.meta.glob('./views/**/*.vue')这行代码做完的事情,等价于你手写出一份懒加载映射表:
const modules = { './views/home/index.vue': () => import('./views/home/index.vue'), './views/order/detail.vue': () => import('./views/order/detail.vue'), './views/settings/profile.vue': () => import('./views/settings/profile.vue'), }关键点在于,这份映射表不是你在运行时通过fs.readdir去读出来的,而是 Vite 在编译阶段扫描真实文件系统后静态生成的。目录里有什么文件,编译结果里就有什么 key;新增文件,重启或热更新后映射表自动变化。这背后的本质,是把“文件系统”和“模块系统”打通了。
这篇文章适合三类人:正在用 Vite 搭建 Vue3 或 React 项目、被各种手写 import 清单折磨的人;使用过import.meta.glob但只停留在“照着抄”层面、不清楚参数和边界的人;以及想更进一步,用 Vite 插件做虚拟文件系统扩展的进阶玩家。下面我会先把编译期扫描原理讲透,再给三个能直接抄进项目的实战代码,最后把最容易翻车的几个角落逐个复盘一遍。
2. 编译期文件映射:glob模式背后的完整生成链路
想用好import.meta.glob,先得明白一个反直觉的事实:它不是一个“运行时函数”,而是一个“编译期宏”。你写下的import.meta.glob('./views/**/*.vue')在最终产物里根本不会以“函数调用”的形式存在,Vite 会在预处理阶段把它替换成一张静态的映射表。为什么会这样设计?因为只有把路径提前确定下来,构建工具才能做代码分割、依赖分析和预加载优化。
2.1 glob不是正则表达式,别指望它能动态匹配
glob 有一套自己的匹配语法,常见的有:
*:匹配单个路径段中的任意字符,但不匹配/**:匹配任意层级的目录,可以跨路径段?:匹配单个字符{a,b}:匹配 a 或 b!:在数组模式中用于排除
举个例子:./src/**/*.vue可以匹配./src/components/Button.vue,也可以匹配./src/views/order/detail.vue,但./src/*.vue只能匹配./src目录下直接存在的 Vue 文件,不能往下钻目录。这个差异经常被人忽略,如果你希望匹配多层目录,**不能省。
Vite 使用的 glob 语义和 shell 里的 glob 非常接近,它不支持正则里的字符类断言、分组捕获这类高级能力。更重要的是,import.meta.glob的模式参数必须是字符串字面量,不能是变量,也不能是模板字符串拼接的结果。原因很简单:Vite 是在编译期静态读取这个参数的,它无法在构建时拿到一个运行期变量。
// 错误:构建时直接报错 const dir = './views' import.meta.glob(`${dir}/**/*.vue`) // 正确:必须写成完整的字面量 import.meta.glob('./views/**/*.vue')这一点是所有初学者最容易踩的坑,后面我会专门展开说。
2.2 Vite如何把glob模式变成真实的模块URL
实际执行流程可以拆成四步:
第一步,Vite 在转换模块时,通过 AST 扫描识别出import.meta.glob调用,读取括号里的字符串字面量和配置对象。
第二步,Vite 把 glob 模式交给底层的文件系统扫描引擎去遍历项目目录,得到一份匹配文件列表。扫描的时候默认会排除node_modules这类大型目录,避免无意义的 IO 开销。
第三步,Vite 遍历这份文件列表,按顺序生成一个对象字面量。每个 key 是文件在 glob 模式下的相对路径,value 是动态 import 的箭头函数。
第四步,Vite 把这份生成结果替换回源码,紧接着继续交给后面的模块转换流程处理。
所以你在开发环境里看到的效果是:import.meta.glob('./views/**/*.vue')被替换成了一份包含了所有匹配文件路径的对象,路径成了模块标识,而模块标识又对应到 Vite 的模块图上。新增文件后,Vite 的文件监听器会捕获到文件系统事件,重新触发 glob 扫描,模块图随之更新。
在打包环境里,Rollup 会把这份动态 import 映射表作为代码分割的入口点,按需加载的 chunk 就是靠它切出来的。这也是为什么import.meta.glob能天然支持懒加载:它生成的每个 loader 函数,本质上就是一个动态 import 调用点。
2.3 为什么默认只能传文件相对路径,而不是绝对路径
import.meta.glob的模式是相对于当前模块文件来解析的。也就是说,你在src/router/index.ts里写'./views/**/*.vue',它扫描的是src/router/views目录,而不是项目根目录下的src/views。很多新手的第一个坑就是路径层级搞错,找半天找不到文件。
如果你写的是/src/views/**/*.vue,Vite 会把它理解为相对项目根目录的路径,但官方并不推荐这样用。项目迁移、代码复用、Monorepo 子包移动时,绝对路径很容易失效。最稳妥的做法始终是写相对路径,配合../向上跳转,让模式跟着文件走。
还有一点需要留意:当 glob 模式指向项目根目录之外的路径时,比如'../../shared/**/*.js',Vite 开发服务器默认的server.fs.allow配置可能不允许访问工作区外的文件,访问时会出现 403 或文件找不到的错误。这在 Monorepo 场景里尤其常见,解决办法是手动在vite.config.ts里把对应目录加入server.fs.allow。
3. eager、query、import三个开关怎么组合,才能少走回头路
基础写法只能拿到一组懒加载函数,但实际项目里经常会遇到“我想直接拿到模块本身”“我想导入文件内容字符串”“我想拿图片 URL”这些需求。Vite 给了三个配置项来解决:eager、query、import。理解这三个开关的组合逻辑,能让你少写大量重复代码。
3.1 eager: true 是把动态加载变成静态引入
默认情况下,匹配结果的值是一个() => import(path)函数,好处是按需加载,坏处是当你同步读取文件内容时,还得先await loader()才能拿到模块。如果文件数量不多、体积不大,或者你明确知道这些模块必须在首屏同步加载,用eager: true会更合适。
const messages = import.meta.glob('./locales/*.json', { eager: true, import: 'default', })加了eager之后,返回对象的值不再是 loader 函数,而是模块对象本身。如果再配合import: 'default',值就直接变成 JSON 的默认导出对象。这个组合在聚合国际化语言包时几乎是标配写法。
有一点要注意:eager: true会破坏代码分割。如果匹配到的是一堆体积很大的组件,全部都 eager 加载,首屏 bundle 会明显变大。所以正确的姿势是:小体积配置文件、常量字典、图标清单用 eager;路由组件、复杂页面组件继续保持懒加载。
3.2 query: 给匹配结果接上Vite内置资源管线
Vite 里导入资源时,可以通过 query 参数让同一份文件走不同处理管线。import.meta.glob支持query配置,常见的有:
query: '?raw':文件内容作为字符串返回,适合读取 Markdown、SVG、SQL 等文本文件query: '?url':文件作为静态资源返回访问 URL,适合图片、字体query: '?import':让文件走正常的模块导入处理
举个例子,你有一个assets/svg目录,里面放了几十个 SVG 图标,以前可能要写几十行import icon1 from './assets/svg/icon1.svg',现在一行就能搞定:
const iconUrls = import.meta.glob('./assets/svg/*.svg', { eager: true, query: '?url', import: 'default', })拿到的是一个以文件路径为 key、URL 字符串为 value 的对象。渲染的时候按iconUrls[key]使用即可。
这里有个细节:query不会改变文件路径 key。无论你加不加?raw,对象的 key 都是相对于当前模块的路径字符串,这点在遍历时要注意。
3.3 import: 指定到底要模块的哪个导出
默认情况下,import.meta.glob的 loader 返回的是模块命名空间对象,类似于你执行import * as X。但当模块存在 default 导出或具名导出时,你往往只需要其中一个。
使用import: 'default'会让 loader 变成() => import(path).then(m => m.default),也就是直接返回默认导出。如果你需要的是一张图片 URL,配合query: '?url'再指定 default,能一步到位。
如果模块里有多个具名导出,也可以直接写名字:
const components = import.meta.glob('./widgets/*.vue', { eager: true, import: 'setup', })不过我实际项目中很少这样用,因为组件模块通常只需要 default 导出。这里更重要的规则是:import和eager经常成对出现。单独用import: 'default'而不加eager时,你会得到一个返回 default 导出的异步 loader,这和默认 loader 的区别只是返回值从命名空间变成了 default 导出。
我把三种配置的使用场景整理成了一张表,方便直接对照:
| 配置组合 | 返回结果 | 推荐场景 |
|---|---|---|
| 不配置 | () => import(path) | Vue 页面懒加载 |
| eager: true | 模块命名空间对象 | 需要同步使用所有模块 |
| eager + query: '?raw' | 文件内容字符串 | Markdown、SVG、JSON 原文 |
| eager + query: '?url' + import: 'default' | 静态资源 URL 字符串 | 图片清单、字体清单 |
| import: 'default' | () => import(path).then(m => m.default) | 只想拿默认导出 |
4. 三个能直接抄的项目场景:路由、语言包、Markdown目录
理论讲再多,不如一个能跑通的例子。我在不同项目里分别用import.meta.glob实现过路由表生成、多语言资源聚合、Markdown 文档目录构建,这三个场景覆盖了大部分日常工作需求,代码可以直接改改路径拿去用。
4.1 自动生成 Vue Router 的路由表
传统写法需要手动定义每条路由:
const routes = [ { path: '/home', component: () => import('../views/home/index.vue'), }, { path: '/order/detail', component: () => import('../views/order/detail.vue'), }, ]文件一多,这段路由表会变得又臭又长。用import.meta.glob扫描views目录后,可以按路径结构自动生成路由:
// src/router/routes.ts const viewModules = import.meta.glob('../views/**/*.vue') export const routes = Object.entries(viewModules).map(([filePath, loader]) => { const path = filePath .replace('../views', '') .replace(/\.vue$/, '') .replace(/\/index$/, '') || '/' return { path, name: path.replace(/^\//, '').replaceAll('/', '-') || 'home', component: loader, } })解释一下这段代码的几个细节。filePath的值形如../views/home/index.vue,第一步替换掉../views,得到/home/index.vue;第二步去掉.vue,得到/home/index;第三步把常见的index约定处理成目录路径,否则用户访问/home时匹配不到路由,而访问/home/index又不符合 URL 习惯。最后|| '/'处理根路径的情况。
这种做法的好处是加页面不再需要碰路由文件。新加一个views/user/list.vue,自动生成/user/list路由。需要注意的是,路由路径和文件路径强绑定之后,你要约定好:目录嵌套层级就是路由层级,index.vue表示目录默认页,尽量不要在views里放非页面组件,否则它们也会被扫进路由表。
4.2 按目录聚合多语言资源文件
中后台项目的国际化资源经常按模块拆分成多个 JSON 或 YAML 文件,比如src/locales/zh-CN/home.json、src/locales/zh-CN/order.json。如果手动引入,每新增一个模块就要在主入口文件里加一行 import,很容易漏。用import.meta.glob可以一次性聚合所有语言包:
// src/locales/index.ts import type { LocaleMessages } from 'vue-i18n' const localeModules = import.meta.glob('./**/*.json', { eager: true, import: 'default', }) as Record<string, Record<string, any>> const locales = { 'zh-CN': {}, 'en-US': {}, } Object.entries(localeModules).forEach(([filePath, moduleContent]) => { const matched = filePath.match(/\/([\w-]+)\.json$/) if (!matched) return const moduleName = matched[1] const locale = filePath.includes('zh-CN') ? 'zh-CN' : 'en-US' locales[locale][moduleName] = moduleContent }) export const messages: LocaleMessages = locales这段代码的核心思路是:通过 glob 拿到所有 JSON 文件的默认导出,再根据文件路径中的语言目录去分组。每个模块的翻译内容会被挂到对应语言的moduleName节点下。
我用这种方式管理一个 30 多个模块的项目,新增语言包文件不用改任何主入口代码。这里有个实际经验:文件命名和目录命名一定要规范,比如严格用zh-CN、en-US这种语言标签做目录名,解析时写正则才不容易出错。
4.3 把 Markdown 文件变成文档目录和内容页
要在 Vite 项目里渲染 Markdown 文档,一种比较简单的方式是把 Markdown 作为原始字符串导入,再交给前端 Markdown 解析器渲染。import.meta.glob配合query: '?raw',可以同时拿到文档列表和文档内容:
// src/docs/index.ts const docModules = import.meta.glob('../docs/**/*.md', { eager: true, query: '?raw', import: 'default', }) as Record<string, string> export interface DocMeta { path: string title: string content: string } export const docs: DocMeta[] = Object.entries(docModules).map(([filePath, content]) => { const path = filePath .replace('../docs', '') .replace(/\.md$/, '') // 简单解析 frontmatter 里的 title const titleMatch = content.match(/^title:\s*(.+)$/m) return { path: path || '/readme', title: titleMatch ? titleMatch[1].trim() : path, content, } })拿到docs数组后,文档侧边栏可以靠它生成,内容页可以按照path去查找对应的 content 再渲染。query: '?raw'的意义在于,Markdown 不是直接作为模块被 import 的,而是把文件内容原样变成字符串,这样你就可以在自己的渲染流程里处理它。
如果文档文件特别多,全部 eager 加载会让首屏拿到的内容文件过多,反而拖慢页面。这种场景下,可以去掉eager: true,改成维护一份“路径到 loader”的映射,点击某篇文档时再动态加载该文件内容。这正好用上默认返回 loader 的特性:
const docLoaders = import.meta.glob('../docs/**/*.md', { query: '?raw', import: 'default', }) // 点击文档时执行 const content = await docLoaders[`../docs${path}.md`]()5. 实际操作中容易翻车的四个角落:字面量、斜杠、缓存与边界
用熟了之后,import.meta.glob确实能省下大量重复劳动,但它毕竟是个编译期功能,不少边界情况会让第一次用的人掉坑里。我把这几年实际遇到过的坑做个复盘,每一个都附上原因和解决方案。
5.1 动态拼接模式字符串,为什么一定会失败
这是新手问得最多的一个问题:我能不能根据运行时的值去动态指定扫描目录?
// 错误示范 function loadPages(directory: string) { return import.meta.glob(`./${directory}/**/*.vue`) }答案是:不能,根本原因在于 Vite 没有运行时的引擎去执行 glob 扫描。所有 glob 匹配的结果,在构建阶段就已经被写死在产物代码里了。如果你把变量传进去,Vite 在 AST 分析时无法确定模式内容,只能直接报错:glob import is not supported或者类似提示。
可行的替代方案有三种:
第一种,把可变部分固定到一个常量目录下,然后运行时从结果里过滤:
const allPages = import.meta.glob('./pages/**/*.vue') const loadPage = (pageName: string) => { const key = `./pages/${pageName}.vue` return allPages[key] || fallback }第二种,如果你需要按模块目录加载,就在每个模块入口处各自写一个import.meta.glob,再把结果传递出去。每个文件中的模式是静态字面量,没问题。
第三种,用后续章节讲的 Vite 插件自定义虚拟模块,把“动态扫描文件系统”的逻辑搬到独立工具函数里。
实际项目里,我通常倾向于第三种,因为可扩展性和可维护性都比散落一堆 glob 调用好。
5.2 返回的路径 key 和真实文件系统路径不是一回事
import.meta.glob返回的对象 key,前缀永远是你传入的模式字符串前缀。也就是说,如果你写的是'../views/**/*.vue',key 就是'../views/home/index.vue',而不是'src/views/home/index.vue'——除了开发服务器配置之外,Vite 并不会把 key 转换成绝对路径。
这带来一个很隐蔽的坑:如果你想把这个 key 交给node:fs去读文件,直接拼绝对路径会出错。我曾经试过用path.resolve(__dirname, key)去读匹配文件,在 Windows 上得到了一堆反斜杠路径,而浏览器里的模块路径统一使用正斜杠,两边对不上,代码直接跑挂。
最稳妥的处理方式是:不要对 key 做文件系统级的拼接,而是把 key 当成模块标识符使用。如果确实需要访问文件系统的绝对路径,就在 Vite 插件里通过this.resolve拿到完整路径,再交给fs处理。
另外注意 Windows 环境下的字符大小写问题。glob 匹配是区分大小写的,但 Windows 文件系统不区分,开发环境和 Linux 服务器上可能得到不同的匹配结果。团队协作时,尽量统一文件命名规范,否则很容易出现“我本地能跑,构建机上报错”的情况。
5.3 新加的文件没进列表,是扫描缓存还是配置问题
有段时间我发现一个新加的 Vue 页面没有出现在 glob 结果里,当时第一反应是 Vite 缓存坏了。排查之后发现,问题出在模式的排除规则上。
import.meta.glob默认不会匹配node_modules目录里的文件,这是为了保证扫描性能。同时,如果你在数组模式里用了!排除规则,要确认排除模式的写法准确无误。比如:
import.meta.glob(['./**/*.vue', '!./**/components/**'])这行代码会排除所有components目录下的 Vue 文件。如果你的页面恰好放在components目录下,它当然不会出现在结果里。这不算 bug,但很容易让人误以为是 glob 失效了。
另外还有一种情况是 Vite 开发服务器的文件监听系统偶尔不会立即捕获新增目录。正常情况下 Vite 会通过文件系统事件感知新增文件,但某些编辑器保存方式、某些网络文件系统(比如挂载的远程目录)下,事件可能丢失。这时重启 dev server 基本能解决。如果你希望更可靠,可以在 Vite 配置里把依赖监控的目录显式加进去,或者在使用 glob 的模块里主动触发一次模块热更新。
5.4 全局 exclude 和 root 边界,怎么避免越权问题
import.meta.glob虽然能扫描文件系统,但它不是随便扫描的。它默认会避开node_modules,同样也受到 Vite 根目录和server.fs.allow的限制。当 glob 模式向上跳出项目根目录时,比如'../../packages/**/*.js',开发服务器可能因为文件系统访问限制而出错。
在 Monorepo 项目里这是很典型的问题。正确的处理方式有两种:一是把需要扫描的目录放入vite.config.ts的server.fs.allow数组里;二是使用 workspace 内的符号链接或相对路径让文件仍然处于项目根目录内。
如果你是在打包环境下遇到这类问题,同样需要关注 Vite 的resolve.alias配置,尽量让模式指向项目内部目录,而不是依赖绝对路径。这种约束看似麻烦,其实是 Vite 保护项目文件安全的一种机制,不要为了省事强行绕过。
6. 再往前一步:虚拟模块插件和浏览器端读文件的分界线
import.meta.glob覆盖了“编译期扫描文件系统”的大部分场景,但它不是一个可以无限扩展的工具。当遇到“动态目录清单”“跨项目文件聚合”这类诉求时,单靠它很难优雅实现。这时候,Vite 插件和虚拟模块就派上用场了。
6.1 为什么说这个API是构建时文件系统,而不是运行时文件系统
很多人会把import.meta.glob和浏览器端的“读取文件”功能搞混。浏览器出于安全考虑,不可能让 JavaScript 直接读取用户磁盘上的任意目录,所以import.meta.glob能扫描的,是构建工具所在机器上的项目文件,而且只在编译期生效。
如果你做的是一个纯前端应用,需求是让用户自己选择文件夹并读取里面的文件,那么正确方案是<input type="file" webkitdirectory>,或者使用 File System Access API 的showDirectoryPicker。这些是浏览器运行时的能力,和 Vite 的 glob 毫无关系。把这两个概念分清楚,能避免不少架构设计上的误解。
6.2 用虚拟模块把“动态文件列表”变成可导入的模块
如果你的需求是“运行时扫描磁盘并生成一个文件清单”,前端代码自己做不到,但可以借助 Vite 插件在构建时把这份清单注入成虚拟模块。
下面是一个最简单的例子,它会在virtual:file-list这个模块里导出一份src/assets目录的文件列表:
// vite.config.ts import { defineConfig, type Plugin } from 'vite' import { fsp, readdir } from 'node:fs/promises' import { resolve } from 'node:path' function fileListVirtualPlugin(): Plugin { const virtualModuleId = 'virtual:file-list' const resolvedVirtualModuleId = '\0' + virtualModuleId return { name: 'file-list-virtual-module', resolveId(id) { if (id === virtualModuleId) { return resolvedVirtualModuleId } }, async load(id) { if (id === resolvedVirtualModuleId) { const assetDir = resolve(__dirname, 'src/assets') const files = await readdir(assetDir) // 在这里监听目录变化,修改文件后自动走 HMR this.addWatchFile(assetDir) return `export const fileList = ${JSON.stringify(files)}` } }, } } export default defineConfig({ plugins: [fileListVirtualPlugin()], })之后你在业务代码里就可以直接导入这个虚拟模块:
import { fileList } from 'virtual:file-list' console.log(fileList)这段代码的意义在于,它把“文件系统读取”的能力封装成了一个标准模块,业务代码感知不到底层扫描逻辑。和import.meta.glob相比,虚拟模块的优势是逻辑更自由,可以接入任意 Node.js API;劣势是你需要自己处理文件监听、缓存、错误边界等问题,复杂度明显更高。
所以我的建议是:能用import.meta.glob解决的,优先用它;当它确实满足不了需求时,再考虑写虚拟模块。工具没有高低之分,只有合适不合适。
6.3 在真实项目中,我是怎么选型的
最后分享一下我在实际项目里的判断标准,供你参考。
如果只是要“把一批文件变成模块”,比如路由组件、语言包、Markdown 文档,我直接用import.meta.glob,因为它零配置、类型推导也好。如果这个文件清单会被插件二次处理,或者需要从多个来源聚合、过滤、排序,我会写一个独立工具函数,把import.meta.glob的结果包一层。如果需求是“动态查询目录并生成文件列表”,比如后台管理系统的资源管理器页面,那import.meta.glob就不合适了,要么走服务端接口,要么用虚拟模块在构建期生成快照。
使用import.meta.glob这几年,我最大的体会是:它把“文件和代码之间的映射关系”从人工维护变成了自动生成,但前提是你对文件结构有清晰的约定。目录命名、文件摆放、模式匹配规则,这些看似琐碎的事情,决定了这个工具能帮你省多少事。约定越规范,工具发挥的作用越大。反过来,如果目录结构混乱,任何自动化工具都救不了你。