Skip to content

编译时为 uni-app 页面注入根框组件

一、适用场景

vite-plugin-uni-root-frame 是一个为 uni-app 项目量身打造的 Vite 插件,它会在编译时读取 pages.json 中声明的页面,按页面/全局两层粒度判断是否需要包裹一层"根框"组件(默认 <AppLayout>)。需要满足以下所有前提:

前提说明
构建工具Vite ≥ 5(插件依赖 transform / handleHotUpdate 等标准钩子)
框架uni-app(基于 @dcloudio/vite-plugin-uni),因为 pages.json 是 uni-app 的核心配置约定
根框组件业务侧存在一个全局组件(如 AppLayout),需要按页面自动注入
pages.json 路径默认 src/pages.json,可通过 pagesJsonPath 自定义

不适合

  • 非 Vite 工程(webpack 项目需另写 loader)
  • 没有 pages.json 配置约定的项目(如纯 Vue/React 工程)
  • 需要在 SSR 运行时包裹的场景(仅编译时包裹)

二、背景

我在 uni-app 全局容器系列第一章:全局容器的实现中讲过"编译时容器注入"的设计思路,但当时是直接在 transform 钩子里写死 <AppLayout> 包裹。落地到真实项目后发现这套逻辑高度可复用,于是把它抽成独立插件:

需求原方案(硬编码)抽成插件后
包裹组件名写死在源码里componentName 配置
是否包裹某页通过代码注释手动标记pages.json 中按页面声明
全局默认策略无回退值defaultEnabled
已包含组件时跳过不处理alreadyWrappedRegex 自动识别
pages.json 热更新不会触发handleHotUpdate 主动失效模块
Windows 路径兼容偶尔匹配失败normalize 归一化

一句话:把"业务侧的页面级布局开关"沉淀成 pages.json 的可声明配置,把"技术侧的编译时注入"沉淀成通用插件


三、能力清单

能力实现位置关键代码
热更新handleHotUpdate监听 pages.json 变更,遍历 moduleGraph 失效受影响的页面模块
路径兼容normalize统一正斜杠 + 小写,解决 Windows 大小写不敏感问题
防重复包裹alreadyWrappedRegex注入前正则检测 <AppLayout
高性能wrapPages 预计算O(1) Set 查询是否包裹
灵活配置options 解构componentName / layoutKey / defaultEnabled / force / pagesJsonPath / verbose

四、配置项

js
import uniRootFrame from './vite-plugins/vite-plugin-uni-root-frame.js'

uniRootFrame({
    componentName: 'AppLayout',     // 包裹的组件名
    layoutKey: 'useLayout',         // style.layout 中读取的开关字段名
    defaultEnabled: true,           // 默认是否包裹(未配置时的回退值)
    force: false,                   // 已包含组件标签时是否强制再包裹
    pagesJsonPath: 'src/pages.json', // pages.json 相对项目根的路径
    verbose: false,                 // 是否输出 info 级别日志
})
配置项类型默认值说明
componentNamestring'AppLayout'包裹用的组件标签名,会转义后用于正则
layoutKeystring'useLayout'style.layout[key] 中查找开关值
defaultEnabledbooleantrue页面与全局都未配置时的兜底值
forcebooleanfalse当页面已包裹时是否再次包裹
pagesJsonPathstring'src/pages.json'相对 config.root 的路径
verbosebooleanfalse是否打印解析与包裹日志

五、数据模型

插件闭包内维护了 6 个状态字段,作用如下:

字段类型用途
pagesPathsSet<string>归一化页面路径集合,用于大小写不敏感匹配
normalizedToOriginalMap<string, string>反查原始路径,供热更新模块失效时使用
pagesConfigsMap<string, style>页面 style 配置缓存
globalLayoutobjectglobalStyle.layout,作为兜底配置
wrapPagesSet<string>预计算的需要包裹的页面,transform 时直接 O(1) 查询
pagesJsonPathstringpages.json 的绝对路径,configResolved 时确定

关键设计:wrapPagesupdatePages 阶段就预计算好。否则每次 transform 都要重新解析 pageStyleglobalLayout,对大型项目是显著开销。


六、执行路径

6.1 configResolved 钩子

js
configResolved(config) {
    pagesJsonPath = path.resolve(config.root, pagesJsonRelPath);
    updatePages();
}

Vite 完成配置解析后立即调用,此时 config.root 已稳定。注意 enforce: 'pre'——让插件在 uni 官方插件之前加载,提前预热数据。

6.2 updatePages 解析逻辑

js
function updatePages() {
    try {
        const content = fs.readFileSync(pagesJsonPath, 'utf-8');
        // 去掉 // 和 /* */ 注释
        const jsonString = content
            .replace(/\/\/.*$/gm, '')
            .replace(/\/\*[\s\S]*?\*\//g, '');
        const config = JSON.parse(jsonString);

        pagesPaths.clear();
        pagesConfigs.clear();
        normalizedToOriginal.clear();
        wrapPages.clear();

        (config.pages || []).forEach(page => {
            const original = page.path;
            const normalized = normalize(original);
            pagesPaths.add(normalized);
            normalizedToOriginal.set(normalized, original);
            pagesConfigs.set(normalized, page.style || {});
        });

        globalLayout = (config.globalStyle && config.globalStyle.layout) || {};

        // 预计算 wrapPages
        (config.pages || []).forEach(page => {
            const normalized = normalize(page.path);
            if (shouldWrapByConfig(page.style || {})) {
                wrapPages.add(normalized);
            }
        });
    } catch (e) {
        console.error(`\x1b[31m[${PLUGIN_NAME}]\x1b[0m Failed to parse pages.json ...`);
    }
}

两个细节值得注意

  1. 支持 ///* */ 注释pages.json 本身是 JSON 不支持注释,但很多 uni-app 开发者会在里面写注释(参考仓库里的 globalStyle.layout 就有 "// showNav": true)。先 strip 注释再 JSON.parse
  2. 预计算 wrapPagesshouldWrapByConfig 的判断逻辑(页面 > 全局 > 默认)放在 updatePages 内执行一次,结果缓存到 Set,transform 阶段直接 has() 查询。

6.3 shouldWrapByConfig 判定规则

js
function shouldWrapByConfig(pageStyle) {
    const pageLayout = (pageStyle && pageStyle.layout) || {};
    const globalValue = globalLayout[layoutKey];

    // 1. 页面级优先
    if (Object.prototype.hasOwnProperty.call(pageLayout, layoutKey)) {
        return pageLayout[layoutKey] !== false;
    }
    // 2. 全局兜底
    if (Object.prototype.hasOwnProperty.call(globalLayout, layoutKey) && globalValue !== undefined) {
        return globalValue !== false;
    }
    // 3. 默认回退
    return defaultEnabled;
}
优先级来源行为
1page.style.layout[layoutKey]页面级声明最优先,!== false 即视为开启
2globalStyle.layout[layoutKey]全局兜底
3defaultEnabled全部未配置时的回退值

为什么用 hasOwnProperty 因为 false 是合法的关闭信号,必须区分"显式设为 false"和"未配置"。if (pageLayout[layoutKey]) 会把 false / 0 / "" 一并吃掉。

6.4 transform 钩子执行流程

js
transform(code, id) {
    // Remove query params from id (e.g. ?vue&type=script...)
    const cleanId = id.split('?')[0];

    if (!cleanId.endsWith('.vue')) return;

    // 与 pages.json 所在目录做相对路径,归一化后与 pagesPaths 比对
    const relativePath = path
        .relative(path.dirname(pagesJsonPath), cleanId)
        .replace(/\\/g, '/');
    const routePath = relativePath.replace(/\.vue$/, '');
    const normalizedRoute = normalize(routePath);

    if (!pagesPaths.has(normalizedRoute)) return;          // 不是注册页面 → 跳过
    if (!wrapPages.has(normalizedRoute)) return;           // 不需要包裹 → 跳过

    let newCode = code;

    // 重复包裹保护:已包含组件标签则跳过(除非 force)
    if (!force && alreadyWrappedRegex.test(newCode)) return;

    // Wrap Template
    const templateOpenRegex = /<template[^>]*>/;
    const templateOpenMatch = newCode.match(templateOpenRegex);
    if (templateOpenMatch) {
        const openTag = templateOpenMatch[0];
        const openIndex = templateOpenMatch.index + openTag.length;
        const closeIndex = newCode.lastIndexOf('</template>');

        if (closeIndex > openIndex) {
            const before = newCode.substring(0, openIndex);
            const content = newCode.substring(openIndex, closeIndex);
            const after = newCode.substring(closeIndex);

            newCode = `${before}\n<${componentName}>\n${content}\n</${componentName}>\n${after}`;
        }
    }

    return newCode;
}

四个判断层层递进,越靠前越便宜:

  1. 路径过滤:非 .vue 文件直接返回,跳出整个 transform。
  2. 路由匹配Set.has 是 O(1),绝大部分页面不在 pagesPaths 中(如组件库文件)会被快速过滤。
  3. 预计算查询wrapPages.has 同样 O(1),区分"是注册页面但不需要包裹"的情况。
  4. 已包裹检测:正则 new RegExp(\<${componentNameEscaped}(\s|>)`)`,避免重复包裹。

path.relative(path.dirname(pagesJsonPath), cleanId) 是一个容易踩坑的点:所有页面路径都相对于 pages.json 所在目录计算。例如 pages.jsonsrc/pages.json,页面 src/pages/index/index.vue 的相对路径就是 pages/index/index.vue,归一化后是 pages/index/index。如果 pages.json 与页面不在同一目录,需要重新校准 pagesJsonPath

6.5 handleHotUpdate 热更新机制

js
handleHotUpdate({ file, server }) {
    if (normalize(file) !== normalize(pagesJsonPath)) return;

    updatePages();

    const affectedModules = [];
    const pagesDir = path.dirname(pagesJsonPath);

    for (const mod of server.moduleGraph.idToModuleMap.values()) {
        if (!mod.id) continue;
        const cleanId = mod.id.split('?')[0];
        if (!cleanId.endsWith('.vue')) continue;
        const rel = normalize(
            path.relative(pagesDir, cleanId).replace(/\\/g, '/').replace(/\.vue$/, '')
        );
        if (pagesPaths.has(rel)) {
            affectedModules.push(mod);
        }
    }

    affectedModules.forEach(mod => server.moduleGraph.invalidateModule(mod));

    server.config.logger.info(
        `\x1b[32m[${PLUGIN_NAME}]\x1b[0m pages.json updated, reloading ${affectedModules.length} page module(s)...`,
        { timestamp: true }
    );

    return affectedModules;
}

两件事

  1. 重新调用 updatePages 刷新内部状态(wrapPages 会重新预计算)。
  2. 遍历 moduleGraph.idToModuleMap 找出所有需要重新编译的页面模块,调用 invalidateModule 让 Vite 下一轮重新触发 transform。

返回 affectedModules 是 Vite 约定:返回的模块会被立即重新请求,实现"改了配置自动看到效果"。


七、关键设计点

7.1 归一化与 Windows 兼容

js
const normalize = (p) => p.replace(/\\/g, '/').toLowerCase();

看似简单,但解决了一类隐蔽问题:

场景不归一化的后果
Windows 下 path.relative 返回 pages\index\index.vuepagesPaths 中的 pages/index/index 比对失败
文件名大小写不一致(git 大小写不敏感配置)包裹时有时无
pages.json 中写 pages/Index/index实际文件叫 pages/index/index

7.2 componentName 转义与正则注入防护

js
const componentNameEscaped = componentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const alreadyWrappedRegex = new RegExp(`<${componentNameEscaped}(\\s|>)`);

如果 componentName 来自外部配置(含 . + * 等),直接拼到正则里既会报错也是潜在注入面。$& 是匹配到的字符串,\\$& 就是给正则元字符加反斜杠转义。

7.3 force 选项与已包裹检测

alreadyWrappedRegex 只检测 <AppLayout\s<AppLayout>,不检测闭合标签。这是有意为之——业务代码经常在 <template> 中嵌入 <AppLayout> 子片段,但只要不是顶层根节点,不应阻止插件在外层再加一层。force: true 时跳过检测,可用于多层嵌套的极端场景。

7.4 正则包裹的局限

当前实现用 <template></template> 的字符串定位:

js
const templateOpenRegex = /<template[^>]*>/;
const closeIndex = newCode.lastIndexOf('</template>');
不适用场景说明
多个 <template> 块(极少见)只匹配第一个开启标签和最后一个闭合标签
模板被注释包裹注释内的 <template> 也会被命中
App.vue(顶层入口)不在 pagesPaths 中,会被前置过滤

如果有这些需求,应该改用 @vue/compiler-sfc 的 parse 阶段处理,而不是 string replace。


八、使用示例

8.1 安装与配置

vite-plugin-uni-root-frame.js 放到项目 vite-plugins/ 下,与 vite-plugin-uni-pages-json.js 配合使用(后者是同系列的虚拟模块插件)。

js
// 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 pages.json 配置示例

json
{
  "easycom": {
    "autoscan": true,
    "custom": {
      "^AppLayout$": "@/components/AppLayout/AppLayout.vue"
    }
  },
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页",
        "layout": {
          "useLayout": true,
          "bgColor": "#E6F7FF"
        }
      }
    },
    {
      "path": "pages/mine/index",
      "style": {
        "navigationBarTitleText": "我的"
      }
    },
    {
      "path": "pages/demo/about",
      "style": {
        "layout": {
          "useLayout": false
        }
      }
    }
  ],
  "globalStyle": {
    "layout": {
      "useLayout": true
    }
  }
}

基于上面的配置:

页面useLayout 来源是否包裹
pages/index/index页面级显式 true
pages/mine/index页面无配置 → 走 globalStyle true
pages/demo/about页面级显式 false
新增页面(未声明)defaultEnabled: true

8.3 验证与排错

构建运行后,devtools 查看任一被包裹页面的 SFC,会发现 <template> 内层多了一层 <AppLayout>

vue
<template>
<AppLayout>
  <view class="container">...</view>
</AppLayout>
</template>

如果没有看到 <AppLayout>,按下列顺序排查:

  1. 打开 verbose: true,确认控制台输出"Parsed N page(s), M will be wrapped"中的 N 与项目页面数一致。
  2. 检查 pages.json 是否能正确解析(合法 JSON,注意插件已支持 // 注释)。
  3. 检查 easycom 是否正确注册了 AppLayout@/components/AppLayout/AppLayout.vue)。
  4. 确认组件名匹配(componentName 区分大小写)。

九、边界与不适用场景

边界说明
App.vue 顶层入口不在 pages.json 中,被前置过滤
组件库页面(components 下)同上,不会被包裹
<template>字符串匹配可能误命中
SSR 模式仅处理编译时 transform,无运行时注入逻辑
自定义 pages.json 路径必须保证与 .vue 页面在同一目录或正确配置 pagesJsonPath

十、小结

vite-plugin-uni-root-frame 是一个纯字符串层的轻量编译时插件,它没有引入 AST 解析、没有运行时副作用,所有逻辑都在 Vite 标准的 transformhandleHotUpdate 钩子内完成。这种"用最朴素的方式解决问题"的思路,适合作为 Vite 插件入门的学习案例。

如果对"为什么需要编译时容器注入"的背景感兴趣,可以回看系列首章全局容器的实现。本章是该思路的"可复用插件形态"补篇,重点在配置粒度与热更新策略。

配套插件

同目录下还有 vite-plugin-uni-pages-json 配套的虚拟模块插件,可让业务代码通过 import { pages } from 'virtual:uni-pages-json' 直接读取 pages.json。

源码:uni-app/vite-plugins/vite-plugin-uni-root-frame.js