ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Uniapp动态tabbar实现:基于权限控制的自定义底部导航方案

Uniapp动态tabbar实现:基于权限控制的自定义底部导航方案 不同角色的登录用户看到的底部导航不一样这个需求我前前后后在三个项目里都碰到过后台管理端要区分管理员和运营企业App要区分员工和访客甚至同一个账号在不同状态下菜单都要变。第一版我天真地想改pages.json里的tabBar不就行了结果被Uniapp的静态配置机制教育了一顿。折腾过两轮之后我把整个方案沉淀成了一套基于权限映射表 自定义tabbar组件 状态管理的完整实现这篇就把它彻底讲清楚包括为什么不直接改原生配置、每个角色菜单怎么定义、tabbar组件如何动态渲染、登录登出和角色切换时怎么避免页面白屏。无论你用的是Vue2还是Vue3这套思路都能直接用。1. 需求拆解为什么改pages.json这条路走不通很多第一次接触这个需求的人第一步就是去pages.json里把tabBar的list改一下比如把订单改成工作台看起来美滋滋。但你很快会发现两个致命问题第一pages.json是编译期的静态配置不是运行时的数据源它里面的list在应用还没启动的时候就被原生层读走了第二就算你用了某种黑科技在运行期强行修改页面栈的注册关系、tab页的切换逻辑、以及原生tabbar的渲染结果都不会跟着正常联动。1.1 静态配置与原生tabBar的本质限制Uniapp的tabBar最终是交给各自平台的原生框架渲染的。在微信小程序里对应的是微信原生的tabBar在App端对应的是原生渲染的底部导航。原生tabBar的设计本来就是固定不变的它存在的意义是保证底部导航这个最高频交互入口在任意页面都是稳定、流畅、即时响应的。所以框架层面给到我们的运行期修改能力非常克制。Uniapp官方提供的运行期API其实只有这几个API能力局限性uni.setTabBarItem修改某一项的文字、图标路径只能修改已存在的项不能增删uni.setTabBarStyle修改选中色、背景色等样式只能改视觉不能改结构uni.hideTabBar整体隐藏tabbar要藏只能全藏不能只藏一项uni.showTabBar整体显示tabbar同上也就是说如果业务需求是不同角色看到的菜单顺序不同、图标文字不同setTabBarItem稍微改改还能凑合顶多是把第2项的文字从订单改成工作台。但如果需求是访客只有2个tab员工有4个tab管理员有5个tab原生这一套API直接歇菜因为它压根不支持动态增删tab项。另一个隐藏更深的坑是pages.json里tabBar的list同时承担着页面的注册关系。只有写在list里的页面才能通过uni.switchTab跳转而且这些页面天然带有tab页的属性页面栈的行为和普通页面不一样。如果你试图在某个角色登录时把某个tab从list里删掉那这个页面本身的tab身份还是存在的用户如果手动进入过这个页面底部的切换还是会出问题。1.2 需求分级你是哪种动态在开始选方案之前我强烈建议先把自己的需求归类一下。我踩过一轮之后就明白了动态tabbar这个词其实覆盖了复杂度完全不同的三个层级低阶菜单项固定只是不同角色看到的文字、图标、选中色不一样。这种需求用uni.setTabBarItem就够不需要上自定义tabbar。中阶菜单项数量不同比如员工4个tab访客3个tab但每个tab页本身是固定的。这种必须自定义tabbar因为数量变了。高阶菜单项不仅数量不同连页面本身都是权限控制的一部分。比如访客根本不应该能进入订单页面只能看到首页和我的。这种情况下光是隐藏入口还不够路由层面也得做拦截。这种需求我的方案是自定义tabbar 路由守卫协同。我后面讲的完整方案默认按照中阶和高阶来设计。如果你的需求只是低阶看完第3节和第4节挑着用就行不用上全套。2. 方案选型对比三种主流做法我为什么选了配置驱动既然原生tabBar的路走不通社区里常见的替代方案有几种我分别试过之后才定的方案。这里把每种做法的思路、坑点、适用场景都摊开讲。2.1 条件编译、setTabBarItem、自定义tabbar三方案对照第一种做法是条件编译。就是#ifdef H5或者#ifdef MP这样的编译指令在微信小程序端和App端分别写死一套pages.json里的tabBar配置。这种做法解决的是不同端长的还不一样的问题和同一位用户之间的角色差异是两码事。除非你的需求恰好是小程序端和App端导航不同否则这个方案帮不上忙。第二种做法是前面提到的uni.setTabBarItem适合低阶需求里那种管理员登录后第二个tab变成审批中心的场景。但即便只是这样你也要等接口把角色信息返回了再调用而且图标路径、页面路径都不能变灵活性有限。另外要注意setTabBarItem在App端和H5端的表现在部分版本里会有兼容差异实测下来有的机型需要延迟一段时间再调不然会偶发不生效。第三种就是自定义tabbar把底部导航彻底接管过来不用原生的tabBar去渲染。它的核心逻辑是原来的tabBar区域不再渲染改成一页自绘的组件用Vue的数据驱动来控制菜单项展示哪几个、高亮哪一项、点击跳转到哪个页面。方案自由度最高适配中阶和高阶需求。三种方案的完整对照如下方案实现成本动态增删菜单路由联动推荐场景条件编译极低不支持不支持不同端展示不同配置setTabBarItem低不支持只能改内容弱同一批菜单改文字图标自定义tabbar中完全支持需自己处理角色动态控制菜单结构2.2 选型决策自由度与成本如何权衡我最终选的是第三种方案里的一个进阶变体配置驱动的自定义tabbar。具体来说就是先把所有可能出现的tab菜单项统一定义成一份总配置然后用角色到菜单key数组的映射表在运行时决定当前用户到底看到哪些菜单项最后把这份计算出结果的菜单列表交给自定义tabbar组件去渲染。很多人的自定义tabbar是写死的比如我把4个tab写进组件管理员登录就显示4个访客登录就硬藏一个。这种做法虽然也能实现需求但有两个后患一是业务下次新增一个角色你得去改组件逻辑二是页面跳转的权限控制和菜单渲染逻辑耦合在一起后期维护成本高。配置驱动的好处是把【哪些角色可以看到哪些菜单】这份数据和【tabbar组件怎么渲染】彻底解耦后续再加角色只需要往配置表里加一行数据组件代码一行都不用动。成本方面自定义tabbar确实比原生的多了一些工作量比如安全区适配、页面缓存清理、跳转逻辑的边界处理。但考虑到权限控制属于典型的越改越复杂的需求这部分前期投入非常值。3. 权限映射表设计让一份配置撑起所有角色整个方案的地基是权限映射表。简单说就是回答一个问题某个角色到底应该看到哪几个底部菜单项。把这道题设计好后面渲染逻辑基本上就是对着数据遍历的事。这一节讲的都是关于数据怎么设计的思路。3.1 前端写死还是后端下发权限映射表放在前端还是后端取决于权限变更的频繁程度。如果你的角色系统非常稳定比如一个内部管理后台角色就管理员、运营、访客三种半年都不带变的那在前端写死一份配置完全够用。但如果你的业务是C端产品权限经常要运营人员在后台调整比如给某个用户临时开放一个功能模块这种情况下前端写死就不合适了。建议后端在登录接口里直接返回当前用户可见的tab列表前端拿到什么就渲染什么。这种方案最灵活后端随时改配置即时生效但它要求前端把菜单key的定义和后端对齐否则后端返回的是order你前端配置里根本没有这个key就白瞎了。我自己的做法是折中前端保留一份完整的tab配置包括key、页面路径、文字、图标后端的角色权限接口返回的是当前可见的tab key数组。页面渲染时用key去前端配置里匹配。这样既保证了前端的安全兜底又保留了后端的动态下发能力。如果后端接口挂了默认走一份保底配置至少首页和我的页永远可见。3.2 数据结构与映射关系设计含代码第一步定义一份全量tab配置。这里的关键是key要全局唯一且稳定不要用页面路径当key因为以后页面迁移路径很可能变。// /config/allTabs.js export const allTabs [ { key: home, pagePath: /pages/home/index, text: 首页, iconPath: /static/tabbar/home.png, selectedIconPath: /static/tabbar/home-active.png }, { key: order, pagePath: /pages/order/index, text: 订单, iconPath: /static/tabbar/order.png, selectedIconPath: /static/tabbar/order-active.png }, { key: message, pagePath: /pages/message/index, text: 消息, iconPath: /static/tabbar/message.png, selectedIconPath: /static/tabbar/message-active.png }, { key: mine, pagePath: /pages/mine/index, text: 我的, iconPath: /static/tabbar/mine.png, selectedIconPath: /static/tabbar/mine-active.png } ]第二步如果走前端配置方案就维护一份角色到菜单key的映射表// /config/roleTabs.js export const roleTabsMap { admin: [home, order, message, mine], operator: [home, order, mine], guest: [home, mine] } export const defaultRoleTabs [home, mine]如果走后端下发方案后端返回的数据结构也建议对齐这个格式{ code: 0, data: { role: operator, tabKeys: [home, order, mine] } }第三步写一个工具函数把key转成真正的tab配置列表。这个函数是整个方案里被调用最频繁的地方所以一定要纯函数、不要有副作用// /utils/tabbar.js import { allTabs } from /config/allTabs export function filterTabsByKeys(tabKeys) { if (!Array.isArray(tabKeys) || tabKeys.length 0) { return [] } return allTabs.filter(item tabKeys.includes(item.key)) }这里有个小细节过滤后的顺序完全是依照tabKeys数组来的吗不是的我上面的写法是按照allTabs原有的顺序返回的。如果你希望tabKeys的顺序就是菜单显示顺序得改成tabKeys数组遍历的方式去匹配。我踩过这个坑第一版就是filter结果后端返回的key顺序和预期的展示顺序不一致菜单顺序乱套了。后来改成这样export function filterTabsByKeys(tabKeys) { if (!Array.isArray(tabKeys) || tabKeys.length 0) { return [] } const tabMap {} allTabs.forEach(item { tabMap[item.key] item }) return tabKeys .map(key tabMap[key]) .filter(Boolean) }这样既保证了key顺序就是展示顺序还能自动滤掉后端返回了但前端配置里不存在的脏数据一举两得。4. 自定义tabbar核心实现状态驱动菜单绕开原生限制配置表设计好了接下来就是把菜单画到页面上。这一节是核心中的核心我会把状态管理、组件渲染、跳转逻辑全部拆开讲。4.1 状态管理初始化Pinia还是VuexUniapp同时支持Vue2的Vuex和Vue3的Pinia我的建议是新项目直接用Pinia因为它的语法对TypeScript更友好组件外面也能直接操作store不用搞辅助函数。如果你的项目还是Vue2那就Vuex思路完全一样只差个写法。在store里我至少会放两个东西一个是当前用户角色一个是计算好的可见菜单列表。角色是原始数据菜单列表是通过getter算出来的派生数据。算好派生的好处是只要角色一变所有依赖菜单的地方都会自动跟着更新不会出现角色已经切换了tabbar还是旧菜单的尴尬。// /stores/user.js (Pinia写法) import { defineStore } from pinia import { filterTabsByKeys } from /utils/tabbar export const useUserStore defineStore(user, { state: () ({ role: , tabKeys: [] }), getters: { visibleTabs(state) { return filterTabsByKeys(state.tabKeys) }, isLoggedIn(state) { return !!state.role } } })登录成功后把角色和tabKeys填进去菜单的数据源头就有了。注意getters里调用filterTabsByKeys本身是纯函数不产生状态修改所以它是安全的。4.2 tabbar组件渲染与权限过滤核心的tabbar组件我的做法是写成一个全局组件然后在每个tab页面底部引入。别把组件放在uni-app的每个页面里手动引入太繁琐了直接在main.js全局注册或者用easycom自动注册优雅得多。// main.js import { createSSRApp } from vue import App from ./App.vue import CustomTabbar from /components/custom-tabbar/index.vue export function createApp() { const app createSSRApp(App) app.component(custom-tabbar, CustomTabbar) return { app } }组件内部的核心逻辑很简单从store里拿visibleTabs然后渲染出来。每个菜单项点击后要切换高亮、跳转页面。高亮状态怎么确定我的做法是在每个tab页面里给当前页面的key传入组件。!-- 页面模板中的使用方式 -- view classpage-wrapper !-- 页面内容 -- custom-tabbar currenthome / /view组件内部template view classcustom-tabbar view v-foritem in visibleTabs :keyitem.key classcustom-tabbar__item clickhandleTab(item) image classcustom-tabbar__icon :srccurrent item.key ? item.selectedIconPath : item.iconPath / text classcustom-tabbar__text :class{ custom-tabbar__text--active: current item.key } {{ item.text }}/text /view /view /template script setup import { useUserStore } from /stores/user const props defineProps({ current: { type: String, default: } }) const userStore useUserStore() const handleTab (item) { if (props.current item.key) { return } uni.switchTab({ url: item.pagePath }) } /script这里有个关键动作点击跳转用的是uni.switchTab而不是uni.navigateTo。为什么因为tab页在页面栈里是特殊的存在用navigateTo会把tab页当作普通页面压入栈中会出现页面栈越堆越深、回退逻辑诡异的问题。switchTab是官方唯一支持跳转tab页的API。但switchTab有一个前提目标页面必须是在pages.json的tabBar list里注册过的。这里就引出一个重要问题——如果你的自定义tabbar方案下某些角色看不到消息页那消息页到底还要不要保留在pages.json的tabBar list里我的答案是保留。这听起来反直觉但原因很实际。因为switchTab的跳转要求目标页在tabBar list里如果某个角色后续可能会被开放消息权限而这个页不在list里那到时候又要重新编译发布。更稳妥的方案是pages.json里的tabBar list保持不变自定义tabbar只是做视觉上的隐藏入口真正的路由开关在前端权限守卫里再拦一层。只要用户不知道入口、路由也进不去这个页面等于不可达权限控制就是有效的。4.3 跳转逻辑switchTab、reLaunch的选择以及主动置灰switchTab是tab页切换的主要手段但有一种情况我建议用reLaunch。什么情况角色切换后当前用户停留在一个他已经没有权限访问的页面这时候不是简单的tab切换而是要清空整个页面栈回到兜底首页。reLaunch能做到这一点它会把所有页面清掉再重新打开目标页页面栈里只剩新打开的页面。另外有些tab项用户看得到入口但点击之后其实还没权限或者需要去完成某个前置操作比如绑定手机号。这种情况不应出现在tabbar里因为tabbar是极高频的交互入口让用户点了之后看到一个无权限的提示体验非常差。我会在配置表里加一个disabled字段置灰展示不可点击而不是直接拦截提示。配置里加这样一个字段{ key: order, pagePath: /pages/order/index, text: 订单, iconPath: /static/tabbar/order.png, selectedIconPath: /static/tabbar/order-active.png, disabled: true }组件里对应处理view v-foritem in visibleTabs :keyitem.key classcustom-tabbar__item :class{ custom-tabbar__item--disabled: item.disabled } clickhandleTab(item) script setup const handleTab (item) { if (item.disabled) { uni.showToast({ title: 该功能暂未开放, icon: none }) return } // 正常的switchTab } /script这里又延伸出一个重要设计原则tabbar菜单项的展示权限和页面路由的访问权限是两层东西。tabbar只管入口长什么样、能不能点路由权限管的是就算知道路径也进不去。两者缺一不可如果只做入口隐藏不做路由拦截会被人直接改URL进去。这个后面第6节我单独讲。5. 登录、登出与角色切换的同步最容易翻车的地方自定义tabbar的组件本身不复杂真正让整个方案崩盘的往往是数据同步问题。登录后菜单没刷新、登出后切到另一个账号还是旧菜单、权限变了用户还停留在旧页面这些情况我全遇到过每一个都有对应的处理方案。5.1 登录后菜单初始化时序登录流程一般是用户输入账号密码请求登录接口拿到登录凭证和用户信息然后跳转到首页。在跳转到首页之前一定要先把自己的用户store初始化完成。很多人的代码是登录成功后直接uni.switchTab去首页首页再异步去拉用户信息。这种做法在自定义tabbar方案里有一个致命问题首页组件已经渲染了但store里的visibleTabs还是空的tabbar就闪一下空白然后才慢慢跳出来菜单。视觉上就是底栏闪一下观感很廉价。正确的时序应该是// 登录页 const login async () { const res await requestLogin(formData) const userStore useUserStore() // 先更新store userStore.setUserInfo(res.data.userInfo) userStore.setRole(res.data.role) userStore.setTabKeys(res.data.tabKeys) // 再跳转 uni.switchTab({ url: /pages/home/index }) }5.2 登出清空与多账号切换登出的时候很多人只做了清理本地缓存和跳转登录页却忘了把用户store重置。这在记住登录状态的应用里会导致一个特别诡异的bug你在A账号退出然后用B账号登录底下的tabbar菜单竟然是A账号的。因为store里的数据是全局的虽然这次登录重新set了一遍但如果B账号的tabKeys和A账号一样页面就显示旧数据。登出必须彻底重置storeconst logout async () { const userStore useUserStore() userStore.$reset() // Pinia的reset方法 uni.removeStorageSync(token) uni.removeStorageSync(userInfo) uni.reLaunch({ url: /pages/login/index }) }这里用reLaunch而不是navigateTo还有一层考虑登录页不应该在页面栈里叠着一堆页面reLaunch能清栈避免用户从登录页返回还能回到旧页面。5.3 当前页面不在新角色菜单中的兜底处理这个场景比较隐蔽但一旦出现就很致命。想象一下运营人员在后台把当前登录的用户角色从管理员降成了访客用户这边的App没退出还在订单页面。下一次任何一次数据请求都可能触发401或者权限不足的报错我们当然要引导用户回首页但不能让他看到并停留在一个已经没有入口的页面。我在方案里加了一个兜底检查App.vue里的onShow或全局路由守卫中每次页面显示时对比当前页面路径是否仍然在visibleTabs对应的pagePath列表里。不在就强制reLaunch回首页。// App.vue import { useUserStore } from /stores/user export default { onShow() { const userStore useUserStore() const pages getCurrentPages() const currentPage pages[pages.length - 1] const currentPath / currentPage.route const tabPaths userStore.visibleTabs.map(item item.pagePath) if (!tabPaths.includes(currentPath)) { uni.reLaunch({ url: /pages/home/index }) } } }这个检查的粒度不用太细只对tab页做检查就够了普通页面该跳转的就跳转。另外要注意这个检查放在onShow里是安全的因为App.vue的onShow每次应用从后台到前台都会触发切tab时页面栈也会变化能覆盖到大多数场景。如果你的项目里还要考虑用户在前台停留时角色被降级的情况那就得加一个定时器或者通过WebSocket推送触发检查但这属于进阶玩法大多数项目用不到。6. 生产环境实战经验安全区、缓存、调试避坑功能跑通是一回事上生产是另一回事。这一节讲的都是我从实际项目里趟出来的经验特别是iPhone安全区、页面缓存、以及调试时容易被误导的几个点。6.1 安全区适配与占位自定义tabbar的一大痛点是底部安全区。原生tabBar会自动处理好iPhone X及以后的home indicator区域但自定义tabbar就是一个fixed定位的view默认是顶在屏幕最底部的图标会被白色横条挡住。解决办法是用safe-area-inset-bottom这个CSS环境变量.custom-tabbar { position: fixed; left: 0; right: 0; bottom: 0; height: calc(50px constant(safe-area-inset-bottom)); height: calc(50px env(safe-area-inset-bottom)); padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); background: #ffffff; display: flex; box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.05); }同时因为tabbar是fixed的页面内容会被底部遮住一部分所以每个tab页面要在内容区底部预留一个和tabbar同高的占位否则最后的内容会被挡住看不到view classpage-wrapper !-- 页面内容 -- view classpage-bottom-placeholder/view custom-tabbar currenthome / /view.page-bottom-placeholder { height: calc(50px constant(safe-area-inset-bottom)); height: calc(50px env(safe-area-inset-bottom)); }这两个高度一定要保持一致不然要么内容被遮要么底部出现一大片空白。6.2 页面缓存与状态遗留自定义tabbar下的页面缓存问题我必须单独拎出来说因为这是最容易让测试觉得是不是有bug的点。原生tabBar切换tab时页面默认是缓存的也就是说你从首页切到订单页再切回首页首页的onLoad不会重新执行只有onShow会执行。自定义tabbar虽然切换逻辑还是用switchTab走的原生机制但页面的缓存行为依然存在。这意味着什么如果你在首页做了一个根据权限动态判断展示某块内容的逻辑放进onLoad里去做那用户从订单页切回来的时候onLoad不执行首页内容还是上一次的值。虽然用户角色一般情况下不会在短时间内变化但角色在别的端被修改了这种场景是真实存在的。我的习惯是凡是跟用户权限相关的数据初始化都放到onShow里去执行不要放onLoad。还有一个更隐蔽的问题v-if和v-show的选择。自定义tabbar的菜单项如果用的是v-if判断visibleTabs里有没有这一项那当角色变化导致visibleTabs变化时Vue的响应式系统会自动增删DOM节点这个没问题。但如果你用v-show来隐藏只是把节点display:none虽然视觉上看不到了但页面路径还在用户如果真的去访问那个路径还是能进去的。所以菜单项的展示用v-if别用v-show。6.3 排查调试建议自定义tabbar的调试比原生tabbar麻烦一些因为菜单的展示是JS计算出来的不像原生tabbar在开发者工具里直接看到配置项。我在实际排查过程中总结了几个一看一个准的调试手段。第一看store数据。在vConsole或者开发者工具里打印userStore的role和tabKeys先确认数据源是不是对的。如果tabKeys是对的但界面没变化那是渲染层的问题如果tabKeys本身是空的那去接口层排查。这个顺序能帮你一下子把问题定位到数据层还是UI层。第二看getCurrentPages。遇到页面跳转异常的时候先打印一下当前页面栈看看这个页面到底是不是tab页、页面栈深度是多少。switchTab跳过去之后页面栈只剩当前页不是的其实tab页切换后页面栈还是保留的但如果你用reLaunch清过栈页面栈就只剩一个了。第三也是最重要的一点测试角色切换时一定要在真机上试不要在开发者工具里自我感动。开发者工具里一个账号退出登录再登录另一个账号store重置不重置的差异可能看不出来因为工具本身的进程可能不清理旧store。但真机上App进程常驻store是全局单例重置和初始化的问题在这个环境下才真正暴露。还有个和自定义tabbar相关的小经验如果你某个页面引入了自定义tabbar组件但忘记传current参数组件里的current会是空字符串所有菜单项都不会高亮。排查这个bug的时候我总是建议先看模板里有没有传current再去看store数据顺序别反了。6.4 后续扩展角标、扫码、自定义动画自定义tabbar方案一旦落地实际上就把底部导航的控制权完全握在自己手里了。原生的tabbar想要加个角标得用uni.setTabBarBadge而且只能是一个小红点或者一个数字样式还不能自己控。自定义tabbar方案下角标就是一个普通的view想怎么画就怎么画甚至可以在角标里塞个图片、动态的数字、动画效果。比如我在某个项目里做过的消息tab在用户未读消息大于0时右上角显示一个红色的数字角标数字由消息推送实时更新。实现方式就是在store里加一个unreadCount字段组件里根据这个字段动态渲染角标view v-ifitem.key message unreadCount 0 classcustom-tabbar__badge {{ unreadCount 99 ? 99 : unreadCount }} /view另外如果你有特殊需求比如某个tab点击后不是切页面而是弹出一个半屏弹窗类似发布按钮自定义tabbar也完全支持直接在handleTab里做分支判断就行。这些在原生的tabbar体系里基本是做不到的或者说做得特别别扭。所以如果你的项目里已经预见到底部导航将来会有很多定制化需求直接上自定义tabbar是值得的。根据我自己的实测经验这套方案在微信小程序端和App端的表现都很稳定H5端也没问题就是调试时注意页面缓存的差异。最后再提醒一句凡是涉及权限的动态展示都要记住入口隐藏不等于权限拦截路由守卫那层一定要做不然就是给安全留了一扇后门。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进