custom-tab-bar 踩坑实录:图标闪烁、selected 状态丢失与 getTabBar 兼容写法
需要角标、中间凸起大按钮这类原生 tabBar 给不了的样式,又不想每次切页都出灵异 bug。 文里的代码片段可以直接抄进项目,按需替换字段名。
上周三晚上十点多,同事老周在工位上吼了一嗓子:线上小程序切 tab 的时候,底部图标会先亮错一个再跳回来,闪一下,用户投诉说手机屏幕是不是坏了。我们拉了代码看,问题就出在 custom-tab-bar 的 selected 状态上——这个坑我们半年前其实踩过一次,当时改了两个页面就完事了,新加的第四个 tab 页忘了补,老毛病复发。这篇文章把 custom-tab-bar 相关的坑从头到尾捋一遍,包括 selected 丢失的底层机制、getTabBar() 的兼容写法、底部安全区遮挡,以及角标和中间凸起按钮的实现取舍,省得下次再白费劲。
原生 tabBar 到底差在哪
微信小程序自带的 tabBar 配置简单,app.json 里写几行就能用。但它有几个绕不开的限制:中间不能放凸起的大按钮(那种外卖 App 风格的"+"号)、角标样式改不了(wx.setTabBarBadge 只能给数字,红点位置和颜色都是固定的)、图标选中态只能靠两张静态图片切换,做不了动画。我们做一个到店点单的小程序时,产品要求中间按钮凸出 tabBar 上沿 20px,带一个旋转的小动画,原生 tabBar 直接判了死刑,只能上自定义。

| 对比项 | 原生 tabBar | 自定义 tabBar(custom: true) |
|---|---|---|
| 中间凸起按钮 | 不支持 | 随便做,WXSS 想怎么摆怎么摆 |
| 角标 | 只能数字或红点,样式固定 | 自己画,可以带动画 |
| 选中态 | 两张图片硬切 | 任意 CSS 动画、字体图标 |
| 状态维护 | 框架全包 | 每个页面的 selected 得自己回写 |
| 切换流畅度 | 原生渲染,零延迟 | 组件渲染,处理不好会闪 |
| 接入成本 | 几行配置 | 一个完整组件 + 每页补代码 |
这张表最后一列就是本文的主角。自定义 tabBar 的接入本身不难,难的是它把「tab 选中状态」这件事的管理权从框架手里接了过来,而你接手的那一刻,坑就开始了。
custom-tab-bar 的目录约定与配置
先说怎么开。app.json 里 tabBar 节点加上 "custom": true,同时 list 必须照常写全——即使你完全自己渲染,框架也要靠 list 来区分哪些页面是 tab 页,switchTab 才能正常工作。这一步漏了 list,switchTab 会直接报错。
{
"tabBar": {
"custom": true,
"color": "#666666",
"selectedColor": "#07C160",
"list": [
{ "pagePath": "pages/index/index", "text": "首页" },
{ "pagePath": "pages/order/order", "text": "订单" },
{ "pagePath": "pages/mine/mine", "text": "我的" }
]
}
}
然后在代码根目录(和 app.json 同级)建一个固定名字的目录 custom-tab-bar,里面放 index.js、index.json、index.wxml、index.wxss 四个文件,一个都不能少。index.json 里必须声明 "component": true,目录名和文件名都是约定死的,写错一个字母整个 tabBar 就不渲染,而且控制台不一定给你报错,页面底部就是干干净净一条空白。老周第一次接手的时候把目录写成了 customTabBar,调试了半个多小时才发现,控制台一片安静,这种静默失败最耗人。
selected 状态为什么会丢:机制剖析
这是整个 custom-tab-bar 体系里最大的一个坑,得把机制讲透。
关键事实是:自定义 tabBar 组件不是全局单例,每个 tab 页面各自持有一个独立的组件实例。你从「首页」切到「订单」,渲染的其实是订单页自己那份 tabBar 组件,首页那份实例还挂在首页上。所以你在首页的实例里 setData({ selected: 0 }),切到订单页时,订单页的实例根本不知道这事儿,它的 selected 还是 data 里的初始值——通常写死成 0,于是订单页底部的 tabBar 高亮停在第一项,这就是「selected 状态丢失」的真面目。不是状态丢了,是状态从来没传过去。
闪烁则是另一个时间差问题。页面切换的时序大致是这样的:
flowchart TD
A[用户点击 tabBar 某项] --> B[组件内部 switchTab 跳转]
B --> C[新 tab 页 onLoad / onShow 触发]
C --> D[新页面自己的 tabBar 实例按 data 初始值渲染]
D --> E[onShow 里 getTabBar 拿到实例]
E --> F[setData 回写正确的 selected]
F --> G[实例重新渲染 图标高亮修正]
D -.渲染早于回写.-> G
注意 D 到 F 之间:新页面的 tabBar 实例先按 data 里的初始值渲染了一帧,然后 onShow 里的 setData 才把正确的 selected 补上。两次渲染之间隔了几十毫秒,肉眼看到的就是图标先亮错、再跳对。data 初始值写 0 的话,从任何非第一个 tab 切进去都会闪。我们实测这台测试机(iPhone SE2,基础库 3.x)上这个时间差大概几十毫秒,肉眼可辨;安卓中端机上更明显一些。
理解了「每页一个实例」这一点,后面所有写法都是围绕它展开的:每个 tab 页的 onShow 里都要自己回写一次 selected,没有一劳永逸的全局开关。
getTabBar 在 onShow 里回写:正确姿势与兼容写法
框架提供了 Page.prototype.getTabBar(),在 tab 页里调用可以拿到当前页面挂着的那个自定义 tabBar 组件实例。标准写法是在每个 tab 页的 onShow 里回写:
// pages/order/order.js 每个 tab 页都要来这么一段
Page({
onShow() {
// getTabBar 返回当前页面挂载的自定义 tabBar 组件实例
// 注意:每个 tab 页持有各自独立的实例,互不相通
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
// 只有 custom-tab-bar 的组件实例存在时才能 setData
// typeof 判断是为了兼容低版本基础库(2.6.2 之前没有该接口)
this.getTabBar().setData({ selected: 1 });
}
}
});
两个细节值得展开。
第一,typeof this.getTabBar === 'function' 这层判断不是多余的防御性代码。getTabBar 是基础库 2.6.2 才加的接口,如果你的小程序还要跑在低版本微信上(有些政企项目确实要兼容),低版本里 this.getTabBar 是 undefined,直接调用会抛 TypeError 把 onShow 整个打断。我们的做法是封装成一个行为混入(Behavior),所有 tab 页复用同一段逻辑,避免每个页面各写一份、改的时候漏。
第二,selected 的值硬编码容易出错。页面一多,「订单页是 1」这种魔法数字散落各处,新增 tab 时极易漏改。更稳的做法是把映射关系收敛到一个常量表里:
// utils/tabbar.js 统一收敛 selected 的回写逻辑
const TAB_INDEX = { 'pages/index/index': 0, 'pages/order/order': 1, 'pages/mine/mine': 2 };
// 注意 module.exports 前面的 Behavior 是小程序的混入构造器
// 所有 tab 页 behaviors 数组里挂上这个模块即可复用回写逻辑
module.exports = Behavior({
// definitionFilter 留空占位,老项目里曾用来做字段过滤
definitionFilter() {},
methods: {
syncTabBar() {
// route 在不同基础库里可能叫 __route__,两个都兜一下
const route = this.route || (this.__route__ || '');
// 用页面路由查表拿到选中下标,避免每个页面硬编码魔法数字
const idx = TAB_INDEX[route];
// getTabBar 不存在(低版本基础库)或实例未挂载时直接放弃
if (typeof this.getTabBar !== 'function' || !this.getTabBar()) return;
// 下标存在才 setData,减少一次无意义渲染
if (idx !== undefined) this.getTabBar().setData({ selected: idx });
}
},
// 组件级生命周期这里用不到,留空结构保持完整
lifetimes: {},
pageLifetimes: {
show() {
// 页面 show 生命周期里触发同步,时序上晚于 tabBar 首次渲染
this.syncTabBar();
}
}
});
这段混入把「查表 + 兼容判断 + 回写」收在一处。想进一步消闪烁,可以把初始 selected 的锅也卸掉:custom-tab-bar 组件的 data 里别写死 0,attached 生命周期里用 getCurrentPages() 拿当前页面路由,查同一张表,把初始值直接算对。这样首帧渲染就是对的,onShow 那次 setData 变成同值写入(或者干脆跳过),闪烁基本消失。
安全区与胶囊:遮挡问题的两层处理
自定义 tabBar 是普通组件,不会自动避开 iPhone 底部的 Home 指示器。不做处理的话,tabBar 的文字和图标会压到那条黑色横条上,全面屏上非常难看。标准解法是用 CSS 的安全区环境变量:
/* custom-tab-bar/index.wxss 底部安全区适配 */
.tab-bar {
/* env() 读取系统安全区内边距 iOS 全面屏约 34px,非全面屏为 0 */
padding-bottom: env(safe-area-inset-bottom);
/* 老版本 iOS 11.0-11.2 只认 constant(),两个都写做降级兼容 */
padding-bottom: constant(safe-area-inset-bottom);
position: fixed;
bottom: 0;
left: 0;
right: 0;
/* 固定定位四边归零,宽高由内容撑开 */
/* 中间凸起按钮要露出去,overflow 不能是 hidden */
overflow: visible;
/* 背景色盖住下方滚过的页面内容 */
background-color: #ffffff;
}
顺序有讲究:constant() 写在 env() 后面,让支持的浏览器用 env() 的值覆盖,不支持的走 constant() 降级。反过来写,新机型会拿到 0。另外如果中间按钮是凸出 tabBar 上沿的,容器千万别写 overflow: hidden,不然凸起部分被裁掉,这个问题排查起来很反直觉——样式看着都对,就是按钮齐刷刷少了半个头。
胶囊遮挡是另一码事。胶囊按钮在右上角,跟 tabBar 本身没关系,但很多自定义 tabBar 项目会顺带做自定义导航栏,这时顶部内容压到胶囊就是重灾区。用 wx.getMenuButtonBoundingClientRect() 拿胶囊的位置,配合 wx.getSystemInfoSync() 的 statusBarHeight,可以精确算出导航区高度,这里不展开,只提醒一句:别把胶囊高度写死成 32px,不同机型的胶囊位置和尺寸有差异,实测有的安卓机胶囊上下边距能差出 4px,写死必翻车。
switchTab 同步与角标、凸起按钮的取舍
自定义 tabBar 里点某一项,跳转要用 wx.switchTab,普通的 wx.navigateTo 进不了 tab 页。事件同步上有个容易忽略的点:组件内 switchTab 之后,目标页的 onShow 会触发,前面那套 getTabBar 回写就自动接住了——所以组件自己其实不需要维护选中态的最终归属,把跳转做完就行,回写交给页面。
sequenceDiagram
participant U as 用户
participant T as tabBar实例(页面A)
participant W as wx.switchTab
participant P as 页面B
U->>T: 点击第2项
T->>W: switchTab 页面B路由
W->>P: 触发页面B onShow
P->>P: getTabBar() 拿到页面B自己的实例
P->>P: setData({ selected: 1 })
P-->>U: 底部高亮落在第2项
角标就完全自力更生了。wx.setTabBarBadge、wx.removeTabBarBadge 这些接口在 custom 模式下不会作用到你的自定义组件上(原生 tabBar 被隐藏了,接口调用等于打在空气上),我们实测确实如此,角标数量得自己存、自己渲染。常见做法是角标数放 app.globalData 或一个全局 store,tabBar 组件里用 observer 监听,订单状态变化时改 store、组件自动更新。要提醒的是别在 tabBar 组件里直接订阅一堆业务事件,它每个页面都有一份实例,五份实例各挂一套监听,事件风暴的时候很容易出诡异的重绘问题,收敛到一个数据源上最省事。
中间凸起按钮的分寸感也说一下。凸起区域的点击热区要用 padding 或透明的占位元素撑出来,别只靠视觉上的图形,小图标实际可点区域太窄,用户会点空。凸起的阴影投影别用 box-shadow 硬打在 tabBar 容器上,分割出来单独一个元素画阴影,不然阴影边线在容器边界会被切一道。
最后是取舍判断,不是所有项目都该上自定义 tabBar。如果你只是想改改颜色、换个图标,原生 tabBar 加 wx.setTabBarStyle / wx.setTabBarItem 就够,维护成本低得多。上自定义的合理理由只有三条:要凸起按钮、要自定义角标样式、要选中态动画。为了这三条,你要付出「每个 tab 页维护回写逻辑、每次加 tab 都要改常量表和四个页面」的持续成本。老周他们那个项目后来复盘,如果当初说服产品把凸起按钮改成普通图标,后面这些坑一个都不会有。你在 custom-tab-bar 上还踩过什么坑,评论区聊聊。
我们项目里沉淀下来的排错对照表,遇到问题先对着查一遍,能省不少时间:
| 症状 | 高频原因 | 处理办法 |
|---|---|---|
| 底部整条空白不渲染 | 目录名或文件名不合规 | 根目录建 custom-tab-bar/index 四件套,json 声明 component: true |
| 切页后高亮停在上一个 tab | 新页面 selected 没回写 | 每个 tab 页 onShow 里 getTabBar().setData 回写 |
| 图标先亮错再跳对 | data 初始值与实际页不一致 | attached 里用 getCurrentPages 查表算初始 selected |
| switchTab 报 page is not in tab bar | app.json 的 list 漏了该页 | custom: true 下 list 仍需写全所有 tab 页 |
| 低版本微信报 getTabBar is not a function | 基础库低于 2.6.2 | typeof 判断兜底,失败时降级为不回写 |
| 凸起按钮少了半个头 | 容器 overflow 被设成 hidden | 容器改 overflow: visible,阴影单独元素画 |
| 文字压到 iPhone 底部横条 | 没做安全区适配 | padding-bottom 用 env() 加 constant() 双写 |
参考与延伸
- 微信官方文档·自定义 tabBar:https://developers.weixin.qq.com/miniprogram/dev/framework/ability/custom-tabbar.html
- 微信官方文档·Page.getTabBar 接口说明:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html
- 微信官方文档·wx.switchTab 路由接口:https://developers.weixin.qq.com/miniprogram/dev/api/route/wx.switchTab.html
- 微信官方文档·tabBar 界面渲染相关 API(setTabBarBadge 等):https://developers.weixin.qq.com/miniprogram/dev/api/ui/tab-bar/wx.setTabBarBadge.html
关键词:微信小程序、custom-tab-bar、getTabBar、selected状态、safe-area-inset-bottom、switchTab、前端开发