uni-app 全局容器实战系列(四):全局容器动态调用设计
一、承接上文
第三章遗留问题
第三章我们实现了配置驱动的 NavBar 和 TabBar 组件 ,但存在一个限制:页面只能被动读取配置,无法主动修改容器的状态。
实际业务中,页面需要动态与全局容器交互:
- 页面加载后根据数据动态修改导航栏标题
- 根据业务状态动态改变标题颜色
- 控制标题栏的折叠/展开行为
- 触发未登录提示弹窗
本章目标
setTitle
setTitleColor
showUnloginNotify
页面
AppLayout
实现页面主动修改容器状态的能力。
二、设计背景
2.1 传统方案的问题
| 方案 | 问题 |
|---|---|
uni.setNavigationBarTitle() |
仅支持标题修改,无法改颜色、样式 |
| 组件间 props 传递 | 需要手动在每个页面引入布局组件 |
| 全局变量共享 | 缺乏生命周期管理,容易内存泄漏 |
2.2 设计目标
| 目标 | 描述 |
|---|---|
| 统一入口 | 页面通过 useAppLayout() 获取操作 API |
| 异步安全 | 确保组件挂载后再执行操作 |
| 自动清理 | 页面卸载时自动移除监听,防止内存泄漏 |
| 类型安全 | 完整的 TypeScript 类型提示 |
三、核心设计
3.1 整体架构
registerLayout AppLayout.vue uni.emit useAppLayout 页面组件 registerLayout AppLayout.vue uni.emit useAppLayout 页面组件 调用 useAppLayout() uni.$emit('SET_TITLE_EVENT') 触发事件监听 更新 userTitle ref 响应式渲染新标题
3.2 事件通信机制
页面与组件通过 uni.$emit / uni.$on 进行事件通信:
javascript
// 事件命名规范:{pageId}:APPLAYOUT:{METHOD}
// 示例:
// 'pages/index/index:0:APPLAYOUT:SET_TITLE'
// pageId namespace method
为什么用 page.route + ':' + page.$page.id?
| 组成 | 示例 | 作用 |
|---|---|---|
page.route |
pages/index/index |
页面路径标识 |
page.$page.id |
0 |
页面实例唯一 ID |
| 组合结果 | pages/index/index:0 |
多实例页面时 ID 唯一 |
为什么需要保证唯一性?
在 tabBar 切换场景中,同一页面路径可能存在多个实例:
tabBar 实例
切换
切换
切换
pages/index/index:0
pages/index/index:1
pages/index/index:2
3.3 异步等待机制
页面调用 API 时,组件可能尚未挂载完成。使用 Promise 机制确保操作在组件就绪后执行:
AppLayout 组件 页面组件 AppLayout 组件 页面组件 此时 Layout 尚未完成 onMounted 事件丢失! 正确做法:先等待注册完成 onMounted() 调用 setTitle() uni.emit('SET_TITLE_EVENT') uni.on() 尚未注册 await getRegisterLayoutPromise(pageId) uni.emit() 时机正确 uni.on() 已就绪
promiseWithResolvers 实现原理
为什么需要这个模式?
标准 Promise 的 resolve 和 reject 无法从外部获取:
javascript
// ❌ 普通 Promise 无法从外部 resolve
const promise = new Promise((resolve) => {
// resolve 只能在 Promise 内部调用
// 外部无法访问到这个 resolve 函数
})
promiseWithResolvers 的核心思想:
javascript
// promiseWithResolvers.js
export const promiseWithResolvers = () => {
// 在函数内部创建 Promise,同时提取 resolve/reject
let resolve, reject
const promise = new Promise((res, rej) => {
// 将 resolve/reject 赋值给外部变量
resolve = res
reject = rej
})
// 返回 Promise 本身 + resolve/reject 函数
return { promise, resolve, reject }
}
工作流程图解:
┌─────────────────────────────────────────────────────────────┐
│ 第一步:初始化 │
├─────────────────────────────────────────────────────────────┤
│ │
│ registerLayoutCache.set(pageId, promiseWithResolvers()) │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ pageId: { │ │
│ │ promise: Promise (pending), │ ← 外部持有引用 │
│ │ resolve: [Function], │ ← 外部持有引用 │
│ │ reject: [Function] │ ← 外部持有引用 │
│ │ } │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 第二步:组件注册 │
├─────────────────────────────────────────────────────────────┤
│ │
│ // AppLayout onMounted 时调用 │
│ resolveRegisterLayout(pageId) → resolve() 执行 │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ pageId: { │ │
│ │ promise: Promise (resolved!), │ ← pending → resolved │
│ │ resolve: [Function], │ │
│ │ reject: [Function] │ │
│ │ } │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 第三步:页面调用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ // 页面调用 useAppLayout().setTitle() │
│ await getRegisterLayoutPromise(pageId) │
│ // 此时 Promise 已经是 resolved,立即执行后续代码 │
│ uni.$emit(...) → 事件正确送达 │
│ │
└─────────────────────────────────────────────────────────────┘
page.$page.id 的来源
uni-app 页面实例具有 $page 属性,记录页面的元信息:
javascript
// 获取当前页面实例
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
// currentPage.$page 结构:
{
id: 0, // ← 页面实例 ID(递增,每次进入页面累加)
route: 'pages/index/index', // 页面路径
options: {} // 页面参数
}
为什么需要 $page.id?
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 首次进入首页 | 只有一个实例,ID = 0 | pages/index/index:0 |
| 再次进入首页 | 已有实例,ID = 1 | pages/index/index:1 |
| 第三次进入 | ID = 2 | pages/index/index:2 |
多实例场景示例:
javascript
// TabBar 页面在 uni-app 中会被缓存在内存中
// 假设用户依次访问了 index → list → detail → index(回到首页)
// 此时内存中存在 3 个 index 页面实例:
// - index:0 (首次进入,已离开但未销毁)
// - index:1 (第二次进入)
// - index:2 (当前显示)
// 事件 'pages/index/index:0:APPLAYOUT:SET_TITLE'
// 只会影响 index:0 这个特定实例,不会影响 index:1 或 index:2
3.4 Map 缓存清理的必要性
内存泄漏场景分析
如果不清理 Map 缓存,会导致以下问题:
javascript
// ❌ 错误做法:onUnmounted 中没有清理
onUnmounted(() => {
uni.$off(...) // 清理了事件监听
// ❌ 但没有清理 registerLayoutCache 中的 Promise
// registerLayoutCache.delete(pageId) 被遗漏
})
内存泄漏场景:
┌──────────────────────────────────────────────────────────────┐
│ 用户访问页面生命周期 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. 进入页面 A │
│ → registerLayoutCache.set('A:0', PromiseWithResolvers) │
│ → 缓存大小: 1 entry │
│ │
│ 2. 离开页面 A │
│ → 事件监听已清理 (uni.$off) │
│ → ❌ 缓存未清理 │
│ → 缓存大小: 1 entry (僵尸数据) │
│ │
│ 3. 进入页面 A 10 次 │
│ → 缓存大小: 10 entries (全部是僵尸数据) │
│ → 内存持续增长! │
│ │
└──────────────────────────────────────────────────────────────┘
内存泄漏后果:
| 问题 | 影响 |
|---|---|
| Map 持续膨胀 | 长时间使用后内存占用不断增加 |
| Promise 对象无法 GC | 虽然 Promise 已 resolved,但其引用仍在 Map 中 |
| 旧页面数据残留 | 再次进入同名页面可能读取到旧的缓存数据 |
正确清理流程
uni.emit/on registerLayoutCache AppLayout 组件 uni.emit/on registerLayoutCache AppLayout 组件 onMounted onUnmounted Map 中无残留数据 组件完全销毁,无内存泄漏 set(pageId, promiseWithResolvers()) uni.on(...) 注册事件 uni.off(...) 清理所有事件监听 delete(pageId) 清理缓存
完整清理代码:
javascript
// 在 registerLayout 中
onUnmounted(() => {
// 1. 清理所有事件监听
uni.$off(getSetTitleEventName(pageId))
uni.$off(getSetTitleColorEventName(pageId))
uni.$off(getSetTitleCollapseEventName(pageId))
uni.$off(getShowUnloginNotifyEventName(pageId))
// 2. 清理缓存(必须!)
cleanUpRegisterLayout(pageId) // → registerLayoutCache.delete(pageId)
})
3.5 事件命名唯一性保证
为什么需要三层唯一性?
{pageId}:APPLAYOUT:{METHOD}
↓ ↓ ↓
pages/ 命名空间 具体方法
index/ 隔离其他 SET_TITLE
index:0 业务 SET_TITLE_COLOR
事件 SET_TITLE_COLLAPSE
| 层级 | 作用 | 示例 |
|---|---|---|
pageId |
区分不同页面实例 | pages/index/index:0 |
APPLAYOUT |
隔离其他业务事件 | 避免与 WxLogin、Analytics 等冲突 |
METHOD |
区分不同操作 | SET_TITLE vs SET_TITLE_COLOR |
TabBar 多实例场景详解
uni-app 中 TabBar 页面存在多实例复用机制:
渲染错误: Mermaid 渲染失败: Parse error on line 20: ...-->|激活| C Note over MultiInstances: ---------------------^ Expecting 'SEMI', 'NEWLINE', 'EOF', 'AMP', 'START_LINK', 'LINK', 'LINK_ID', got 'NODE_STRING'
实例 ID 分配规则:
| 操作 | 实例变化 |
|---|---|
| 首次打开小程序 | index:0 被创建 |
| 切换到 list | index:0 缓存,list:0 创建 |
| 切换到 profile | list:0 缓存,profile:0 创建 |
| 切回 index | profile:0 缓存,index:0 激活 |
| 再次切换到 list | index:0 缓存,list:1 创建(ID 递增) |
事件隔离验证:
javascript
// 假设当前页面是 index:2
// 调用 setTitle('新标题')
// 事件名:'pages/index/index:2:APPLAYOUT:SET_TITLE'
// 只会影响 index:2 这个实例
// index:0、index:1 不会受到影响
事件冲突场景与避免
可能冲突的场景:
| 场景 | 问题 | 解决 |
|---|---|---|
| 不同页面同名事件 | A:0:SET_TITLE 和 B:0:SET_TITLE |
pageId 包含完整路径 |
| 关闭页面再打开 | 新实例 ID 不同 | ID 由框架分配,无法预测 |
| 多小程序共享代码 | 事件名可能相同 | APPLAYOUT 命名空间隔离 |
四、API 设计
4.1 useAppLayout Hook
typescript
interface AppLayoutAPI {
setTitle: (title: string) => Promise<void>
setTitleColor: (color: string) => Promise<void>
setTitleCollapse: (collapse: boolean) => Promise<void>
showUnloginNotify: () => Promise<void>
}
/**
* 页面通信入口 Hook
* 页面通过此 Hook 获取操作 AppLayout 的 API
*/
export const useAppLayout = (): AppLayoutAPI => {
const pageId = getPageId()
const setTitle = async (title: string) => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getSetTitleEventName(pageId), title)
}
const setTitleColor = async (color: string) => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getSetTitleColorEventName(pageId), color)
}
const setTitleCollapse = async (collapse: boolean) => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getSetTitleCollapseEventName(pageId), collapse)
}
const showUnloginNotify = async () => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getShowUnloginNotifyEventName(pageId))
}
return { setTitle, setTitleColor, setTitleCollapse, showUnloginNotify }
}
4.2 registerLayout 注册函数
typescript
/**
* 布局事件注册 - 仅在 AppLayout 组件中调用
*/
export const registerLayout = ({
setTitle,
setTitleColor,
setTitleCollapse,
showUnloginNotify
}) => {
const pageId = getPageId()
onMounted(() => {
// 监听来自页面的事件
uni.$on(getSetTitleEventName(pageId), (title) => setTitle(title))
uni.$on(getSetTitleColorEventName(pageId), (color) => setTitleColor(color))
uni.$on(getSetTitleCollapseEventName(pageId), (collapse) => setTitleCollapse(collapse))
uni.$on(getShowUnloginNotifyEventName(pageId), () => showUnloginNotify())
// 标记组件已就绪,唤醒等待的 Promise
resolveRegisterLayout(pageId)
})
onUnmounted(() => {
// 清理事件监听,防止内存泄漏
uni.$off(getSetTitleEventName(pageId))
uni.$off(getSetTitleColorEventName(pageId))
uni.$off(getSetTitleCollapseEventName(pageId))
uni.$off(getShowUnloginNotifyEventName(pageId))
// 清理注册缓存
cleanUpRegisterLayout(pageId)
})
}
五、NavBar 特性实现
5.1 标题动态修改
页面调用:
javascript
const { setTitle } = useAppLayout()
onMounted(async () => {
await setTitle('商品详情')
})
组件实现:
javascript
// AppLayout.vue
const userTitle = ref(null) // 用户设置的标题(优先级更高)
const layoutProps = computed(() => ({
titleText: userTitle.value || navigationBarTitleText, // 优先使用用户设置的值
}))
registerLayout({
setTitle: (title) => { userTitle.value = title },
})
5.2 标题颜色动态修改
页面调用:
javascript
const { setTitleColor } = useAppLayout()
// 修改标题为白色
await setTitleColor('#FFFFFF')
组件实现:
javascript
// AppLayout.vue
const userTitleColor = ref(null)
const layoutProps = computed(() => ({
titleColor: userTitleColor.value || navigationBarTextStyle?.color || 'black',
}))
registerLayout({
setTitleColor: (color) => { userTitleColor.value = color },
})
5.3 标题折叠控制
使用场景:滚动页面时,标题栏内容需要折叠隐藏
是
否
页面滚动
scrollTop <= threshold?
显示标题
隐藏标题
页面调用:
javascript
const { setTitleCollapse } = useAppLayout()
// 禁用标题折叠效果
await setTitleCollapse(false)
六、TabBar 特性实现
6.1 未登录提示设计
为什么将弹窗挂载在 AppLayout 而不是页面?
| 方案 | 问题 | 解决 |
|---|---|---|
| 每个页面自己引入弹窗 | 代码重复、状态不统一 | AppLayout 统一提供 |
| 全局变量直接控制 | 缺乏生命周期管理 | 通过 API 封装 |
| 直接操作组件实例 | 组件未挂载时报错 | Promise 等待机制 |
6.2 组件实现
UnloginNotify.vue - 未登录提示弹窗组件:
vue
<template>
<uv-popup ref="popup" bgColor="transparent" :customStyle="{ overflow: 'visible' }">
<view class="popup-content">
<image class="illustration" src="@/static/images/login/img_login_illustration.png" />
<image class="text" src="@/static/images/login/img_login_text.png" />
<view class="actions">
<button class="cancel-btn" @click="onCancel">取消</button>
<button class="confirm-btn" @click="onConfirm">登录</button>
</view>
</view>
</uv-popup>
</template>
<script setup>
import { ref } from 'vue'
import useUserStore from '@/store/modules/user'
import { loginPage } from '@/permission'
const popup = ref(null)
const onCancel = () => popup.value?.close()
const onConfirm = () => {
useUserStore().logOut().then(() => {
uni.reLaunch({ url: loginPage })
})
}
defineExpose({
open() {
popup.value?.open()
},
})
</script>
AppLayout 集成:
vue
<!-- AppLayout.vue -->
<template>
<Layout v-bind="layoutProps">
<slot></slot>
<UnloginNotify ref="unloginNotify" />
</Layout>
</template>
<script setup>
import { ref } from 'vue'
import UnloginNotify from './UnloginNotify.vue'
const unloginNotify = ref(null)
registerLayout({
showUnloginNotify: () => { unloginNotify.value?.open() },
})
</script>
页面调用:
vue
<script setup>
import { useAppLayout } from '@/components/AppLayout/useAppLayout'
import { useUserStore } from '@/stores/user'
const { showUnloginNotify } = useAppLayout()
const userStore = useUserStore()
// 检测未登录状态并触发弹窗
if (!userStore.isLogin) {
await showUnloginNotify()
}
</script>
七、完整代码
7.1 promiseWithResolvers.js
javascript
export const promiseWithResolvers = () => {
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
7.2 useAppLayout.js
javascript
import { onMounted, onUnmounted } from "vue"
import { promiseWithResolvers } from "./promise-with-resolvers"
const registerLayoutCache = new Map()
const getRegisterLayout = (pageId) => {
if (!registerLayoutCache.get(pageId)) {
registerLayoutCache.set(pageId, promiseWithResolvers())
}
return registerLayoutCache.get(pageId)
}
const getRegisterLayoutPromise = (pageId) => getRegisterLayout(pageId).promise
const resolveRegisterLayout = (pageId) => getRegisterLayout(pageId).resolve
const cleanUpRegisterLayout = (pageId) => registerLayoutCache.delete(pageId)
const getPageId = () => {
const page = getCurrentPages()[getCurrentPages().length - 1]
return page.route + ':' + page.$page.id
}
const APPLAYPOUTNS = 'APPLAYOUT'
const genEventName = (method, ns, pageId) => `${pageId}:${ns}:${method}`
const METHOD_SET_TITLE = 'SET_TITLE'
const getSetTitleEventName = (pageId) => genEventName(METHOD_SET_TITLE, APPLAYPOUTNS, pageId)
const METHOD_SET_TITLE_COLOR = 'SET_TITLE_COLOR'
const getSetTitleColorEventName = (pageId) => genEventName(METHOD_SET_TITLE_COLOR, APPLAYPOUTNS, pageId)
const METHOD_SET_TITLE_COLLAPSE = 'SET_TITLE_COLLAPSE'
const getSetTitleCollapseEventName = (pageId) => genEventName(METHOD_SET_TITLE_COLLAPSE, APPLAYPOUTNS, pageId)
const METHOD_SHOW_UNLOGIN_NOTIFY = 'SHOW_UNLOGIN_NOTIFY'
const getShowUnloginNotifyEventName = (pageId) => genEventName(METHOD_SHOW_UNLOGIN_NOTIFY, APPLAYPOUTNS, pageId)
export const useAppLayout = () => {
const pageId = getPageId()
const setTitle = async (title) => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getSetTitleEventName(pageId), title)
}
const setTitleColor = async (color) => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getSetTitleColorEventName(pageId), color)
}
const setTitleCollapse = async (collapse) => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getSetTitleCollapseEventName(pageId), collapse)
}
const showUnloginNotify = async () => {
await getRegisterLayoutPromise(pageId)
uni.$emit(getShowUnloginNotifyEventName(pageId))
}
return { setTitle, setTitleColor, setTitleCollapse, showUnloginNotify }
}
export const registerLayout = ({
setTitle,
setTitleColor,
setTitleCollapse,
showUnloginNotify
}) => {
const pageId = getPageId()
onMounted(() => {
uni.$on(getSetTitleEventName(pageId), (title) => setTitle(title))
uni.$on(getSetTitleColorEventName(pageId), (color) => setTitleColor(color))
uni.$on(getSetTitleCollapseEventName(pageId), (collapse) => setTitleCollapse(collapse))
uni.$on(getShowUnloginNotifyEventName(pageId), () => showUnloginNotify())
resolveRegisterLayout(pageId)
})
onUnmounted(() => {
uni.$off(getSetTitleEventName(pageId))
uni.$off(getSetTitleColorEventName(pageId))
uni.$off(getSetTitleCollapseEventName(pageId))
uni.$off(getShowUnloginNotifyEventName(pageId))
cleanUpRegisterLayout(pageId)
})
}
八、使用示例
8.1 基础用法
vue
<script setup>
import { useAppLayout } from '@/components/AppLayout/useAppLayout'
const { setTitle, setTitleColor } = useAppLayout()
onMounted(async () => {
await setTitle('商品详情')
await setTitleColor('#FFFFFF')
})
</script>
<template>
<view>页面内容</view>
</template>
8.2 组合式使用
vue
<script setup>
import { useAppLayout } from '@/components/AppLayout/useAppLayout'
import { useUserStore } from '@/stores/user'
const { setTitle, showUnloginNotify } = useAppLayout()
const userStore = useUserStore()
onMounted(async () => {
const goods = await fetchGoods()
if (goods.isLoginRequired && !userStore.isLogin) {
await showUnloginNotify()
} else {
await setTitle(goods.name)
}
})
</script>
九、设计总结
9.1 核心优势
| 特性 | 实现方式 |
|---|---|
| 异步安全 | Promise + promiseWithResolvers 等待组件就绪 |
| 内存安全 | onUnmounted 自动清理事件监听 |
| 类型安全 | 完整的 TypeScript 类型定义 |
| 统一入口 | useAppLayout() Hook 提供一致 API |
9.2 事件命名规范
{pageId}:APPLAYOUT:{METHOD}
| 组成部分 | 说明 | 示例 |
|---|---|---|
pageId |
确保多实例隔离 | pages/index/index:0 |
APPLAYOUT |
命名空间,避免冲突 | APPLAYOUT |
METHOD |
操作类型 | SET_TITLE、SET_TITLE_COLOR |
9.3 扩展思路
基于当前架构,可以轻松扩展更多 API:
| API | 说明 |
|---|---|
setNavBg(color) |
修改导航栏背景色 |
setTabBg(color) |
修改 TabBar 背景色 |
hideNavBar() |
隐藏导航栏 |
hideTabBar() |
隐藏 TabBar |
setBadge(index, count) |
设置 TabBar 微标 |
useAppLayout Hook
setTitle
setTitleColor
setTitleCollapse
showUnloginNotify
扩展 API
setNavBg
setTabBg
hideNavBar
hideTabBar
setBadge
十、系列总结
四章回顾
| 章节 | 核心问题 | 解决方案 |
|---|---|---|
| 第一章 | 如何让每个页面自动包裹布局组件? | 编译时注入 vite-plugin-uni-layout |
| 第二章 | 如何在运行时读取 pages.json 配置? |
Vite 虚拟模块 virtual:uni-pages-json |
| 第三章 | 如何设计可配置的 NavBar 和 TabBar? | AppLayout + Layout 组件分层 |
| 第四章 | 页面如何与全局容器通信? | useAppLayout + registerLayout |
技术架构
API 层
useAppLayout
页面通信入口
registerLayout
事件注册
组件层
AppLayout.vue
业务聚合层
Layout.vue
基础容器
插件层
vite-plugin-uni-layout
编译时注入
vite-plugin-uni-pages-json
虚拟模块
基础设施建设
pages.json
核心价值
| 维度 | 价值 |
|---|---|
| 开发效率 | 一次配置/实现,全局生效 |
| 可维护性 | 集中管理,修改一处全站生效 |
| 类型安全 | 完整的 TypeScript 支持 |
| 可扩展 | 基于 Hook 模式,易于扩展新 API |
| 零侵入 | 业务代码无需感知底层实现 |