uni-app 全局容器实战系列(二):Vite 虚拟模块
系列总览
章节脉络
本系列围绕 全局容器 这一主题,从底层原理到上层应用,分为四个章节:
| 章节 | 主题 | 核心问题 |
|---|---|---|
| 第一章 | 全局容器的实现 | 如何让每个页面自动包裹布局组件? |
| 第二章 | Vite 虚拟模块 | 如何在运行时读取 pages.json 配置? |
| 第三章 | 组件设计 | 如何设计可配置的 NavBar 和 TabBar? |
| 第四章 | 动态调用设计 | 页面如何与全局容器进行通信? |
整体架构
第三章
第二章
第一章
基础设施建设
第四章
await
useAppLayout Hook
页面通信入口
registerLayout
事件注册函数
promiseWithResolvers
异步等待机制
pages.json
页面配置
vite-plugin-uni-layout
编译时注入容器
vite-plugin-uni-pages-json
虚拟模块读取配置
AppLayout.vue
布局聚合层
Layout.vue
基础容器
NavBar 组件
TabBar 组件
依赖关系:
- 第一章和第二章是基础设施,可独立理解
- 第三章依赖第一、二章
- 第四章依赖第三章
一、设计背景
1.1 问题场景
在实际 uni-app 项目中,全局导航栏(Navbar)和 TabBar 通常需要根据 pages.json 中的配置来设置样式:
- 页面标题从
navigationBarTitleText读取 - TabBar 列表从
tabBar.list读取 - 全局样式从
globalStyle读取
1.2 uni-app 平台限制
uni-app 官方对 pages.json 的访问存在诸多限制:
| 限制点 | 说明 |
|---|---|
| 运行时无法直接读取 | pages.json 是编译时配置文件,运行时 uni-app 不提供读取 API |
| 原生读取方式繁琐 | 需要使用条件编译或原生插件,侵入性强 |
| 类型支持缺失 | 直接解析后无 TypeScript 类型提示 |
| 配置分散 | 页面样式需要在每个页面的 style 中单独配置 |
| 热更新困难 | 修改 pages.json 后需要重新编译或手动刷新 |
1.3 核心矛盾
编译时读取
运行时读取
pages.json
配置文件
原生支持 ✅
业务组件
Navbar/TabBar
受限 ❌
问题 :业务组件需要在运行时读取 pages.json 中的配置,但平台不提供运行时读取能力。
二、解决方案:Vite 虚拟模块
2.1 什么是虚拟模块?
虚拟模块(Virtual Module)是 Vite 提供的一种核心特性,允许在构建时动态生成模块内容,而无需实际存在对应的物理文件。
核心原理:
虚拟模块 Vite 构建 用户代码 虚拟模块 Vite 构建 用户代码 拦截 virtual: 前缀 import { pages } from 'virtual:uni-pages-json' 请求虚拟模块 返回转换后的内容 ESM 模块
虚拟模块的标识约定
Vite 虚拟模块有两种形式:
| 形式 | 示例 | 说明 |
|---|---|---|
| 用户自定义 | virtual:uni-pages-json |
用户定义的虚拟模块 ID |
| Vite 内部 | \0virtual:uni-pages-json |
带 \0 前缀的内部表示 |
为什么需要 \0 前缀?
┌─────────────────────────────────────────────────────────┐
│ virtual:uni-pages-json │ ← 用户 import 时使用
│ ↓ │
│ \0virtual:uni-pages-json ← Vite 内部处理时添加 \0 前缀 │
│ ↓ │
│ 唯一标识符(带 null 字符前缀,防止与真实文件冲突) │
└─────────────────────────────────────────────────────────┘
\0 是 ASCII NULL 字符,文件系统路径不可能包含此字符,因此永远不会与真实文件路径冲突。
javascript
// 拦截时使用用户可见的 ID
resolveId(id) {
if (id === 'virtual:uni-pages-json') {
// 返回带 \0 前缀的内部 ID,标记为虚拟模块
return '\0' + id
}
}
// 加载时使用内部 ID
load(id) {
if (id === '\0virtual:uni-pages-json') {
// 实际返回模块内容
}
}
2.2 方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 条件编译 | 官方支持 | 需维护多份代码,无法运行时动态 |
| 原生插件 | 可读取文件 | 侵入性强,平台差异大 |
| 静态 JSON 导入 | 简单 | 无法热更新,无类型提示 |
| 虚拟模块 | 编译时读取、ESM 导出、热更新、类型友好 | 仅 Vite 项目可用 |
三、插件实现
3.1 vite-plugin-uni-pages-json
功能 :将 pages.json 转换为可导入的 ESM 模块
核心代码
javascript
// vite-plugin-uni-pages-json.js
import fs from 'fs';
import path from 'path';
export default function uniPagesJsonPlugin() {
const virtualModuleId = 'virtual:uni-pages-json';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
let pagesJsonPath = '';
return {
name: 'vite-plugin-uni-pages-json',
// 1. 获取 pages.json 路径
configResolved(config) {
pagesJsonPath = path.resolve(config.root, 'src/pages.json');
},
// 2. 拦截虚拟模块请求
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
// 3. 加载并转换模块
load(id) {
if (id === resolvedVirtualModuleId) {
try {
const content = fs.readFileSync(pagesJsonPath, 'utf-8');
// 支持注释的 JSON 解析
// 1. 移除行注释:// 开头到行尾的所有内容
// /\/\/.*$/gm
// \/\/ 转义斜杠
// .* 任意字符
// $ 行尾
// g 全局匹配
// m 多行模式(使 $ 匹配每行行尾)
//
// 2. 移除块注释:/* */ 包裹的所有内容
// /\/\*[\s\S]*?\*\//g
// \/\* 转义左斜杠+星号
// [\s\S]*? 非贪婪匹配任意字符(包括换行)
// \*\/ 转义右斜杠+星号
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: {} };
`;
}
}
},
// 4. 热更新监听
handleHotUpdate({ file, server }) {
if (normalize(file) === normalize(pagesJsonPath)) {
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId);
if (mod) {
server.moduleGraph.invalidateModule(mod);
server.ws.send({ type: 'full-reload', path: '*' });
}
}
}
};
}
三钩子执行流程详解
虚拟模块的完整生命周期涉及三个核心钩子,它们按以下顺序执行:
浏览器编译 transform load resolveId import 语句 浏览器编译 transform load resolveId import 语句 import { pages } from 'virtual:uni-pages-json' 仅检查 ID 是否存在 不关心内容 根据 ID 返回模块内容 只执行一次 浏览器解析、执行 load 只执行这一次 模块内容被缓存 'virtual:uni-pages-json' '\0virtual:uni-pages-json' '\0virtual:uni-pages-json' ESM 模块代码
各钩子职责详解:
| 钩子 | 职责 | 调用时机 | 调用次数 |
|---|---|---|---|
resolveId |
将用户 ID 转为内部 ID | 模块首次被 import 时 | 1 次/模块 |
load |
返回模块内容 | resolveId 返回后 | 1 次/模块(后续缓存) |
transform |
转换模块内容 | 可选,本方案未使用 | 多次 |
为什么只需要 resolveId + load?
用户视角:import X from 'virtual:xxx'
↓
resolveId('virtual:xxx')
↓ 匹配成功
return '\0virtual:xxx'
↓
load('\0virtual:xxx')
↓ 返回内容
ESM 模块 → 浏览器执行
虚拟模块不需要 transform 是因为内容已经在 load 中完全确定。
热更新机制详解
当 pages.json 修改时,热更新流程如下:
浏览器 Module Graph Vite Dev Server pages.json 浏览器 Module Graph Vite Dev Server pages.json 1. 找到虚拟模块实例 2. 使模块失效,强制重新 load 3. 通知浏览器热更新 文件变更 handleHotUpdate 触发 getModuleById('\0virtual:...') 返回缓存的模块对象 invalidateModule(mod) ws.send({ type: 'full-reload' }) 刷新页面 / 更新模块
invalidateModule 的作用:
| 操作 | 作用 |
|---|---|
getModuleById |
从模块图中获取缓存的模块对象 |
invalidateModule |
标记模块为"已失效",下次访问时重新 load |
ws.send |
通知浏览器有重大变更(需刷新) |
为什么不用 HMR 而用 full-reload?
javascript
// HMR(热模块替换):局部更新,不刷新页面
// 适用场景:组件内部状态、样式变化
server.ws.send({ type: 'full-reload', path: '*' })
// full-reload(整页刷新):重新加载整个应用
// 适用场景:配置变更、路由变化、全局状态重置
pages.json 变更属于全局配置变更,页面标题、TabBar 等都可能受影响,因此采用 full-reload 确保状态一致。
使用方式
javascript
// 业务组件中直接导入
import { pages, tabbar, globalStyle } from 'virtual:uni-pages-json';
// pages - 页面列表,用于判断页面路径等
// tabbar.list - TabBar 项目列表,用于渲染 TabBar
// globalStyle - 全局样式配置,用于设置 Navbar 默认样式
四、架构集成
4.1 整体架构
业务层
组件层
插件层
配置层
pages.json
vite-plugin-uni-pages-json
读取配置、ESM导出、热更新
vite-plugin-uni-layout
自动包裹布局组件
AppLayout 组件
Navbar + TabBar + 插槽
业务代码
import from virtual:uni-pages-json
4.2 插件注册
javascript
// vite.config.js
import { defineConfig } from 'vite';
import uniPagesJson from './vite/plugins/vite-plugin-uni-pages-json';
import uniLayout from './vite/plugins/vite-plugin-uni-layout';
import uni from '@dcloudio/vite-plugin-uni';
export default defineConfig({
plugins: [
uniPagesJson(), // 虚拟模块:读取 pages.json
uniLayout(), // 自动包裹布局组件
uni(),
],
});
五、核心价值
| 维度 | 传统方式 | 本方案 |
|---|---|---|
| 配置读取 | 条件编译/原生插件 | ESM 直接导入 |
| 类型提示 | 无 | TypeScript 完整支持 |
| 热更新 | 手动刷新 | 自动 HMR |
| 代码侵入 | 需修改组件 | 透明导入 |
| 配置集中 | 分散在多处 | 统一 pages.json |
六、总结
本方案通过 Vite 虚拟模块技术栈,解决了 uni-app 平台无法在运行时读取 pages.json 的核心矛盾:
| 核心要点 | 说明 |
|---|---|
| 配置中心化 | 配置与业务代码解耦,统一由 pages.json 管理 |
| 开发体验 | 热更新、类型提示、IDE 智能提示 |
| 自动化处理 | 虚拟模块自动解析,无需手动维护 |
| 可扩展性 | 可基于虚拟模块扩展更多能力 |
附录:为什么 JSON 不支持注释?
标准 JSON 的限制
根据 RFC 8259 规范,JSON(JavaScript Object Notation)是完全不支持注释的:
JSON SHALL NOT contain controls characters (U+0000 ~ U+001F).
A JSON parser MUST NOT accept input containing control characters.
标准 JSON 语法定义中没有注释语法:
json
// ❌ 这不是有效的 JSON(行注释)
{
"name": "app"
}
/* ❌ 这也不是有效的 JSON(块注释) */
{
"name": "app"
}
为什么要支持注释?
尽管 JSON 标准不支持注释,但实际开发中注释非常有用:
| 用途 | 示例 |
|---|---|
| 配置说明 | // 页面标题 |
| 临时禁用 | // "debug": true |
| 文档注释 | /* tabBar 配置 */ |
常见的"类 JSON"格式
| 格式 | 注释支持 | 使用场景 |
|---|---|---|
| JSON | ❌ 不支持 | 标准数据交换 |
| JSONC | ✅ 支持 | VS Code 配置文件 |
| JSON5 | ✅ 支持 | 配置文件 |
| YAML | ✅ 支持 | Kubernetes、CI 配置 |
JSONC 示例(VS Code 的 settings.json):
jsonc
{
// 这是单行注释
/* 这是块注释 */
"editor.fontSize": 14,
"editor.tabSize": 2 // 行尾注释
}
本方案的处理方式
javascript
// 移除注释后再解析
const jsonString = content
.replace(/\/\/.*$/gm, '') // 移除 // 行注释
.replace(/\/\*[\s\S]*?\*\//g, ''); // 移除 /* */ 块注释
const config = JSON.parse(jsonString)
注意 :这是"尽力而为"的处理,如果 JSON 字符串内容中包含 // 或 /* */,也会被误移除:
javascript
// 边界 case:字符串内容被误移除
{
"url": "https://example.com/api/getUserById/1" // 整个 URL 会被移除
}
生产环境建议:如果 JSON 内容中可能包含 URL 或类似文本,不要使用注释解析功能。
下章预告
第二章解决了「如何在运行时读取 pages.json 配置」的问题。
下一章我们将探讨:[第三章] 如何基于配置设计可复用的 NavBar 和 TabBar 组件?