16 KiB
自定义导航组件方案
📋 背景
为什么不使用原生 tabBar?
需求:
- "找伙伴"功能需要根据 API 配置动态显示/隐藏
- 不同环境(开发/测试/生产)可能有不同的功能开关
- 需要灵活控制导航栏的显示逻辑
原生 tabBar 的限制:
- ❌ 静态配置:
app.json中的tabBar.list是固定的,无法动态增删 - ❌ 无法隐藏单个 tab:只能显示或隐藏整个 tabBar
- ❌ 样式受限:虽然支持自定义 tabBar,但配置复杂
- ❌ 功能限制:自定义 tabBar 需要在每个页面手动管理状态
自定义组件的优势:
- ✅ 完全动态:可以根据任何条件显示/隐藏任意 tab
- ✅ 样式自由:完全控制样式,可以实现中间凸起按钮等特殊效果
- ✅ 状态统一:通过 props 传递当前页面,组件内部管理激活态
- ✅ 跨平台一致:Web 和小程序使用相同的组件逻辑
🔧 技术方案
方案对比
| 特性 | 原生 tabBar | 自定义 tabBar | 自定义组件(当前方案) |
|---|---|---|---|
| 动态显示/隐藏 | ❌ | ⚠️ 复杂 | ✅ 简单 |
| 样式自由度 | ❌ 受限 | ✅ 自由 | ✅ 完全自由 |
| 中间凸起按钮 | ❌ 不支持 | ⚠️ 复杂 | ✅ 简单 |
| 跨平台一致性 | ❌ 不同 | ⚠️ 需额外处理 | ✅ 一致 |
| 页面跳转 | wx.switchTab |
wx.switchTab |
wx.reLaunch |
| 配置复杂度 | 简单 | 复杂 | 中等 |
结论:使用自定义组件方案
📝 实现细节
1. 移除原生 tabBar 配置
文件:newpp/build/miniprogram.config.js
appExtraConfig: {
sitemapLocation: 'sitemap.json',
// ✅ 不配置 tabBar,使用完全自定义的导航组件
// 原因:需要根据 API 配置动态显示/隐藏"找伙伴"功能
},
说明:
- 不在
app.json中配置tabBar - 完全依赖自定义组件
BottomNav.jsx
2. 修改路由跳转方式
文件:newpp/src/adapters/router.js
Before(使用 wx.switchTab)
export function switchTab(path) {
if (isMiniProgram()) {
wx.switchTab({ url: toMpPath(path) }) // ❌ 需要原生 tabBar 配置
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
After(使用 wx.reLaunch)
export function switchTab(path) {
if (isMiniProgram()) {
// ✅ 使用 wx.reLaunch 代替 wx.switchTab
// 原因:没有配置原生 tabBar,使用自定义组件
wx.reLaunch({ url: toMpPath(path) })
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
wx.reLaunch vs wx.switchTab:
| API | 说明 | 页面栈 | 限制 | 适用场景 |
|---|---|---|---|---|
wx.switchTab |
跳转到 tabBar 页面 | 清空 | 只能跳转到原生 tabBar 页面 | 原生 tabBar |
wx.reLaunch |
关闭所有页面,打开某页面 | 清空 | 无 | 自定义导航 |
wx.redirectTo |
关闭当前页面,跳转 | 替换栈顶 | 无 | 页面跳转 |
wx.navigateTo |
保留当前页面,跳转 | 新增栈 | 最多10层 | 详情页 |
为什么选择 wx.reLaunch?
- ✅ 清空页面栈,避免页面堆积
- ✅ 无限制,可以跳转到任何页面
- ✅ 模拟 tabBar 的行为(清空栈)
- ⚠️ 缺点:每次跳转会重新加载页面(但对于导航栏切换这是正常的)
3. 自定义组件实现
文件:newpp/src/components/BottomNav.jsx
核心逻辑
export default function BottomNav({ current }) {
const [matchEnabled, setMatchEnabled] = useState(true)
const [configLoaded, setConfigLoaded] = useState(false)
useEffect(() => {
if (isMiniProgram()) {
// ✅ 小程序:从 app.globalData 读取配置
try {
const app = getApp()
if (app && app.globalData) {
setMatchEnabled(app.globalData.matchEnabled !== false)
}
} catch (e) {
// 默认显示
} finally {
setConfigLoaded(true)
}
} else {
// ✅ Web:从 API 加载配置
fetch('/api/db/config')
.then((res) => res.json())
.then((data) => {
if (data.features) {
setMatchEnabled(data.features.matchEnabled === true)
}
})
.catch(() => {
setMatchEnabled(false)
})
.finally(() => {
setConfigLoaded(true)
})
}
}, [])
// ✅ 根据配置动态生成可见的 tabs
const visibleTabs = matchEnabled ? tabs : tabs.filter((t) => t.id !== 'match')
const handleTabClick = (path) => {
if (path === current) return
// ✅ 使用 switchTab(内部调用 wx.reLaunch)
try {
switchTab(path)
} catch (e) {
console.error('switchTab error:', e)
}
}
return (
<div style={styles.nav}>
<div style={styles.container}>
{visibleTabs.map((tab) => {
// ✅ 根据 isCenter 渲染不同样式
if (tab.isCenter) {
return (/* 中间凸起按钮 */)
}
return (/* 普通按钮 */)
})}
</div>
</div>
)
}
配置加载流程
┌─────────────────┐
│ 组件挂载 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 判断环境 │
│ isMiniProgram? │
└────┬──────┬─────┘
│ Yes │ No
▼ ▼
┌─────────┐ ┌──────────────┐
│小程序环境│ │ Web 环境 │
│getApp() │ │fetch('/api') │
│.globalData│ │ .then() │
└────┬────┘ └──────┬───────┘
│ │
└──────┬──────┘
▼
┌───────────────┐
│setMatchEnabled│
│setConfigLoaded│
└───────┬───────┘
▼
┌───────────────┐
│ 动态渲染 tabs │
│ visibleTabs │
└───────────────┘
🎯 配置管理
Web 环境
API 端点:/api/db/config
返回格式:
{
"features": {
"matchEnabled": true // ✅ 控制"找伙伴"功能
}
}
配置位置:数据库或配置文件
// app/api/db/config/route.ts
export async function GET() {
const config = await db.collection('config').findOne({ key: 'features' })
return NextResponse.json({
features: {
matchEnabled: config?.matchEnabled ?? true, // 默认开启
},
})
}
小程序环境
配置位置:miniprogram/app.js
App({
globalData: {
matchEnabled: true, // ✅ 控制"找伙伴"功能
// 其他配置...
},
onLaunch() {
// ✅ 可以从服务器加载配置
this.loadFeatureConfig()
},
async loadFeatureConfig() {
try {
const res = await wx.request({
url: 'https://your-api.com/config',
method: 'GET',
})
if (res.data && res.data.features) {
this.globalData.matchEnabled = res.data.features.matchEnabled
// ✅ 触发页面更新(如果需要)
this.notifyConfigUpdate()
}
} catch (e) {
console.error('Load config error:', e)
}
},
notifyConfigUpdate() {
// 通知所有页面配置已更新
// 可以使用事件总线或全局状态管理
},
})
📱 页面集成
在每个导航页面中使用
示例:newpp/src/pages/HomePage.jsx
import BottomNav from '../components/BottomNav'
export default function HomePage() {
return (
<div>
<div style={styles.page}>
{/* 页面内容 */}
</div>
{/* ✅ 传入当前路径 */}
<BottomNav current="/" />
</div>
)
}
其他页面:
ChaptersPage.jsx:<BottomNav current="/chapters" />MatchPage.jsx:<BottomNav current="/match" />MyPage.jsx:<BottomNav current="/my" />
🐛 已知问题与解决方案
问题 1:页面切换时闪烁
症状:使用 wx.reLaunch 时,页面会重新加载,可能出现白屏
原因:wx.reLaunch 会关闭所有页面,然后打开新页面
解决方案:
-
方案 A:优化页面加载速度
- 使用骨架屏
- 预加载数据
- 缓存页面状态
-
方案 B:使用页面栈管理(推荐)
export function switchTab(path) { if (isMiniProgram()) { const pages = getCurrentPages() const currentPage = pages[pages.length - 1] const currentPath = '/' + currentPage.route.replace(/^pages\//, '').replace(/\/index$/, '') // ✅ 如果是相同页面,不跳转 if (currentPath === path) { return } // ✅ 检查页面栈中是否已有目标页面 const targetPageIndex = pages.findIndex((p) => { const pPath = '/' + p.route.replace(/^pages\//, '').replace(/\/index$/, '') return pPath === path }) if (targetPageIndex >= 0) { // ✅ 如果已有,返回到该页面 const delta = pages.length - 1 - targetPageIndex wx.navigateBack({ delta }) } else { // ✅ 如果没有,使用 reLaunch wx.reLaunch({ url: toMpPath(path) }) } } else { window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html' } } -
方案 C:保留部分页面栈
export function switchTab(path) { if (isMiniProgram()) { const pages = getCurrentPages() // ✅ 如果栈中只有1个页面,使用 redirectTo if (pages.length === 1) { wx.redirectTo({ url: toMpPath(path) }) } else { // ✅ 否则,返回到第一个页面,然后 redirectTo wx.navigateBack({ delta: pages.length - 1 }) setTimeout(() => { wx.redirectTo({ url: toMpPath(path) }) }, 100) } } else { window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html' } }
推荐:方案 B(检查页面栈)
问题 2:配置更新不及时
症状:修改 app.globalData.matchEnabled 后,导航栏不更新
原因:组件已挂载,useEffect 只执行一次
解决方案:
-
方案 A:监听配置变化
useEffect(() => { if (isMiniProgram()) { const app = getApp() // ✅ 监听配置变化 const checkConfig = () => { if (app && app.globalData) { setMatchEnabled(app.globalData.matchEnabled !== false) } } // ✅ 初始加载 checkConfig() // ✅ 定时检查(或使用事件监听) const interval = setInterval(checkConfig, 1000) return () => clearInterval(interval) } }, []) -
方案 B:使用全局状态管理
// store/index.js const useStore = create((set) => ({ matchEnabled: true, setMatchEnabled: (enabled) => set({ matchEnabled: enabled }), })) // BottomNav.jsx const { matchEnabled } = useStore() -
方案 C:页面激活时刷新
useEffect(() => { const handleShow = () => { // ✅ 页面显示时重新读取配置 if (isMiniProgram()) { const app = getApp() if (app && app.globalData) { setMatchEnabled(app.globalData.matchEnabled !== false) } } } // ✅ 监听页面显示事件 if (isMiniProgram() && typeof wx !== 'undefined') { wx.onAppShow?.(handleShow) } return () => { wx.offAppShow?.(handleShow) } }, [])
推荐:方案 C(页面激活时刷新)
问题 3:Web 和小程序跳转体验不一致
症状:Web 使用 location.href 会刷新整个页面,小程序使用 wx.reLaunch 有过渡动画
原因:Web 和小程序的路由机制不同
解决方案:
-
Web 端使用 SPA 路由(如果是 Next.js)
import { useRouter } from 'next/router' export function switchTab(path) { if (isMiniProgram()) { wx.reLaunch({ url: toMpPath(path) }) } else { // ✅ 使用 Next.js 路由 const router = useRouter() router.push(path) } } -
小程序端优化过渡
export function switchTab(path) { if (isMiniProgram()) { wx.reLaunch({ url: toMpPath(path), success: () => { // ✅ 跳转成功 }, fail: (err) => { console.error('reLaunch fail:', err) // ✅ 降级到 navigateTo wx.navigateTo({ url: toMpPath(path) }) }, }) } else { window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html' } }
✅ 测试清单
功能测试
- 配置加载:小程序启动时正确读取
matchEnabled - 动态显示/隐藏:
matchEnabled: true时,显示"找伙伴" tabmatchEnabled: false时,隐藏"找伙伴" tab
- 页面跳转:
- 点击"首页" → 跳转到首页
- 点击"目录" → 跳转到目录页
- 点击"找伙伴" → 跳转到找伙伴页
- 点击"我的" → 跳转到我的页
- 激活态:当前页 tab 高亮显示
样式测试
- 中间凸起按钮:渐变色 + 阴影
- 普通按钮:灰色/高亮切换
- 安全区适配:底部留出安全区高度
性能测试
- 配置加载速度:< 100ms
- 页面跳转速度:< 500ms
- 内存占用:无内存泄漏
📊 对比总结
| 方案 | 原生 tabBar | 自定义 tabBar | 自定义组件(当前) |
|---|---|---|---|
| 动态控制 | ❌ | ⚠️ | ✅ |
| 样式自由 | ❌ | ✅ | ✅ |
| 配置复杂度 | 低 | 高 | 中 |
| 跳转方式 | wx.switchTab |
wx.switchTab |
wx.reLaunch |
| 页面栈 | 清空 | 清空 | 清空 |
| 跨平台一致性 | ❌ | ⚠️ | ✅ |
| 维护成本 | 低 | 高 | 中 |
结论:自定义组件方案最适合当前需求
🚀 后续优化
Priority P1(推荐)
-
优化页面跳转体验
- 实现方案 B(页面栈检查)
- 避免不必要的页面重新加载
-
配置热更新
- 实现配置变化监听
- 自动更新导航栏显示
-
添加骨架屏
- 减少页面切换时的白屏
- 提升用户体验
Priority P2(可选)
-
图标优化
- 使用图片替换 emoji
- 支持激活态和非激活态
-
动效优化
- 添加页面切换动画
- 优化按钮点击反馈
-
埋点统计
- 记录 tab 点击次数
- 分析用户行为
📚 相关文档
总结:使用自定义组件方案,完全控制导航栏的显示逻辑,满足根据 API 配置动态显示/隐藏"找伙伴"功能的需求。