vite-plugin-uni-pages-json 源码解读
一、适用场景
vite-plugin-uni-pages-json 是一个为 uni-app 项目量身打造的 Vite 插件,它会在编译时把 src/pages.json 解析为一个名为 virtual:uni-pages-json 的虚拟模块,业务侧可以直接 import { pages, tabbar, globalStyle } from 'virtual:uni-pages-json' 拿到结构化数据。需要满足以下所有前提:
| 前提 | 说明 |
|---|---|
| 构建工具 | Vite ≥ 5(依赖 resolveId / load / handleHotUpdate 钩子) |
| 框架 | uni-app(基于 @dcloudio/vite-plugin-uni) |
| pages.json 路径 | 默认 src/pages.json(相对 config.root) |
| 业务代码 | 需要通过 virtual: 前缀导入(TypeScript 需配 shim) |
它不适合:
- 非 Vite 工程(webpack 项目需另写 loader / 自定义 resolver)
- 没有
pages.json配置约定的项目(如纯 Vue/React 工程) - 需要运行时动态改 pages.json 的场景(虚拟模块只在编译时存在)
本插件与姊妹篇 vite-plugin-uni-root-frame 功能正交:root-frame 负责"按 pages.json 写入布局组件",pages-json 负责"把 pages.json 读取为 ESM 模块"。
二、背景
uni-app 业务层经常需要在运行时拿到 pages.json 中的数据:导航栏标题、TabBar 列表、globalStyle 默认值。原生限制是:
| 限制点 | 说明 |
|---|---|
| 运行时无读取 API | pages.json 是编译时配置,运行时 uni-app 不暴露 |
| 类型支持缺失 | 直接解析后无 TypeScript 类型提示 |
| 热更新困难 | 修改后无统一刷新机制 |
| 配置分散 | 页面样式需在每个页面 style 中重复声明 |
Vite 提供的虚拟模块能力恰好解决这一矛盾:把 pages.json 在编译时"物化"成一个真实可 import 的 ESM 模块,业务代码以透明导入的方式拿到结构化数据。
虚拟模块原理、虚拟 ID 与
\0前缀的细节,系列第二章 Vite 虚拟模块 已有完整推导,本篇不再重复。
三、与 root-frame 的"读 / 写"分工
两个插件同源(同仓库 vite-plugins/ 目录下),但语义完全不同。把它们放在一起看:
| 维度 | vite-plugin-uni-pages-json | vite-plugin-uni-root-frame |
|---|---|---|
| 方向 | 读(pages.json → ESM 模块) | 写(按 pages.json → 包裹 .vue) |
| 核心钩子 | resolveId + load + handleHotUpdate | configResolved + transform + handleHotUpdate |
| 产物 | 虚拟模块(消费者是业务代码) | 转换后的 .vue 代码(消费者是 Vite) |
| 触发时机 | load 时按需解析 | 每个 .vue 文件 transform 时调用 |
| 典型配置位置 | vite.config.js 顶部 | 同上,紧随其后 |
| 降级行为 | try/catch 返回空模块 | try/catch 仅 console.error,wrapPages 为空 |
vite.config.js 中两者通常顺序注册:
import uniPagesJson from './vite-plugins/vite-plugin-uni-pages-json.js'
import uniRootFrame from './vite-plugins/vite-plugin-uni-root-frame.js'
export default defineConfig({
plugins: [
uniPagesJson(), // 1. 先把 pages.json 物化为模块
uniRootFrame({ componentName: 'AppLayout' }), // 2. 再按 pages.json 注入根框
uni(), // 3. 最后走 uni 官方插件
],
})为什么要 pages-json 在前? pages-json 不依赖任何其他插件,但 root-frame 的 configResolved 会立即读取 pages.json;如果在 pages-json 还没准备好(极端情况:多实例冲突)就被调用,wrapPages 会回退为空。
四、错误韧性
这是这个插件最值得作为开源范例的地方:用最朴素的 try/catch 提供降级,而不是抛错中断构建。
4.1 load 钩子的双轨返回值
load(id) {
if (id === resolvedVirtualModuleId) {
try {
const content = fs.readFileSync(pagesJsonPath, 'utf-8');
const jsonString = content
.replace(/\/\/.*$/gm, '')
.replace(/\/\*[\s\S]*?\*\//g, '');
const config = JSON.parse(jsonString);
return `
export const pages = ${JSON.stringify(config.pages || [])};
export const tabbar = ${JSON.stringify(config.tabBar || {})};
export const globalStyle = ${JSON.stringify(config.globalStyle || {})};
export default { pages, tabbar, globalStyle };
`;
} catch (e) {
console.error('[vite-plugin-uni-pages-json] Failed to parse pages.json', e);
return `
export const pages = [];
export const tabbar = {};
export const globalStyle = {};
export default { pages: [], tabbar: {}, globalStyle: {} };
`;
}
}
}两个分支返回结构相同的 ESM 模块,唯一的差别是数据多少。这是有意为之:
| 失败原因 | 行为 |
|---|---|
pages.json 不存在 | readFileSync 抛 ENOENT → catch 兜底返回空模块 |
| 非法 JSON(注释未去除干净) | JSON.parse 抛 SyntaxError → catch 兜底 |
config.pages 缺失 | ` |
config.tabBar 拼写错误(如 tabbar) | 静默回退到空对象,业务侧用 .list 时返回 undefined |
为什么不直接抛错? 业务侧的 import { pages } from 'virtual:uni-pages-json' 是静态依赖——一旦抛错,构建失败,业务代码不可调试。降级到空模块至少能让 dev server 起来,开发者能从控制台报错定位问题,而不是面对一个红屏。
开源建议:作为通用插件范式,错误降级应满足"形态一致、数据有损、构建不挂"三条原则。本插件是教科书级别的实现。
4.2 注释去除的鲁棒性
const jsonString = content
.replace(/\/\/.*$/gm, '') // 行注释
.replace(/\/\*[\s\S]*?\*\//g, ''); // 块注释| 写法 | 解释 |
|---|---|
/\/\/.*$/gm | // 后到行尾的所有字符,m 让 $ 匹配每行行尾 |
/\/\*[\s\S]*?\*\//g | /* ... */ 块注释,[\s\S]*? 非贪婪跨行匹配 |
pages.json 本身不支持注释,但 uni-app 生态里大量页面用 "// xxx": true 这种伪注释。两次正则去注释后再 JSON.parse,即可兼容真实仓库用法(参考 src/pages.json 的 globalStyle.layout 里就有 "// showNav": true)。
鲁棒性边界:如果注释出现在字符串字面量内(如
"// not comment"),本正则会误伤。这是已知简化,业务侧约定不在字符串里写//。
五、虚拟模块在 moduleGraph 中的角色
虚拟模块是 Vite 内部的一等公民,与物理文件走同一条 moduleGraph 流水线。本插件用到的虚拟模块相关字段:
| 字段 | 值 | 作用 |
|---|---|---|
virtualModuleId | 'virtual:uni-pages-json' | 用户 import 时使用的 ID |
resolvedVirtualModuleId | '\0' + virtualModuleId | Vite 内部标识,加 \0 防止与真实文件冲突 |
5.1 三段生命周期
关键点:
\0前缀不会出现在用户代码中,仅作为 moduleGraph 的内部缓存键。load只执行一次——模块内容稳定后 Vite 会缓存。下次再import,直接走 moduleGraph。- 依赖反向追踪:当 A 模块
import了虚拟模块,A 会被记录为该虚拟模块的"依赖者",虚拟模块 invalidate 时 A 也会被联动 invalidate。
5.2 handleHotUpdate 的失效策略
handleHotUpdate({ file, server }) {
if (normalize(file) === normalize(pagesJsonPath)) {
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId);
if (mod) {
server.moduleGraph.invalidateModule(mod);
server.config.logger.info(
`\x1b[32m[vite-plugin-uni-pages-json]\x1b[0m pages.json updated, reloading...`,
{ timestamp: true }
);
return [mod];
}
}
}三个关键动作:
| 动作 | 作用 |
|---|---|
getModuleById('\0virtual:uni-pages-json') | 拿到 moduleGraph 中的虚拟模块实例 |
invalidateModule(mod) | 标记失效,下次访问时重新 load |
return [mod] | 告诉 Vite 这些模块需要立即重编译 |
为什么是 full-reload 而不是精细 HMR? 系列第二章已经讲过:pages.json 变更属于"全局配置变更",可能影响页面标题、TabBar 列表等跨模块状态。本插件早期版本里其实有:
// server.ws.send({ type: 'full-reload', path: '*' });这一行被注释掉了。现在仅依赖 invalidateModule + moduleGraph 反向追踪:当虚拟模块失效后,所有 import 它的业务模块都会重新求值,相当于"业务侧自动重渲染"。
这是更优雅的策略:不再显式发全量刷新信号,而是让 moduleGraph 自然驱动重渲染。前提是业务模块没有持久化不可重算的状态(如全局计数器)。
六、TypeScript shim 协作
直接 import from 'virtual:uni-pages-json' 在 TypeScript 项目里会报错:"找不到模块"。需要在 src/shims-uni.d.ts(或任意 .d.ts 文件)中声明模块:
declare module 'virtual:uni-pages-json' {
export const pages: Array<{
path: string
style?: Record<string, any>
}>
export const tabbar: {
list?: Array<{
pagePath: string
text?: string
iconPath?: string
selectedIconPath?: string
}>
}
export const globalStyle: Record<string, any>
const _default: {
pages: typeof pages
tabbar: typeof tabbar
globalStyle: typeof globalStyle
}
export default _default
}这样 IDE 与 tsc 都能识别虚拟模块,编辑器智能提示生效。
本插件本身不强制类型(虚拟模块的
load返回的就是any),类型由 shim 层负责定义。这是一个清晰的边界划分:插件只负责产数据,类型是消费方的责任。
七、可拓展点
这是本插件最容易被低估的一面:作为一个仅 ~70 行的微插件,它的"形态一致性"使得扩展成本极低。常见扩展场景:
7.1 暴露 easycom
return `
export const pages = ${JSON.stringify(config.pages || [])};
export const tabbar = ${JSON.stringify(config.tabBar || {}))};
export const globalStyle = ${JSON.stringify(config.globalStyle || {})};
export const easycom = ${JSON.stringify(config.easycom || {})};
export default { pages, tabbar, globalStyle, easycom };
`;业务侧即可:
import { easycom } from 'virtual:uni-pages-json'
// 自定义 easycom 校验、生成 .d.ts、构建时扫描7.2 支持 subPackages(分包)
const subPackages = (config.subPackages || []).map(sp => ({
root: sp.root,
pages: sp.pages || [],
}))
// 同样通过 JSON.stringify 导出7.3 平台分支裁剪
const platform = process.env.UNI_PLATFORM || 'h5'
const tabbarList = (config.tabBar?.list || []).filter(item => {
if (!item[platform]) return true
return item[platform].visible !== false
})扩展原则:保持 ESM 模块的"导出字段稳定、形状可预测"。一旦在某个版本删字段,所有消费方都会编译失败。
八、使用示例
8.1 vite.config.js
import { defineConfig } from 'vite'
import uniPlugin from '@dcloudio/vite-plugin-uni'
import uniPagesJson from './vite-plugins/vite-plugin-uni-pages-json.js'
import uniRootFrame from './vite-plugins/vite-plugin-uni-root-frame.js'
const uni = uniPlugin.default || uniPlugin
export default defineConfig({
plugins: [
uniPagesJson(),
uniRootFrame({ componentName: 'AppLayout' }),
uni(),
],
})8.2 业务侧导入
<script setup>
import { pages, tabbar, globalStyle } from 'virtual:uni-pages-json'
const activePagePath = ref('')
// 用 pages 判断当前路径是否在已注册页面里
const isKnownPage = computed(() =>
pages.some(p => p.path === activePagePath.value)
)
</script>8.3 排错清单
| 现象 | 排查方向 |
|---|---|
| import 报"找不到模块" | 检查 shims-uni.d.ts 是否声明了 virtual:uni-pages-json |
控制台报 Failed to parse pages.json | 检查 pages.json 是否合法 JSON(插件已支持 // 注释) |
| 改了 pages.json 没生效 | 确认 dev server 是否在运行;尝试手动刷新(早期版本需要) |
| tabbar.list 是 undefined | pages.json 中字段是 tabBar(大写 B),不是 tabbar |
九、边界与不适用场景
| 边界 | 说明 |
|---|---|
字符串内的 // | 正则会误伤,约定业务侧不写 |
自定义 pages.json 路径 | 当前硬编码 src/pages.json,如需自定义需改源码 |
| 运行时改 pages.json | 不支持,虚拟模块是编译时产物 |
| TypeScript 类型 | 由 shim 层声明,插件本身无强类型 |
| SSR 模式 | 行为未验证(与 @dcloudio/vite-plugin-uni 的 SSR 模式需额外测试) |
十、小结
vite-plugin-uni-pages-json 是一个仅 ~70 行的微插件,但完整覆盖了 Vite 插件的核心能力:虚拟模块、热更新、错误降级。它的设计哲学可以总结为三点:
| 原则 | 实现 |
|---|---|
| 失败形态一致 | try/catch 返回结构相同的空模块 |
| 数据集中、形态稳定 | ESM 命名导出 + 默认导出双重暴露 |
| 副作用最小 | 无 transform、无运行时注入,仅在 load 与 handleHotUpdate 两点活动 |
作为姊妹篇,vite-plugin-uni-root-frame 负责"按 pages.json 写",本插件负责"把 pages.json 读为 ESM"。两者协同,可让一个 uni-app 项目的所有 pages.json 配置同时具备"编译时驱动"与"运行时可读"两种能力。
虚拟模块原理与 \0 前缀机制详见系列第二章 Vite 虚拟模块。
