微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
原生 tabBar 配置简单,但样式自由度太低——中间凸起按钮、自定义字体图标、深色模式适配、消息红点动画,这些需求 app.json 里的 tabBar 都做不到。好在微信提供了 custom-tab-bar 方案。这篇把完整的落地方案和那些官方文档没写的坑都过一遍。
一、为什么要自定义 tabBar
先看原生 tabBar 的三个硬伤:
- 图标只能是本地图片,不支持 iconfont,多色图标要切两套(普通/选中)
- 中间凸起样式做不了,电商类 App 常见的"发布"大按钮没法实现
- 红点/角标能力弱,
wx.setTabBarBadge只能显示数字,做不了小红点+动画
而 custom-tab-bar 的原理是:app.json 里开启 "custom": true 后,每个 tabBar 页面底部会渲染一个独立的组件实例,完全由你自己实现。
二、基础搭建
2.1 开启配置
// app.json
{
"tabBar": {
"custom": true,
"color": "#666666",
"selectedColor": "#07C160",
"backgroundColor": "#ffffff",
"list": [
{ "pagePath": "pages/index/index", "text": "首页" },
{ "pagePath": "pages/category/category", "text": "分类" },
{ "pagePath": "pages/publish/publish", "text": "发布" },
{ "pagePath": "pages/message/message", "text": "消息" },
{ "pagePath": "pages/mine/mine", "text": "我的" }
]
}
}注意:即使全部自定义,list 字段仍然要完整声明——微信靠它识别哪些页面是 tabBar 页面。color 等字段也建议保留,作为兜底(自定义组件加载失败时会降级显示原生 tabBar)。
2.2 创建组件目录
目录名固定为 custom-tab-bar,位置在项目根目录(与 pages 平级,不是组件目录):
├── custom-tab-bar/
│ ├── index.js
│ ├── index.json
│ ├── index.wxml
│ └── index.wxss
├── pages/
└── app.json// custom-tab-bar/index.json
{
"component": true
}2.3 组件实现
// custom-tab-bar/index.js
Component({
data: {
selected: 0,
color: '#666666',
selectedColor: '#07C160',
list: [
{ pagePath: '/pages/index/index', text: '首页', icon: 'home' },
{ pagePath: '/pages/category/category', text: '分类', icon: 'category' },
{ pagePath: '/pages/publish/publish', text: '发布', icon: 'publish', center: true },
{ pagePath: '/pages/message/message', text: '消息', icon: 'message', badge: true },
{ pagePath: '/pages/mine/mine', text: '我的', icon: 'mine' }
]
},
methods: {
switchTab(e) {
const url = '/' + e.currentTarget.dataset.path
wx.switchTab({ url })
}
}
})<!-- custom-tab-bar/index.wxml -->
<view class="tab-bar">
<view
wx:for="{{list}}"
wx:key="pagePath"
class="tab-item {{item.center ? 'center' : ''}}"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<!-- 中间凸起按钮 -->
<view wx:if="{{item.center}}" class="center-btn">
<text class="iconfont icon-{{item.icon}}"></text>
</view>
<!-- 普通按钮 -->
<block wx:else>
<view class="icon-wrap">
<text class="iconfont icon-{{item.icon}}"></text>
<view wx:if="{{item.badge && unreadCount > 0}}" class="badge">{{unreadCount}}</view>
</view>
<view class="text" style="color: {{selected === index ? selectedColor : color}}">
{{item.text}}
</view>
</block>
</view>
</view>三、最大的坑:每个页面一个独立实例
这是 custom-tab-bar 最反直觉的地方:每个 tabBar 页面都有自己独立的一份 tabBar 组件实例。
表现出来的 bug 是:从首页切到"消息"页,tabBar 上的选中态不更新,还停留在"首页"。
3.1 解决方案:页面 onShow 同步选中态
// pages/message/message.js
Page({
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
selected: 3 // 当前页在 list 中的索引
})
}
}
})每个 tabBar 页面的 onShow 都要写这段。可以封装一个高阶函数减少重复:
// utils/tab-bar.js
function withTabBar(pageConfig, tabIndex) {
const originalOnShow = pageConfig.onShow
pageConfig.onShow = function () {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: tabIndex })
}
originalOnShow && originalOnShow.call(this)
}
return pageConfig
}
module.exports = { withTabBar }
// 页面使用
Page(withTabBar({
// 原有配置
}, 3))3.2 实例隔离带来的另一个问题:状态不同步
消息红点数量存在全局状态里,但 tabBar 是多个实例——首页实例更新了 unreadCount,消息页的实例还是旧值。
解法是把未读数等共享状态放到全局 store 或本地缓存,每个实例在 attached 生命周期里读取:
// custom-tab-bar/index.js
Component({
lifetimes: {
attached() {
const app = getApp()
this.setData({ unreadCount: app.globalData.unreadCount })
}
}
})配合一个极简的发布订阅,让所有实例响应式更新:
// custom-tab-bar/index.js
Component({
lifetimes: {
attached() {
const app = getApp()
this._onUnreadChange = (count) => this.setData({ unreadCount: count })
app.on('unreadChange', this._onUnreadChange)
},
detached() {
getApp().off('unreadChange', this._onUnreadChange)
}
}
})四、胶囊按钮对齐
自定义 tabBar 后,页面内容区的高度计算会变复杂,尤其要处理和右上角胶囊按钮的对齐关系。
获取胶囊位置信息:
// custom-tab-bar/index.js
Component({
lifetimes: {
attached() {
const menuButton = wx.getMenuButtonBoundingClientRect()
const systemInfo = wx.getSystemInfoSync()
// tabBar 整体高度 = 胶囊底部 + 上间距 + 内容高度
const tabBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + 50
this.setData({ tabBarHeight })
}
}
})自定义导航栏页面同样需要这段逻辑,把 tabBarHeight 存到全局,页面 onLoad 时读取,保证自定义导航栏和 tabBar 视觉上同一套高度体系。
五、深色模式适配
app.json 开启 "darkmode": true 后,自定义 tabBar 不会自动变色,需要手动监听:
// app.json
{
"darkmode": true,
"themeLocation": "theme.json"
}// custom-tab-bar/index.js
Component({
data: {
theme: 'light'
},
lifetimes: {
attached() {
const app = getApp()
this.setData({ theme: app.globalData.theme || 'light' })
// 监听系统主题切换
this._onThemeChange = ({ theme }) => this.setData({ theme })
wx.onThemeChange(this._onThemeChange)
},
detached() {
wx.offThemeChange(this._onThemeChange)
}
}
})WXSS 里用 CSS 变量切换:
/* custom-tab-bar/index.wxss */
.tab-bar {
--bg: #ffffff;
--text: #666666;
background: var(--bg);
}
.tab-bar.dark {
--bg: #1f1f1f;
--text: #999999;
background: var(--bg);
}坑:微信开发者工具模拟深色模式在部分版本有 bug,真机预览才准。另外 iOS 上 wx.onThemeChange 回调时机比页面 onShow 晚,首次进入深色模式下的页面会闪一下白色——可以在 app.js 的 onLaunch 里提前用 wx.getSystemInfoSync().theme 初始化一次。
六、性能与体验细节
- tabBar 组件不要放业务请求。它是每个 tab 页都要实例化的组件,接口请求放这里会导致切换 tab 重复请求。只做状态展示。
- 切页动画的"延迟感"。
wx.switchTab本身有页面切换开销,如果再在 tabBar 的 tap 回调里做动画,会显得卡。建议 tap 时立即更新本组件的选中态,不要等页面 onShow 回来再切:
switchTab(e) {
const { path, index } = e.currentTarget.dataset
this.setData({ selected: index }) // 立即切换,不等 onShow
wx.switchTab({ url: '/' + path })
}页面 onShow 里的同步逻辑保留作为兜底(覆盖 wx.switchTab API 直接调用、其他页面跳转回来的场景)。
- 中间凸起按钮的点击区域。凸出的部分超出了 tabBar 容器,注意
overflow: hidden别加在外层,同时用padding扩大热区到 88rpx 以上。
七、避坑清单
| 问题 | 原因 | 解法 |
|---|---|---|
| 选中态不更新 | 每个 tab 页独立实例 | 各页面 onShow 里 getTabBar().setData |
| 红点数不同步 | 实例间状态隔离 | 全局发布订阅 or 本地缓存 |
| 首次进入闪白 | 主题初始化晚 | app.js onLaunch 提前读 theme |
| 真机不显示 tabBar | 目录名/位置错误 | 必须是根目录 custom-tab-bar |
| 切 tab 闪烁 | setData 时序 | tap 时先本地切选中态 |
| 图片资源路径失效 | 组件内相对路径 | 用绝对路径 /images/xxx |
写在最后
custom-tab-bar 的本质是"把 tabBar 当成一个跨页面共享的组件来管理"。理解了多实例这个核心设定,选中态同步、状态共享、主题响应这些问题的方案就都顺理成章了。如果你的项目 tab 样式并不复杂,原生 tabBar + wx.setTabBarItem 动态改文案其实也够用,不要为了自定义而自定义。
评论 (0)