最近在做一个uni-app的社区项目,设计稿里底部TabBar中间有个凸起的发布按钮。本来想用原生tabBar配个图片,结果原生不支持这种样式。然后我改成了自定义TabBar,却又遇到一个老问题:有些页面(比如详情页)需要隐藏底部TabBar,而使用v-if控制组件显示隐藏时,页面切换过程中总会出现一瞬的闪烁,甚至在小程序端偶尔会看到TabBar先显示再消失。
后来试了好几种方案,最后用一套简单的 store + mixin 搞定了,再也不用手动在每一个页面开关变量。思路分享出来,希望帮你少踩坑。
为什么不要用v-if控制TabBar
v-if确实能控制组件渲染,但TabBar这个组件往往是在被隐藏和显示之间频繁切换。在小程序里,每个页面都是一个独立的webview,如果TabBar组件存在于多个页面,你每次切换都要重新渲染一遍,闪烁是小事,性能损耗也大。更关键的是,当页面从A切到B,B页面还没准备好,TabBar已经因为v-if变成false先消失了,体验就非常生硬。
所以我的原则是:TabBar组件始终在每个页面里渲染,通过CSS类或样式让它整体移出屏幕,而不是直接销毁。
结构上的基本设计
我们做一个自定义TabBar组件,放在components/custom-tab-bar.vue里。它接收一个current属性表示当前选中项,通过switchTab来跳转。外层用一个view固定定位,内部是几个view模拟的按钮。
关键的实现是:这个组件会读取一个全局状态tabbarVisible,根据它来决定自己是否出现在视觉中。我用一个叫useTabbarStore的组合式API(如果你用vue2,就用一个Vue.observable对象)。
// utils/tabbar-store.js
import { reactive, computed } from 'vue';
import { storeToRefs } from 'pinia'; // 如果你用pinia,也可以直接useStore
const state = reactive({
visible: true
});
export function useTabbarStore() {
const setVisible = (val) => {
state.visible = val;
};
return {
visible: computed(() => state.visible),
setVisible
};
}
如果你用的是Vue2 + Vuex,那就等价于一个module里存一个visible变量。为了简单,下面例子我用Vue3 + Pinia展示,但你完全可以改成Vuex。
TabBar组件中的写法
<template>
<view class="custom-tabbar" :class="!visible ? 'tabbar-hidden' : ''">
<view class="tabbar-inner">
<view class="tab-item" :class="current == 0 ? 'active' : ''" @tap="switchTo('/pages/index/index')">
<text>首页</text>
</view>
<view class="tab-item middle-btn" @tap="goPublish">
<text class="mid-icon">+</text>
</view>
<view class="tab-item" :class="current == 1 ? 'active' : ''" @tap="switchTo('/pages/mine/mine')">
<text>我的</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import { useTabbarStore } from '@/utils/tabbar-store';
const props = defineProps({
current: {
type: Number,
default: 0
}
});
const store = useTabbarStore();
// 当 visible 为 false 时,整体移出屏幕
const visible = computed(() => store.visible);
function switchTo(url) {
uni.switchTab({ url }); // 前提是这些页面在pages.json里配置了tabBar
}
function goPublish() {
uni.navigateTo({ url: '/pages/publish/publish' });
}
</script>
注意这里switchTab要求目标页面必须是在pages.json里注册过的原生tabBar页面。如果我们不想注册原生tabBar,也可以使用uni.redirectTo或uni.reLaunch,不过那样组件本身的跳转逻辑就不同了。我的实际项目里有两个tab页,我用的是uni.reLaunch模拟tab切换,并且配合页面onLoad逻辑保证不会一直叠加页面栈。
function switchTo(url) {
uni.reLaunch({ url });
}
关键一步:用全局mixin让页面自动控制可见性
如果每进入一个详情页都要去手动调用setVisible(false),那和v-if也没多大差别,所以我把这个逻辑抽成了一个全局mixin。在onShow生命周期里根据页面路由的name或path来判断:如果当前页面属于需要隐藏TabBar的页面,就把visible设为false,否则设为true。
// mixins/tabbar-page.js
import { useTabbarStore } from '@/utils/tabbar-store';
// 需要隐藏TabBar的页面列表
const HIDE_PAGES = [
'/pages/detail/detail',
'/pages/publish/publish',
'/pages/chat/chat'
];
export default {
onShow() {
const store = useTabbarStore();
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
if (!currentPage) return;
const route = '/' + currentPage.route;
const shouldShow = !HIDE_PAGES.includes(route);
store.setVisible(shouldShow);
}
}
然后在main.js里全局混入:
import { createSSRApp } from 'vue';
import App from './App.vue';
import tabbarPageMixin from './mixins/tabbar-page';
export function createApp() {
const app = createSSRApp(App);
app.mixin(tabbarPageMixin);
return {
app
};
}
这样所有页面在显示的时候都会自动执行这个判断。在页面已经显示的情况下,如果通过某个事件动态改变路由(比如发表完成后返回),同样会触发onShow,从而恢复TabBar显示。全程不用在每个页面写一行代码。
隐藏动画的平滑处理
组件中我们通过class来把TabBar移出屏幕。为了不闪烁,我建议不要用display:none,而是用transform或opacity。组件模板中加了一个tabbar-hidden类,它施加在根view上。
.custom-tabbar {
transition: transform 0.2s ease-in-out;
}
.tabbar-hidden {
transform: translateY(100%);
}
这样隐藏和显示都有个轻微的过渡,视觉上不突兀。如果你不喜欢动画,直接不要transition也行,但依然比v-if消失得干净。
中间凸起按钮的实现
中间的发布按钮是一个圆形,通过CSS设置渐变背景和阴影。由于uni-app在App端和小程序端都支持flex布局,所以这个样式很容易实现。注意中间按钮需要稍微突出tabbar的上边界,用了负的margin或transform实现。
.middle-btn {
width: 96rpx;
height: 96rpx;
background: linear-gradient(135deg, #ff9a56, #ff6a3d);
border-radius: 50%;
margin-top: -40rpx;
box-shadow: 0 8rpx 20rpx rgba(255, 106, 61, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
在App端使用这个方式没有问题。如果小程序端有原生组件占用了cover-view,也不影响,因为我们整个TabBar都是普通view。
为什么不用官方自带的uni.hideTabBar
官方提供uni.hideTabBar,但它只能隐藏原生TabBar,而且隐藏之后原来底部会空出一块。如果你用了自定义TabBar,这个API根本不起作用。所以我的方案完全不依赖原生TabBar。
还有一个“自定义原生TabBar”的技术,需要custom-tab-bar/index目录和特定的组件方法,那个方式在小程序端比较好,但H5和App端兼容性不一致。我这个纯view方案在三个端都能用,代码也直白。
实际项目里的完整案例
为了说明效果,我贴一个简单页面中如何使用:
<template>
<view class="detail-page">
<text>这里是详情页,TabBar会自动隐藏</text>
<CustomTabBar :current="0" />
</view>
</template>
<script setup>
import CustomTabBar from '@/components/custom-tab-bar.vue';
// 不需要额外写任何逻辑,mixin已经管理了visible
</script>
在首页:
<template>
<view class="index-page">
<text>首页,TabBar应该显示</text>
<CustomTabBar :current="0" />
</view>
</template>
只要每个页面都引入这个组件,全局mixin就会根据路由自动更新组件的显示。如果你有一个页面是在视图层内部通过按钮切换的,比如A页面一部分内容像B页面,你可以手动调用store里的setVisible来覆盖默认行为,这给我们留了余地。
这几个坑我帮你踩过了
- 不要用v-if包裹TabBar,用类名切换过渡更自然。
- 切换到详情页时,有时路由还没跳完,onShow里getCurrentPages可能拿不到新页面,可以延迟到setTimeout里执行判断,但大多数情况不会遇到。
- 如果使用uni.reLaunch来切换tab页,每次切换都会重新加载页面,如果不想页面状态丢失,建议还是把首页和我的页面配成原生tabBar,然后在自定义组件里用uni.switchTab。原生tabBar可以设置为透明背景,但保留占位。这个方式也很主流。
- 在App端,自定义组件在页面底部的fixed定位是生效的,放心使用。
最后说点感受
这套方案我已经在两个uni-app项目里用了,无论是在微信小程序上还是Android端,都没有出现过闪烁或错位。关键是它把“隐藏TabBar”从业务代码中完全剥离,不再需要在每个页面写一堆setVisible。以后需求变更,要新增一个隐藏TabBar的页面,只需要往HIDE_PAGES里加一行路由,十分省心。
如果你也有类似的自定义TabBar需求,不妨抛弃v-if的方式,让路由感知组件,而不是事件驱动组件。这样代码更干净,体验也更顺滑。

