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 组件」的问题。

但目前页面只能被动读取配置,无法主动修改标题、颜色等。如果页面需要根据业务状态动态修改标题,该如何实现?

下一章我们将探讨:[第四章] 页面如何与全局容器进行通信?

相关推荐
一颗小青松14 小时前
uniapp输入框fixed定位,导致页面顶起解决方案
前端·uni-app
2501_9151063221 小时前
深入解析无源码iOS加固原理与方案,保护应用安全
android·安全·ios·小程序·uni-app·cocoa·iphone
万能小林子1 天前
2026 AI开发新范式:Vibe Coding生成网页 + 3分钟打包成App,非技术人也能独立发布自己的App!
人工智能·uni-app·ai编程·web app·vibecoding
一颗小青松1 天前
uniapp 集成友盟并且上传页面路径
前端·vue.js·uni-app
00后程序员张2 天前
HTTPS单向认证、双向认证、抓包原理与反抓包策略详解
网络协议·http·ios·小程序·https·uni-app·iphone
h_65432102 天前
uniapp-APP端获取拍照时的方向角,同一位置横竖屏拍方向角一致
uni-app
梦梦代码精2 天前
LikeShop按摩到家系统:2026年本地生活创业新风口,上门服务O2O源码私有化部署实战
大数据·docker·小程序·uni-app·生活·高并发·开源软件
这是个栗子2 天前
【uni-app微信小程序问题解决】Uni-app 微信小程序组件不渲染
微信小程序·小程序·uni-app