编译时为 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 |
四、配置项
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 级别日志
})| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
componentName | string | 'AppLayout' | 包裹用的组件标签名,会转义后用于正则 |
layoutKey | string | 'useLayout' | 在 style.layout[key] 中查找开关值 |
defaultEnabled | boolean | true | 页面与全局都未配置时的兜底值 |
force | boolean | false | 当页面已包裹时是否再次包裹 |
pagesJsonPath | string | 'src/pages.json' | 相对 config.root 的路径 |
verbose | boolean | false | 是否打印解析与包裹日志 |
五、数据模型
插件闭包内维护了 6 个状态字段,作用如下:
| 字段 | 类型 | 用途 |
|---|---|---|
pagesPaths | Set<string> | 归一化页面路径集合,用于大小写不敏感匹配 |
normalizedToOriginal | Map<string, string> | 反查原始路径,供热更新模块失效时使用 |
pagesConfigs | Map<string, style> | 页面 style 配置缓存 |
globalLayout | object | globalStyle.layout,作为兜底配置 |
wrapPages | Set<string> | 预计算的需要包裹的页面,transform 时直接 O(1) 查询 |
pagesJsonPath | string | pages.json 的绝对路径,configResolved 时确定 |
关键设计:wrapPages 在 updatePages 阶段就预计算好。否则每次 transform 都要重新解析 pageStyle 与 globalLayout,对大型项目是显著开销。
六、执行路径
6.1 configResolved 钩子
configResolved(config) {
pagesJsonPath = path.resolve(config.root, pagesJsonRelPath);
updatePages();
}Vite 完成配置解析后立即调用,此时 config.root 已稳定。注意 enforce: 'pre'——让插件在 uni 官方插件之前加载,提前预热数据。
6.2 updatePages 解析逻辑
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 ...`);
}
}两个细节值得注意:
- 支持
//和/* */注释。pages.json本身是 JSON 不支持注释,但很多 uni-app 开发者会在里面写注释(参考仓库里的globalStyle.layout就有"// showNav": true)。先 strip 注释再JSON.parse。 - 预计算
wrapPages。shouldWrapByConfig的判断逻辑(页面 > 全局 > 默认)放在updatePages内执行一次,结果缓存到 Set,transform 阶段直接has()查询。
6.3 shouldWrapByConfig 判定规则
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;
}| 优先级 | 来源 | 行为 |
|---|---|---|
| 1 | page.style.layout[layoutKey] | 页面级声明最优先,!== false 即视为开启 |
| 2 | globalStyle.layout[layoutKey] | 全局兜底 |
| 3 | defaultEnabled | 全部未配置时的回退值 |
为什么用 hasOwnProperty? 因为 false 是合法的关闭信号,必须区分"显式设为 false"和"未配置"。if (pageLayout[layoutKey]) 会把 false / 0 / "" 一并吃掉。
6.4 transform 钩子执行流程
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;
}四个判断层层递进,越靠前越便宜:
- 路径过滤:非
.vue文件直接返回,跳出整个 transform。 - 路由匹配:
Set.has是 O(1),绝大部分页面不在pagesPaths中(如组件库文件)会被快速过滤。 - 预计算查询:
wrapPages.has同样 O(1),区分"是注册页面但不需要包裹"的情况。 - 已包裹检测:正则
new RegExp(\<${componentNameEscaped}(\s|>)`)`,避免重复包裹。
path.relative(path.dirname(pagesJsonPath), cleanId) 是一个容易踩坑的点:所有页面路径都相对于 pages.json 所在目录计算。例如 pages.json 在 src/pages.json,页面 src/pages/index/index.vue 的相对路径就是 pages/index/index.vue,归一化后是 pages/index/index。如果 pages.json 与页面不在同一目录,需要重新校准 pagesJsonPath。
6.5 handleHotUpdate 热更新机制
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;
}两件事:
- 重新调用
updatePages刷新内部状态(wrapPages会重新预计算)。 - 遍历
moduleGraph.idToModuleMap找出所有需要重新编译的页面模块,调用invalidateModule让 Vite 下一轮重新触发 transform。
返回 affectedModules 是 Vite 约定:返回的模块会被立即重新请求,实现"改了配置自动看到效果"。
七、关键设计点
7.1 归一化与 Windows 兼容
const normalize = (p) => p.replace(/\\/g, '/').toLowerCase();看似简单,但解决了一类隐蔽问题:
| 场景 | 不归一化的后果 |
|---|---|
Windows 下 path.relative 返回 pages\index\index.vue | 与 pagesPaths 中的 pages/index/index 比对失败 |
| 文件名大小写不一致(git 大小写不敏感配置) | 包裹时有时无 |
pages.json 中写 pages/Index/index | 实际文件叫 pages/index/index |
7.2 componentName 转义与正则注入防护
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> 的字符串定位:
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 配合使用(后者是同系列的虚拟模块插件)。
// 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 配置示例
{
"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>:
<template>
<AppLayout>
<view class="container">...</view>
</AppLayout>
</template>如果没有看到 <AppLayout>,按下列顺序排查:
- 打开
verbose: true,确认控制台输出"Parsed N page(s), M will be wrapped"中的 N 与项目页面数一致。 - 检查
pages.json是否能正确解析(合法 JSON,注意插件已支持//注释)。 - 检查 easycom 是否正确注册了
AppLayout(@/components/AppLayout/AppLayout.vue)。 - 确认组件名匹配(
componentName区分大小写)。
九、边界与不适用场景
| 边界 | 说明 |
|---|---|
| App.vue 顶层入口 | 不在 pages.json 中,被前置过滤 |
| 组件库页面(components 下) | 同上,不会被包裹 |
多 <template> 块 | 字符串匹配可能误命中 |
| SSR 模式 | 仅处理编译时 transform,无运行时注入逻辑 |
自定义 pages.json 路径 | 必须保证与 .vue 页面在同一目录或正确配置 pagesJsonPath |
十、小结
vite-plugin-uni-root-frame 是一个纯字符串层的轻量编译时插件,它没有引入 AST 解析、没有运行时副作用,所有逻辑都在 Vite 标准的 transform 和 handleHotUpdate 钩子内完成。这种"用最朴素的方式解决问题"的思路,适合作为 Vite 插件入门的学习案例。
如果对"为什么需要编译时容器注入"的背景感兴趣,可以回看系列首章全局容器的实现。本章是该思路的"可复用插件形态"补篇,重点在配置粒度与热更新策略。
配套插件
同目录下还有 vite-plugin-uni-pages-json 配套的虚拟模块插件,可让业务代码通过 import { pages } from 'virtual:uni-pages-json' 直接读取 pages.json。
