最近在做 uniapp 项目时,被底部导航栏折腾得够呛。项目初期图省事,直接在 pages.json 里配了原生 tabBar,结果产品经理一句“这个选中状态要换个渐变图标”“中间那个按钮要凸起来”,原生 tabBar 直接原地宣布报废。好,那就自己造轮子呗。
结果,轮子造完,新的问题又冒出来了:点击切换选项卡的时候,页面内容总是“闪”一下,特别是列表页和带图片的页面,闪得那叫一个明显,用户都以为页面崩溃了。后来翻了社区、查了文档、又在自己项目里反复定位测试,总算把这个问题从原理到方案摸清了。
这里我把自定义底部导航栏的整体思路、切换页面闪烁的原因分析、以及我最终采用的解决方案完整记录下来。不是纯翻译文档,是我自己踩坑踩出来的实操经验,希望能帮你少走点弯路。
1. 项目现状分析:为什么原生 tabBar 满足不了需求
1.1 原生 tabBar 的局限性
先理清一个前提:uniapp 项目里,原生 tabBar 是挂在 pages.json 里的。它属于小程序或 App 原生层面渲染的组件,地位超然,普通组件根本盖不住它。
原生 tabBar 最大的优势是稳定、流畅、切换无闪烁,但它的问题也很直接:
- 图标和文字只能静态配置,不能动态修改任何样式,比如“角标数量变了,图标换成动态的”,做不了。
- 选中态图片和文字颜色是写死的几套属性,想做“渐变选中图标”这种设计,基本没戏。
- 中间的“凸起按钮”或“特殊形状”,原生 tabBar 根本支持不了,除非你把它当作页面的悬浮组件处理,但层级和交互又会有新的问题。
- 无法监听 tabBar 的点击事件,不能做“点击当前 tab 回到顶部”“双击刷新”这类交互。
这些限制在普通业务里还勉强能忍,一旦产品经理提出“自定义一点”的要求,原生 tabBar 就会成为整个项目里最尴尬的组件——你在页面里再怎么写,它都纹丝不动。
1.2 常规自定义方案的核心痛点
既然原生的不行,大家自然而然会想到两条路:
- 每条 tab 页面的 v-if 控制,写一个自定义 tabBar 组件,页面切换时通过 v-if 切换页面内容。
- cover-view 方案,在 App 端使用 cover-view 实现可覆盖原生组件的 tabBar。
第一种方案是最常见的。做法是在 index 页面里引入两个子页面组件,用 v-if / v-show 控制切换,把底部导航栏也做成一个组件,通过 defineProps 或全局状态控制当前选中的 tab。
这套方案功能上没问题,但还是有两个关键痛点:
- 页面数据无法保持。因为 v-if 切换,页面销毁重建,列表滚动位置、表单输入内容全丢了。v-show 虽然能保留 DOM 和数据,但所有页面同时渲染,生命周期变得非常奇怪,初始性能损耗大。
- 切换“闪烁”。v-if 切换子组件瞬间,底部导航栏和页面内容存在“新旧组件同时存在”的窗口期,视觉上就会看到白屏或闪一下。
而且这还没算上“页面栈”这个核心问题。uniapp 本质上是多页面框架,pages.json 里注册的每个页面都有自己的页面栈。你在 index 页面里用 v-if 切换内容,本质上根本没有切换页面,只是切换子组件,这跟原生 tabBar 那种“每个 tab 就是独立页面栈”的体验差距很大。
cover-view 方案是另一个老牌做法。cover-view 是专门设计用来覆盖原生组件的,可以盖在 video、map 这些原生组件上面。用它做 tabBar 的确能解决“控制层级”的问题,但如果做复杂交互(比如点击弹出二级菜单)、动画、圆角、阴影这些样式,cover-view 的限制也多,开发体验并不好。
综合对比下来,我最终的方案是:保留页面栈结构,用 uni.switchTab 控制真实页面切换,底部导航栏做成全局组件,并在每个 tab 页面上以组件形式引入,配合页面生命周期来统一管理选中态。这个方案是 uniapp 社区里公认比较成熟的自定义 tabBar 方案,也是解决闪烁问题的基础。
2. 自定义底部导航栏的整体设计与实现
2.1 从原生 tabBar 迁移到自定义 tabBar 的落地思路
确认要自定义后,第一件事就是改 pages.json,把原生 tabBar 配置删掉。这一步千万别懒,不然会出现“原生 tabBar 还在,自定义 tabBar 又套了一层”的情况,底部被双份导航遮挡,页面高度也会算错,而且原生 tabBar 还遮挡不到上面的自定义 tabBar,层级问题会变得极其复杂。
pages.json 里保留 tab 页面注册即可,像下面这样:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/category/category", "style": { "navigationBarTitleText": "分类" } }, { "path": "pages/cart/cart", "style": { "navigationBarTitleText": "购物车" } }, { "path": "pages/mine/mine", "style": { "navigationBarTitleText": "我的" } } ] }删除 tabBar 配置后,页面内容区域会自动扩展到底部,所以自定义 tabBar 组件必须用固定定位,固定在屏幕底部。
这里有一个非常重要的细节:每个 tab 页面都必须预留出底部 tabBar 的高度区域。我一般用 CSS 变量或者公共样式类来处理。
/* App.vue 或公共样式文件 */ page { --tabbar-height: 50px; } .tabbar-page { padding-bottom: var(--tabbar-height); }每个 tab 页面的根节点加上 tabbar-page 类,这样页面内容就不会被固定定位的 tabBar 遮挡。
2.2 自定义 tabBar 组件的基本实现
自定义 tabBar 组件的核心是两部分:数据配置和点击切换。
先说数据配置,我习惯把 tabBar 的配置单独抽到一个 JS 文件里,这样改起来方便,不用在组件里翻代码。
// config/tabbar.js export default [ { path: "/pages/index/index", text: "首页", icon: "/static/tabbar/home.png", activeIcon: "/static/tabbar/home-active.png" }, { path: "/pages/category/category", text: "分类", icon: "/static/tabbar/category.png", activeIcon: "/static/tabbar/category-active.png" }, { path: "/pages/cart/cart", text: "购物车", icon: "/static/tabbar/cart.png", activeIcon: "/static/tabbar/cart-active.png" }, { path: "/pages/mine/mine", text: "我的", icon: "/static/tabbar/mine.png", activeIcon: "/static/tabbar/mine-active.png" } ];组件本身不复杂,关键是选中状态的判断。这里不能简单地在组件内部用 current 值,因为每个页面都是一个独立的页面实例,组件在各自页面里渲染,需要知道“当前是哪个页面”才能高亮对应的 tab。
我在组件里通过 getCurrentPages() 获取当前页面路由,然后和配置里的 path 做匹配。
<template> <view class="tabbar"> <view class="tabbar-item" v-for="(item, index) in tabbarList" :key="index" @click="switchTab(item)" > <image class="tabbar-item-icon" :src="currentPath === item.path ? item.activeIcon : item.icon" /> <text class="tabbar-item-text" :class="{ 'tabbar-item-text_active': currentPath === item.path }" > {{ item.text }} </text> </view> </view> </template> <script> import tabbarList from "@/config/tabbar.js"; export default { data() { return { tabbarList: tabbarList, currentPath: "" }; }, created() { this.updateCurrentPath(); }, onShow() { // 页面每次显示时重新获取路径,确保切换回来时高亮正确 this.updateCurrentPath(); }, methods: { updateCurrentPath() { const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; this.currentPath = `/${currentPage.route}`; }, switchTab(item) { if (this.currentPath === item.path) { return; } uni.switchTab({ url: item.path }); } } }; </script>这里有个易错点:getCurrentPages() 返回的页面对象里,route 属性是不带斜杠的,比如 “pages/index/index”,而 tabBar 配置里我写的是 “/pages/index/index”,所以要手动拼上斜杠进行匹配。
另一个易错点是 onShow 生命周期。组件挂载在页面上,页面从其他 tab 切回来时,会触发页面 onShow,但组件自身不一定触发 created/mounted,所以要监听页面的 onShow 并重新获取路径。如果组件里配置了 onShow,需要在组件的 options 里开启多页面刺探,或者更简单的方式是把 currentPath 的逻辑放到父页面里,通过 props 传给组件。
我实践下来,用 mixin 的方式更简单:写一个 mixin,在每个 tab 页面的 onShow 里计算当前路径,赋值给 data,然后在模板里给 tabBar 组件传 activePath prop。
// mixins/tabbar.js export default { data() { return { activeTabPath: "" }; }, onShow() { const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; this.activeTabPath = `/${currentPage.route}`; } };父页面里这样使用:
<template> <view class="tabbar-page"> <!-- 页面内容 --> <custom-tabbar :active-path="activeTabPath"></custom-tabbar> </view> </template> <script> import customTabbar from "@/components/custom-tabbar.vue"; import tabbarMixin from "@/mixins/tabbar.js"; export default { components: { customTabbar }, mixins: [tabbarMixin] }; </script>这样每个 tab 页面都引入同一个 mixin,只要页面 onShow,activeTabPath 就会更新,tabBar 组件根据 prop 推导出高亮项。
2.3 中间“凸起按钮”等特殊样式的实现方式
自定义 tabBar 最大的优势之一,就是可以做特殊造型。最常见的是中间一个“发布”按钮,比普通 tab 大一圈,而且凸起。
实现方式其实非常简单,在 tabBar 组件里单独判断索引即可。
<template> <view class="tabbar"> <view class="tabbar-item" v-for="(item, index) in tabbarList" :key="index" @click="handleClick(item, index)" > <view v-if="item.isSpecial" class="tabbar-item-special" > <image class="tabbar-item-special-icon" :src="item.icon" /> </view> <template v-else> <image class="tabbar-item-icon" :src="activePath === item.path ? item.activeIcon : item.icon" /> <text class="tabbar-item-text">{{ item.text }}</text> </template> </view> </view> </template>样式方面,中间特殊按钮用 transform: translateY(-10px) 往上偏移,配合圆角和阴影就能做出“凸起”效果。需要注意的还是一样的问题:凸起部分可能会被页面内容遮挡,所以要给特殊按钮所在的容器设置高于普通 tabBar 的 z-index。
点按特殊按钮时,一般不会做页面切换,而是弹出操作面板或者跳转一个非 tab 页面。这个逻辑可以在 handleClick 里通过 isSpecial 判断。
3. 切换选项卡页面闪烁的根因分析与解决
3.1 闪烁现象的现场还原
自定义 tabBar 做好之后,我遇到了那个让所有 uniapp 开发者头疼的问题:页面切换闪烁。
具体表现是:从首页切到分类页,或者从分类页切回首页,屏幕底部会出现一个很短促的白色“闪动”,有时候是整个页面内容白一下,有时候是底部导航栏区域闪一下,有时候是图片密集的页面闪得特别明显。
这个问题在 H5 端偶尔出现,在 App 端比较明显,在小程序端真正严重——因为小程序 WebView 的渲染机制和页面切换机制跟 H5、App 都不太一样。
刚开始我以为是页面加载慢的问题,于是加了各种 loading 状态,没用。又以为是图片太大的问题,做了图片压缩,还是有闪。最后我把问题拆解成两个方向才找到突破口。
3.2 闪烁问题背后的三个深层原因
第一个原因是页面栈切换导致的“白屏窗口期”。
uniapp 的 tab 页面切换,本质上还是多页面之间的切栈。当你调用 uni.switchTab 时,目标页面会经历“创建 WebView → 加载页面结构 → 执行逻辑 → 渲染完成”这个过程。在这个过程完成之前,屏幕上有短暂的时间是空白的,或者只有底部导航栏(如果你的导航栏是全局组件),甚至在部分机型上,上一个页面已经退场、下一个页面还在入场,中间会出现一个“真空期”。
这就是闪烁的第一个来源,也是最根本的原因。原生 tabBar 之所以不闪,是因为 tabBar 本身由原生渲染,不受 WebView 加载影响,切换时原生 tabBar 一直在,用户感觉不到“页面框架空了”。自定义 tabBar 之后,tabBar 也是页面的一部分,页面加载期间 tabBar 和内容一起消失或空白,闪烁感自然就来了。
第二个原因是“可滚动容器高度突变”。
很多自定义 tabBar 的项目里,页面内容是滚动区域。从 A 页面切到 B 页面,如果 B 页面内容比较短,滚动区域没有被撑开,页面高度就会跟 A 页面不同。在 WebView 或小程序渲染层里,这会引起 scroll-view 或页面的重排重绘,视觉上表现为内容跳动或闪烁。
第三个原因是“图片资源的重新加载”。
tab 页面通常都有图片,尤其是电商项目,列表页、首页密密麻麻全是图。页面每次切换时,如果图片没有走缓存,或者图片路径不是网络 URL 而是本地静态资源,WebView 可能不会立即从内存缓存里取图,而是重新走一遍加载流程。在图片加载完成的瞬间,页面会从一个“无图状态”变为“有图状态”,视觉上就是闪一下。
3.3 解决闪烁的思路:让“切换”变成“隐藏”
解决闪烁问题的核心思路是:不要频繁销毁和重建页面,而是让页面“隐藏”和“显示”。原生 tabBar 能做到不闪,也是同样的道理——tab 页面常驻,切换只是前端显示层的切换,不会重新加载。
在 uniapp 里,要实现这个效果,最直接的手段就是:使用 v-show 或 keep-alive 缓存页面状态。但 uniapp 的多页面结构里,不能简单地在某一个页面里用 v-show 去控制其他页面。那怎么办?
我的方案是:把 tab 页面改成“单页 + 多组件”的结构。也就是让 tabBar 的四个 tab 都作为组件存在于同一个页面(比如首页入口),用 v-show 控制它们的显示隐藏。
具体做法是这样的:
- pages.json 里只注册一个 tab 页面,其他三个 tab 的内容不再注册为独立页面,而是作为组件写进这个 tab 页面里。
- 这个 tab 页面通过 uni.switchTab 切换时,真正发生的是“切换了 tabBar 的选中项”,但页面本身不销毁,组件还挂在内存里。
- 组件之间通过 v-show 切换显示,因为组件一直存在,不存在重新渲染的过程,所以不会有白屏闪烁。
但是,这个方案有个很大的限制:如果业务上必须使用独立的页面(比如每个 tab 页面有自己的导航栏、有自己的页面生命周期逻辑、或者页面结构非常复杂),把页面改成组件会带来很大改动。
所以我这里介绍另一个更通用、还要解决“页面级闪烁”的方案:把自定义 tabBar 设计为“全局覆盖层”,让 tabBar 的高亮和切换逻辑完全脱离页面渲染,同时利用 uni.switchTab 的页面缓存特性,降低闪烁发生的概率。
在 uniapp 中,uni.switchTab 本身是有缓存机制的。如果你从 tab1 切到 tab2,再切回 tab1,tab1 页面并不要重新执行 onLoad,而是直接触发 onShow。页面实例是缓存的,DOM 也在。但在小程序端,由于渲染层和逻辑层是分离的,页面切栈时渲染层可能会短暂地“重新挂载组件”,这是平台机制决定的,开发侧很难完全规避。
针对这个平台层面的“闪”,我的实践是:把图片资源做成本地化或绝对地址,避免切换页面时重新请求图片;同时在 tabBar 组件上做“切换过渡”的 CSS 动画,用视觉上的“顺滑感”掩盖底层的短促闪烁。
这几种方法配合起来,我的项目里闪烁问题基本消失了。虽然不敢说 100% 完美解决,但在真机测试里,用户感知已经非常轻微。
4. 页面缓存的落地方式与性能取舍
4.1 v-if 和 v-show 的选择指南
在自定义 tabBar 的场景里,v-if 和 v-show 的选择非常关键。很多人知道 v-show 不会销毁组件、v-if 会,但不知道这背后对页面性能的影响到底有多大。
v-if 是惰性的,条件为 false 时,组件连渲染都不会发生,性能好,但切换时有“创建”的过程;v-show 是控制 display 切换,组件第一次渲染后就会一直存在,切换时没有重新创建的消耗,但缺点是一开始所有组件都会渲染。
如果做“单页 + 多组件”的 tab 方案,我建议首次启动时只渲染第一个 tab(v-show 之外,再加一层首次渲染标记),其他 tab 等用户第一次点击时再初始化“持久化渲染”。简单来说就是:首次用 v-if 创建,创建后改成 v-show 常驻。
实现思路是这样:
<template> <view class="tab-page"> <view v-show="currentTab === 0 && homeInited"> <home-page v-if="homeInited"></home-page> </view> <view v-show="currentTab === 1 && categoryInited"> <category-page v-if="categoryInited"></category-page> </view> </view> </template> <script> export default { data() { return { currentTab: 0, homeInited: true, categoryInited: false, cartInited: false, mineInited: false }; }, methods: { switchTab(index) { const initedKey = ["homeInited", "categoryInited", "cartInited", "mineInited"][index]; if (!this[initedKey]) { this[initedKey] = true; } this.currentTab = index; } } }; </script>第一次点击“分类”时,categoryInited 变为 true,组件才会创建。之后再用 v-show 控制显示,切换不会重建组件。这套逻辑兼顾了首屏性能和切换流畅性。
4.2 页面数据与滚动位置如何保持
自定义 tabBar 换页后,如果页面数据没了、滚动位置丢了,那体验还不如原生 tabBar。
如果你用的是“多页面 + uni.switchTab”的方案,页面数据天然保持,因为页面是缓存的。只需要在 onShow 时做数据刷新策略,比如:
- 列表页每次显示时,如果下拉刷新时间距上次超过 5 分钟,自动重新拉取数据。
- 表单页每次显示时,保持原数据,不做重置。
如果你用的是“单页 + 多组件”的方案,那就需要自己做状态保持:
- 每个 tab 组件的 data 本身就是独立的,只要组件不销毁,数据就在。
- 滚动位置恢复,需要在 tab 组件内部用 scroll-view 绑定 scroll-top,或者在组件 onShow(这里其实是组件显示时的回调)时,手动把滚动容器的 scrollTop 设置为之前保存的值。
我一般这么处理滚动恢复:
<template> <scroll-view :scroll-top="scrollTop" @scroll="handleScroll" class="tab-scroll" > <!-- 列表内容 --> </scroll-view> </template> <script> export default { data() { return { scrollTop: 0, scrollY: 0 }; }, activated() { this.scrollTop = this.scrollY; }, methods: { handleScroll(e) { this.scrollY = e.detail.scrollTop; } } }; </script>这样,每次切回这个 tab 时,滚动条会自动恢复到上次的位置。
4.3 首屏加载策略:避免所有页面一次性渲染
既然用 v-show 保持组件常驻,那一个很直接的问题就是:四个 tab 页面如果都很大,首屏会不会卡死?
答案是:对,会卡。如果你一开始就把四个 tab 都渲染出来,每个 tab 里是长列表、大量图片,首屏加载时间会非常难看。
所以必须做“懒初始化”,也就是上面提到的:点击时才首次创建组件。这个策略在用户第一次点击“我的”时,可能那一下会有一两百毫秒的延迟,但相比首屏卡顿,这个取舍是值得的。
更深一步的优化是,把首页放在页面级,其他 tab 用组件懒加载,或者反过来,页面级的 tab 保留 uni.switchTab 切换,组件级的不保留。这个看具体业务,没有绝对最优的方案,核心是要理解每种方案的成本。
5. switchTab 切换的监听与联动处理
5.1 切换后如何刷新页面数据
自定义 tabBar 用 uni.switchTab 切换页面时,页面 onShow 会触发,但 onLoad 不会重复触发。所以,如果你的页面需要在每次展示时刷新数据,不要在 onLoad 里做,要在 onShow 里做。
这里有一个很容易犯的错误:有人会在 onShow 里不加判断地拉数据,导致切换回来时页面“闪一下 loading”,体验很差。更好的做法是:第一次进入时用缓存数据渲染,onShow 时静默请求,数据返回后再更新页面。
onShow() { // 静默刷新,不展示 loading this.fetchList({ showLoading: false }); }如果列表是上拉加载更多的场景,onShow 刷新后还要注意页码状态:如果数据有变化,分页页码要重置为第一页;如果没有变化,要保持当前页码,避免滚动位置和数据量不匹配。
5.2 点击当前 tab 触发的特殊交互
项目里还常遇到一个需求:点击当前已经在显示的 tab,比如在首页,再点一下首页按钮,要让页面列表回到顶部。这个交互用自定义 tabBar 做起来非常方便。
在自定义 tabBar 组件的点击事件里,判断当前点击的 path 是否等于 activePath,如果相等,就触发一个自定义事件,父页面监听后执行滚动到顶部等方法。
<!-- custom-tabbar.vue --> <view class="tabbar-item" v-for="(item, index) in tabbarList" :key="index" @click="handleTabClick(item)" > </view>methods: { handleTabClick(item) { if (this.activePath === item.path) { // 点击的是当前 tab,触发二次点击事件 this.$emit("tabbar-again", item); return; } uni.switchTab({ url: item.path }); } }父页面:
<custom-tabbar :active-path="activeTabPath" @tabbar-again="handleTabbarAgain" ></custom-tabbar>methods: { handleTabbarAgain() { // 滚动到顶部 uni.pageScrollTo({ scrollTop: 0, duration: 200 }); } }这个交互在电商项目里很实用,用户逛了半天列表,点 tab 能快速回顶,体验比原生 tabBar 还好。
5.3 底部导航栏与页面生命周期的协作
这个知识点很容易被遗漏,但真的是自定义 tabBar 的“命门”。
uniapp 页面有 onLoad、onShow、onReady、onHide 等生命周期。自定义 tabBar 组件挂载在页面上后,组件的 created、mounted 只会在页面第一次加载时执行。如果你在组件的 created 里做了初始化操作(比如设置当前选中 tab),当从页面 A 切到页面 B 时,页面 B 的组件创建并初始化,页面 A 的组件不会重新执行 created。
如果你想让每次页面显示时组件都做点事情,比如重置 tab 状态、更新角标、请求未读消息,就不能依赖组件自身的生命周期,要在页面的 onShow 里给组件传参或调用方法。
具体做法是用 ref 调用子组件方法:
<template> <view class="tabbar-page"> <custom-tabbar ref="tabbarRef" :active-path="activeTabPath" ></custom-tabbar> </view> </template> <script> export default { onShow() { this.updateTabbarBadge(); }, methods: { updateTabbarBadge() { // 页面每次显示时,主动调用组件方法更新角标 this.$refs.tabbarRef.updateBadge(2, 5); } } }; </script>这样能保证每次页面展示时,底部导航栏的状态都是最新的,不会出现“角标显示残留旧数据”的问题。
6. 深入排查:隐藏的坑与真机调试经验
6.1 自定义 tabBar 的层级与安全区问题
自定义 tabBar 的层级问题,经常在真机上才暴露出来。
在部分安卓机型上,如果 tabBar 组件没有设置足够高的 z-index,页面里的弹窗、气泡、悬浮按钮可能会盖住 tabBar,或者在 tabBar 之上漂浮,看起来非常奇怪。
我习惯给 tabBar 根节点设置 z-index: 999,同时给页面内容区域设置一个低于这个值的 z-index。对于真正的全屏弹窗,弹窗遮罩层要设置更高的 z-index(比如 1000),才能盖住 tabBar。
再说安全区。iPhone 全面屏底部有 home indicator 区域,如果 tabBar 直接贴在底部,会被手势条挡到。解决方案是给 tabBar 底部加上安全区 padding:
.tabbar { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }同时,tabBar 的高度计算也要把安全区算进去。如果 tabBar 设置了固定高度 50px,在 iPhone X 上还硬要加 safe-area-inset-bottom,会导致 tabBar 实际高度超过 50px,页面预留的 padding-bottom 就不够了。我一般是把整体高度设为 50px + safe-area-inset-bottom,给 tabBar 设置 flex 布局,让子元素在安全区分界线之上排列。
6.2 页面切换的白屏问题:真机与开发者工具的差异
还有一点要特别提醒:开发者工具里的表现和真机上的表现,两个完全不一样。
在微信开发者工具里,自定义 tabBar 切换页面基本不闪,因为工具的渲染机制和真机 WebView 不一样。但真机上(特别是安卓),“白屏闪烁”很常见。
我的排查步骤是这样的:
第一步,直接在真机上跑,打开开发者工具的调试模式,把渲染层的 console 输出打开,看看切换页面时有没有组件报错。
第二步,检查是否用了原生组件(video、map、canvas 等)。这些原生组件在页面切换时会有更高概率出现闪烁,因为它们和页面不在同一个渲染层。此时可以用 cover-view 包一层做过渡,或者延迟加载原生组件。
第三步,检查页面图片。把本地静态图片改成网络图片(走 CDN 缓存),或者反过来把网络图片改成 base64 内联,测试哪种方式在你的项目里表现稳定。没有绝对的标准答案,不同项目差异很大。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 切换 tab 后底部导航栏高亮错误 | 页面 onShow 后未更新 activePath | 用 mixin 统一处理页面 onShow,重新获取当前页面路径 |
| tabBar 被页面内容遮挡 | z-index 未设置或设置过低 | 给 tabBar 设置 z-index: 999,弹窗层级单独控制 |
| iPhone 底部被手势条遮挡 | 未考虑安全区 | 使用 env(safe-area-inset-bottom) 做 padding-bottom |
| 切换页面后列表滚动位置丢失 | 页面或组件被销毁重建 | 使用 v-show 保持组件,scroll-view 记录 scroll-top |
| 切换时页面白屏 | 页面图片重新加载或页面初始化过慢 | 图片资源预加载,配合静默刷新策略 |
| tabBar 角标数据不更新 | 组件生命周期未触发 | 父页面 onShow 时通过 ref 调用组件行为 |
| 点击凸起按钮无反应 | 凸起部分超出组件边界被裁剪 | 检查父容器 overflow 属性,不要设置为 hidden |
| 二次点击当前 tab 无回顶 | 未监听当前 tab 的点击事件 | 在组件点击事件中判断 activePath 是否相同 |
| 切换页面时底部闪烁 | 页面切换与 tabBar 渲染不同步 | 使用页面缓存 + 图片预加载方案 |
| 真机正常但工具卡顿 | 开发者工具与真机渲染机制不同 | 优先以真机调试验证,工具仅作参考 |
7. 进一步优化:预加载、离屏渲染与体验升级
7.1 图片预加载策略
tab 页面切换闪烁的一个重要原因是图片加载。为了尽量缓解,我在项目里做了一个简单的图片预加载管理器。
在 App.vue 的 onLaunch 里,把 tab 页面里可能用到的核心图片(底部导航栏的图标、首页首屏的 banner 图等)提前加载。
// App.vue onLaunch() { // 预加载关键图片 const images = [ "/static/tabbar/home-active.png", "/static/tabbar/category-active.png", "/static/banner/main-banner.png" ]; images.forEach(src => { uni.getImageInfo({ src, complete: () => {} }); }); }uni.getImageInfo 会触发图片的缓存流程,页面真正用到这些图片时,WebView 可以直接从缓存中读取,减少白屏窗口。
注意:预加载不要做太多图片,否则 app 启动时会抢占带宽,反而拖慢首屏。
7.2 离屏渲染 tab 页面
如果你是在 App 端开发,还有一个进阶操作:使用 plus.nativeObj.View 或者 web-view 的离屏渲染来做 tab 页面的过渡。
但这个方案复杂度较高,需要引入原生的渲染配置,一般业务项目不太推荐。多数情况下,做到“页面缓存 + 图片预加载 + v-show 保持”已经能满足用户感知了。
7.3 配置 manifest 的一些注意点
热搜词里提到了 manifest 配置,这点在自定义 tabBar 场景下很容易被忽略。如果你用到了一些特殊的功能(比如跨域请求、App 端调试等),manifest.json 里的配置会影响页面加载和渲染速度。
举例来说,小程序端如果开启了“上传代码时自动压缩图片”,开发环境和正式环境的图片加载速度可能差异很大。如果正式变慢,可以检查是否开启了过狠的压缩策略。
还有一点,如果你的 App 端页面里用了 uni.webview.js 相关的功能,需要确认 manifest.json 里是否包含了对应的模块配置,否则切换到特定页面时可能会白屏或报错。
8. 个人实测体验与最终建议
自定义 tabBar 这件事,做之前觉得只是“把原生的换成自己的”,做之后才发现,它背后牵扯的是页面结构、缓存策略、生命周期管理、图片加载优化、安全区适配这一连串问题。
我在项目里最终定型的方案是:
- pages.json 保留原生页面注册,不配置 tabBar;
- 每个 tab 页面引入自定义 tabBar 组件,用 mixin 统一处理 activePath;
- 页面内的主要数据在 onLoad 加载一次,onShow 静默刷新;
- 图片资源做预加载,tab 切换的闪烁问题基本消除;
- 用 ref 方式支持父页面主动更新 tabBar 的角标和特殊状态;
- 二次点击当前 tab 回顶功能,产品经理反馈很好。
如果你想直接抄作业,那我建议从“多页面 + uni.switchTab + 自定义 tabBar 组件”这个方案开始。它和原生 tabBar 的交互逻辑最接近,改动成本相对低,而且遇到问题时,社区里能搜到的经验也最多。像“单页 + 多组件”的方案虽然能更好地解决闪烁问题,但如果你项目已经成型了,改造成本很高,除非新项目一开始就这么设计,否则别轻易动。
最后分享一个实操小技巧:真机调试时,把开发者工具的“模拟操作”和真机渲染对比着看。很多闪烁和层级问题,工具上是看不出来的,只有真机才能反映真实情况。我踩过的坑,十有八九都是在真机调试的时候才暴露出来的。所以,不要省真机调试那点时间,尤其是自定义 tabBar 这种跟渲染层强相关的功能。