Skip to content

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

一、设计背景

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 中的配置,但平台不提供运行时读取能力。


二、解决方案:Vite 虚拟模块

2.1 什么是虚拟模块?

虚拟模块(Virtual Module)是 Vite 提供的一种核心特性,允许在构建时动态生成模块内容,而无需实际存在对应的物理文件。

核心原理

虚拟模块的标识约定

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: '*' });
                }
            }
        }
    };
}

三钩子执行流程详解

虚拟模块的完整生命周期涉及三个核心钩子,它们按以下顺序执行:

各钩子职责详解

钩子职责调用时机调用次数
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 修改时,热更新流程如下:

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 整体架构

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


系列总览

章节主题核心问题
第一章全局容器的实现如何让每个页面自动包裹布局组件?
第二章Vite 虚拟模块如何在运行时读取 pages.json 配置?
第三章组件设计如何设计可配置的 NavBar 和 TabBar?
第四章动态调用设计页面如何与全局容器进行通信?
第五章登录鉴权模块如何设计完整的登录鉴权体系?