uni-app 全局容器实战系列(三):全局 NavBar 和 TabBar 组件设计
系列总览
章节脉络
本系列围绕 全局容器 这一主题,从底层原理到上层应用,分为四个章节:
| 章节 | 主题 | 核心问题 |
|---|---|---|
| 第一章 | 全局容器的实现 | 如何让每个页面自动包裹布局组件? |
| 第二章 | Vite 虚拟模块 | 如何在运行时读取 pages.json 配置? |
| 第三章 | 组件设计 | 如何设计可配置的 NavBar 和 TabBar? |
| 第四章 | 动态调用设计 | 页面如何与全局容器进行通信? |
整体架构
第三章
第二章
第一章
基础设施建设
第四章
await
useAppLayout Hook
页面通信入口
registerLayout
事件注册函数
promiseWithResolvers
异步等待机制
pages.json
页面配置
vite-plugin-uni-layout
编译时注入容器
vite-plugin-uni-pages-json
虚拟模块读取配置
AppLayout.vue
布局聚合层
Layout.vue
基础容器
NavBar 组件
TabBar 组件
依赖关系:
- 第一章和第二章是基础设施,可独立理解
- 第三章依赖第一、二章
- 第四章依赖第三章
一、整体架构
1.1 组件层级
基于第一章的容器注入和第二章的虚拟模块,本章实现具体的组件设计:
virtual:uni-pages-json
AppLayout.vue
业务聚合层
Layout.vue
基础容器
NavBar
导航栏组件
TabBar
标签栏组件
1.2 组件职责
| 组件 | 职责 | 层级 |
|---|---|---|
AppLayout.vue |
聚合层:读取配置、分发 Props、注册事件 | 业务层 |
Layout.vue |
基础容器:渲染 NavBar + Content + TabBar | 基础层 |
useSafeArea |
安全区域计算(刘海屏等) | 工具层 |
1.3 数据流
NavBar Layout.vue AppLayout.vue virtual:uni-pages-json NavBar Layout.vue AppLayout.vue virtual:uni-pages-json 提供配置数据 计算 Props 传递 Props 渲染导航栏
二、设计要点
2.1 配置驱动的渲染
AppLayout 根据 pages.json 配置自动判断页面类型并渲染对应组件:
javascript
// 判断是否为 TabBar 页面
// 条件说明:
// 1. tabbar.custom:uni-app 中 custom 属性表示"自定义 TabBar"
// - 当 custom: true 时,TabBar 由用户通过组件实现,而非原生 TabBar
// - 当 custom: false 或不设置时,使用原生 TabBar
// 2. tabbar.list.some(...):遍历 TabBar 列表,检查是否存在页面路径匹配
// - some() 找到第一个匹配即返回 true,性能优于 find()
const isTabPage = computed(() => {
return tabbar.custom && tabbar.list.some(tab => tab.pagePath === currentRoute.value)
})
// 获取当前页面的配置(全局配置 + 页面单独配置合并)
// 合并策略:globalStyle 作为基础,page.style 覆盖同名属性
// 这样既保证有默认值,又允许页面单独定制
const currentPageConfig = computed(() => {
const page = pages.find(item => item.path === currentRoute.value)
return {
...globalStyle,
...(page?.style || {}),
layout: {
...globalStyle.layout,
...(page?.style?.layout || {}),
},
}
})
uni-app TabBar 配置结构:
json
{
"tabBar": {
"custom": true, // ⚠️ 关键字段:是否使用自定义 TabBar
"list": [
{
"pagePath": "pages/index/index", // 页面路径(必须与 pages.json 中一致)
"text": "首页", // TabBar 文字
"iconPath": "static/tabbar/home.png", // 未选中图标
"selectedIconPath": "static/tabbar/home-active.png" // 选中图标
}
]
}
}
2.2 Props 计算与分发
javascript
const layoutProps = computed(() => {
const { navigationStyle, navigationBarTitleText = '', layout } = currentPageConfig.value
return {
titleText: navigationBarTitleText,
titleColor: navigationBarTextStyle?.color || 'black',
showNav: !isTabPage.value && navigationStyle === 'custom',
showTab: isTabPage.value,
navBg: layout?.navBg || '#FFFFFF',
tabBg: layout?.tabBg || '#FFFFFF',
}
})
2.3 插槽机制
| 插槽名 | 说明 | 用途 |
|---|---|---|
#nav-left |
导航栏左侧区域 | 返回按钮、自定义操作 |
#nav-right |
导航栏右侧区域 | 更多菜单、分享等 |
#tab-bar |
TabBar 区域 | 渲染 TabBar 项目 |
| 默认插槽 | 页面内容 | 页面主体内容 |
三、落地实现
3.1 Layout 基础容器
vue
<!-- Layout.vue - 基础容器组件 -->
<template>
<view class="layout-container" :style="{ backgroundColor, backgroundImage }">
<!-- Navbar -->
<view v-if="showNav" class="app-navbar" :style="[navStyle]">
<view class="app-navbar-inner">
<view class="app-navbar-left">
<slot name="nav-left"></slot>
</view>
<view class="app-navbar-title" :class="{ 'is-collapsed': !isTitleVisible }">
<text :style="{ color: titleColor }">{{ titleText }}</text>
</view>
<view class="app-navbar-right">
<slot name="nav-right"></slot>
</view>
</view>
</view>
<!-- Content -->
<view class="app-content">
<slot></slot>
</view>
<!-- TabBar -->
<view v-if="showTab" class="app-tabbar" :style="[tabStyle]">
<view class="app-tabbar-inner">
<slot name="tab-bar"></slot>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { useSafeArea } from './useSafeArea'
const props = defineProps({
titleText: { type: String, default: '' },
titleColor: { type: String, default: '#fff' },
bgColor: { type: String, default: 'transparent' },
bgImage: { type: String, default: undefined },
showNav: { type: Boolean, default: false },
navHeight: { type: Number, default: 44 },
navBg: { type: String, default: 'transparent' },
showTab: { type: Boolean, default: false },
tabHeight: { type: Number, default: 50 },
tabBg: { type: String, default: '#ffffff' },
scrollTop: { type: Number, default: 0 },
titleCollapse: { type: Boolean, default: false },
collapseThreshold: { type: Number, default: 1 },
})
const { insets } = useSafeArea()
const navTotalHeight = computed(() => props.navHeight + insets.value.top)
const tabTotalHeight = computed(() => props.tabHeight + insets.value.bottom)
const contentPaddingTop = computed(() => props.showNav ? navTotalHeight.value : 0)
const contentPaddingBottom = computed(() => props.showTab ? tabTotalHeight.value : insets.value.bottom)
const isTitleVisible = computed(() => {
if (!props.titleCollapse) return true
return props.scrollTop <= props.collapseThreshold
})
</script>
<style scoped>
.app-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 900;
box-sizing: border-box;
}
.app-navbar-inner {
display: grid;
align-items: center;
height: 100%;
grid-template-columns: auto 1fr auto;
}
.app-navbar-left,
.app-navbar-right {
display: flex;
align-items: center;
height: 100%;
}
.app-navbar-title {
display: flex;
justify-content: flex-start;
align-items: center;
height: 100%;
overflow: hidden;
transition: opacity 0.3s ease-out;
}
.app-navbar-title.is-collapsed {
opacity: 0;
}
.app-content {
box-sizing: border-box;
width: 100%;
min-height: 100vh;
}
.app-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
box-sizing: border-box;
}
</style>
3.4 useSafeArea 完整实现
useSafeArea 是处理异形屏(刘海屏、挖孔屏、圆角屏)的关键 Hook:
javascript
// useSafeArea.js
import { ref, onMounted } from 'vue'
/**
* 获取安全区域信息
* 用于适配 iPhone X+、华为刘海屏、小米挖孔屏等异形屏幕
*/
export function useSafeArea() {
// insets 包含 top/bottom/left/right 四个方向的安全区域
// 单位为 px(不是 rpx),需在组件中转换
const insets = ref({
top: 0,
bottom: 0,
left: 0,
right: 0
})
onMounted(() => {
// uni.getSystemInfoSync() 获取系统信息
// safeArea 字段包含安全区域信息
const systemInfo = uni.getSystemInfoSync()
const safeArea = systemInfo.safeArea || {}
insets.value = {
top: safeArea.top || 0, // 状态栏高度
bottom: safeArea.bottom || 0, // 底部安全区(如 Home Indicator)
left: safeArea.left || 0, // 左侧安全区(折叠屏可能用到)
right: safeArea.right || 0 // 右侧安全区
}
// iOS 异形屏安全区域参考值:
// iPhone 14 Pro Max: top=59, bottom=34
// iPhone 14 Pro: top=59, bottom=34
// iPhone 14: top=47, bottom=34
//
// Android 刘海屏安全区域参考值:
// 华为 P50 Pro: top=59, bottom=34
// 小米 13 Pro: top=38, bottom=34
})
return { insets }
}
为什么需要安全区域?
┌────────────────────────────────────┐
│ ████ 状态栏 (刘海/胶囊区域) ████ │ ← top = 状态栏高度
├────────────────────────────────────┤
│ │
│ 内容区域 │
│ │
├────────────────────────────────────┤
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← bottom = Home Indicator 高度
└────────────────────────────────────┘
rpx vs px 转换:
javascript
// uni-app 中单位换算
// 设计稿通常以 750rpx 为基准(iPhone 6 屏幕宽度)
// 实际转换需要考虑屏幕宽度比例
const pxToRpx = (px) => {
const systemInfo = uni.getSystemInfoSync()
const ratio = 750 / systemInfo.windowWidth
return px * ratio
}
// 在组件中使用
const navTotalHeight = computed(() => {
// props.navHeight 单位是 rpx
// insets.value.top 单位是 px
// 需要统一单位后相加
return props.navHeight + pxToRpx(insets.value.top)
})
3.5 watchEffect 与 uni.hideTabBar() 副作用说明
为什么需要隐藏原生 TabBar?
当我们使用自定义 TabBar 组件时,原生 TabBar 仍然存在,会造成:
- 底部出现两层 TabBar
- 自定义 TabBar 被原生 TabBar 遮挡
- 页面高度计算错误
javascript
// 监视 TabBar 页面状态
watchEffect(() => {
// 当检测到是 TabBar 页面时
if (isTabPage.value) {
// uni.hideTabBar() 隐藏原生 TabBar
// 这是同步操作,会立即隐藏原生 TabBar
uni.hideTabBar()
}
})
watchEffect vs watch 的区别:
| 特性 | watchEffect | watch |
|---|---|---|
| 初始化时执行 | ✅ 自动执行 | ❌ 需设置 immediate: true |
| 依赖追踪 | 自动追踪所有响应式依赖 | 显式指定监听目标 |
| 回调参数 | 只接收 effect 函数 | 接收 (newVal, oldVal) |
| 适用场景 | 副作用操作 | 值变化前后对比 |
副作用清理:
javascript
// watchEffect 会自动收集依赖,但不会自动清理
// 如果组件切换到非 TabBar 页面,需要考虑是否要显示原生 TabBar
watchEffect(() => {
if (isTabPage.value) {
uni.hideTabBar()
}
// 注意:这里没有 else 分支
// 因为组件可能切换到其他 TabBar 页面,不需要重新显示原生 TabBar
})
多次调用 hideTabBar 的问题:
javascript
// ⚠️ 潜在问题:重复调用 hideTabBar
// 场景:用户快速切换 TabBar 页面
// watchEffect 可能被多次触发
// 解决:确保 uni.hideTabBar() 是幂等的(多次调用效果相同)
// 实际上 hideTabBar() 本身就是幂等的,无需特殊处理
// 另一个问题:切换到非 TabBar 页面后
// 原生 TabBar 是否需要重新显示?
// 答案:不需要,因为我们在全局容器层统一处理
// 非 TabBar 页面使用自定义 NavBar + 无 TabBar
3.6 安全区域计算逻辑详解
javascript
// Layout.vue 中安全区域的计算逻辑
const { insets } = useSafeArea()
// 导航栏总高度 = 开发者设置高度 + 状态栏高度
// navHeight: 由 AppLayout props 传入,单位 rpx
// insets.value.top: 系统返回,单位 px
const navTotalHeight = computed(() => props.navHeight + insets.value.top)
// ⚠️ 这里有个单位问题:
// navHeight 是 rpx
// insets.top 是 px
// 直接相加会导致计算错误!
// 正确做法:统一单位
const navTotalHeight = computed(() => {
const systemInfo = uni.getSystemInfoSync()
const ratio = 750 / systemInfo.windowWidth
return props.navHeight + insets.value.top * ratio
})
// 底部 TabBar 高度计算同理
const tabTotalHeight = computed(() => props.tabHeight + insets.value.bottom)
// 内容区域的内边距计算
// 需要补偿导航栏和 TabBar 所占的空间
const contentPaddingTop = computed(() => props.showNav ? navTotalHeight.value : 0)
const contentPaddingBottom = computed(() => props.showTab ? tabTotalHeight.value : insets.value.bottom)
为什么 contentPaddingBottom 有两种情况?
| 场景 | 计算方式 | 原因 |
|---|---|---|
| 有 TabBar | tabTotalHeight |
需要留出 TabBar 高度 |
| 无 TabBar | insets.value.bottom |
只留出底部安全区(Home Indicator) |
3.2 AppLayout 业务聚合层
vue
<!-- AppLayout.vue - 业务聚合层 -->
<template>
<Layout v-bind="layoutProps" :scrollTop="scrollTop" :collapseThreshold="50">
<!-- NavBar 左侧插槽:返回按钮 -->
<template #nav-left>
<view class="pl-[10rpx] w-[40rpx] h-[40rpx] flex justify-center items-center" @click="onNavigateBack">
<slot name="nav-back-icon">
<image class="w-28rpx h-28rpx" mode="scaleToFill" src="@/static/images/common/ic_back.png" />
</slot>
</view>
<slot name="nav-left"></slot>
</template>
<!-- NavBar 右侧插槽 -->
<template #nav-right>
<slot name="nav-right"></slot>
</template>
<!-- 页面内容插槽 -->
<slot></slot>
<!-- TabBar 插槽 -->
<template #tab-bar>
<view
v-for="tab in tabbar.list"
class="w-[110rpx] h-full flex justify-center items-center m-[0_20rpx]"
@click="switchTab(tab)"
>
<image
v-if="isTabActive(tab)"
class="w-[38rpx] h-[38rpx] mr-[8rpx]"
:src="`/${tab.selectedIconPath}`"
mode="scaleToFill"
/>
<image
v-else
class="w-[25rpx] h-[25rpx] mr-[8rpx]"
:src="`/${tab.iconPath}`"
mode="scaleToFill"
/>
<text
:class="[
'text-[14rpx]',
isTabActive(tab) ? 'font-600 text-[15rpx] !text-[#32A2FF]' : 'text-[#656B71]'
]"
>
{{ tab.text }}
</text>
</view>
</template>
</Layout>
</template>
<script setup>
import { computed, ref, watchEffect } from 'vue'
import { pages, tabbar, globalStyle } from 'virtual:uni-pages-json'
import Layout from '@/components/Layout'
import { onPageScroll } from '@dcloudio/uni-app'
const currentRoute = ref(null)
onMounted(() => {
const pages = getCurrentPages()
if (pages.length > 0) {
currentRoute.value = pages[pages.length - 1].route
}
})
// 判断是否为 TabBar 页面
const isTabPage = computed(() => {
return tabbar.custom && tabbar.list.some(tab => tab.pagePath === currentRoute.value)
})
// 当前页面配置(全局 + 页面单独)
const currentPageConfig = computed(() => {
if (!currentRoute.value) return globalStyle
const page = pages.find(item => item.path === currentRoute.value)
return {
...globalStyle,
...(page?.style || {}),
layout: {
...globalStyle.layout,
...(page?.style?.layout || {}),
},
}
})
// 计算传递给 Layout 的 Props
const layoutProps = computed(() => {
const { navigationStyle, navigationBarTitleText = '', navigationBarTextStyle, layout } = currentPageConfig.value
if (!layout) return {}
const {
bgColor = 'transparent',
bgImage,
showTab = isTabPage.value,
tabHeight = 55,
tabBg = '#FFFFFF',
showNav = !isTabPage.value && navigationStyle === 'custom',
navHeight = 60,
navBg = '#FFFFFF',
navPlaceholder = true,
titleCollapse = true,
} = layout
return {
titleText: navigationBarTitleText,
titleColor: navigationBarTextStyle?.color || 'black',
bgColor,
bgImage,
showTab,
tabHeight,
tabBg,
showNav,
navHeight,
navBg,
useNavPlaceholder: navPlaceholder,
titleCollapse,
}
})
// TabBar 显示时隐藏原生 TabBar
watchEffect(() => {
if (isTabPage.value) {
uni.hideTabBar()
}
})
// TabBar 项目是否激活
const isTabActive = (item) => currentRoute.value === item.pagePath
// 切换 Tab
const switchTab = (item) => {
if (isTabActive(item)) return
uni.switchTab({ url: '/' + item.pagePath })
}
// 返回上一页或跳转到首页
const indexRoute = pages[0].path
const onNavigateBack = () => {
if (getCurrentPages().length > 1) {
uni.navigateBack()
} else {
if (indexRoute !== currentRoute.value) {
uni.reLaunch({ url: `/${indexRoute}` })
}
}
}
// 滚动监听
const scrollTop = ref(0)
onPageScroll((e) => { scrollTop.value = e.scrollTop })
</script>
四、核心价值
| 能力 | 传统方式 | 本方案 |
|---|---|---|
| 全局样式 | 每个页面单独配置 | pages.json 统一配置 |
| 页面类型判断 | 手动维护 | 自动根据 tabbar.list 判断 |
| 标题设置 | uni.setNavigationBarTitle |
配置驱动 + Props |
| 动态样式 | 页面内手动计算 | Props 透传 |
| 插槽扩展 | 受限 | 灵活的左右侧插槽 |
五、总结
本组件体系基于虚拟模块实现了:
| 核心要点 | 说明 |
|---|---|
| 配置中心化 | Navbar/Tabbar 样式来源 pages.json |
| 自动化渲染 | AppLayout 自动识别页面类型并渲染对应组件 |
| 插槽化设计 | 灵活自定义 NavBar 左右区域、TabBar 内容 |
| 安全区域适配 | useSafeArea 自动处理异形屏 |
当前限制 :页面只能读取配置,无法动态修改容器的标题、颜色等样式。下一章我们将解决这一问题。
下章预告
第三章解决了「如何设计可配置的 NavBar 和 TabBar 组件」的问题。
但目前页面只能被动读取配置,无法主动修改标题、颜色等。如果页面需要根据业务状态动态修改标题,该如何实现?
下一章我们将探讨:[第四章] 页面如何与全局容器进行通信?