news 2026/9/15 13:22:45

uni-app 自定义底部导航栏实战:组件化、路由切换与安全区适配

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
uni-app 自定义底部导航栏实战:组件化、路由切换与安全区适配

简介:这份zip源码面向uniapp开发者,采用Vue语法实现自定义底部导航栏,能够解决小程序、App等多端底部导航定制与样式适配难题,适合中初级前端学习者参考,也可供需要快速搭建自定义布局的开发者直接借鉴。压缩包内共23个文件,涵盖vue页面文件(负责导航结构)、js逻辑脚本(处理点击切换与交互)、json页面配置、css样式表以及html预览页面,整体大小仅122KB,结构精简,方便直接对照学习和迁移修改。当前已有213人学习下载。资源不仅展示了底部导航栏从配置到渲染的完整链路,还包含点击切换、动态样式、图标与文字组合等实现细节;附带的说明文档可帮助理清目录结构和运行方式,既能作为课堂项目参考,也能在此基础扩展实现更多自定义导航效果,例如结合多端适配思路调整选中态与动画,是一份轻量实用的uni-app源码范例。

1. uni-app 自定义底部导航栏为什么值得自己写一套

在 uni-app 项目里做自定义底部导航栏,很多人第一反应是去 pages.json 配一项 tabBar,跑通很容易,可需求一旦变成动态角标、中间凸起按钮、按角色显示或隐藏某个 tab,原生 tabBar 就开始拖后腿。换成 Vue 语法手写一套导航组件后,导航栏就从框架能力降级成普通视图,交互逻辑、样式、数据来源全部收归业务代码,H5、微信小程序和 App 端还能保持同一套表现。这篇实战笔记基于一份 uni-app 自定义底部导航栏项目源码整理,覆盖组件写法、路由切换、安全区适配和常见报错,适合正在做跨端改版或想绕开原生限制的开发者参考。

2. 拆开原生 tabBar 的边界:为什么必须组件化

2.1 原生 tabBar 的边界与自定义方案选型

原生 tabBar 的优势是零代码、首屏快、自带页面切换缓存,短板也在它自己身上:icon 只能用本地静态图片,tab 数量固定,无法在中间放一个凸起的“发布”按钮,动态角标能力在部分端上还受版本限制。早期版本的 uni-app 里想隐藏某一个 tab 只能改 pages.json 后重新编译,运行期无法控制,这在实际业务中基本是劝退项。等到需要权限控制、运营配置或者特殊样式时,开发通常会转向自定义组件方案。

自定义导航组件在项目中的定位是业务组件而非框架能力,因此设计时要把数据源、路由跳转和样式完全解耦。我的建议是 items 数组只描述“长什么样、点哪里”,选中态由页面在 onShow 里同步,避免组件内部与页面互相引用。下面这张表是几种常见方案的适用范围,方便按项目现状对号入座:

方案适用场景主要限制
原生 tabBar图标固定、tab 固定、无角标需求无法中间凸起、动态增删 tab
cover-view 覆盖只改视觉效果,不改逻辑H5 端还原度差
自定义组件 + 页面占位角标、凸起、权限控制、复杂样式需要处理安全区与路由状态
插件市场现成组件赶进度,接受样式依赖深度定制时改造成本高

看起来是简单的底部导航,放到跨端项目里会牵扯路由缓存、页面栈和样式隔离,先把边界想清楚,比写完再返工要省得多。

2.2 页面结构与路由配置:把 tab 页当成普通页面注册

自定义导航的前提是放弃原生 tabBar,也就是 pages.json 里不写 tabBar 字段,把原本的 tab 页面注册成普通页面。如果项目里已经有原生 tabBar 配置,先删掉,否则页面底部会出现原生 bar 与自定义组件叠在一起的双底栏,这是一个很明显的实现瑕疵。

{ "pages": [ { "path": "pages/home/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/category/index", "style": { "navigationBarTitleText": "分类" } }, { "path": "pages/publish/index", "style": { "navigationBarTitleText": "发布" } }, { "path": "pages/cart/index", "style": { "navigationBarTitleText": "购物车" } }, { "path": "pages/mine/index", "style": { "navigationBarTitleText": "我的" } } ] }

这些页面变成普通页面后,跳转不能再使用 uni.switchTab,否则会报找不到 tabBar 页面。常规做法是首页、分类、购物车、我的这类低频栈页面用 uni.reLaunch,发布这种功能页用 uni.redirectTo。两者的差别在于 reLaunch 会关闭所有页面再打开目标页,页面栈只积累一层;redirectTo 只关闭当前页,保留进入前的页面栈,适合返回链条清晰的模块。页面注册顺序同样有影响,小程序端首屏页面放在 pages 数组第一位能减少启动时的页面加载耗时。

2.3 数据驱动:组件里的选中态从哪来

导航组件内部不需要知道业务细节,只要接收一个 items 数组和一个 current 索引。items 的每一项包含 pagePath、iconPath、selectedIconPath、text,需要扩展时追加 badge、openType 字段即可。数据源可以是静态常量,也可以来自 Vuex 或 Pinia 的全局状态,但只要多个页面共用,就要保证状态来源单一。

<template> <view class="custom-tabbar"> <view v-for="(item, index) in items" :key="item.pagePath" class="tabbar-item" :class="{ active: currentIndex === index }" @tap="handleTabClick(item, index)" > <image class="tabbar-icon" :src="currentIndex === index ? item.selectedIconPath : item.iconPath" /> <text class="tabbar-text">{{ item.text }}</text> </view> </view> </template> <script> export default { name: 'CustomTabbar', props: { items: { type: Array, required: true }, current: { type: Number, default: 0 } }, data() { return { currentIndex: this.current }; }, watch: { current(val) { this.currentIndex = val; } }, methods: { handleTabClick(item, index) { if (index === this.currentIndex) return; if (item.openType === 'reLaunch') { uni.reLaunch({ url: item.pagePath }); } else { uni.redirectTo({ url: item.pagePath }); } } } }; </script>

这段代码里的关键逻辑有三处:props 传入的 current 只作为初始值和外部同步入口,组件内部维护 currentIndex 是为了让点击响应更跟手;watch 监听 current 是为了支持非点击场景下的选中态变化,比如从支付完成页返回时外部重新指定当前 tab;handleTabClick 里根据 openType 区分 reLaunch 和 redirectTo,避免所有 tab 都走同一种跳转导致页面栈不可控。图标切换用三元表达式实时替换 src,比同时渲染两个 image 再切换 display 更省性能。

3. Vue 语法实现:从组件模板到页面接入的完整源码

3.1 模板与样式:fixed 定位和安全区适配

导航栏视觉上要固定在底部,样式上用 position: fixed,left、right、bottom 置 0。高度一般取 100rpx,对应 50px,具体以设计稿为准。真正的坑是 iPhone 全面屏底部的手势条区域,需要在组件根部加上 safe-area 的 padding 处理,否则最后一个 tab 的文字会被手势条压住。

<template> <view class="custom-tabbar"> <view class="tabbar-content"> <block v-for="(item, index) in items" :key="item.pagePath"> <view class="tabbar-item" :class="{ active: currentIndex === index }" @tap="handleTabClick(item, index)" > <view class="tabbar-icon-wrap"> <image class="tabbar-icon" :src="currentIndex === index ? item.selectedIconPath : item.iconPath" mode="aspectFit" /> <text v-if="item.badge" class="tabbar-badge">{{ item.badge }}</text> </view> <text class="tabbar-text">{{ item.text }}</text> </view> </block> <slot name="center"></slot> </view> </view> </template> <style scoped> .custom-tabbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 999; background-color: #ffffff; box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06); } .tabbar-content { display: flex; height: 100rpx; align-items: center; justify-content: space-around; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); } .tabbar-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .tabbar-icon { width: 48rpx; height: 48rpx; } .tabbar-icon-wrap { position: relative; } .tabbar-badge { position: absolute; top: -10rpx; right: -16rpx; min-width: 28rpx; height: 28rpx; padding: 0 6rpx; background-color: #fa3534; color: #ffffff; border-radius: 14rpx; font-size: 20rpx; line-height: 28rpx; text-align: center; } </style>

关键参数集中在底部安全区处理:padding-bottom 同时写 constant(safe-area-inset-bottom) 和 env(safe-area-inset-bottom),前者是 iOS 11 前后的兼容写法,后者是标准写法,两行缺一不可。height: 100rpx 会在不同屏幕上自动换算,普通设计稿给 50px 高度,乘 2 就是 100rpx。tabbar-badge 的最小宽度设成 28rpx 并配合 border-radius: 14rpx,数字超过两位时会自动拉长成胶囊形状。

3.2 跳转逻辑:switchTab 不再可用后的正确姿势

组件里跳转不能直接复用原生 tab 的 switchTab,因为页面已经不再是 tabBar 页面。经验做法是给每一项配置 openType,默认走 reLaunch,发布、编辑这类需要保留返回栈的页面配 redirectTo。

handleTabClick(item, index) { if (index === this.currentIndex) return; const url = item.pagePath; if (item.openType === 'reLaunch') { uni.reLaunch({ url, success: () => { this.currentIndex = index; }, fail: (err) => { console.error('[CustomTabbar] reLaunch failed', err); } }); } else { uni.redirectTo({ url, fail: (err) => { console.error('[CustomTabbar] redirect failed', err); } }); } }

成功回调里再更新 currentIndex,而不是点击后立刻改,是为了避免跳转失败时选中态已经变化,页面和导航栏状态对不上。调试时看到 fail 回调,第一反应是检查 pages.json 里的 path 是否带前导斜杠、文件名是否正确。另一个容易忽略的点是:reLaunch 会清空页面栈,从 tab 页跳详情页后想用返回回到前一个 tab 是做不到的,这种场景要把详情页设计成普通页面跳转,而不是 tab 入口。

3.3 页面接入:占位高度与原生导航栏隐藏

页面底部要留出和组件等高的占位,否则固定定位的导航栏会盖住内容。占位高度必须加上安全区高度,iOS 上才不会被手势条遮住最后一行的操作按钮。接入时组件放在页面模板的最外层,并传入当前页对应的 items 和 current。

<template> <view class="page"> <view class="page-content"> <text class="page-title">首页内容区</text> </view> <view class="tabbar-placeholder"></view> <CustomTabbar :items="tabbarItems" :current="0" /> </view> </template> <script> import CustomTabbar from '@/components/custom-tabbar/index.vue'; export default { components: { CustomTabbar }, data() { return { tabbarItems: [ { pagePath: '/pages/home/index', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', text: '首页', openType: 'reLaunch' }, { pagePath: '/pages/category/index', iconPath: '/static/tabbar/category.png', selectedIconPath: '/static/tabbar/category-active.png', text: '分类', openType: 'reLaunch' }, { pagePath: '/pages/publish/index', iconPath: '/static/tabbar/publish.png', selectedIconPath: '/static/tabbar/publish-active.png', text: '发布', openType: 'redirectTo' }, { pagePath: '/pages/cart/index', iconPath: '/static/tabbar/cart.png', selectedIconPath: '/static/tabbar/cart-active.png', text: '购物车', openType: 'reLaunch' }, { pagePath: '/pages/mine/index', iconPath: '/static/tabbar/mine.png', selectedIconPath: '/static/tabbar/mine-active.png', text: '我的', openType: 'reLaunch' } ] }; } }; </script> <style scoped> .page { min-height: 100vh; background-color: #f7f8fa; } .tabbar-placeholder { height: 100rpx; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); } </style>

tabbarItems 里的 pagePath 必须与 pages.json 注册的路径一致,多一个斜杠或者少一个斜杠都会导致 reLaunch 失败。图标路径这里写成 /static/tabbar/xxx.png,字符串形式在小程序端最稳。存在多个 tab 页面时,建议把 tabbarItems 抽取到公共配置文件里,各页面直接从配置引入,避免每个页面各写一份,改一个 tab 要全量替换。占位 view 的样式与导航栏高度保持同步,组件高度一旦调整,这里也要跟着改。

样式值px 值说明
100rpx50px导航栏高度
48rpx24px图标宽高
28rpx14px角标最小宽度
20rpx10px角标字号

这里的 rpx 和 px 换算基准是 750 设计稿宽度,rpx 在宽屏设备上自动缩放,做适配时直接用 rpx 写尺寸比手算 px 更稳。

4. 进阶改造:角标、凸起按钮与多端差异处理

4.1 把 uni.setTabBarBadge 换成自己的角标渲染

原生方案里给 tab 加角标要调用 uni.setTabBarBadge 并指定 index,自定义组件方案直接在数据层控制。items 里预留 badge 字段,值为 0 或空字符串时不渲染,大于 99 时显示 99+,这个规则和很多消息列表的角标规范一致。

setBadge(tabIndex, count) { if (!this.items[tabIndex]) return; this.$set(this.items[tabIndex], 'badge', count > 99 ? '99+' : String(count)); }

用 $set 是为了让新增的 badge 字段具备响应式能力,直接 this.items[tabIndex].badge = xxx 在小程序端可能不会触发视图更新。调用方在父组件里通过 ref 拿子组件实例:this.$refs.tabbar.setBadge(0, 5)。如果项目里同时残留原生 tabBar,setTabBarBadge 会和自定义角标各自渲染一个,出现双角标叠影,所以接入自定义方案时原生 tabBar 要从 pages.json 里彻底移除。

需要在角标上支持小红点模式时,可以再加一个 badgeType 字段。值为 dot 时只渲染 8rpx 的圆点,值为 number 时走数字逻辑,运营活动要的“有小圆点但不显示数字”也能在一个组件里兼容。

4.2 中间凸起按钮:slot 插槽与 z-index 控制

中间按钮凸起一般分两种:一种图标比两边稍大但并不超出导航栏,另一种是整体按钮向上偏移并带自己的点击事件。后一种更适合用 slot 实现,模板里预留一个 center 插槽,默认内容显示“发布”按钮,业务页面需要定制时可以覆盖。

<view class="tabbar-center-slot" @tap="handleCenterClick"> <slot name="center"> <view class="center-default"> <image class="center-icon" src="/static/tabbar/center.png" /> <text class="center-text">发布</text> </view> </slot> </view>

对应样式让插槽容器绝对定位于导航栏中间偏上:

.tabbar-center-slot { position: absolute; left: 50%; bottom: 30rpx; transform: translateX(-50%); z-index: 1000; width: 120rpx; height: 120rpx; display: flex; align-items: center; justify-content: center; }

transform: translateX(-50%) 配合 left: 50%,让按钮在任意宽度下都居中,比 left 写死像素要稳。z-index 要比导航栏容器高,但低于页面上常见弹层的层级,否则弹层出现时中间按钮会穿透显示。点击区域不要小于 88rpx,真机上手指触摸区域过窄容易误触相邻 tab。H5 端这段定位没有兼容问题,小程序端注意不要在插槽外层使用 overflow: hidden,否则凸出的部分会被裁掉。

4.3 多端差异:H5、微信小程序与 App 的表现差异

同一套组件在不同端的差异,集中体现在安全区、图片渲染和路由转场上。H5 端 env(safe-area-inset-bottom) 在部分安卓浏览器里不生效,需要额外的媒体查询兜底;小程序端的 image 组件默认有 320px 宽度的上限,必须显式设置 width 和 height;App 端使用 vue 页面时,页面切换自带原生转场,导航栏的选中态如果不在 onShow 里及时同步,切换回来时会看到选中态闪一下再归位。

安全区图片渲染切换动画
H5env() 部分安卓不生效CSS 控制即可无原生转场
微信小程序部分机型返回值偏大需显式宽高转场由基础库控制
App需结合 manifest 屏幕适配网络图可缓存原生转场易造成闪烁

建议每个 tab 页面都在 onShow 里同步当前索引,不要在 onLoad 里只设置一次。因为从普通页面返回 tab 页时 onLoad 不一定执行,onShow 每次显示都会触发。同步代码就一行:通过当前页面路由匹配 items 下标,匹配成功后把 current 传给组件。对于做过多端项目的团队,还建议把这一行同步逻辑放到页面公共 mixin 里,避免复制到每个页面后各写各的。

5. 排错与验证:照着这几步排查自定义导航栏问题

5.1 真机底部被 iPhone 手势条遮挡

检查 .tabbar-content 是否同时写了 constant(safe-area-inset-bottom) 和 env(safe-area-inset-bottom),且占位 view 的高度与组件保持一致。遗漏的情况下,导航栏内容被手势条压住,但页面内容未必会被遮挡,因为 fixed 定位与占位是两条独立的渲染路径。

5.2 页面切换后导航栏选中态不对

优先检查当前页面的 onShow 是否覆盖了组件 current。不要依赖组件内部的 currentIndex 自增,用户可能通过左上角返回、扫码或推送消息进入任意页面,导航栏必须根据当前路由反推选中索引。

onShow() { const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const route = `/${currentPage.route}`; const idx = this.tabbarItems.findIndex((item) => item.pagePath === route); if (idx > -1) { this.currentIndex = idx; } }

这段代码放在页面 data 对应的脚本里,核心是 route 与 pagePath 的精确匹配。getCurrentPages 在小程序端拿到的是页面栈,App 端同样适用,如果匹配不到索引,说明 pages.json 中的 path 与 tabbarItems 中的 pagePath 不一致,去查斜杠和大小写即可。

5.3 图标不显示或显示 404

先看路径是否以 /static/ 开头,相对路径在小程序端经常解析失败。再看图片格式,svg 在小程序部分基础库版本不被 tab 图标支持,webp 也有版本门槛,统一转成 png 或 jpg 最省事。H5 端还涉及跨域,图片服务器没有开启 CORS 时控制台会直接报加载失败。

5.4 用构建命令验证产物

HBuilderX 里可视化运行时不易接力 IDE 工具链,建议保留 npm 脚本用于验证。微信小程序端跑 npm run dev:mp-weixin,产物生成到 dist/dev/mp-weixin,打开微信开发者工具导入该目录即可。验收时逐项过这张表:

验证项通过标准
pages.json 是否移除 tabBar 字段无原生底栏残留
每个页面占位高度是否正确最后一屏内容不被遮挡
currentIndex 与路由是否匹配切换后选中态不变
安全区两条 padding 是否齐全iPhone 手势条不压导航栏
图标资源是否为 png/jpg控制台无 404

按这张表逐项过一遍,基本能在十分钟内定位自定义导航栏的常见问题。最后提醒一个细节:微信开发者工具里模拟 iPhone 13 与真机表现经常有偏差,安全区相关问题建议以真机为准。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 13:22:37

KMP网络层分层设计:Android/iOS/桌面端统一网络请求架构

1. 项目概述&#xff1a;为什么KMP成了Android网络层的新基建&#xff1f;“AndroidKMP之网络请求”——这六个字背后&#xff0c;不是又一个“Kotlin Multiplatform Retrofit”的简单拼接&#xff0c;而是一次对Android工程架构底层逻辑的重新校准。我从2019年第一批在生产环…

作者头像 李华
网站建设 2026/9/15 13:22:10

基于Vue 3的会议室预定系统源码:从冲突检测到部署实践

简介&#xff1a;一套基于Vue框架开发的会议室预定系统设计源码&#xff0c;面向需要快速搭建或学习企业级会议室管理场景的前端开发者&#xff0c;也可作为毕业设计或课程项目参考&#xff0c;帮助解决会议资源冲突、预定流程繁琐等常见问题。源码包共33个文件&#xff0c;约8…

作者头像 李华
网站建设 2026/9/15 13:18:21

如何用 Vitest 的 vi.when 按不同参数让 mock 函数返回不同结果

如何用 Vitest 的 vi.when 按不同参数让 mock 函数返回不同结果 【免费下载链接】vitest Next generation testing framework powered by Vite. 项目地址: https://gitcode.com/GitHub_Trending/vi/vitest 当一个 spy 需要针对不同的调用参数返回不同结果时&#xff0c;…

作者头像 李华