
1. 为什么放弃原生 tabBar需求倒推与方案选型1.1 你遇到的“好看但原生做不到”的需求长什么样先直接说结论微信小程序的 tabBar 是有原生能力的微信官方在 app.json 里配置 tabBar 字段就能获得底部导航栏性能好、实现简单、不需要写任何布局代码。但原生 tabBar 的定制能力非常有限它只能改图标、改文字、改选中颜色连最基础的“中间按钮凸起”“某个页面不显示 tabBar”“tab 上带数字角标”这些需求都做不到。我这次接到的需求很典型一个电商类小程序底部需要五个入口分别是首页、分类、购物车、消息、我的但中间那个“购物车”必须做成凸起的红色大按钮上面还要带商品数量角标同时商品详情页不能显示底部导航栏不同 tab 切换时还要做统一的点击埋点。这些需求用原生 tabBar 一个都做不了只能上自定义方案。这时候如果再查资料十篇帖子有八篇让你去改 pages 目录下手写一个 view 模拟 tabBar。这条路我劝你尽量别走因为手写 view 的 tabBar 会遇到一个绕不开的坑你跳转时用的是 wx.navigateTo它会不断叠加页面栈返回按钮出现一堆用户按返回键时整个 tab 流程是乱的而且页面之间互相跳转没有原生“选中态”联动你自己管理状态很容易出现 tab 切过去之后高亮不对的情况。正确做法是走微信官方提供的自定义 tabBar 机制在 app.json 里把 tabBar.custom 置为 true然后在根目录放一个叫 custom-tab-bar 的组件目录微信会自动接管“底部栏区域”的渲染而 tab 切换仍然使用 wx.switchTab页面栈行为完全保持原生规则。这也是本文案例落地使用的方案。1.2 官方自定义机制的运行原理自定义 tabBar 并不是“完全抛弃原生”而是对原生 tabBar 做了一层可替换渲染。微信会在你配置了 custom: true 之后不再渲染默认底栏而是去根目录寻找 custom-tab-bar 目录下的组件把它当作底栏渲染出来。但关键点在于哪些页面属于 tabBar 页面、点击后跳转到哪里仍然由 app.json 里的 tabBar.list 决定。所以 app.json 里那一份 list 配置不是摆设也不能删掉。它承担了两件事第一声明 tab 页面的范围这些页面跳转时使用 wx.switchTab不走页面栈叠加第二兜底逻辑如果自定义渲染失败了微信还能按默认样式渲染出一个能用的底栏。实践中我见过有人为了“告别原生限制”把 list 删成空数组结果小程序直接编译报错tabBar 相关页面全部白屏。自定义 tabBar 的组件本身就是一个标准的自定义组件基于 Component 构造器编写。它在页面底部渲染具体定位方式需要你在 wxss 里自己用 position: fixed 固定。页面要获取这个组件实例时调用页面实例上的 this.getTabBar() 方法。这个方法是微信在基础库 2.6.2 之后提供的专门用于“页面侧与自定义 tabBar 通信”。我习惯把这个机制理解为自定义 tabBar 把“展示层”交给你把“路由层”留在微信手里。你改样式、改布局、加角标、加凸起按钮都行但 tab 页面的切换逻辑始终走 switchTab这样才不会被半路跳出的页面栈问题坑到。1.3 什么人适合自定义什么人建议保持原生并不是所有项目都需要自定义 tabBar。如果你的需求只是“图标好看一点”“选中颜色贴合品牌色”那原生 tabBar 完全可以满足改两行配置就好性能还比自定义更好。自定义方案带来的问题包括需要维护一个组件目录、每个 tab 页都要在 onShow 里同步选中态、页面底部要为固定 bar 预留占位空间、真机上还要额外处理安全区。这些成本是实打实的。什么时候值得上自定义我把自己的判断标准说直白一点需要 tab 图标有动画、有 SVG 可变描边、有品牌视觉强绑定需要中间按钮凸起、缺口、异形背景这类特殊布局需要某个 tab 页隐藏底部栏或按登录态动态显示/隐藏需要 tab 上带实时角标、红点、文案提醒需要所有 tab 点击统一走埋点统计且不想在每个页面写重复代码。如果你一个都没踩中原生足够踩中两条以上建议直接上自定义一次把架构做对。接下来第二部分聊实现之前的几个关键设计点这一块直接决定后面写代码时会不会反复返工。2. 动手前的关键设计目录、配置与联动约定2.1 app.json 里的 tabBar 配置为何不能删先给出一份标准的自定义 tabBar 配置示例下面这段是我在这类项目里常用的骨架{ tabBar: { custom: true, color: #8A8F9E, selectedColor: #4B6AFE, backgroundColor: #FFFFFF, borderStyle: black, list: [ { pagePath: pages/index/index, text: 首页, iconPath: assets/tab/home.png, selectedIconPath: assets/tab/home-active.png }, { pagePath: pages/category/category, text: 分类, iconPath: assets/tab/category.png, selectedIconPath: assets/tab/category-active.png }, { pagePath: pages/message/message, text: 消息, iconPath: assets/tab/message.png, selectedIconPath: assets/tab/message-active.png }, { pagePath: pages/mine/mine, text: 我的, iconPath: assets/tab/mine.png, selectedIconPath: assets/tab/mine-active.png } ] } }注意即使开启了 custom 模式iconPath、selectedIconPath 这些字段我仍然推荐全部写上。原因有两个一是万一自定义组件加载失败微信还能渲染出最基础的原生底栏作为降级体验二是部分调试场景、开发者工具模拟器里list 中的图标可以作为自定义组件的默认素材来源参考。真机上 custom 模式不展示原生的这套图标但配置完整性会让整个 tabBar 的初始化流程更稳定。另外 list 中页面数量必须保持在 2 到 5 个这个限制是微信底层强制的。页面路径也必须是真实的、存在的页面。我见过有人想着“自定义了就可以随便在 list 里放不存在的页面路径”结果编译报错路径找不到页面。list 的本质是“tab 集合定义”不是只给原生渲染用的摆设。2.2 custom-tab-bar 组件的固定要求自定义 tabBar 的目录名是写死的custom-tab-bar。这个目录必须放在小程序项目的根目录和 app.json 同级不能放到 pages 里面也不能改名。目录下需要四个文件index.js、index.json、index.wxml、index.wxss对应一个标准自定义组件。index.json 里的内容很简单但很容易漏{ component: true }如果这个文件里没有 component: true组件无法正常工作页面侧调用 this.getTabBar() 时会拿不到实例。这个坑我在实际开发里踩过当时自定义 tabBar 一直渲染不出来排查了半天才发现是新建文件时模板里默认没有 component 字段。组件本身使用 Component 构造器而不是 Page 构造器。它的 data 里可以放 tab 数组、当前选中索引、徽标数据。methods 里放事件处理函数。这里有一个需要特别注意的地方组件默认开启 styleIsolation也就是样式隔离custom-tab-bar 的 wxss 不会影响页面页面的 wxss 也不会影响 custom-tab-bar。如果你想要页面侧给 tabBar 传类名去改样式需要在 index.json 里配置 styleIsolation: shared或者改用 externalClasses 方案。还有一点容易被忽略自定义 tabBar 组件默认是 “普通自定义组件”它不会自动处理底部安全区。这就是为什么很多项目自定义 tabBar 之后在 iPhone 带刘海/底部横条的机型上最后一个 tab 的文字被 Home Indicator 盖住。你必须在 wxss 里自己加安全区 padding后面我会单独讲。2.3 页面与 tabBar 的选中态同步原生 tabBar 的选中态是微信自动管理的你切到哪个 tab底栏就自动高亮哪个。自定义之后这个行为就没了需要每个 tab 页面自己在可见时把选中索引同步给 tabBar 组件。常规做法是在每个 tab 页面的 onShow 生命周期里写Page({ onShow() { if (typeof this.getTabBar function this.getTabBar()) { this.getTabBar().setData({ selectedIndex: 0 }) } } })这段代码是自定义 tabBar 场景下的核心联动逻辑。onShow 而不是 onLoad原因在于 tabBar 页面实例是复用的首次进入时 onLoad 触发一次之后在 tab 之间切换页面实例并不会重新创建只触发 onShow。如果写在 onLoad 里你会发现从“首页”切到“我的”再切回“首页”底栏高亮可能停在“我的”上状态完全错乱。我把选中序号的维护规则定为每个页面负责自己对应的索引索引下标从 0 开始与 app.json list 中的顺序一致。首页就是 0分类是 1消息是 2我的是 3。这样写起来最不容易乱也方便后面维护。2.4 安全区、导航栏与底部占位自定义 tabBar 因为是自己写的组件固定到底部时需要手动适配安全区。我这里给出常用的 wxss 写法.tab-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; height: 110rpx; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); background-color: #ffffff; box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06); z-index: 999; }constant 是 iOS 11.0 之前的环境变量写法env 是新写法两条都写上是为了兼容老版本。实际运行环境里iPhone 底部安全区大约在 34px所以加了 padding-bottom 后整个 tabBar 的实际视觉高度是 110rpx 加上底部安全区的高度看起来不会突兀。页面侧也要做配合。原生 tabBar 是微信底层渲染的页面内容会自动避开底栏区域自定义 tabBar 使用 position: fixed页面内容继续往上走底栏会盖住页面底部内容。所以每个 tab 页面底部需要预留一个占位空间。我的做法是在页面 wxml 最后放一个空 view设置高度为底栏总高度也就是 110rpx 加安全区高度view classtabbar-placeholder styleheight: 130rpx;/view这里 130rpx 是给没有安全区的普通机型预留的具体数值可以根据你实际计算的底栏高度调整。如果某些页面需要隐藏 tabBar那么占位 view 也应该同步隐藏否则页面底部会多出一大片空白。导航栏也值得提一下。自定义 tabBar 不影响顶部导航栏页面继续用微信原生 navigationBar 或自定义导航都可以。但如果你的 tab 页面既自定义 tabBar 又自定义顶部导航那整页上下都有自己控制的区域需要额外注意 iPhone 状态栏高度和胶囊按钮位置。本文案例里我用的是原生顶部导航栏降低复杂度将注意力聚焦在底栏。3. 完整实战双态图标 中部凸起按钮的五个 Tab3.1 需求清单与文件规划这次案例的目标是做出一个电商风格底栏布局为首页、分类、发布中部凸起、消息、我的。其中“发布”不是原生意义上的 tab而是一个特殊入口点击后跳转到非 tab 页面比如发布商品页。这种设计在社区类、电商类小程序里很常见中间那个按钮承担“主操作入口”的职责视觉上突出点击行为也不进入 tab 切换逻辑。文件规划如下app.jsontabBar 配置开启 customlist 中放 4 个真实 tab 页面。custom-tab-bar/index.js / index.json / index.wxml / index.wxss底栏组件本身。pages/index/index、pages/category/category、pages/message/message、pages/mine/mine四个 tab 页面。pages/publish/publish发布入口对应的非 tab 页面。为什么 list 里只放 4 项而不是 5 项因为“发布”按钮即使视觉上占了第五个位置它也不是一个真正的 tab 页面。如果把它加进 list你就必须给它分配一个 pagePath而且点击后必须用 wx.switchTab 跳转但你又希望它用 wx.navigateTo 跳到一个可返回的发布页——这两者冲突。所以正确做法是list 只放真 tab特殊按钮在组件模板里单独渲染。3.2 app.json 配置示例{ pages: [ pages/index/index, pages/category/category, pages/message/message, pages/mine/mine, pages/publish/publish ], window: { navigationBarTitleText: 自定义Tab案例, navigationBarBackgroundColor: #ffffff, navigationBarTextStyle: black }, tabBar: { custom: true, color: #8A8F9E, selectedColor: #4B6AFE, backgroundColor: #FFFFFF, list: [ { pagePath: pages/index/index, text: 首页, iconPath: assets/tab/home.png, selectedIconPath: assets/tab/home-active.png }, { pagePath: pages/category/category, text: 分类, iconPath: assets/tab/category.png, selectedIconPath: assets/tab/category-active.png }, { pagePath: pages/message/message, text: 消息, iconPath: assets/tab/message.png, selectedIconPath: assets/tab/message-active.png }, { pagePath: pages/mine/mine, text: 我的, iconPath: assets/tab/mine.png, selectedIconPath: assets/tab/mine-active.png } ] }, sitemapLocation: sitemap.json }页面路径、图片路径都按实际项目调整。assets 目录我习惯放在根目录图片用 PNG 格式尺寸在 70x70 到 100x100 像素之间。自定义 tabBar 里图标的显示尺寸由 wxss 控制素材本身大一点没有关系mode 设为 aspectFit 防止变形。3.3 custom-tab-bar 组件实现先看 index.js。这里的关键点在于数据驱动渲染、点击事件统一处理、对外暴露方法。Component({ data: { selectedIndex: 0, show: true, list: [ { pagePath: /pages/index/index, text: 首页, iconPath: /assets/tab/home.png, selectedIconPath: /assets/tab/home-active.png }, { pagePath: /pages/category/category, text: 分类, iconPath: /assets/tab/category.png, selectedIconPath: /assets/tab/category-active.png }, { pagePath: /pages/message/message, text: 消息, iconPath: /assets/tab/message.png, selectedIconPath: /assets/tab/message-active.png }, { pagePath: /pages/mine/mine, text: 我的, iconPath: /assets/tab/mine.png, selectedIconPath: /assets/tab/mine-active.png } ], badgeMap: {} }, methods: { handleTap(e) { const { path, index } e.currentTarget.dataset if (this.data.selectedIndex index) { return } wx.switchTab({ url: path }) this.setData({ selectedIndex: index }) }, handlePublish() { wx.navigateTo({ url: /pages/publish/publish }) }, setBadge(index, count) { this.setData({ [badgeMap[${index}]]: count }) }, hide() { this.setData({ show: false }) }, show() { this.setData({ show: true }) } } })这里面有几个设计逻辑值得展开说。第一为什么点击后先 wx.switchTab 再 setData 修改 selectedIndex因为 switchTab 是异步的页面 onShow 里还会再同步一次选中索引组件内的 setData 是即时生效的这样双重保障能避免点击后出现“页面已经切换但底栏高亮还没变”的短暂闪烁。第二为什么用 badgeMap 存角标而不是在 list 里直接放 count 字段因为 list 是静态配置角标是动态数据把两者分开渲染时组合逻辑更清晰也方便统一清零。第三handlePublish 走了 wx.navigateTo不会修改 selectedIndex。这样发布页打开后底栏高亮仍然停留在进入发布页之前那一个 tab 上。很多新手在这里会踩坑把发布按钮也当成一个 tab点击后高亮跑到中间返回后又切回原来的页面底栏状态对不上。解决方案就是我上面写的特殊按钮不碰选中索引。接下来是 index.wxml。我要渲染 4 个 tab 项和 1 个发布按钮在 wx:for 循环里直接渲染发布按钮单独写。view classtab-bar wx:if{{show}} view classtab-bar-item wx:for{{list}} wx:keypagePath >.tab-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; height: 110rpx; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); background-color: #ffffff; box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06); z-index: 999; } .tab-bar-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; } .tab-icon-wrap { position: relative; width: 52rpx; height: 52rpx; display: flex; align-items: center; justify-content: center; } .tab-icon { width: 52rpx; height: 52rpx; } .badge { position: absolute; top: -12rpx; right: -16rpx; min-width: 32rpx; height: 32rpx; padding: 0 8rpx; border-radius: 16rpx; background-color: #ff4d4f; color: #ffffff; font-size: 20rpx; line-height: 32rpx; text-align: center; box-sizing: border-box; } .tab-text { margin-top: 4rpx; font-size: 22rpx; color: #8a8f9e; line-height: 1.4; } .tab-text-active { color: #4b6afe; font-weight: 500; } .tab-bar-item-publish { position: relative; } .publish-btn { position: absolute; top: -40rpx; width: 96rpx; height: 96rpx; border-radius: 50%; background: linear-gradient(135deg, #ff7a45, #ff4d4f); box-shadow: 0 8rpx 16rpx rgba(255, 77, 79, 0.25); display: flex; align-items: center; justify-content: center; } .publish-icon { width: 48rpx; height: 48rpx; }这里的 top: -40rpx 是让发布按钮从底栏向上凸出 40rpx配合底部 padding 之后整体视觉很协调。因为发布按钮也要显示文字而文字还在底栏常规区域内所以发布项的整体布局和普通 tab 项一致只是图标部分通过绝对定位凸出去了。3.4 页面侧联动代码每个 tab 页面里都需要在 onShow 同步选中索引。我以首页为例其余页面只要改数字即可Page({ data: { productList: [] }, onShow() { if (typeof this.getTabBar function this.getTabBar()) { this.getTabBar().setData({ selectedIndex: 0 }) } } })分类页 index 为 1消息页为 2我的页为 3。如果你的页面里也有登录态判断、未读消息拉取建议把这些也放在 onShow 里因为页面实例不会重新创建只有 onShow 会在每次切换到该 tab 时触发。页面底部记得加占位 view否则内容会被 fixed 定位的底栏盖住。我把占位高度与底栏总高度对齐底栏 110rpx 加安全区大约 34px普通机型占位高度可以设 130rpxiPhone 有安全区的机型稍微大一点但视觉上差别不大统一用 130rpx 也问题不大因为底栏本身包含了安全区 padding 的视觉延伸页面占位略高一点点不会穿帮。3.5 中部“发布”按钮的两种实现路线第一种是把发布按钮当作特殊 tab 项放在 list 里给它配一个页面路径点击时 wx.switchTab。这种做法的好处是布局简单五个位置平均分配代码里不需要额外加一个独立节点。但它有一个致命问题点击发布按钮时底栏高亮会跳到中间。哪怕你在 setData 里强行不更新 selectedIndexswitchTab 之后页面 onShow 里如果又同步了一次状态仍然会乱。更麻烦的是发布页如果是 tab 页它就不能 wx.navigateTo用户没法“返回”到之前浏览的页面这在实际业务里很不合理。第二种就是本文案例用的发布按钮不在 list 中组件模板里单独渲染一个节点点击走 wx.navigateTo。这样发布页是一个普通页面导航栏自带返回按钮用户从首页进入发布页之后可以正常返回底栏高亮也不会受发布页影响。代价是布局时需要给发布按钮单独留位置视觉上稍微多写几行样式但代码逻辑更干净。我的建议是永远走第二种方案。理由很简单自定义 tabBar 的核心目标是既保住原生的 switchTab 页面栈行为又获得视觉自由。特殊入口如果破坏了页面栈行为就违背了使用自定义 tabBar 的初衷。3.6 动态隐藏 tabBar 与动态角标产品需求经常要求“某些页面不显示底栏”。在原生 tabBar 下这个需求做不到因为 tabBar 在 tab 页面上是全局显示的在自定义 tabBar 下可以通过页面 onShow 时调用组件隐藏方法实现。商品详情页隐藏底栏的示例Page({ onShow() { const tabBar this.getTabBar this.getTabBar() if (tabBar) { tabBar.hide() } }, onHide() { const tabBar this.getTabBar this.getTabBar() if (tabBar) { tabBar.show() } } })这里有个细节onHide 里要恢复显示否则用户从详情页返回 tab 页时底栏还是隐藏状态。因为 tab 页面实例复用的详情页 onHide 触发时tabBar 组件还是同一个实例当前选中状态不会自动重置。每个管理底栏显示状态的页面都要按照“onShow 设置自己需要的状态、onHide 恢复默认状态”去做。角标使用我在组件里暴露的 setBadge 方法。比如购物车未读数量变化后在页面里调用this.getTabBar().setBadge(2, unreadCount)角标数字超过 99 时组件已经做了压缩显示直接显示 99。红点场景可以把 setBadge 的第二个参数传 0页面根据服务端返回的 unread 标记决定显示还是不显示。如果你的需求是“有没有红点”可以在组件里再暴露一个 setBadgeVisible(index, visible) 方法或者像我上面那样用 badgeMap[index] 0 控制按业务情况微调即可。4. 进阶玩法状态共享、统一埋点与主题切换4.1 登录态变化时刷新 tabBar自定义 tabBar 和页面之间是“组件实例 页面调用”的关系不是全局数据流所以登录态变化时tabBar 不会自己感知。比如“我的”页需要登录才能显示特定入口未登录时底栏“我的”tab 上要带一个红点提示登录用户登录成功后这个红点必须马上消失不能等下次重启小程序才刷新。我的做法是登录成功后在任意业务页面里主动调用 tabBar 实例的刷新方法。比如登录成功的回调在全局 store 里页面监听状态变化后调用const app getApp() Page({ onShow() { const tabBar this.getTabBar this.getTabBar() if (tabBar) { tabBar.setBadge(3, app.globalData.isLogin ? 0 : 1) } } })也可以把 tabBar 组件实例存到一个全局可访问的位置。组件内部可以调用 getApp()所以更彻底的做法是在组件 methods 里写一个 syncLoginState 方法内部读取 getApp().globalData根据登录态更新角标和显隐状态。页面不需要在每个 onShow 里重复判断只需要在登录态变化时去调用一次。实际项目中我习惯把“底栏当前状态”当成一份和页面无关的“全局 UI 状态”来维护统一存放在 globalData 里包括 selectedIndex、badgeMap、show。页面 onShow 时从 globalData 读数据同步到组件组件数据变化时再写回 globalData。这样多个页面同时操作底栏时不会出现状态互相覆盖。4.2 在 handleTap 里做点击埋点自定义 tabBar 还给埋点统计带来了很大的便利。原生 tabBar 你要统计“底部入口点击”比较麻烦因为微信没有直接暴露原生 tabBar 的点击事件自定义 tabBar 之后所有点击都经过组件里的 handleTap集中埋点非常方便。我的埋点代码通常这样写handleTap(e) { const { path, index } e.currentTarget.dataset wx.reportEvent(tabbar_click, { tab_index: index, tab_path: path, page_from: getCurrentPages().slice(-1)[0].route || }) if (this.data.selectedIndex index) { return } wx.switchTab({ url: path }) this.setData({ selectedIndex: index }) }wx.reportEvent 是微信广告/分析体系里的事件上报接口如果你的项目用的是其他统计 SDK在这里调用对应方法即可。关键在于上报逻辑只写一处后续新增 tab 项时不需要在页面里加埋点代码。page_from 可以记录当前所在页面方便分析用户从哪个页面点了哪个 tab。如果你还需要区分“点击后停留在当前 tab”和“切换到新 tab”两种行为可以在 return 之前也报一次事件参数加一个 action 字段区分。产品需求要是问“用户重复点击同一个 tab 有多少次”这种数据也能从埋点里拿出来。4.3 暗黑模式与品牌色切换自定义 tabBar 的另一个好处是支持主题切换。原生 tabBar 的主题色只能在 app.json 里写死想要跟随用户切换暗黑模式基本不可能自定义组件则可以通过 setData 改变样式变量。我实现主题切换的方案比较轻量在组件 data 里加一个 theme 字段默认是 light暗黑模式时页面调用 tabBar.setData({ theme: dark })。wxml 里给底栏根节点动态加 classview classtab-bar {{theme dark ? tab-bar-dark : }} wx:if{{show}}wxss 里定义一组覆盖样式.tab-bar-dark { background-color: #1f1f1f; box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.3); } .tab-bar-dark .tab-text { color: #c8c9cc; } .tab-bar-dark .tab-text-active { color: #ffffff; }这样切主题时只需要把 theme 传进去即可。如果你的品牌色会在运营活动期间临时换成节日色同理加一个 primaryColor 字段用内联 style 控制选中图标下面的文字颜色。日常业务里这个功能不一定用得上但一旦产品提出来你会发现自定义 tabBar 的扩展性非常舒服。5. 常见问题与排查技巧实录5.1 getTabBar() 返回 null 或 undefined这个问题在社区里被问得最多。我排查时按顺序检查这几项基础库版本是否低于 2.6.2低于这个版本 getTabBar 方法不存在custom-tab-bar 目录是否放在项目根目录且文件名必须是 index.js/index.json/index.wxml/index.wxssapp.json 里是否配置了 tabBar 且 custom 为 true当前页面是否真的是 tabBar 页面只有 list 中声明的页面才能拿到 getTabBar 实例页面是否启用了自定义组件模式页面 json 里不要写 usingComponents 去引用 custom-tab-bar官方机制是全自动的调用时机是否过早onLoad 里偶尔拿不到统一改到 onShow。最常见的还是路径问题。有人把 custom-tab-bar 放到了 pages 目录下或者改成了 customTabBar微信找不到组件getTabBar 自然返回 undefined。5.2 自定义 tabBar 白屏、不固定在底部白屏大概率是组件本身没渲染成功。先看 console 有没有报错比如 json 缺少 component: true、wxml 里绑定的方法不存在、图片路径 404。如果没报错但不显示检查 wxss 里有没有写 position: fixed。我把这一点强调出来是因为官方 demo 的 wxss 里写了 fixed很多人手写组件时容易漏掉导致 tabBar 跑到页面流底部翻到页面末尾才能看到。如果底栏显示了但位置不对检查父节点有没有 transform 或 filter 样式。自定义 tabBar 虽然是一个组件但它内部根节点如果被页面或其他组件包住了某些样式属性会创建新的 containing block影响 fixed 定位。通常保持 custom-tab-bar 根节点的 wxss 最简单不要在外面套任何动画容器。还有一个隐蔽问题如果你在小程序开发者工具里开启了“单页模式”调试自定义 tabBar 可能不生效这是工具限制不代表代码有问题真机预览或重新编译即可。5.3 切换后选中状态错乱这个问题的根源几乎都是页面 onShow 里没有同步 selectedIndex或者同步的数字写错了。tabBar 页面实例是复用的onLoad 只执行一次你在第一个页面把 selectedIndex 设置成 0切到“我的”页时页面 onShow 把选中索引改成 3再切回首页时如果首页 onShow 里没有重新设置底栏就停留在 3。我在每个页面 onShow 里都会写同步代码而且确保所有 tab 页面都写不要只写其中一个。你可以在开发时做一个小的公共 mixin把同步逻辑抽出来页面里直接调用减少漏写概率。另外注意不要用 selected 字段而用 selectedIndex。以索引为核心便于数组渲染时做等值比较。也有人用 currentPath 字符串判断效果一样但我在表格里对比过索引方式更容易处理 badgeMap 这种下标映射关系。5.4 图标闪烁与字体加载问题自定义 tabBar 的图标切换本质上是切换 src。如果每张图标都是网络图片点击切换瞬间会有一个空白等待视觉上就是闪一下。解决办法有两个一是把图标全部打成本地包路径直接写相对路径加载速度最快二是用 image 组件的 lazy-load 属性或者预加载两张图片。本地图标基本不会闪烁所以这套方案里我全部使用 assets 目录下的本地 PNG。字体图标在自定义 tabBar 里也经常出问题。小程序里引入外部字体文件需要在 wxss 里用 font-face但字体文件体积大加载是异步的底栏渲染时字体还没就位文字会先显示默认字体等字体加载完成后突然变化。我的经验是底栏文字和图标尽量不用字体图标方案直接用 PNG 加 text稳定性最高。如果必须用字体图标把字体文件体积控制到 50KB 以内并且使用微信官方提供的“分包”方式预加载。5.5 真机与开发者工具表现不一致开发者工具里一切正常真机上 getTabBar 拿不到实例这个现象通常是基础库版本问题。开发者工具默认可能是最新基础库而真机上用户微信版本较老或者你调试用的真机基础库版本低于 2.6.2。解决方式是检查项目详情里“调试基础库”的版本业务要求适配的版本范围内按最低版本验证一遍逻辑。安全区相关样式也要在真机上重点测。iPhone X 及以后机型底部横条区域是 34px加了 env(safe-area-inset-bottom) 之后底栏正常。Android 机型五花八门有的系统虚拟按键会挡住底栏有的不会。我这里曾在某品牌 Android 手机上发现 env 不生效最后是在页面底部手动加了 40rpx 的额外 padding双保险。真机上偶尔还会遇到底栏点击事件穿透点击 tabBar 空白区域触发了页面的滚动或点击。原因是底栏虽然视觉上覆盖了底部但某些区域没有绑定事件事件穿透到了下层页面。解决方式是在底栏根节点上加上 catchtouchmove 或 catchtap阻止事件冒泡。自定义组件里事件处理默认是比较克制的我建议在根节点加一个空 catchtap 兜底不影响业务逻辑。5.6 输入法把自定义 tabBar 顶上去的处理这个场景在实际业务中还挺常见一个 tab 页里有个搜索框或评论输入框用户点击输入时键盘弹起底部 tabBar 被顶到键盘上方页面布局瞬间变乱。原生 tabBar 在键盘弹起时会被系统自动遮挡或位移自定义 tabBar 同样会受到影响。我的处理方案是在页面 json 里关闭输入框的默认 adjust-position改用自行控制滚动{ usingComponents: {}, disableScroll: true }然后输入框组件加上 adjust-position{{false}}这样键盘弹起时页面不会因系统调整而整体上推。输入框如果被键盘挡住就自己写一个滚动逻辑让输入框滚动到可视区域。底栏在键盘弹起时可以调用 hide() 隐藏输入完成后调用 show() 恢复。这个逻辑虽然要写几行代码但比任由系统推送效果好得多底栏不会在键盘上方来回跳动。最后再分享一个我自己在改版过程中的体会自定义 tabBar 看起来只是一个组件但它在整个小程序架构里处于“页面路由 全局 UI”的交汇点。很多问题不是组件本身写错了而是你对它的定位没想清楚哪些是页面负责的状态哪些是组件自己负责的状态哪些是全局共享状态。我在做了几次改版之后最大的经验是把 tabBar 当成一个“被页面调用的服务组件”而不是“自己管理一切的页面壳”。所有状态都由页面主动同步进来组件只做渲染和事件上报这样每加一个新页面、每改一个新需求都只需要在对应页面的 onShow 里加一行同步代码不用去拆组件内部逻辑。如果你刚开始接自定义 tabBar按本文这套结构跑通一个最简单的版本再加凸起按钮、角标、隐藏逻辑一步步来。之后遇到问题时逐个 tab 页面的 onShow 去检查同步十有八九问题就定位了。希望这篇实战笔记能给你搭好一个底气。