小程序自定义底部导航栏技术方案与最佳实践
一、为什么要自定义底部导航栏?
1.1 系统TabBar的局限性
微信小程序和UniApp原生的TabBar存在以下限制:
- 样式限制:只能简单设置颜色、图标、文字,无法实现复杂的UI效果
- 功能限制:无法添加中间凸起按钮、动画效果、红点角标的精细控制
- 交互限制:无法实现自定义点击事件、拦截跳转逻辑
- 个性化不足:无法实现渐变色、毛玻璃效果、动态主题等
1.2 自定义TabBar的优势
- ✅ 完全自定义样式,支持任意UI设计
- ✅ 可实现复杂交互(中间凸起按钮、动画切换等)
- ✅ 灵活控制显示隐藏逻辑
- ✅ 可动态修改TabBar配置
- ✅ 支持权限控制、登录拦截等业务逻辑
二、微信原生小程序实现方案
2.1 官方Custom TabBar方案
微信小程序从基础库 2.5.0 起支持自定义TabBar。
2.1.1 配置文件设置
// app.json
{
"tabBar": {
"custom": true,
"color": "#7A7E83",
"selectedColor": "#3cc51f",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/cart/cart",
"text": "购物车"
},
{
"pagePath": "pages/user/user",
"text": "我的"
}
]
}
}
2.1.2 创建custom-tab-bar组件
项目根目录
├── custom-tab-bar/
│ ├── index.js
│ ├── index.json
│ ├── index.wxml
│ └── index.wxss
// custom-tab-bar/index.js
Component({
data: {
selected: 0,
color: "#7A7E83",
selectedColor: "#3cc51f",
list: [
{
pagePath: "/pages/index/index",
iconPath: "/images/icon_home.png",
selectedIconPath: "/images/icon_home_active.png",
text: "首页"
},
{
pagePath: "/pages/cart/cart",
iconPath: "/images/icon_cart.png",
selectedIconPath: "/images/icon_cart_active.png",
text: "购物车"
},
{
pagePath: "/pages/user/user",
iconPath: "/images/icon_user.png",
selectedIconPath: "/images/icon_user_active.png",
text: "我的"
}
]
},
methods: {
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
wx.switchTab({ url })
this.setData({
selected: data.index
})
}
}
})
<!-- custom-tab-bar/index.wxml -->
<view class="tab-bar">
<view wx:for="{{list}}" wx:key="index" class="tab-bar-item" data-path="{{item.pagePath}}" data-index="{{index}}" bindtap="switchTab">
<image src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></image>
<view style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
</view>
2.1.3 在页面中更新TabBar状态
// pages/index/index.js
Page({
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
selected: 0 // 当前页面的索引
})
}
}
})
三、UniApp实现方案
3.1 方案一:全局组件方案(推荐)
3.1.1 创建TabBar组件
<!-- components/custom-tabbar/custom-tabbar.vue -->
<template>
<view class="custom-tabbar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
<view
v-for="(item, index) in tabList"
:key="index"
class="tabbar-item"
@click="switchTab(item, index)"
>
<view class="icon-box" :class="{ 'is-middle': item.isMiddle }">
<image
class="icon"
:src="currentIndex === index ? item.selectedIconPath : item.iconPath"
mode="aspectFit"
/>
<view v-if="item.badge" class="badge">{{ item.badge }}</view>
<view v-if="item.redDot" class="red-dot"></view>
</view>
<text
class="text"
:style="{ color: currentIndex === index ? selectedColor : color }"
>
{{ item.text }}
</text>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
current: {
type: Number,
default: 0
}
})
const emit = defineEmits(['change'])
const currentIndex = ref(props.current)
const color = '#999999'
const selectedColor = '#40AF18'
const safeAreaBottom = computed(() => {
return uni.getSystemInfoSync().safeAreaInsets?.bottom || 0
})
const tabList = ref([
{
pagePath: '/pages/index/index',
iconPath: '/static/tabbar/home.png',
selectedIconPath: '/static/tabbar/home-active.png',
text: '首页',
badge: '',
redDot: false
},
{
pagePath: '/pages/index/category',
iconPath: '/static/tabbar/category.png',
selectedIconPath: '/static/tabbar/category-active.png',
text: '分类',
isMiddle: false
},
{
pagePath: '/pages/index/cart',
iconPath: '/static/tabbar/cart.png',
selectedIconPath: '/static/tabbar/cart-active.png',
text: '购物车',
badge: '3'
},
{
pagePath: '/pages/index/user',
iconPath: '/static/tabbar/user.png',
selectedIconPath: '/static/tabbar/user-active.png',
text: '我的'
}
])
const switchTab = (item, index) => {
if (currentIndex.value === index) return
// 可以在这里添加登录拦截等逻辑
if (item.needLogin && !isLogin()) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
currentIndex.value = index
emit('change', index)
uni.switchTab({
url: item.pagePath
})
}
const isLogin = () => {
// 检查登录状态
return !!uni.getStorageSync('token')
}
// 暴露方法供外部调用
defineExpose({
setTabBarBadge: (index, badge) => {
if (tabList.value[index]) {
tabList.value[index].badge = badge
}
},
setTabBarRedDot: (index, show) => {
if (tabList.value[index]) {
tabList.value[index].redDot = show
}
}
})
</script>
<style lang="scss" scoped>
.custom-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
height: 100rpx;
background: #FFFFFF;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
z-index: 9999;
.tabbar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
.icon-box {
position: relative;
width: 48rpx;
height: 48rpx;
margin-bottom: 8rpx;
&.is-middle {
width: 80rpx;
height: 80rpx;
margin-top: -40rpx;
background: linear-gradient(135deg, #40AF18 0%, #5DD135 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(64, 175, 24, 0.3);
}
.icon {
width: 100%;
height: 100%;
}
.badge {
position: absolute;
top: -10rpx;
right: -20rpx;
background: #FF3B30;
color: #FFFFFF;
font-size: 20rpx;
padding: 4rpx 8rpx;
border-radius: 20rpx;
min-width: 32rpx;
text-align: center;
line-height: 1;
}
.red-dot {
position: absolute;
top: 0;
right: 0;
width: 16rpx;
height: 16rpx;
background: #FF3B30;
border-radius: 50%;
}
}
.text {
font-size: 20rpx;
line-height: 1;
}
}
}
</style>
3.1.2 在TabBar页面中使用
<!-- pages/index/index.vue -->
<template>
<view class="page">
<!-- 页面内容 -->
<view class="content">
首页内容
</view>
<!-- TabBar -->
<custom-tabbar :current="0" />
</view>
</template>
<script setup>
import CustomTabbar from '@/components/custom-tabbar/custom-tabbar.vue'
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
padding-bottom: 120rpx; // 给TabBar留出空间
}
</style>
3.2 方案二:主包单页面方案(性能最优)⭐
这是一种更高级的优化方案,通过在主包中只放置一个容器页面,动态加载不同的子页面内容,从而实现:
- ✅ 减少主包体积
- ✅ 避免每个TabBar页面都引入TabBar组件
- ✅ TabBar切换更流畅(无需页面跳转)
- ✅ 更好的状态管理
3.2.1 项目结构
pages/
├── tabbar/
│ └── index.vue # 唯一的主包TabBar页面
├── home/
│ └── home.vue # 首页内容组件(分包)
├── category/
│ └── category.vue # 分类页面组件(分包)
├── cart/
│ └── cart.vue # 购物车页面组件(分包)
└── user/
└── user.vue # 个人中心组件(分包)
3.2.2 pages.json配置
{
"pages": [
{
"path": "pages/tabbar/index",
"style": {
"navigationStyle": "custom"
}
}
],
"subPackages": [
{
"root": "pages/home",
"pages": [
{
"path": "home",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/category",
"pages": [
{
"path": "category",
"style": {
"navigationBarTitleText": "分类"
}
}
]
},
{
"root": "pages/cart",
"pages": [
{
"path": "cart",
"style": {
"navigationBarTitleText": "购物车"
}
}
]
},
{
"root": "pages/user",
"pages": [
{
"path": "user",
"style": {
"navigationBarTitleText": "我的"
}
}
]
}
],
"tabBar": {
"list": [
{
"pagePath": "pages/tabbar/index"
}
]
}
}
3.2.3 容器页面实现
<!-- pages/tabbar/index.vue -->
<template>
<view class="tabbar-container">
<!-- 动态内容区域 -->
<view class="content-wrapper">
<component
:is="currentComponent"
v-if="currentComponent"
:key="currentIndex"
/>
</view>
<!-- 底部TabBar -->
<view class="custom-tabbar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
<view
v-for="(item, index) in tabList"
:key="index"
class="tabbar-item"
@click="switchTab(index)"
>
<view class="icon-box">
<image
class="icon"
:src="currentIndex === index ? item.selectedIconPath : item.iconPath"
/>
</view>
<text
class="text"
:style="{ color: currentIndex === index ? '#40AF18' : '#999999' }"
>
{{ item.text }}
</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, shallowRef, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
// 动态导入页面组件
const HomePage = shallowRef(null)
const CategoryPage = shallowRef(null)
const CartPage = shallowRef(null)
const UserPage = shallowRef(null)
const currentIndex = ref(0)
const currentComponent = shallowRef(null)
const safeAreaBottom = computed(() => {
return uni.getSystemInfoSync().safeAreaInsets?.bottom || 0
})
const tabList = ref([
{
text: '首页',
iconPath: '/static/tabbar/home.png',
selectedIconPath: '/static/tabbar/home-active.png',
component: 'home'
},
{
text: '分类',
iconPath: '/static/tabbar/category.png',
selectedIconPath: '/static/tabbar/category-active.png',
component: 'category'
},
{
text: '购物车',
iconPath: '/static/tabbar/cart.png',
selectedIconPath: '/static/tabbar/cart-active.png',
component: 'cart'
},
{
text: '我的',
iconPath: '/static/tabbar/user.png',
selectedIconPath: '/static/tabbar/user-active.png',
component: 'user'
}
])
// 懒加载组件
const loadComponent = async (componentName) => {
switch(componentName) {
case 'home':
if (!HomePage.value) {
const module = await import('@/pages/home/home.vue')
HomePage.value = module.default
}
return HomePage.value
case 'category':
if (!CategoryPage.value) {
const module = await import('@/pages/category/category.vue')
CategoryPage.value = module.default
}
return CategoryPage.value
case 'cart':
if (!CartPage.value) {
const module = await import('@/pages/cart/cart.vue')
CartPage.value = module.default
}
return CartPage.value
case 'user':
if (!UserPage.value) {
const module = await import('@/pages/user/user.vue')
UserPage.value = module.default
}
return UserPage.value
}
}
const switchTab = async (index) => {
if (currentIndex.value === index) return
currentIndex.value = index
const componentName = tabList.value[index].component
currentComponent.value = await loadComponent(componentName)
}
onLoad((options) => {
// 根据参数加载对应页面
const index = parseInt(options.index || '0')
switchTab(index)
})
onShow(() => {
// 页面显示时的处理
})
</script>
<style lang="scss" scoped>
.tabbar-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
.content-wrapper {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.custom-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
height: 100rpx;
background: #FFFFFF;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
z-index: 9999;
.tabbar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.icon-box {
width: 48rpx;
height: 48rpx;
margin-bottom: 8rpx;
.icon {
width: 100%;
height: 100%;
}
}
.text {
font-size: 20rpx;
line-height: 1;
}
}
}
}
</style>
3.2.4 其他页面跳转到TabBar
// 跳转到首页
uni.navigateTo({
url: '/pages/tabbar/index?index=0'
})
// 跳转到购物车
uni.navigateTo({
url: '/pages/tabbar/index?index=2'
})
3.3 方案三:使用Pinia状态管理(适合复杂项目)
3.3.1 创建TabBar Store
// stores/tabbar.js
import { defineStore } from 'pinia'
export const useTabbarStore = defineStore('tabbar', {
state: () => ({
current: 0,
visible: true,
list: [
{
pagePath: '/pages/index/index',
iconPath: '/static/tabbar/home.png',
selectedIconPath: '/static/tabbar/home-active.png',
text: '首页',
badge: '',
needLogin: false
},
{
pagePath: '/pages/index/cart',
iconPath: '/static/tabbar/cart.png',
selectedIconPath: '/static/tabbar/cart-active.png',
text: '购物车',
badge: '',
needLogin: false
},
{
pagePath: '/pages/index/user',
iconPath: '/static/tabbar/user.png',
selectedIconPath: '/static/tabbar/user-active.png',
text: '我的',
needLogin: true
}
]
}),
actions: {
setCurrent(index) {
this.current = index
},
setVisible(visible) {
this.visible = visible
},
setBadge(index, badge) {
if (this.list[index]) {
this.list[index].badge = badge
}
},
setCartBadge(count) {
// 购物车角标
const cartIndex = this.list.findIndex(item => item.text === '购物车')
if (cartIndex !== -1) {
this.list[cartIndex].badge = count > 0 ? String(count) : ''
}
}
},
getters: {
currentTab: (state) => state.list[state.current]
}
})
3.3.2 TabBar组件使用Store
<script setup>
import { useTabbarStore } from '@/stores/tabbar'
const tabbarStore = useTabbarStore()
const switchTab = (item, index) => {
if (item.needLogin && !isLogin()) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
tabbarStore.setCurrent(index)
uni.switchTab({ url: item.pagePath })
}
</script>
3.3.3 在业务代码中控制TabBar
import { useTabbarStore } from '@/stores/tabbar'
// 隐藏TabBar
const tabbarStore = useTabbarStore()
tabbarStore.setVisible(false)
// 设置购物车角标
tabbarStore.setCartBadge(5)
四、进阶技巧与最佳实践
4.1 中间凸起按钮实现
<template>
<view class="tabbar-item" :class="{ 'is-middle': item.isMiddle }">
<view class="icon-box" :class="{ 'middle-icon': item.isMiddle }">
<image class="icon" :src="item.iconPath" />
</view>
</view>
</template>
<style lang="scss" scoped>
.tabbar-item {
&.is-middle {
.middle-icon {
width: 100rpx;
height: 100rpx;
margin-top: -50rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
border-radius: 50%;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 107, 0.4);
display: flex;
align-items: center;
justify-content: center;
.icon {
width: 60rpx;
height: 60rpx;
}
}
}
}
</style>
4.2 登录拦截
const switchTab = (item, index) => {
// 需要登录的页面
if (item.needLogin) {
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '温馨提示',
content: '请先登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/login' })
}
}
})
return
}
}
// 继续跳转
currentIndex.value = index
uni.switchTab({ url: item.pagePath })
}
4.3 权限控制
// 根据用户身份动态显示TabBar项
const initTabList = () => {
const userInfo = uni.getStorageSync('userInfo')
const isVip = userInfo?.isVip
const baseList = [
{ text: '首页', pagePath: '/pages/index/index', ... },
{ text: '分类', pagePath: '/pages/category/category', ... },
{ text: '购物车', pagePath: '/pages/cart/cart', ... },
{ text: '我的', pagePath: '/pages/user/user', ... }
]
// VIP用户显示特殊入口
if (isVip) {
baseList.splice(2, 0, {
text: 'VIP专区',
pagePath: '/pages/vip/vip',
...
})
}
tabList.value = baseList
}
4.4 性能优化
1. 使用 shallowRef 替代 ref(对于组件引用)
import { shallowRef } from 'vue'
const currentComponent = shallowRef(null) // 避免深层响应式
2. 图片预加载
const preloadImages = () => {
tabList.value.forEach(item => {
const img1 = new Image()
const img2 = new Image()
img1.src = item.iconPath
img2.src = item.selectedIconPath
})
}
onMounted(() => {
preloadImages()
})
3. 防抖处理
import { debounce } from '@/utils/common'
const switchTab = debounce((item, index) => {
// ... 切换逻辑
}, 300)
4.5 动画效果
<template>
<view class="tabbar-item" @click="handleClick">
<view class="icon-box" :class="{ 'bounce': isActive }">
<image class="icon" :src="iconPath" />
</view>
</view>
</template>
<style lang="scss" scoped>
@keyframes bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
}
.icon-box {
&.bounce {
animation: bounce 0.3s ease;
}
}
</style>
五、方案对比与选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 微信原生Custom TabBar | 官方支持,稳定可靠 | 只能用于微信小程序 | 纯微信小程序项目 |
| UniApp全局组件 | 跨平台,简单易用 | 每个页面都需引入 | 中小型项目 |
| 主包单页面方案 | 性能最优,包体积小 | 实现复杂,路由管理麻烦 | 大型商城、性能要求高的项目 |
| Pinia状态管理 | 状态统一,易维护 | 需要学习Pinia | 复杂业务逻辑的项目 |
推荐方案
- 🔰 小型项目:使用 UniApp 全局组件方案
- 🚀 中大型项目:使用 Pinia + 全局组件方案
- ⚡ 性能要求极高:使用主包单页面方案
- 📱 纯微信小程序:使用官方 Custom TabBar
六、常见问题与解决方案
6.1 TabBar与页面内容重叠
<style>
/* 方案1:给页面底部预留空间 */
.page {
padding-bottom: calc(100rpx + env(safe-area-inset-bottom));
}
/* 方案2:使用固定定位 */
.custom-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
</style>
6.2 iPhone底部安全区域适配
const safeAreaBottom = computed(() => {
const systemInfo = uni.getSystemInfoSync()
return systemInfo.safeAreaInsets?.bottom || 0
})
<view class="custom-tabbar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
<!-- TabBar内容 -->
</view>
6.3 切换页面时TabBar闪烁
// 使用 uni.switchTab 而不是 uni.navigateTo
uni.switchTab({
url: '/pages/index/index',
success: () => {
// 切换成功后再更新状态
currentIndex.value = 0
}
})
6.4 购物车角标实时更新
// 使用 uni.$on 监听购物车变化
uni.$on('cartCountChange', (count) => {
tabbarStore.setCartBadge(count)
})
// 在添加购物车时触发
uni.$emit('cartCountChange', newCount)
七、总结
自定义TabBar的核心思路:
- 样式自由:完全掌控UI设计
- 逻辑灵活:可添加任意业务逻辑
- 性能优化:合理选择实现方案
- 体验提升:动画、交互、细节打磨
选择合适的方案,需要综合考虑:
- 项目规模
- 性能要求
- 团队技术栈
- 维护成本
对于您当前的 稻香商城 项目,建议:
- 采用 Pinia + 全局组件 方案
- 将 TabBar 相关状态(当前索引、购物车数量、显示隐藏)统一管理
- 在
pages/index/index、pages/index/cart、pages/index/user等页面引入自定义 TabBar 组件 - 利用 Pinia 实现购物车角标、消息提醒等功能的实时更新
技术栈参考:
- UniApp 3.0+
- Vue 3 Composition API
- Pinia 2.0+
- SCSS
兼容性:
- ✅ 微信小程序
- ✅ H5
- ✅ App
- ✅ 支付宝小程序
- ✅ 其他小程序平台