Skip to content

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.routepages/index/index页面路径标识
page.$page.id0页面实例唯一 ID
组合结果pages/index/index:0多实例页面时 ID 唯一

为什么需要保证唯一性?

在 tabBar 切换场景中,同一页面路径可能存在多个实例:

3.3 异步等待机制

页面调用 API 时,组件可能尚未挂载完成。使用 Promise 机制确保操作在组件就绪后执行:

promiseWithResolvers 实现原理

为什么需要这个模式?

标准 Promise 的 resolvereject 无法从外部获取:

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 = 0pages/index/index:0
再次进入首页已有实例,ID = 1pages/index/index:1
第三次进入ID = 2pages/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 中
旧页面数据残留再次进入同名页面可能读取到旧的缓存数据

正确清理流程

完整清理代码

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隔离其他业务事件避免与 WxLoginAnalytics 等冲突
METHOD区分不同操作SET_TITLE vs SET_TITLE_COLOR

TabBar 多实例场景详解

uni-app 中 TabBar 页面存在多实例复用机制:

实例 ID 分配规则

操作实例变化
首次打开小程序index:0 被创建
切换到 listindex:0 缓存,list:0 创建
切换到 profilelist:0 缓存,profile:0 创建
切回 indexprofile:0 缓存,index:0 激活
再次切换到 listindex:0 缓存,list:1 创建(ID 递增)

事件隔离验证

javascript
// 假设当前页面是 index:2
// 调用 setTitle('新标题')

// 事件名:'pages/index/index:2:APPLAYOUT:SET_TITLE'

// 只会影响 index:2 这个实例
// index:0、index:1 不会受到影响

事件冲突场景与避免

可能冲突的场景

场景问题解决
不同页面同名事件A:0:SET_TITLEB:0:SET_TITLEpageId 包含完整路径
关闭页面再打开新实例 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_TITLESET_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?
第四章动态调用设计页面如何与全局容器进行通信?
第五章登录鉴权模块如何设计完整的登录鉴权体系?