小程序那点事之自定义底部导航栏

104 阅读9分钟

小程序自定义底部导航栏技术方案与最佳实践

一、为什么要自定义底部导航栏?

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的核心思路:

  1. 样式自由:完全掌控UI设计
  2. 逻辑灵活:可添加任意业务逻辑
  3. 性能优化:合理选择实现方案
  4. 体验提升:动画、交互、细节打磨

选择合适的方案,需要综合考虑:

  • 项目规模
  • 性能要求
  • 团队技术栈
  • 维护成本

对于您当前的 稻香商城 项目,建议:

  • 采用 Pinia + 全局组件 方案
  • 将 TabBar 相关状态(当前索引、购物车数量、显示隐藏)统一管理
  • pages/index/indexpages/index/cartpages/index/user 等页面引入自定义 TabBar 组件
  • 利用 Pinia 实现购物车角标、消息提醒等功能的实时更新

技术栈参考:

  • UniApp 3.0+
  • Vue 3 Composition API
  • Pinia 2.0+
  • SCSS

兼容性:

  • ✅ 微信小程序
  • ✅ H5
  • ✅ App
  • ✅ 支付宝小程序
  • ✅ 其他小程序平台