
做小程序开发底部 tab 导航加个人中心页基本是所有项目的标配哪怕一个 demo 也绕不开。这个标题写在第三篇说明前面已经把工程基础跑通了到这一篇要解决两件事一是把个人信息页面做出来二是通过 tabBar 把多个页面串成完整的底部导航布局。注意输入里把 tabBar 写成了 tabar实际配置项是 tabBar别敲错了。这篇内容适合什么水平的人看呢如果你刚接触小程序会用 wxml 和 wxss 写简单页面但对 app.json 配置、tabBar 机制、页面跳转限制这些概念还很模糊那整篇实操过程正好能帮上忙。已经写过一两个页面、但搞不明白为什么 tab 切换时页面会被“重置”、数据明明没保存的朋友也能在后面找到答案。1. tabBar 的定位先从标题里的拼写说开去先纠正一个细节标题里的“tabar”应该是 tabBar。这是英文 camelCase 的写法B 要大写。很多人在搜资料的时候会用 tabar 搜索也能搜到一些结果但如果你直接打开官方文档看字段名就是tabBar配置文件里少写一个 B 或多写一个 B小程序都会直接报错或静默忽略这也是新手最常踩的坑。1.1 tabBar 解决的不只是“切换”tabBar 是微信小程序提供的原生底部导航栏它解决的核心问题不是“怎么从一个页面跳到另一个页面”而是“如何在多个一级页面之间做高频切换并保持每个页面的独立状态”。你可以把它理解为手机系统里的物理返回键它不属于某一个页面而是悬浮在页面之外的导航层级。点击 tab 时小程序会在对应页面之间切换并且每个 tab 页面都有自己的生命周期。更关键的是tabBar 页面一旦加载过切走再切回来时通常不会重新执行 onLoad而只是触发 onShow。这就带来了“数据在哪里刷新、状态在哪里保持”这一系列问题后面我会专门讲。1.2 原生 tabBar 和你自己手写导航的区别我以前见过不少同学觉得原生 tabBar 样式太死板打算自己用 view 写一个底部工具栏然后点击时切换页面内容。想法没错但手写方案的问题是状态管理容易失控。你需要自己维护当前选中项、自己渲染图标和文字、自己处理页面内容区切换还得考虑如何隐藏原生导航栏甚至要处理安全区避让。原生 tabBar 的好处在于它是系统级组件渲染在原生层性能更好选中高亮、切换动画、点击反馈都是现成的底部安全区的适配也由系统默认处理。除非你真的需要中间凸起按钮、动态控制某项显隐、或者每个 tab 的图标要做到完全定制的效果否则没有必要一开始就走自定义 tabBar 的路子。1.3 为什么我建议你先把 tabBar 做出来再谈其他标题里把页面布局和 tabBar 放在一起是有原因的。tabBar 决定了你整个小程序的骨架结构四个 tab 还是三个 tab哪些功能放一级入口哪些功能藏到二级页面都会影响后续的开发方式。与其页面都写完了再回头改 app.json不如项目一开始就先把 tabBar 和对应的四个空白页建好后面每个页面往里面填内容这种感觉是完全不一样的。我在实际开发中都是先建页面目录再配 tabBar最后才写页面内部逻辑。因为一旦 tabBar 能跑通页面间的跳转关系就明确了后面的开发路径也顺了。2. 项目搭建先把 tabBar 配置起来这一节我们直接从工程和配置入手。假设你已经用微信开发者工具初始化了基础模板下面要做的就是改目录结构、改 app.json然后在对应的页面里补上内容。2.1 工程目录与 pages 的关系小程序项目的核心配置文件是 app.json里面有一个 pages 数组它定义了整个小程序包含哪些页面。每个页面对应一个目录目录下通常有四件套.js、.json、.wxml、.wxss。pages 数组的第一项就是小程序启动后默认打开的第一页。这里有一个很容易被忽略的规则tabBar 里的页面必须同时存在于 pages 数组中。如果你在 tabBar 里写了一个页面路径但在 pages 数组里漏写了运行时会直接报错或者导航条上少一个 tab。反过来如果 pages 数组里有页面但它没有进 tabBar 的 list那它就是一个普通页面不带底部导航栏。先看一个最小目录结构miniprogram/ ├── app.json ├── app.js ├── app.wxss ├── pages/ │ ├── index/ │ │ ├── index.js │ │ ├── index.json │ │ ├── index.wxml │ │ └── index.wxss │ ├── profile/ │ │ ├── profile.js │ │ ├── profile.json │ │ ├── profile.wxml │ │ └── profile.wxss │ └── settings/ │ ├── settings.js │ ├── settings.json │ ├── settings.wxml │ └── settings.wxss我的习惯是每个页面一个目录目录名就是页面名。这样页面路径清晰后续找代码也方便。你不要把多个页面文件堆在一个目录里一旦页面多了会非常混乱。2.2 app.json 里 tabBar 的完整配置下面给出一份能直接跑的 tabBar 配置三个 tab分别是首页、个人、设置。这里把个人信息页放在第二个 tab实际项目里个人中心通常放在最后一个 tab但放在中间也完全可以看需求怎么定。{ pages: [ pages/index/index, pages/profile/profile, pages/settings/settings ], window: { navigationBarTitleText: 个人中心Demo, navigationBarBackgroundColor: #ffffff, navigationBarTextStyle: black }, tabBar: { color: #999999, selectedColor: #3f7cf6, backgroundColor: #ffffff, borderStyle: black, list: [ { pagePath: pages/index/index, text: 首页, iconPath: images/home.png, selectedIconPath: images/home-active.png }, { pagePath: pages/profile/profile, text: 我的, iconPath: images/user.png, selectedIconPath: images/user-active.png }, { pagePath: pages/settings/settings, text: 设置, iconPath: images/settings.png, selectedIconPath: images/settings-active.png } ] } }逐个说一下配置项的含义。color是未选中时文字的颜色selectedColor是选中时文字的颜色都是十六进制。backgroundColor是底部导航栏的背景色同样必须是十六进制。borderStyle只有两个取值black和white控制导航栏上边框的颜色默认是 black。list里每一项都是一个 tab。pagePath对应页面路径不需要带.js后缀也不需要以斜杠开头。text是导航栏上显示的文字我建议控制在 4 个汉字以内超过的话在部分机型上会折行或显示不全。iconPath和selectedIconPath是未选中态和选中态的图标路径图片建议用 png尺寸 81px * 81px大小控制在 40kb 以内而且必须用项目内的相对路径不能用网络图片。这里有个细节需要强调list 的长度最少 2 个最多 5 个。少于 2 个是配不出来 tabBar 的多于 5 个也会报错。如果你只有一个一级页面想加底部栏先想想是需求本身不适合用 tab还是你打算用自定义 tabBar。2.3 配置后应该看到的效果与校验逻辑配置完成后编译运行正常的现象是底部出现一条导航栏里面有三个 tab第一个默认选中。点击“我的”页面切换到个人中心页底部选中项变成蓝色文字和对应的高亮图标。点击“设置”再切到设置页。如果你发现页面能切换但底部没有导航条大概率是 pages 数组里的第一项不是 tabBar 中的页面。小程序启动后默认打开 pages 数组的第一项如果它不是 tab 页面页面就不带 tabBar那自然看不见底部栏。解决办法是把 tabBar list 里的某一个页面放到 pages 数组的第一位。还有一个容易踩的坑如果你的某个 tab 页面里页面级配置navigationStyle设置了custom那你看到的导航栏会变成通栏这个不是 tabBar 的问题而是页面导航栏的设置问题别混淆。3. 个人信息页面开发布局、样式与数据tabBar 跑起来之后重点就落在个人信息页面上了。3.1 个人中心页的经典结构拆解个人中心页的信息密度比较高从上到下通常分成三个区域顶部用户信息区、中部功能入口区、底部操作区。顶部用户信息区一般放头像、昵称可能还有一行签名或介绍。中部功能入口区是订单、收藏、地址、客服这类功能的集合最常用的是九宫格或列表形式。底部操作区一般是退出登录或版本信息。这里的页面不需要花里胡哨重点是把信息层次理清楚。先看 wxml 骨架view classpage view classheader button classavatar-wrapper open-typechooseAvatar bind:chooseavataronChooseAvatar image classavatar src{{avatarUrl}} modeaspectFill/ /button input typenickname classnickname-input placeholder点击设置昵称 value{{nickname}} bind:bluronNicknameChange/ /view view classmenu-list view classmenu-item wx:for{{menuList}} wx:keyid bindtaponMenuTap>.header { display: flex; flex-direction: column; align-items: center; padding: 60rpx 40rpx; background: linear-gradient(180deg, #e8f1ff 0%, #ffffff 100%); } .avatar-wrapper { width: 160rpx; height: 160rpx; padding: 0; border-radius: 50%; overflow: hidden; background: #f0f0f0; border: none; line-height: 1; } .avatar { width: 160rpx; height: 160rpx; border-radius: 50%; }这里把 button 的默认样式清掉了把 padding 设为 0把边框去掉否则头像外面会有一圈灰色的边框非常丑。菜单列表区域我倾向于用纯 view 实现而不是用系统的 picker 或 navigator 堆一堆组件。因为菜单项的表现形式比较固定左边一个图标或文字右边一个箭头。自己用一个wx:for循环渲染数据维护起来反而简单。.menu-item的样式要点是一行显示、两边留白、点击时要有按压反馈。按压反馈可以通过 hover-class 来实现比如设置hover-classmenu-item-hover里面加一个background-color: #f5f5f5。第三底部安全区。全面屏手机底部有一条 home indicator如果你在页面底部放了按钮按钮可能会被系统手势条挡住。解决方法是给底部元素加上这个样式.footer { padding-bottom: env(safe-area-inset-bottom); }env(safe-area-inset-bottom)是 iOS 上系统提供的安全区高度安卓会返回 0。加上这个属性之后按钮就不会贴在海域线上了。3.3 头像昵称获取用当前微信推荐的方案这里专门说一下头像和昵称的问题因为这里的接口政策变化过一次很多旧教程已经过时了。以前用wx.getUserProfile可以直接弹窗拿到用户的微信头像和昵称但从 2022 年 10 月 25 日之后微信调整了规则新发布的小程序再调用wx.getUserProfile也拿不到真实头像和真实昵称了返回的是一套默认头像和“微信用户”这种占位昵称。所以现在开发新项目要按照官方推荐的头像昵称填写能力来做也就是我上面 wxml 里写的chooseAvatar和nicknameinput 那套方案。js 里对应的代码如下Page({ data: { avatarUrl: /images/default-avatar.png, nickname: }, onChooseAvatar(e) { this.setData({ avatarUrl: e.detail.avatarUrl }); }, onNicknameChange(e) { this.setData({ nickname: e.detail.value }); }, saveProfile() { wx.setStorageSync(avatarUrl, this.data.avatarUrl); wx.setStorageSync(nickname, this.data.nickname); wx.showToast({ title: 已保存, icon: success }); } });这里e.detail.avatarUrl返回的是一个本地临时路径用户一旦重新选择头像临时路径会变。如果项目有后端你应该把这个临时文件通过wx.uploadFile上传到自己的服务器拿到一个永久的 CDN 地址保存下来。如果只是本地演示用wx.setStorageSync存起来也够用了。顺带提醒一点如果你在小程序的 app.json 里配置了接口权限或者涉及收集用户头像昵称记得在小程序管理后台完善用户隐私保护指引声明收集头像昵称的目的和用途。这是微信审核时的标准要求不做的话可能会遇到审核失败甚至是接口调用被限制。3.4 页面数据的存取逻辑个人信息页的数据从哪里来常规思路是启动时从本地缓存读一份默认数据进入页面时先展示缓存内容然后再向服务器请求最新数据回来后覆盖到页面上。这样即使网络慢或请求失败用户至少不会看到一个空空如也的页面。在 onShow 里做数据拉取而不要放在 onLoad 里原因我会在 4.2 节里仔细讲。先给一个 onShow 拉取缓存的例子onShow() { const avatarUrl wx.getStorageSync(avatarUrl) || /images/default-avatar.png; const nickname wx.getStorageSync(nickname) || ; this.setData({ avatarUrl, nickname }); }这种做法还有一个好处如果用户从设置页修改了昵称切回个人中心页时onShow 会重新触发页面数据能自动刷新。你不需要在页面间发事件通知也不用担心状态不同步。4. tabBar 多个页面的切换、传参与生命周期现在 tabBar 有了个人页也做了接下来是理解多页面布局的关键部分页面之间是怎么切换的切换时都发生了什么。4.1 页面跳转方式和 tabBar 的限制小程序里常用的跳转 API 有wx.navigateTo、wx.redirectTo和wx.switchTab。简单区分一下wx.navigateTo跳转到普通页面保留当前页面目标页面左上角会有返回按钮。wx.redirectTo关闭当前页面跳转到普通页面不能返回。wx.switchTab跳转到 tabBar 页面会关闭其他所有普通页面。关键限制是tabBar 里的页面只能用wx.switchTab跳转。如果你用wx.navigateTo跳一个 tab 页面控制台会报错大致意思是无法跳转到 tab 页面。反过来普通页面之间也不适合用 switchTab因为普通页面不在 tabBar list 里强行调用同样会报错。一句话记住navigateTo用于普通页面switchTab用于 tab 页面别混。如果你在个人中心页里放了一个“设置”入口点击后想进设置页那设置页如果不在 tabBar 里就应该用navigateTo跳过去goToSettings() { wx.navigateTo({ url: /pages/settings/settings }); }这里还有一个很实用的小技巧wxml 里可以用navigator组件替代wx.navigateTo。比如菜单列表的点击跳转直接写成这样更省事navigator url/pages/order/order hover-classnone view classmenu-item text我的订单/text text classmenu-arrow›/text /view /navigator但记住navigator 组件跳 tab 页面时要加open-typeswitchTab否则也会被拦截。这点很容易被忽略我估计三分之一的新手都在这里卡过。4.2 页面生命周期为什么数据刷新要写在 onShowtab 页面的生命周期和非 tab 页面有些不同。以我的三个页面为例假设我在首页点“我的”切到个人中心页第一次进入个人中心页时执行顺序是onLoad → onShow → onReady。onLoad 时拿到options参数onReady 后页面完成渲染可以调用 canvas 这类需要节点渲染完成的功能。当你从个人中心页切回首页时个人中心页触发 onHide首页如果之前已加载过不会再执行 onLoad而是直接执行 onShow。再切回个人中心页时它同样不会再走 onLoad只走 onShow。这个机制带来的后果是什么呢如果你把数据初始化都写在 onLoad 里那你第一次进入页面之后数据永远只在第一次加载时拿到后面每次从其他 tab 切回来页面都不会主动刷新。比如用户从设置页改了昵称切回个人中心时如果不刷新看到的还是旧昵称。所以我的经验是需要在每次切入页面时同步的数据写入逻辑放在 onShow 里只需要第一次加载时执行一次的逻辑放在 onLoad 里。两者分工明确页面状态就会清楚很多。4.3 tab 页之间传参的变通方案tabBar 还有一个不太方便的地方wx.switchTab不支持在 url 中带参数。也就是说你没法写成wx.switchTab({ url: /pages/profile/profile?id1 })后面的 query 参数会被直接忽略。那 tab 页面之间需要传参怎么办最常用的变通方案有两种。第一种是全局变量。小程序根目录的 app.js 里可以定义 globalData。页面里通过getApp()拿到全局对象把要传的数据塞进去目标页面在 onShow 里再取出来。// 首页里写入 const app getApp(); app.globalData.fromPage 签到活动; // 个人中心页读取 const fromPage app.globalData.fromPage;第二种是本地缓存。用wx.setStorageSync和wx.getStorageSync传数据这种方式在页面被销毁、小程序重启之后依然能读到适合需要持久化的数据。缺点是数据不会自动过期需要自己控制清除时机。这两种方式都有效但用的时候要想清楚数据是否敏感、是否应当持久化。如果是临时交互数据用 globalData 就够了如果是需要下次启动还能用的配置用 storage 更靠谱。4.4 自定义 tabBar 的适用场景关于 tabBar最后提一个进阶选项自定义 tabBar。在 app.json 的 tabBar 配置里加上custom: true然后在项目根目录建一个custom-tab-bar目录里面放一套自己写的导航组件就能完全控制底部导航的外观。什么时候需要这么做呢比如中间的 tab 要做一个凸起的发布按钮或者某个 tab 要根据用户登录状态动态显示或隐藏又或者某几个页面共享一套带角标的底部导航。这些需求原生 tabBar 实现不了只能自定义。但自定义 tabBar 意味着页面切换、选中态高亮、角标更新这些逻辑全都要自己写复杂度会明显上升而且自定义组件里操作wx.switchTab时要注意页面栈的状态同步。我建议第一个版本先老老实实用原生 tabBar等产品设计上确实需要特殊样式了再迁移到自定义方案。前期过度设计往往会拖慢项目进度。5. 常见问题排查与实践坑点汇总这一节整合我在实际开发中遇到最多的几类问题。如果你照着上面的步骤做还是有问题大概率能在下面找到原因。5.1 底部 tabBar 不显示的排查清单底部导航不显示是最典型的问题。按照这个顺序排查很快能定位检查项正确做法list 长度2 到 5 个 tab少于 2 个不显示页面路径tabBar 的 pagePath 必须与 pages 数组里的路径完全一致大小写、后缀都不能错首页位置pages 数组第一项最好是 tabBar 页面否则启动页不带底部栏字段名确保是 tabBar 而不是 tabbar大小写严格color 颜色color、selectedColor、backgroundColor 都必须是十六进制颜色其中“pages 数组第一项不是 tab 页面”最容易踩。很多人习惯把启动页放在 pages 第一个但启动页不是 tab 页面导致小程序一打开就没底部栏。把第一个 tab 页面放到 pages 数组第一位就能解决。5.2 tabBar 跳转报错与图标不显示的坑如果你在点击某个 tab 页面里的按钮时控制台报错说“navigateTo:fail can not navigateTo a tabbar page”那就是你用 navigateTo 跳了 tab 页面。改成wx.switchTab或者给 navigator 组件加open-typeswitchTab。如果图标不显示先看图标文件是否存在再看路径是否相对 app.json。tabBar 的 iconPath 是相对项目根目录的路径不是相对当前页面的路径。还有就是图标大小和格式png 格式建议 81px * 81px40kb 以内比较稳妥。超过这个体积部分机型会出现图标不加载的情况。另外图标不支持网络 URL只能使用本地图片。还有一点如果你配了position: top顶部导航不会显示图标只有文字。这不是 bug是设计如此。顶部 tabBar 一般我很少用因为不符合移动端的主流交互习惯。5.3 页面状态被“重置”的排查很多同学问我为什么从其他 tab 切回个人中心填了一半的表单内容没了其实有两类原因。第一如果你在 onLoad 里初始化了表单字段而 tab 页面首次加载后实例被缓存切回来不会重新 onLoad所以数据不会丢。但你如果在小程序中遇到了页面被回收的情况比如内存不足、被系统清理了 webview那页面会重新走 onLoad数据自然就重置了。这种情况要用wx.setStorageSync做草稿保存在 onShow 里恢复。第二如果你在页面隐藏时手动清了数据比如在 onHide 里调用了某个 reset 方法那切回来自然就是空的。检查一下有没有这类隐藏逻辑。总之表单草稿、滚动位置这类数据建议在 onHide 时存缓存在 onShow 时恢复。tab 页面虽然相对稳定但小程序本身不是一个常驻系统该保存的还是要保存。6. 实操心得与后续可以做的事最后写一点我自己实际开发中积累的习惯供你参考。6.1 开发顺序和文件组织的经验我先写 app.json 确定页面结构再建所有页面空目录每个页面先用固定模板写一行文字确认页面能互相跳转了再往 wxml 里填真正的业务内容。这个顺序能避免你埋头写了一天业务代码结果发现页面之间的导航关系根本连不上回头再改配置文件白白浪费工时。文件命名也尽量统一。页面目录用小写字母加连字符比如order-detail、profile-setting不要用乱七八糟的拼音缩写也不要大小写混用。图片资源单独建 images 目录按照 tab 图标、页面配图、用户头像默认图分开放。工程一旦扩大这种细节能帮你省很多找文件的时间。6.2 这个项目后续可以怎么扩展这一步做完之后个人中心页和 tabBar 的骨架已经立起来了接下来普通的功能扩展就很容易往上加。比如增加一个订单列表页面从个人中心页的菜单项navigateTo跳过去比如设置页里做退出登录退出时清掉本地登录态再switchTab回首页。还有一个值得做的优化是把个人中心页的用户信息区抽成独立组件user-profile-card这样首页、个人页可以共用同一个头像昵称展示组件修改样式时只需要改一处。组件化是后续扩展时很自然的方向等页面多了你会发现提前拆组件远比后期重构省力。以我自己的经验来说写个人中心页最忌贪多。一个页面里堆了十几个菜单、几十个跳转看起来功能齐全实际上后期维护特别痛苦。先保证 tabBar 的切换顺畅、个人信息的展示和保存稳定比多做一两个花哨入口重要得多。这个项目现在刚好处于“骨架成型、可继续加肉”的阶段后面要接订单、接会员体系都可以在这个基础上逐层叠上去。每加一个模块先回到页面的核心职责想一想这个页面的主要任务是什么能不能拆到更细的子页面去把这些问题想清楚了路就会越走越顺。