微信小程序自定义TabBar:官方扩展与自绘方案对比解析
发布时间:2026/10/17 13:08:58 锦皓数字建站

微信小程序里底部那个 tab 栏几乎每个带多页面的项目都绕不开。默认 TabBar 看着省事但真要接需求的时候处处受限想加个中间凸起按钮想做带消息红点的图标想根据登录状态动态显示某个标签基本都要另想办法。我前前后后做过不少小程序自定义底部固定 Tabbar 的套路试过很多种最后沉淀下来好用的也就两条路线一条是官方提供的 custom-tab-bar 扩展机制另一条是完全自己画一个固定底部导航组件。这两条路差别很大选错了后期改起来会非常痛。这篇就把两条路线完整展开连同我踩过的坑一起说清楚。1. 先想清楚默认 TabBar 到底哪里不够用自定义的到底是 UI 还是页面模型很多需求是产品经理提的但到底为什么要抛弃默认 TabBar开发心里得有个明确的账。微信官方 TabBar 本质上是客户端原生渲染的它好处是稳定、不占页面布局、切换动画也是系统级的但限制同样明显。默认 TabBar 最多只能放 5 个标签每个标签的图标、文字、选中颜色能配置可也就到此为止。你想在图标右上角加一个自定义样式的红点或者加一个带动画的角标原生接口虽然有wx.setTabBarBadge但它只能显示纯数字文本不能自定义样式。更别说把中间某个按钮做成凸起效果这在默认 TabBar 里完全没有实现空间。还有一个容易被忽略的点默认 TabBar 是客户端控制的它不接受页面里的普通 CSS 样式覆盖。你写position: fixed; bottom: 0也好写z-index: 99999也好都压不到原生 TabBar 上去。所以想彻底改观感就必须把原生那一层 UI 让出来换成自己渲染的。但在动手之前必须分清楚一个概念你要自定义的是“UI 长什么样”还是“页面导航机制本身”。前者是大多数业务场景的真实需求也就是 TabBar 的视觉外观、交互细节要变但页面之间的切换逻辑最好还是沿用原生 tab 页模型——毕竟wx.switchTab、页面栈、tab 之间的状态保持都是现成的丢掉很可惜。后者则是彻底不依赖原生 TabBar把主导航当作普通页面来处理自己用固定定位组件模拟底部栏页面跳转自己控制。这两种诉求对应着完全不同的实现路线也就是我接下来要详细讲的两种方法。很多人一开始没想清楚做到一半发现官方方案撑不住想要的动画又跳到自绘方案重写来回折腾的代价非常明显。所以先把方向定下来比直接搜代码重要得多。2. 官方扩展方案用 custom-tab-bar 保留原生 tab 机制的底栏官方扩展方案的核心思路是保留 app.json 中 tabBar 配置及其“tab 页面”的页面模型但通过custom: true让客户端不渲染原生 UI转而去加载开发者写的自定义组件。2.1 app.json 配置与 custom-tab-bar 组件目录的约定首先要做的就是给 app.json 里的 tabBar 配置加上custom字段。这里有一个编码外的硬性约定自定义组件必须放在项目根目录下的custom-tab-bar文件夹里文件名必须是index否则客户端找不到。先看 app.json 的配置示例{ pages: [ pages/home/home, pages/message/message, pages/mine/mine ], tabBar: { custom: true, color: #8a8a8a, selectedColor: #1aad19, backgroundColor: #ffffff, list: [ { pagePath: pages/home/home, text: 首页 }, { pagePath: pages/message/message, text: 消息 }, { pagePath: pages/mine/mine, text: 我的 } ] } }注意即使加了custom: truelist 依然不能省略而且 list 里的每一项 pagePath 必须真实存在。这个 list 是“tab 页面”的注册表决定了哪些页面可以被wx.switchTab跳转。color、selectedColor 这些配置在 custom 模式下不会体现在渲染结果里但建议还是写上万一以后去掉custom降级回原生 TabBar不至于完全裸奔。然后创建custom-tab-bar/index.json{ component: true }这个文件只需要声明它是一个组件不需要任何额外配置。2.2 组件完整代码cover-view 与 cover-image 的取舍接下来是核心的custom-tab-bar/index.wxml。官方偏向使用cover-view和cover-image原因很简单它们属于“原生组件覆盖层”有能力浮在 video、map、canvas 这类原生组件之上。写普通view的话某些低端机会出现地图把底部导航盖住的情况。cover-view classtab-bar cover-view classtab-bar-item wx:for{{list}} wx:keypagePath >.tab-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; height: 100rpx; padding-bottom: env(safe-area-inset-bottom); background: #ffffff; z-index: 9999; } .tab-bar-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .tab-icon { width: 48rpx; height: 48rpx; } .tab-label { margin-top: 6rpx; font-size: 20rpx; line-height: 1.2; }index.js里需要维护一份 TabBar 数据并且提供点击切换方法Component({ data: { selected: 0, color: #8a8a8a, selectedColor: #1aad19, list: [ { pagePath: /pages/home/home, text: 首页, icon: /assets/tab/home.png, selectedIcon: /assets/tab/home-active.png }, { pagePath: /pages/message/message, text: 消息, icon: /assets/tab/message.png, selectedIcon: /assets/tab/message-active.png }, { pagePath: /pages/mine/mine, text: 我的, icon: /assets/tab/mine.png, selectedIcon: /assets/tab/mine-active.png } ] }, methods: { switchTab(e) { const { path, index } e.currentTarget.dataset if (index this.data.selected) { return } wx.switchTab({ url: path }) this.setData({ selected: index }) } } })这里顺便提一句如果公司用的是字体图标在cover-view里通常没法直接渲染字体。兼容做法是在cover-image上用图片图标。我实际项目中为了减少图片资源也见过有人把cover-view换成普通view这样就能用字体图标和更灵活的动效代价是如果页面里混用了原生组件底部栏层级可能出现问题。这个取舍后面会在坑点里细说。2.3 每个 tab 页面的选中态同步这是官方方案最容易被新手忽略的环节。自定义组件本身不知道当前打开了哪个页面选中的高亮状态必须在每个 tab 页面的onShow里手动同步。以首页为例pages/home/home.js里这样写Page({ onShow() { if (typeof this.getTabBar function this.getTabBar()) { this.getTabBar().setData({ selected: 0 }) } } })消息页就同步成selected: 1我的页面同步成selected: 2。关键点有三个必须在onShow里调用不能在onLoad里调用因为第一次进入时组件实例可能还没就绪。getTabBar()只有在当前页面是 tab 页面、且 app.json 配置了custom: true时才可用。调用前加typeof this.getTabBar function判断可以避免基础库版本较低时直接报错。这个方法看似每个页面都要复制一遍但其实每个页面就三行代码成本很低而且逻辑非常明确。它保住的最大好处是tab 页面依然享受原生页面栈管理切换后页面状态不丢。3. 完全自绘方案自定义组件 页面级调用彻底绕开原生 TabBar第二种方法和官方方案的本质区别在于不用 app.json 里的 tabBar 配置把主导航当作普通页面来处理。这样做的好处是自由度极高TabBar 不再受任何原生机制约束普通组件的所有 CSS 能力都能用代价是页面切换和状态保持要自己负责。3.1 为什么有时候必须放弃原生 TabBar说“彻底自由”听起来很爽但要清楚它到底解决了什么问题。官方自定义方案虽然 UI 是自己画的但页面模型仍是 tab 页模型仍然受 tab 数量上限、页面类型限制。如果业务里需要底部栏本身动态变化比如根据用户角色显示不同数量的导航项或者底部栏中间某个按钮要弹出复杂面板甚至要做一个“播放中吸附条”贴在 TabBar 上方官方方案写起来会很别扭。这种情况下完全自绘是更干净的选择。做法是app.json 里完全不配置tabBar字段所有页面都当普通页面注册。然后自己写一个components/tab-bar组件在需要的页面底部引入。一个最小可用的组件components/tab-bar/index.jsconst app getApp() Component({ properties: { current: { type: Number, value: 0 } }, data: { list: [ { path: /pages/home/home, text: 首页 }, { path: /pages/message/message, text: 消息 }, { path: /pages/mine/mine, text: 我的 } ] }, methods: { handleTap(e) { const { path, index } e.currentTarget.dataset if (index this.data.current) { return } wx.reLaunch({ url: path }) } } })组件模板用普通view编写想加动画就加动画想用什么 CSS 都没限制view classtab-bar view classtab-bar-item {{current index ? active : }} wx:for{{list}} wx:keypath >.tab-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; height: 100rpx; padding-bottom: env(safe-area-inset-bottom); background: #ffffff; z-index: 9999; } .tab-bar-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .active .tab-label { color: #1aad19; }页面里用的时候先在pages/home/home.json注册组件{ usingComponents: { tab-bar: /components/tab-bar/index } }然后在页面模板底部引入并留出占位空间view classpage-container !-- 页面内容 -- /view view classtab-bar-placeholder/view tab-bar current{{currentTab}} /页面 JS 里给currentTab赋当前页面对应的索引即可。关键是怎么防止内容被固定栏遮住占位符的高度要和 TabBar 的真实高度一致.tab-bar-placeholder { height: calc(100rpx env(safe-area-inset-bottom)); }3.2 切换跳转的方式switchTab 不能再用了reLaunch 怎么选这是完全自绘方案里最需要理解的地方。因为 app.json 里没有配置 tabBar这些页面就不算 tab 页面wx.switchTab直接不可用。那用什么切换常见的是wx.reLaunch和wx.redirectTo两个接口。wx.redirectTo会关闭当前页面跳转到目标页wx.reLaunch会关闭所有页面再打开目标页。主导航场景下我推荐wx.reLaunch因为主导航之间的切换不应该保留一层又一层的历史栈否则用户按返回键会不断回到上一个 tab交互上非常奇怪。但这个方案有一个绕不开的代价每次切换都会重新触发目标页面的onLoad。页面里的数据、滚动位置、临时状态统统不会保留。这是完全自绘方案的“天然缺陷”。如果项目对 tab 页面状态保持有强要求比如用户在首页看到了很长的列表切到消息再切回来希望还停在原来的位置那就要自己用全局数据或缓存来恢复状态这非常麻烦。所以我在实际项目中通常这样判断页面内容单纯、以浏览为主的项目可以用自绘方案有强状态保持、表单多、要频繁切换需求的项目老老实实回官方方案。4. 两种方案的横向对比与选型判断说了这么多把两种方案放到一个表格里对比选型思路会非常清楚。对比维度官方 custom-tab-bar完全自绘组件页面类型原生 tab 页面普通页面切换接口wx.switchTabwx.reLaunch / wx.redirectTo页面状态保持页面栈保留天然保持切换即重建需要自己缓存tab 数量限制继承原生限制2~5 个无硬性限制UI 自由程度组件内可用 CSS但 cover-view 有限制完全 CSS 自由覆盖原生组件能力cover-view 可覆盖 video/map 等普通 view 在部分机上覆盖不稳实现复杂度配 custom 每页 onShow 同步写通用组件 每页引入 缓存状态适合场景业务型小程序、状态敏感强个性化、动态导航、复杂动画从表格能看出官方方案更像是在原生框架内部做“换皮”保住了稳定性完全自绘更像是另起炉灶换来的是 UI 自由失去的是原生页面模型带来的便利。我个人的判断标准很简单底部导航项基本固定、页面状态敏感的选官方方案导航项会动态变化、或者需要动画和特殊交互的才考虑完全自绘。项目不是越大越好而是越符合业务模型越好。做过一个工具类小程序底部就三个 tab但中间那个按钮要做了一个向上弹出的半屏面板官方方案里写这个交互很别扭后来重构成完全自绘方案代码反而清爽了。反过来一个后台管理型小程序页面里要填一堆表单状态保持要求高那种情况用完全自绘方案就是给自己找罪受。还有一点一旦选定方案尽量不要中途切换。官方方案改成自绘所有 tab 页面都要从 tab 页面类型里摘出来wx.switchTab全部要改页面状态逻辑重写自绘改成官方方案则要把 tab 页面重新注册所有占位符删掉。中途改动的成本往往是之前工作量的两倍。5. 实操中最容易踩的坑以及我的最终建议最后一个部分把我在真机和开发工具里反复遇到的问题集中列一下。这些坑看起来小但每个都可能导致线上体验翻车。5.1 安全区不适配底部被手势条挡住iPhone X 以后的机型底部有一个横条如果不处理自定义 TabBar 会被它挡住一部分点击区域也变窄。正确做法是给 TabBar 容器加padding-bottom: env(safe-area-inset-bottom)或者把高度改成height: calc(100rpx env(safe-area-inset-bottom));在较老的 iOS 版本上可能不识别env还需要补一个constant的兜底写法。另外占位符的高度要和 TabBar 总高度完全一致差 1px 都会出现内容遮挡或底部留白的问题。5.2 cover-view 的 CSS 限制不可忽视官方方案用cover-view是为了覆盖原生组件但它不是万能容器。transform、部分动画、部分定位属性在不同手机平台上表现不一致字体图标也基本没法用。我遇到最多的情况是开发工具里动画正常真机上 cover-view 里的元素不动或者某个样式直接不生效。应对方法有两个方向如果业务里没有 video、map、canvas 这类原生组件干脆把cover-view换成普通view换取完整的 CSS 能力如果躲不开原生组件那就接受 cover-view 的限制把动画尽量简化图标用cover-image图代替字体图标。5.3 getTabBar() 在 onLoad 里拿不到实例前面提过一次但这里还是单独强调因为踩的人真的很多。官方方案的getTabBar()返回组件实例的时机比较晚在onLoad里调用很可能是undefined只有在onShow之后才稳定。另外也要注意getTabBar()在非 tab 页面里并不存在不要在普通详情页里直接调用。防御性写法永远是if (typeof this.getTabBar function this.getTabBar()) { this.getTabBar().setData({ selected: index }) }这样既不会报错将来如果页面类型调整也不用到处改。5.4 完全自绘方案最隐蔽的坑原生 TabBar 残留这个坑要专门说一下。如果 app.json 里配了 tabBar 列表但没设置custom: true原生 TabBar 会一直渲染在底部。你想在页面里放一个自己画的固定 TabBar 去遮住它是遮不住的因为原生组件层级高得离谱普通view永远压在它下面。所以完全自绘方案的前提必须是 app.json 完全不配置 tabBar 字段或者确保页面类型不是 tab 页面。很多开发者第一次尝试自绘方案时会把原生 TabBar 和自绘组件同时停在页面底部发现两个底栏叠在一起还以为是自己样式写错了其实就是页面模型没理清。5.5 真机验证才是最终标准开发工具里看效果、做调试都没问题但自定义 TabBar 这种涉及安全区、原生组件层级、基础库兼容的功能一定得在真机上走一轮完整测试。至少覆盖一部 iPhone 和一部 Android 中低端机重点看底部栏是否被手势条遮挡、覆盖层的 z-index 是否正常、tab 切换回来时选中态是否同步。就我个人经验来说这套东西并不复杂但它属于那种“看起来简单、做起来细节多”的模块。项目只要用了自定义 TabBar后续维护时就一定要把它当成一个正式功能模块看待不能只当成样式问题来改。先把页面模型想明白再决定用哪条路线后面会省下大量返工时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。