uni-app 全局容器实战系列(四):全局容器动态调用设计
一、承接上文
第三章遗留问题
第三章我们实现了配置驱动的 NavBar 和 TabBar 组件,但存在一个限制:页面只能被动读取配置,无法主动修改容器的状态。
实际业务中,页面需要动态与全局容器交互:
- 页面加载后根据数据动态修改导航栏标题
- 根据业务状态动态改变标题颜色
- 控制标题栏的折叠/展开行为
- 触发未登录提示弹窗
本章目标
实现页面主动修改容器状态的能力。
二、设计背景
2.1 传统方案的问题
| 方案 | 问题 |
|---|---|
uni.setNavigationBarTitle() | 仅支持标题修改,无法改颜色、样式 |
| 组件间 props 传递 | 需要手动在每个页面引入布局组件 |
| 全局变量共享 | 缺乏生命周期管理,容易内存泄漏 |
2.2 设计目标
| 目标 | 描述 |
|---|---|
| 统一入口 | 页面通过 useAppLayout() 获取操作 API |
| 异步安全 | 确保组件挂载后再执行操作 |
| 自动清理 | 页面卸载时自动移除监听,防止内存泄漏 |
| 类型安全 | 完整的 TypeScript 类型提示 |
三、核心设计
3.1 整体架构
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 切换场景中,同一页面路径可能存在多个实例:
3.3 异步等待机制
页面调用 API 时,组件可能尚未挂载完成。使用 Promise 机制确保操作在组件就绪后执行:
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:23.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 中 |
| 旧页面数据残留 | 再次进入同名页面可能读取到旧的缓存数据 |
正确清理流程
完整清理代码:
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 页面存在多实例复用机制:
实例 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 标题折叠控制
使用场景:滚动页面时,标题栏内容需要折叠隐藏
页面调用:
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 微标 |
十、系列总结
四章回顾
| 章节 | 核心问题 | 解决方案 |
|---|---|---|
| 第一章 | 如何让每个页面自动包裹布局组件? | 编译时注入 vite-plugin-uni-layout |
| 第二章 | 如何在运行时读取 pages.json 配置? | Vite 虚拟模块 virtual:uni-pages-json |
| 第三章 | 如何设计可配置的 NavBar 和 TabBar? | AppLayout + Layout 组件分层 |
| 第四章 | 页面如何与全局容器通信? | useAppLayout + registerLayout |
整体架构
核心价值
| 维度 | 价值 |
|---|---|
| 开发效率 | 一次配置/实现,全局生效 |
| 可维护性 | 集中管理,修改一处全站生效 |
| 类型安全 | 完整的 TypeScript 支持 |
| 可扩展 | 基于 Hook 模式,易于扩展新 API |
| 零侵入 | 业务代码无需感知底层实现 |
系列总览
| 章节 | 主题 | 核心问题 |
|---|---|---|
| 第一章 | 全局容器的实现 | 如何让每个页面自动包裹布局组件? |
| 第二章 | Vite 虚拟模块 | 如何在运行时读取 pages.json 配置? |
| 第三章 | 组件设计 | 如何设计可配置的 NavBar 和 TabBar? |
| 第四章 | 动态调用设计 | 页面如何与全局容器进行通信? |
| 第五章 | 登录鉴权模块 | 如何设计完整的登录鉴权体系? |
