uni-app 全局容器实战系列(二):Vite 虚拟模块

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 组件?

相关推荐
Omics Pro9 小时前
前沿学科:量子生物学!
大数据·数据库·人工智能·windows·redis·量子计算
__zRainy__9 小时前
uni-app 全局容器实战系列(一):全局容器的实现
uni-app·vite
IceSugarJJ10 小时前
Windows下VSCode+ WSL项目启动流程
linux·windows·vscode·ubuntu·wsl
Kiling_070410 小时前
面向对象和集合编程题 ( 一 )
jvm·windows
boldiy10 小时前
如何在MAC电脑中实现自动切换windows快捷键
windows·macos
鹿野素材屋11 小时前
Unity预加载:减少游戏中首次加载资源时的卡顿
windows·游戏·unity
xiaoshuaishuai811 小时前
C# CUDA 到 OpenCL 迁移
开发语言·windows·c#
hikktn11 小时前
Excel模板智能转PDF:零硬编码的通用打印解决方案
windows·pdf
安生生申11 小时前
uni-app 连接 JDY-31 蓝牙串口模块实践
c语言·前端·javascript·stm32·单片机·嵌入式硬件·uni-app