
微信小程序自定义 tabBar 踩坑实录selected 状态丢失、图标闪烁与 getTabBar 兼容写法适用读者正在用微信小程序做底部导航被自定义 tabBar 折磨过的前端开发者。需要角标、中间凸起大按钮这类原生 tabBar 给不了的样式又不想每次切页都出灵异 bug。文里的代码片段可以直接抄进项目按需替换字段名。TL;DR自定义 tabBar 的 selected 状态丢失根因是每个 tab 页各持有一份独立组件实例状态不会自动同步必须在每个页面的 onShow 里用 getTabBar() 回写。闪烁的根治方案是在组件 attached 生命周期里用 getCurrentPages() 查表算对初始 selected让首帧渲染就是正确高亮。SEO 摘要本文面向使用微信小程序自定义 tabBar 的前端开发者深入剖析 selected 状态丢失与图标闪烁的底层机制给出 getTabBar 兼容写法、attached 生命周期查表算初始 selected 的根治方案并覆盖底部安全区适配、角标与凸起按钮实现等实战细节附完整可复制的代码示例与排错对照表。上周三晚上十点多同事老周在工位上吼了一嗓子线上小程序切 tab 的时候底部图标会先亮错一个再跳回来闪一下用户投诉说手机屏幕是不是坏了。我们拉了代码看问题就出在 custom-tab-bar 的 selected 状态上——这个坑我们半年前其实踩过一次当时改了两个页面就完事了新加的第四个 tab 页忘了补老毛病复发。这篇文章把 custom-tab-bar 相关的坑从头到尾捋一遍包括 selected 丢失的底层机制、getTabBar() 的兼容写法、底部安全区遮挡以及角标和中间凸起按钮的实现取舍省得下次再白费功夫。原生 tabBar 到底差在哪微信小程序自带的 tabBar 配置简单app.json 里写几行就能用。但它有几个绕不开的限制中间不能放凸起的大按钮那种外卖 App 风格的号、角标样式改不了wx.setTabBarBadge 只能给数字红点位置和颜色都是固定的、图标选中态只能靠两张静态图片切换做不了动画。我们做一个到店点单的小程序时产品要求中间按钮凸出 tabBar 上沿 20px带一个旋转的小动画原生 tabBar 直接出局只能上自定义。对比项原生 tabBar自定义 tabBarcustom: true中间凸起按钮不支持随便做WXSS 想怎么摆怎么摆角标只能数字或红点样式固定自己画可以带动画选中态两张图片硬切任意 CSS 动画、字体图标状态维护框架全包每个页面的 selected 得自己回写切换流畅度原生渲染零延迟组件渲染处理不好会闪接入成本几行配置一个完整组件 每页补代码这张表最后一列就是本文的主角。自定义 tabBar 的接入本身不难难的是它把「tab 选中状态」这件事的管理权从框架手里接了过来而你接手的那一刻坑就开始了。custom-tab-bar 的目录约定与配置先说怎么开。app.json 里 tabBar 节点加上custom: true同时list必须照常写全——即使你完全自己渲染框架也要靠 list 来区分哪些页面是 tab 页switchTab 才能正常工作。这一步漏了 listswitchTab 会直接报错。{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 状态丢失」的真面目。不是状态丢了是状态从来没传过去。闪烁则是另一个时间差问题。页面切换的时序大致是这样的渲染早于回写用户点击 tabBar 某项组件内部 switchTab 跳转新 tab 页 onLoad / onShow 触发新页面自己的 tabBar 实例按 data 初始值渲染onShow 里 getTabBar 拿到实例setData 回写正确的 selected实例重新渲染 图标高亮修正注意 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(typeofthis.getTabBarfunctionthis.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 的回写逻辑constTAB_INDEX{pages/index/index:0,pages/order/order:1,pages/mine/mine:2};// 注意 module.exports 前面的 Behavior 是小程序的混入构造器// 所有 tab 页 behaviors 数组里挂上这个模块即可复用回写逻辑module.exportsBehavior({// definitionFilter 留空占位老项目里曾用来做字段过滤definitionFilter(){},methods:{syncTabBar(){// route 在不同基础库里可能叫 __route__两个都兜一下constroutethis.route||(this.__route__||);// 用页面路由查表拿到选中下标避免每个页面硬编码魔法数字constidxTAB_INDEX[route];// getTabBar 不存在低版本基础库或实例未挂载时直接放弃if(typeofthis.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 里别写死 0attached 生命周期里用 getCurrentPages() 拿当前页面路由查同一张表把初始值直接算对。这样首帧渲染就是对的onShow 那次 setData 变成同值写入或者干脆跳过闪烁基本消失。下面给出 custom-tab-bar 组件完整的 index.js 实现把「attached 里查表算初始 selected」和「组件内 switchTab 跳转」两件事都收进来// custom-tab-bar/index.js// 自定义 tabBar 组件逻辑负责渲染底部导航、维护选中态、处理点击跳转// 路由到选中下标的映射表与 utils/tabbar.js 里的 TAB_INDEX 保持一致// 新增 tab 页时这里和 utils/tabbar.js 两处都要同步补上constTAB_INDEX{pages/index/index:0,// 首页pages/order/order:1,// 订单pages/mine/mine:2// 我的};Component({// 组件数据selected 是当前高亮项的下标list 是底部导航的配置项data:{selected:0,// 初始值先给 0attached 里会用真实路由覆盖list:[{pagePath:/pages/index/index,text:首页,iconPath:/images/home.png,selectedIconPath:/images/home-active.png},{pagePath:/pages/order/order,text:订单,iconPath:/images/order.png,selectedIconPath:/images/order-active.png},{pagePath:/pages/mine/mine,text:我的,iconPath:/images/mine.png,selectedIconPath:/images/mine-active.png}]},// 组件生命周期attached 在组件被挂载到页面时触发// 此时页面路由已经确定正好用来把初始 selected 算对避免首帧闪错attached(){// getCurrentPages() 返回当前页面栈栈顶就是当前正在显示的页面constpagesgetCurrentPages();constcurrentPagepages[pages.length-1];// 页面实例上的 route 字段就是当前页面的路由如 pages/order/orderconstroutecurrentPage?currentPage.route:;// 查表拿到当前页对应的选中下标查不到非 tab 页就保持默认 0constidxTAB_INDEX[route];if(idx!undefined){// 首帧渲染前就把 selected 设对从源头消掉「先亮错再跳对」的闪烁this.setData({selected:idx});}},methods:{// 点击底部某一项时触发event.currentTarget.dataset 里带着该页的路径switchTab(e){constpathe.currentTarget.dataset.path;// 先本地把高亮切过去视觉反馈即时不等页面 onShow 回写// 注意这里只更新当前实例的 selected目标页自己的实例// 会在它的 onShow 里通过 getTabBar 回写两处各管各的constidxthis.data.list.findIndex(itemitem.pagePathpath);if(idx!-1){this.setData({selected:idx});}// 跳转 tab 页必须用 wx.switchTabwx.navigateTo 进不了 tab 页// 目标页 onShow 触发后会走前面那套 getTabBar 回写逻辑wx.switchTab({url:path});}}});这段代码把两件事收在同一个组件里attached里用getCurrentPages()拿到当前页面路由查表把初始selected直接算对首帧渲染就是正确高亮从源头消掉闪烁switchTab方法里先本地更新高亮、再调wx.switchTab跳转目标页的onShow会自动接住回写组件自己不需要维护最终归属。配合前面utils/tabbar.js的混入页面侧和组件侧各司其职新增 tab 时只需同步维护两张映射表。安全区与胶囊遮挡问题的两层处理自定义 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 回写就自动接住了——所以组件自己其实不需要维护选中态的最终归属把跳转做完就行回写交给页面。页面Bwx.switchTabtabBar实例(页面A)用户页面Bwx.switchTabtabBar实例(页面A)用户点击第2项switchTab 页面B路由触发页面B onShowgetTabBar() 拿到页面B自己的实例setData({ selected: 1 })底部高亮落在第2项角标就完全自力更生了。wx.setTabBarBadge、wx.removeTabBarBadge 这些接口在 custom 模式下不会作用到你的自定义组件上原生 tabBar 被隐藏了接口调用等于打在空气上我们实测确实如此角标数量得自己存、自己渲染。常见做法是角标数放 app.globalData 或一个全局 storetabBar 组件里用 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 查表算初始 selectedswitchTab 报 page is not in tab barapp.json 的 list 漏了该页custom: true 下 list 仍需写全所有 tab 页低版本微信报 getTabBar is not a function基础库低于 2.6.2typeof 判断兜底失败时降级为不回写凸起按钮少了半个头容器 overflow 被设成 hidden容器改 overflow: visible阴影单独元素画文字压到 iPhone 底部横条没做安全区适配padding-bottom 用 env() 加 constant() 双写参考与延伸微信官方文档·自定义 tabBarhttps://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 界面渲染相关 APIsetTabBarBadge 等https://developers.weixin.qq.com/miniprogram/dev/api/ui/tab-bar/wx.setTabBarBadge.html关键词微信小程序、custom-tab-bar、getTabBar、selected状态、safe-area-inset-bottom、switchTab、前端开发APIsetTabBarBadge 等https://developers.weixin.qq.com/miniprogram/dev/api/ui/tab-bar/wx.setTabBarBadge.html关键词微信小程序、custom-tab-bar、getTabBar、selected状态、safe-area-inset-bottom、switchTab、前端开发