小程序学到第十七篇终于要碰一个真实业务里的高频场景了后台搭好了运营在后台配置了一批商品分类现在需要小程序前台把这些分类数据取下来按照层级渲染到页面上。如果前面的生命周期、数据绑定、网络请求、路由跳转都已经跑过一遍这一篇就是把它们串起来的一次完整实操。这篇笔记我会把“获取前台分类数据并渲染”这条链路掰开讲清楚包括接口怎么约定、数据怎么处理、页面怎么渲染、遇到问题怎么排查适合已经学完小程序基础、准备上手综合案例的人。整个过程会放在一个模拟的商城项目里不依赖任何特定后端你只要能看懂接口返回结构就能直接套到自己项目里用。1. 先把需求看清楚分类数据渲染到底要解决什么问题1.1 前台分类不是“后台给什么就显示什么”很多人第一次做分类页面会觉得这不就是把一个数组用wx:for渲染出来嘛。真上手做一遍就会发现事情没那么简单。分类数据在项目里的真实流转路径是后台管理系统配置分类 → 后端存储并提供接口 → 小程序发起请求 → 拿到数据后做清洗和映射 → 渲染到页面。这里的“清洗和映射”经常被人忽略恰恰是出问题最多的地方。我在某个模拟商城项目里遇到的实际需求是这样的后台的分类表里有字段id、parentId、name、icon、sort、isShow、createTime。前端页面需要展示的只有name、icon并且必须按照sort排序同时只展示isShow 1的分类还要把平铺的一维数组拼成两级树形结构。这个例子想说明的是前台渲染依赖的是“处理后的数据”不是“原始数据”。如果直接把后台返回的一维数组丢到页面上渲染排序顺序可能不对隐藏分类会漏出来父子层级关系也完全没法表达。所以做这个功能之前先想清楚三件事字段映射关系是什么、是否需要过滤、是否需要按层级重组。另外还有一个容易被忽略的点分类数据在接口返回时通常是平铺结构也就是每条记录带一个parentId父分类的parentId为 0子分类的parentId指向父分类的id。渲染前要做一次树形化把“平铺列表”变成“树结构”或者至少变成“一级数组 二级嵌套数组”。这一步不做页面就只能渲染出一级分类。1.2 渲染链路拆解从接口到页面到底经过了哪几步完整的数据流可以理解为五步小程序启动后进入分类页面触发onLoad。页面调用封装好的request方法请求分类列表接口。请求进入拦截器统一处理公共参数、错误码。拿到的原始数据在业务层完成过滤、排序、树形化。把处理后的数据通过setData交给视图层WXML用wx:for渲染。这五步看起来平淡但每一步都有对应的坑。比如第二步里request封装得不好每个页面重复写wx.request后续改域名、加 token、统一处理 401 都会非常痛苦。比如第四步里数据清洗如果放在页面里页面会越来越胖多个页面复用同一种数据时只能复制粘贴。我自己的做法是会把“请求数据”“处理数据”“渲染数据”尽量分开。页面里只保留取数和赋值逻辑复杂的树形化、排序、过滤逻辑抽成公共函数放到独立的模块里。这样分类页面想加一个搜索框或者另一个页面也想复用这套分类数据时不需要重写业务处理逻辑。从学习和维护角度看这一步的分层思路比具体代码更值得重视。小程序本身是轻量框架项目一大App 中的数据管理混乱会直接导致改一个功能牵一发动全身。2. 做编码之前先和后端把接口和数据约定好2.1 统一接口返回格式前端才能省心我在实际项目里最怕遇到“每个接口返回格式都不一样”的后端。有的接口直接返回数组有的返回{ data: [...] }有的错误码叫code有的叫status。前期不约定联调阶段就是在互相猜谜。比较推荐的统一返回结构是这样的{ code: 0, message: success, data: { list: [ { id: 101, parentId: 0, name: 手机数码, icon: https://example.com/icon/phone.png, sort: 1, isShow: 1 } ] } }code为 0 表示业务成功非 0 表示业务异常message用于错误提示data存放具体业务数据。所有接口都按这个格式返回前端封装request时就可以统一判断code出错时统一 toast业务代码里基本不用再重复写错误处理。分类列表接口可以命名为GET /api/category/list请求参数可以带上type比如type1表示前台分类type2表示后台管理分类。这样前后台用同一个分类表但返回给前台的字段可以做裁剪不把后台敏感字段暴露出去。这里有个细节值得注意接口返回的字段名到底用parentId还是parent_id。如果后端使用的是 Java 系框架常用驼峰命名前端拿到就是parentId如果后端是 Python 系常用下划线命名拿到的是parent_id。小程序setData里用哪种都行只要访问路径一致。真正的问题是如果后端在两种命名之间切换前端页面里所有字段名都要跟着改非常容易漏。所以接口文档里明确约定字段命名风格比约定返回结构还要优先。2.2 分类字段设计每个字段都不能随便删设计分类数据结构时有几个字段是无论如何都不能缺的。字段含义为什么重要id分类唯一标识跳转详情页、商品列表页都要用它传参parentId父分类 ID决定分类层级一级分类为 0name分类名称页面直接展示的文案icon分类图标前台展示图片地址sort排序权重运营想调整展示顺序就靠它isShow是否展示运营临时下架分类不用删数据改它就行这里面有两个字段特别值得说。一个是parentId。没有它前端就只能渲染一层分类或者靠后端提前返回嵌套结构。如果还没做二级分类很多人会直接把parentId省略。等运营哪天真需要加子分类后端表结构要改前端树形化逻辑也要补工作量反而更大。所以哪怕当前只做一级分类数据模型里也应该保留这个字段这是低成本的前瞻设计。另一个是sort。分类列表的排序如果依赖id或者创建时间运营想调整顺序只能改数据库太痛苦。用sort字段权重小的排前面同权重的按id排前端拿到数据直接sort一下就能完成排序。我在项目里见过运营在后台拖拽排序前端接口返回的顺序就是排好的就是因为后端在查询时已经按sort排序前端不用再做二次排序。isShow字段也容易被忽略。运营在后台停用了一个分类如果不做过滤前端还会把停用的分类渲染出来用户点击后进入一个空列表页体验非常差。正确做法是前端拿到数据后先过滤isShow ! 1的记录再进入后续处理。3. 核心实现请求、处理、渲染三步走3.1 先封装一个统一的请求方法很多小程序新手直接在页面里写wx.request页面一多就开始痛苦。比如后端加了新的接口路径前缀你得去几十个页面里改 URL接口返回 401 时想统一跳登录也只能在每个页面里重复写。所以做分类数据渲染之前我建议先把request封装好。以下是实际项目里足够用的封装方式先新建utils/request.jsconst BASE_URL https://api.example.com; function request({ url, method GET, data {} }) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL url, method, data, header: { Content-Type: application/json, // token 可以从缓存里取登录后再填 Authorization: wx.getStorageSync(token) || }, success(res) { const { code, message, data: responseData } res.data || {}; if (code 0) { resolve(responseData); } else { wx.showToast({ title: message || 请求失败, icon: none }); reject(res.data); } }, fail(err) { wx.showToast({ title: 网络异常请检查网络, icon: none }); reject(err); } }); }); } module.exports { request, BASE_URL };这里做三层处理请求头的统一注入、业务码的统一判断、网络异常的兜底提示。页面调用时只需要关心成功后的数据错误分支完全不用重复写。把success里的逻辑展开就是上一节说的“统一返回格式约定”带来的直接收益。另外域名要配置在小程序后台的 request 合法域名里不然真机上请求直接失败。这一点在开发工具里勾选“不校验合法域名”能跳过但上线前必须配好。3.2 页面取数逻辑onLoad 里请求并处理数据分类页面一般就是一个pages/category/category页面。核心逻辑分四步进入页面显示加载状态。调用分类列表接口。拿到原始数据后做过滤、排序、树形化。用setData更新渲染数据关闭加载状态。以下代码是简化后的示例放在category.js里const { request } require(../../utils/request); const { buildCategoryTree } require(../../utils/category); Page({ data: { categoryList: [], loading: true, error: false }, onLoad() { this.fetchCategory(); }, fetchCategory() { this.setData({ loading: true, error: false }); request({ url: /api/category/list, data: { type: 1 } }) .then((res) { const list res.list || []; const tree buildCategoryTree(list); this.setData({ categoryList: tree, loading: false }); }) .catch(() { this.setData({ loading: false, error: true }); }); }, retry() { this.fetchCategory(); } });buildCategoryTree是单独抽出去的工具函数因为在很多页面里都要用。它的作用是把平铺数组整理成树形结构同时过滤掉isShow ! 1的数据并按sort排序。具体实现我放到下一小节细讲。这里页面代码保持得很干净读代码的人一眼就能看出这个页面干了什么。这里还要注意一个细节加载状态要让loading和error同时存在。只设计loading一个状态的话请求失败后页面会一直停留在空白或者 loading 状态对用户不友好。3.3 树形化工具函数平铺数组变成层级结构buildCategoryTree最核心的逻辑是这样的先把所有记录按parentId分组一级分类的parentId为 0子分类的parentId指向父分类id然后遍历一级分类把子分类挂到对应的children数组上。function buildCategoryTree(list) { const visibleList (list || []) .filter(item item.isShow 1) .sort((a, b) a.sort - b.sort); const map {}; const roots []; visibleList.forEach((item) { map[item.id] { ...item, children: [] }; }); visibleList.forEach((item) { if (item.parentId map[item.parentId]) { map[item.parentId].children.push(map[item.id]); } else { roots.push(map[item.id]); } }); return roots; } module.exports { buildCategoryTree };实现思路比较经典用map暂存所有节点再循环两次第一次创建节点和children空数组第二次把子节点挂到父节点上。父节点找不到就当作根节点处理这样即使数据里出现个别父级缺失也不会整棵树崩掉。排序这里做了一个细节处理先按isShow过滤再按sort排。如果后端已经排好序前端再排一次也不会出错属于“防御性排序”。sort字段相同的情况下排序结果不稳定也没关系一般不会有大量同类权重出现的场景真遇到可以在排序条件里加一个id兜底。树形化做完之后渲染层就轻松了无论是一级分类还是两级分类都只需要两层wx:for。3.4 WXML 渲染分类列表页面拿到处理好的categoryList之后渲染是最后一步。这里展示一个常见的一级分类 二级分类的页面结构view classcategory-page view wx:if{{loading}} classloading-tip加载中.../view view wx:elif{{categoryList.length 0}} classempty-tip 暂无分类数据 /view view wx:elif{{error}} classerror-tip bindtapretry 加载失败点击重试 /view view wx:else classcategory-container view classcategory-list view wx:for{{categoryList}} wx:keyid classcategory-item >onCategoryTap(event) { const { id, parentId } event.currentTarget.dataset; const url /pages/product-list/product-list?categoryId${id}; if (parentId) { const parentUrl /pages/product-list/product-list?parentId${parentId}categoryId${id}; // 二级分类跳转可能需要带上父级 id 用于筛选 } wx.navigateTo({ url }); }这里容易踩的坑有两个。一是参数没有编码。如果分类名也需要传递比如页面标题要用分类名称直接把中文拼到 URL 里模拟器上可能正常真机上很容易乱码。解决方案是跳转前用encodeURIComponent(name)编码接收页用decodeURIComponent(options.name)解码。二是接收页从options里取参数时参数名必须和 URL 里的 key 完全一致。不注意的话常见的报错是categoryId拼成categoryid取出来就是undefined。4. 渲染细节与交互优化别让页面停留在“能看”的水平4.1 图片的懒加载、比例和兜底缺一不可分类图标看起来是一件小事实际操作里问题最多。运营上传的图标尺寸不统一有的图是 200×200有的是 800×600直接用一个固定宽高渲染图片会被拉伸变形。我的做法是给image设置modewidthFix让它宽度撑满容器高度按比例自动计算这样图标比例不会乱。图标大小一般在 96rpx 到 160rpx 之间具体看设计稿。lazy-load属性要加上。尤其分类数量多、图标多的时候懒加载能明显减少首屏网络请求压力。微信小程序官方对lazy-load的支持已经比较成熟直接写在image标签上就行。图片 URL 为空或加载失败时页面会显示破图。可以在image标签的binderror事件里换成默认图onIconError(event) { const index event.currentTarget.dataset.index; this.setData({ [categoryList[${index}].icon]: /assets/default-category.png }); }这种利用 setData 的路径式更新方式在嵌套数组里改某个字段非常好用。不过要注意这里是按一级下标index定位如果数据后续有删除或排序变化下标会乱。更稳妥的方案是直接用分类id匹配再更新代价是代码多一些但逻辑更可靠。4.2 加载骨架屏比 loading 文字好得多很多项目的分类页面加载中就是一个居中的“加载中”文字看着很简陋。如果有精力可以做一个简单的骨架屏也就是在数据没回来时先渲染一排灰色占位块。骨架屏的实现并不复杂先用固定结构模仿真实页面的布局再用 CSS 动画让灰色块闪烁。数据回来后把骨架屏整体替换成真实内容。这一步不做也不影响功能但对质感提升非常明显尤其分类页往往是小程序里被用户浏览最多的页面之一。我的经验是先用一个isSkeleton变量控制骨架屏的显示然后不管数据是否加载成功只要请求结束就把isSkeleton置为false。后续想动态调整骨架屏时长可以加一个最小展示时间避免页面刚打开闪一下真内容又闪回骨架屏。4.3 下拉刷新与缓存追求极致的流畅感分类数据相对固定不像订单列表那样实时变化所以很适合做缓存。每次进入页面直接发起请求用户等待时间不是很长但是切换 Tab 再切回来或者下拉刷新时如果每次都重新拉取数据和体验都是浪费。我在某个项目里采用过一种简单的缓存方案const CACHE_KEY category_cache; const CACHE_TIME 60 * 60 * 24; // 一天 function saveCache(data) { wx.setStorageSync(CACHE_KEY, { data, time: Date.now() }); } function getCache() { const cached wx.getStorageSync(CACHE_KEY); if (!cached) return null; if (Date.now() - cached.time CACHE_TIME) return null; return cached.data; }加载时先读缓存有缓存就直接渲染同时后台发请求更新缓存没有缓存就显示骨架屏等请求回来再渲染。这样首屏秒开数据最终也能保证是最新的。需要注意一点如果运营后台改了分类配置用户还拿着旧缓存今天是看不到变化的。这个问题可以通过后台发布时改变缓存版本号来解决给缓存 key 加一个version参数后台配置变更时前端强制清缓存。不过这个设计要后端配合小项目可以先不做等运营反馈“改了分类前台没变”再说。5. 常见问题与排查实录这里都是真实踩过的坑5.1 请求成功但页面一直空白这个问题的概率非常高我在排查询中遇到太多次了。请先确认一个前提你真的拿到数据了吗打开调试器的 Network 面板看请求返回值里data.list到底存不存在。有时候后端返回的字段叫data.categories你的代码里却写了res.list结果是 undefined赋值给了categoryList页面渲染一个空数组自然什么都没有。还有一种常见情况是wx:for遍历的不是数组而是对象。比如某些后端把分类数据返回成{ 0: {...}, 1: {...} }这种伪数组结构前端用wx:for一看好像循环是没有问题的但实际渲染不出来。检查方法就是console.log(Array.isArray(list))如果不是数组前端需要先Object.keys转成数组。再有一种隐蔽的情况是树形化函数里写错了挂载逻辑导致所有分类都挂到了同一个父分类下父分类页面渲染正常但子分类预期是两列却挤在一列里。这种问题从 Network 和 AppData 面板都能看出来调试器里直接展开categoryList看结构一眼就能定位。5.2 图片裂开或显示不出来打开分类页面文字都在图标全部裂开。先别急着怀疑图片地址先按这个顺序查打开调试器的 Network 面板看图片请求是不是 404 或者 403。如果请求根本没发出来看图片 URL 是不是相对路径。比如icon: /upload/a.png在小程序前端解析成当前域名的相对路径肯定不对。后端必须返回完整的https://地址。如果是一个完整的https://地址但图片还是不出来检查这个域名有没有配置在小程序后台的 downloadFile 合法域名里。开发和真机调试可以通过设置跳过域名校验但主要针对 request 和 uploadFile图片加载同样受 downloadFile 域名限制。最后再确认图片协议是不是https。小程序真机上默认不允许加载http协议的图片即便合法域名配置了也不行。图片不显示这件事80% 都是域名配置或协议问题。开发工具里一切正常一上真机就全裂开绝大多数原因就是没有配置合法域名或者用了http。5.3 点击分类跳转后参数丢失或中文乱码跳转传参这块我见过最离谱的一次是从分类页跳商品列表页死活取不到分类 id。最后发现是 URL 参数名拼错了跳转时写的categoryId接收页读的categoryid大小写不一致。JS 对象属性名是区分大小写的这看起来像是低级错误但联调时一着急真有人会犯。传中文参数时记得三步走跳转前encodeURIComponent接收后decodeURIComponent如果中文还作为 URL 的一部分拼在路径里那更是要编码。不编码的话部分 Android 真机上拿到的是%E6%89%8B%E6%9C%BA这种乱码或者被截断接口请求直接 404。还有一种情况是参数传到了页面但页面在请求商品列表接口时没把参数带上。这种情况定位起来特别容易忘先在页面onLoad(options)里console.log(options)看参数过来了没有再往下查接口是否带了参数。5.4 缓存导致分类配置不更新加了缓存之后运营改了分类名称用户端两天都没变。这不是缓存设计错了而是缺少主动失效机制。我建议简单点处理只要分类后台发布配置时给前端缓存的 key 加一个版本号。比如缓存 key 从category_cache变成category_cache_v2前端发现新的版本号跟当前缓存 key 对不上就会直接丢弃旧缓存重新请求。如果后端暂时没有提供版本号那就在前端用wx.setStorageSync存一个修改时间后台修改时间超过一定值比如 1 天就强制更新。这个方案虽然不能秒级生效但至少不会让旧缓存持续太久。5.5 快速排查表现象优先排查项常见原因页面空白Network 请求返回字段名不匹配 / 返回非数组图片裂开图片 URL 完整性和域名配置相对路径 / http / 未配合法域名跳转参数丢失onLoad 里打印 options参数大小写不一致中文乱码编码解码方法未编码或未解码缓存不更新缓存版本策略缺少失效机制子分类显示异常树形化数据结构父级 id 对应错误6. 几个值得养成的习惯分类数据渲染这件事做下来我最大的体会是业务代码里不要藏太多临时逻辑。分类这种通用功能很容易被一个页面反复写然后每个页面还写得不一样。我后来会把分类树形化、缓存读取、状态管理都整理成公共方法任何页面需要分类数据直接调用同一个入口能省下不少排查时间。还有一个习惯是使用 setData 时尽量精确更新。在小程序里setData的性能开销不算小尤其是数据量大时完整更新一个大数组会产生明显的卡顿。如果只需要修改其中一个元素的属性可以用categoryList[0].icon这种路径式写法性能会好很多。分类页面作为小程序里的高频页面值得多花一点时间打磨交互细节。就算不加花哨的动效至少把加载态、空态、错误态、正常态都补齐把跳转逻辑和参数传递规范好整体质量就能超过绝大多数项目。如果后续还要继续深入可以把左侧一级分类、右侧二级分类的联动布局做出来再加一个分类页内的全局搜索。这些都是在“获取分类数据并渲染”这个基础上叠加的交互层工作基础扎实了往上加功能会轻松很多。