custom-tab-bar 踩坑实录:图标闪烁、selected 状态丢失与 getTabBar 兼容写法

2026-10-01 01:16:41 1 次浏览
微信小程序前端性能优化踩坑实录

需要角标、中间凸起大按钮这类原生 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、前端开发

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。