简介:这是一份基于Vue语法的uni-app自定义底部导航栏完整项目源码,面向小程序、H5及App跨端开发者,用来解决默认导航栏在图标样式、选中态、交互反馈等方面不够灵活的痛点,也适合学习uni-app组件化开发的初中级前端人员上手研读。压缩包共23个文件,整体大小仅122KB,包含6个HTML页面用于预览多端页面结构,5个JavaScript文件承载导航切换及页面交互逻辑,3个CSS样式文件控制配色和布局,另有2个Vue组件、3个JSON配置、wxss样式以及README说明,目录划分清晰,便于按需查阅。目前已有213人学习下载。这份源码不但展示了自定义导航栏的完整实现链路,还体现了uni-app一次编写、多端运行的典型写法;读者可参考其中组件拆分、事件通信与样式适配方式,直接复用到自己的项目中,有效缩短底部导航功能的开发调试周期。
1. uni-app 自定义底部导航栏:用 vue 语法重写 tabBar 的思路
uni-app 自定义底部导航栏,听起来只是改一个pages.json里的tabBar配置,但真正要交付的项目,几乎都会把底部导航做成一个 vue 语法格式的组件。原因很直接:原生 tabBar 只能放“图标+文字”,不能放数字角标、不能插入突出按钮、不能根据接口返回动态调整 tab 顺序。把底部导航栏从配置项变成组件,等于把这一块 UI 的渲染、交互和数据绑定全部交给 vue 来管,后续不管做运营活动、多角色切换还是埋点统计,都只改组件源码,不碰框架底层。这个方案适合刚接触 uni-app 的开发者,也适合准备把 demo 升级成项目源码的老手。
2. 自定义底部导航栏的选型与边界:原生 tabBar、custom 模式、自绘组件怎么选
2.1 原生 tabBar 的五个局限
uni-app 内置的tabBar是原生渲染的,它的优点很突出:性能好、切换不需要重新加载页面、生命周期完整。但如果你把它往“自定义”方向推,很快会撞到边界。
第一个局限是样式。原生 tabBar 允许配置的颜色和图标很固定,无法使用 iconfont,也不能在图标上叠加任意组件。第二个局限是没有角标能力,购物车数量、未读消息这个小红点必须自己画一个 view 盖上去,但原生 tabBar 的层级很高,普通页面里的 view 很难盖住它。第三个局限是中间按钮,外卖类应用里常见的“发布”“扫码”凸出按钮,原生 tabBar 完全做不了。第四个局限是动态配置,后台返回的菜单不能实时更新原生 tabBar。第五个局限是跨端差异,同一个tabBar配置在不同端上解析出来的样式不总是一致。
这些局限意味着:一旦原型图里出现的不是规规矩矩的四个方 tab,基本就要考虑放弃原生方案。
2.2 三种自定义方案的对比
2.2.1 原生 tabBar 配置保留,只换文字和图标
这是最轻量的方式,适用场景是 tab 数量固定、也没有特殊形态要求。做法是在pages.json里写tabBar节点,只使用原生支持的颜色、图片字段。这种方式改起来最快,但它不叫“自定义底部导航栏”,它只是改配置。
2.2.2 原生 tabBar 的 custom 模式
uni-app 支持在pages.json的tabBar节点中加入"custom": true,此时原生 tabBar 会被隐藏,改由代码提供自定义组件接管 tab 渲染和切换。这个方案在微信小程序侧走的是官方自定义 tabBar 通道,页面栈和uni.switchTab都能正常工作。
但需要注意,H5 端和 App 端对custom模式的支持不完全一致,不同版本的小程序基础库行为也有差异。如果团队要同时保住 H5 和 App,这个方案需要额外做条件编译,维护成本并不低。
2.2.3 自绘组件 + 普通页面跳转
做法是把底部导航栏写成一个普通 vue 组件,放置在 tab 页面底部,用fixed定位固定在屏幕下缘。页面与页面之间通过uni.reLaunch或uni.redirectTo切换,完全脱离原生 tabBar 体系。这是兼容性最稳定、样式最自由的做法,也是大多数 uni-app 项目源码里真正采用的方案。
三种方案的对比,可以直接落在表格里:
| 方案 | 自定义程度 | 页面状态保留 | 平台兼容性 | 维护成本 |
|---|---|---|---|---|
| 原生 tabBar 改配置 | 低 | 好 | 最好 | 低 |
| 原生 tabBar + custom 模式 | 中 | 好 | 小程序好,H5/App 有差异 | 中 |
| 自绘组件 + reLaunch 跳转 | 高 | 一般 | 全平台一致 | 中低 |
2.3 为什么选择自绘组件,以及和 vue-router 的差异
这里的核心判断是:底部导航栏属于 UI 层,不该被框架的原生能力绑死。用 vue 语法格式实现组件后,图标、角标、动画、埋点都能用常规代码控制。同时要注意,uni-app 的页面跳转与 Vue 生态里的 vue-router 完全是两套东西,底部导航栏里不能用this.$router.push来写跳转,必须走uni全局对象上的路由方法。
有些人会在这里自然想到 vue 路由参数怎么办,答案是 uni-app 页面路由传参只能通过url字符串携带参数。如果你把底部导航栏做成通用组件,这四个 tab 页面的路径就是写死在组件数据里的。
3. 用 vue 语法写一个 uni-app 自定义底部导航栏组件:源码与参数说明
3.1 组件目录结构与 barList 数据结构
我一般会在项目源码里像这样维护自定义导航栏的目录:
src/ ├─ pages/ │ ├─ index/ │ │ └─ index.vue │ ├─ category/ │ │ └─ index.vue │ ├─ cart/ │ │ └─ index.vue │ └─ mine/ │ └─ index.vue └─ components/ └─ custom-tab-bar/ ├─ index.vue └─ README.mdindex.vue就是自定义底部导航栏组件的唯一入口。下面这段代码是script部分的数据源,把 tab 的路径、文字、图标收敛到一个数组里:
data() { return { barList: [ { path: '/pages/index/index', text: '首页', iconPath: '/static/tab/home.png', selectedIconPath: '/static/tab/home-active.png' }, { path: '/pages/category/index', text: '分类', iconPath: '/static/tab/category.png', selectedIconPath: '/static/tab/category-active.png' }, { path: '/pages/cart/index', text: '购物车', iconPath: '/static/tab/cart.png', selectedIconPath: '/static/tab/cart-active.png' }, { path: '/pages/mine/index', text: '我的', iconPath: '/static/tab/mine.png', selectedIconPath: '/static/tab/mine-active.png' } ], current: 0 } }path必须与pages.json里注册的页面路径完全一致,否则跳转时会报页面找不到的错误。iconPath与selectedIconPath一般放静态目录/static/tab/下,小程序端不支持远程图片作为 tab 图标,所以图标一定要本地化。
3.2 组件模板与样式驱动的四个 tab 项
模板部分用v-for渲染barList,代码量很小,但每一步都要理解:
<template> <view class="custom-tab-bar"> <view v-for="(item, index) in barList" :key="item.path" class="custom-tab-bar__item" :class="{ 'custom-tab-bar__item--active': current === index }" @tap="handleTabTap(index, item)" > <image :src="current === index ? item.selectedIconPath : item.iconPath" class="custom-tab-bar__icon" mode="aspectFit" /> <text class="custom-tab-bar__text">{{ item.text }}</text> </view> </view> </template>v-for的key使用item.path,这样比默认的 index 作为 key 更稳定,方便后续增删 tab。current === index同时控制图标高亮和文字颜色。@tap绑定点击事件,注意编译到小程序端时@click会被转成bindtap,写@tap可以避免某些端上事件不触发的差异。
样式部分,重点是position: fixed和display: flex:
.custom-tab-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 999; display: flex; height: 50px; background: #ffffff; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); } .custom-tab-bar__item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .custom-tab-bar__icon { width: 24px; height: 24px; }这里z-index: 999是为了盖住页面局部滚动时露出的内容,但 z-index 不能解决所有问题,后面第 4 章里安全区适配的那部分还得搭配padding-bottom使用。
3.3 点击切换逻辑与 uni.reLaunch 的传参细节
组件的核心切换方法如下:
methods: { handleTabTap(index, item) { if (index === this.current) { return } uni.reLaunch({ url: item.path }) } }使用uni.reLaunch而不是uni.redirectTo,原因是reLaunch会关闭所有页面,把当前页面从栈中清空并打开一个新的页面。当四个 tab 页面之间互相切换时,旧页面、新页面的事件栈不容易乱。缺点是页面实例会重新创建,像列表滚动位置这类页面状态不会保留,所以如果你的 tab 页面里有需要长期保存的 UI 状态,可以在负一屏提交到 vuex,这样切回来再重新取。
url传参必须完整:'/pages/index/index'前面斜杠不能省,小程序端跳转会校验路径,少斜杠会直接提示找不到页面。
3.4 自动确定选中 tab:用 getCurrentPages 计算 current
上面是手动传current,在工程化项目里更常见的是让组件自己从路由栈里判断。我把current从固定值改成监听路由变化后计算,这样外界不需要显式给组件传 selected 变量:
onShow() { const pages = getCurrentPages() if (!pages.length) { return } const route = `/${pages[pages.length - 1].route}` const index = this.barList.findIndex((item) => item.path === route) this.current = index > -1 ? index : 0 }getCurrentPages()是宿主环境提供的页面栈数组,最后一个元素是当前页面。用这个逻辑,无论从哪个页面进入,组件都能自动高亮对应 tab,省去了每个页面的onShow里手工设置当前索引的冗余代码。
4. 把自定义底部导航栏接入 uni-app 项目:页面引入、安全区与数据共享
4.1 页面引入组件与 pages.json 配置
组件写好后,在 tab 页面中使用,以首页为例:
<template> <view class="page-wrapper"> <view class="page-main"> <!-- 页面主体内容 --> </view> <custom-tab-bar /> </view> </template> <script> import CustomTabBar from '@/components/custom-tab-bar/index.vue' export default { components: { CustomTabBar } } </script>组件放在page-wrapper内部最后一个位置,配合组件的fixed定位实现浮层效果。因为页面主体内容会顶到屏幕最底部,所以page-main需要一个padding-bottom,避免内容被导航栏遮住:
.page-main { padding-bottom: calc(50px + constant(safe-area-inset-bottom)); padding-bottom: calc(50px + env(safe-area-inset-bottom)); }这时的pages.json里四个 tab 页面会被注册成普通页面,不再写tabBar节点:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/category/index", "style": { "navigationBarTitleText": "分类" } }, { "path": "pages/cart/index", "style": { "navigationBarTitleText": "购物车" } }, { "path": "pages/mine/index", "style": { "navigationBarTitleText": "我的" } } ] }navigationStyle不要动,底部导航栏只影响底部,顶部保留默认导航栏即可。
4.2 iPhone 底部安全区适配的两个关键参数
iPhone X 之后的全屏机型,底部有一个 home indicator 区域,如果自定义导航栏直接停在bottom: 0高度 50px,会被系统手势条挡住。正确做法是给导航栏容器增加底部安全距离:
.custom-tab-bar { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }constant是 iOS 11.0 之前的环境变量写法,env是 iOS 11.2 之后的写法。两条声明都要写,顺序必须是constant在前、env在后,这样低版本读constant,高版本读env。
4.3 用全局状态同步 tab 数据
自定义底部导航栏如果只是纯静态展示,page 之间不需要通信。但实际项目里经常有这个场景:用户登录后,第三个 tab 的文字要变成“我的订单”,或者购物车 tab 要显示角标。这时把barList和current提到 vuex 里更合适。
在store/index.js里维护一份与组件完全一致的数据:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) const store = new Vuex.Store({ state: { tabBar: { current: 0, list: [ { path: '/pages/index/index', text: '首页' }, { path: '/pages/category/index', text: '分类' }, { path: '/pages/cart/index', text: '购物车' }, { path: '/pages/mine/index', text: '我的' } ] } }, mutations: { SET_CURRENT_TAB(state, index) { state.tabBar.current = index } } }) export default store组件里不直接操作data,而是连接mapState和mapMutations:
import { mapState, mapMutations } from 'vuex' computed: { ...mapState({ barList: (state) => state.tabBar.list, current: (state) => state.tabBar.current }) }, methods: { ...mapMutations(['SET_CURRENT_TAB']) }这样后台接口改了 tab 文字或顺序,只需要改动 store 里的数组,页面上所有自定义导航栏实例自动同步更新,不需要逐个页面改源码。
5. 自定义底部导航栏的三个进阶技巧:路由联动、角标更新与真机排错
5.1 用 getCurrentPages 自动联动选中态
在自绘组件方案里,每次reLaunch都会新开页面,旧页面不复用,所以组件里的current需要重新计算。常见做法是每个 tab 页面的onShow中调用组件的updateCurrent方法,但更省事的做法是给组件加一个watch,监听页面栈变化。
下面这个写法可以放在App.vue的onShow中派发一个小事件,通知导航栏刷新当前选中态:
// App.vue 中 onShow() { const pages = getCurrentPages() const route = pages.length ? `/${pages[pages.length - 1].route}` : '' uni.$emit('updateCurrentTab', route) }组件里监听:
onLoad() { uni.$on('updateCurrentTab', (route) => { const index = this.barList.findIndex((item) => item.path === route) if (index > -1) { this.setCurrentTab(index) } }) }uni.$emit与uni.$on是 uni-app 内置的事件总线,适合轻量通信。注意页面卸载时用uni.$off反注册,避免多次进入页面导致事件重复绑定。
5.2 快速给 tab 加角标
角标是自定义导航栏最常见的需求。在barList的数据结构里,为每个 tab 增加一个badge字段,模板部分用条件渲染控制显隐:
<view v-for="(item, index) in barList" :key="item.path" class="custom-tab-bar__item" @tap="handleTabTap(index, item)" > <view class="custom-tab-bar__icon-wrapper"> <image :src="current === index ? item.selectedIconPath : item.iconPath" class="custom-tab-bar__icon" /> <view v-if="item.badge" class="custom-tab-bar__badge"> {{ item.badge }} </view> </view> <text class="custom-tab-bar__text">{{ item.text }}</text> </view>角标字段的值由业务异步更新,比如购物车数量从接口返回后,执行 vuex 的 mutation 修改badge。更新脚本里需要判断数字大小,超过 99 显示99+:
updateCartBadge(count) { const tab = this.barList.find((item) => item.path === '/pages/cart/index') tab.badge = count > 99 ? '99+' : String(count) }5.3 真机预览与常见问题排查
真机上出现“自定义底部导航栏不显示”或“切换不生效”,通常先看三处:第一,图标路径是否对应,H5 端路径大小写不敏感,小程序端大小写必须完全一致;第二,pages.json里页面路径和barList中的path是否一致,这是最常见的跳转失败场景;第三,手机系统 WebView 是否旧版本,导致env(safe-area-inset-bottom)未生效,这时候页面底部会出现一条白色空隙,临时解决方案是把背景色设置为与导航栏一致。
另一个值得留意的点:如果页面里有长列表,自定义导航栏用fixed定位后,列表滚动时内容会从导航栏下方穿过去,检查一下页面容器是否设置了正确的padding-bottom,而不是依赖fixed的z-index硬撑。
本文还有配套的精品资源,点击获取