做uni-app小程序开发,列表页多状态筛选几乎绕不开。我最近带团队做了一款任务管理类小程序,需求从单一状态筛选扩展到多状态组合筛选,前后端联调时踩了一串坑才跑通。前后端接口从单状态到IN查询的向后兼容设计,是个看着简单、实际很容易翻车的点。这篇文章完整记录这次的选型、后端改造、前端适配、联调排查全过程,适合正在做小程序列表筛选,或者需要给老接口加多选筛选能力的同学参考。你会发现,核心问题不只是把status=1改成status in (1,2),更是如何让新旧两套前端同时正常工作。
1. 先理清需求:多状态筛选到底改了什么
1.1 业务场景与原来的单状态接口
我们这款任务管理小程序里,任务状态枚举是固定的:0未开始、1进行中、2已暂停、3已完成、4已取消。旧的列表页顶部只有一个“全部/待办/已完成”的picker,选中后请求后端时带上status=0或status=3。后端的list接口用Django ORM做查询:
tasks = Task.objects.filter(owner=request.user) if status: tasks = tasks.filter(status=status)这版接口很简单,前端也省心。但产品新需求出来后,问题来了:运营人员希望在“任务管理”页面同时勾选“进行中”和“已暂停”两个状态,一次性把双方交接中的任务都列出来。除此之外,可能还要组合“已完成+已取消”作为历史归档视图。
如果前端沿用旧接口,能想到的第一版做法可能是循环请求:先请求status=1,再请求status=2,然后把两份结果合并。想法很直觉,但真去实现就麻烦。举个例子:第一页返回10条,进行中6条,已暂停4条,第二页判断是否继续加载就要跨两份列表计算,排序也没法保证全局一致。更别说运营还要筛选“未开始+进行中+已暂停”三种状态,循环请求会指数级变复杂。所以这个需求必须从接口层解决。
单状态筛选的接口只适合“一次选一种”的页面,一旦页面允许复选,数据源就必须支持多值传入。这也是这次改造最本质的驱动力。
1.2 可行的接口方案对比
在做后端改造前,我们列出了四种常见方案,并逐个衡量。其实这也算列表筛选接口的经典选项,可以直接拿来对比。
| 方案 | 请求示例 | 优点 | 坑点 |
|---|---|---|---|
| 循环请求后前端合并 | status=1请求两次 | 后端不用改 | 分页、排序、去重全乱 |
| 重复参数 | status=1&status=2 | 符合HTTP语义,后端可getlist | 参数解析依赖后端框架,旧网关可能只取第一个 |
| 逗号分隔字符串 | statuses=1,2 | 直观、日志友好、兼容旧参数容易 | 后端要自己拆分和校验 |
| JSON数组 | statuses=[1,2] | 表达力强 | URL编码容易出问题,小程序端拼query麻烦 |
最终我们选择了逗号分隔字符串,并新增加一个statuses参数,而不是直接修改旧的status参数语义。原因有这么几点:逗号分隔在日志和抓包里一眼能看懂,排错成本低;小程序端join(',')一步就搞定,不需要处理编码;最大的好处是能和旧参数并存——旧版本小程序继续传status=1,新版本传statuses=1,2,后端可以非常干净地做兼容。
如果项目里已经用OpenAPI、GraphQL这类强规范,可能会倾向重复参数或者枚举数组,但我们现在是常规REST接口,前端又是小程序,简单直接是第一原则。
2. 后端接口设计:从参数解析到IN查询
2.1 参数定义和优先级规则
为了不让新老前端打架,我们给后端接口定了三条明确规则:
- 当
statuses参数存在且解析出至少一个合法状态值时,完全忽略旧的status参数。 - 当
statuses缺失或为空时,退回使用旧的status参数,等价于单元素状态列表。 - 当两个参数都没有时,表示不按状态过滤,返回该用户全部任务,由前端默认页自行展示全部或置空。
这份规则写进接口文档的第一行,所有后端开发、前端开发、测试同学都按这个来。后面所有测试用例也是围绕这三条展开的。
这里有个容易忽略的细节:statuses参数值虽然是字符串,但在真实HTTP请求里一定是statuses=1,2这种形态。小程序端在组装参数时如果粗心,可能变成statuses=1%2C2,看起来乱,后端按split(',')拆出来只有一个元素,状态筛选就不生效。所以我们专门约定参数值采用半角逗号,前后不能带空格,不做URL编码以外的额外处理。
2.2 Django ORM查询实现
后端用的Django,改造后的核心逻辑如下。注意我们保留了一个白名单常量,状态枚举值就五个,任何不在白名单里的数字都应该被静默丢弃,而不是直接报错。这样做的原因是:列表筛选接口天生要面对脏数据,一个非法值不应该让整个列表接口挂掉。
TASK_STATUS_CHOICES = {0, 1, 2, 3, 4} def parse_status_filter(request): statuses_raw = request.GET.get('statuses', '').strip() if not statuses_raw: statuses_raw = request.GET.get('status', '').strip() if not statuses_raw: return None statuses = [] for item in statuses_raw.split(','): item = item.strip() if item.isdigit(): value = int(item) if value in TASK_STATUS_CHOICES: statuses.append(value) # 去重并固定顺序 statuses = sorted(set(statuses)) return statuses if statuses else None status_filter = parse_status_filter(request) if status_filter is not None: tasks = tasks.filter(status__in=status_filter)status__in在Django ORM里会生成类似WHERE status IN (1, 2)的SQL条件。这个查询建立在status字段索引上,即使筛选多状态,只要状态值数量有限,性能就不会有问题。真正需要注意的反而是空列表的情况:如果前端传了statuses=,,解析后得到空列表,我们这里会让status_filter返回None,也就是当作不筛选。如果不做这个判断,直接执行tasks.filter(status__in=[]),Django会生成一个恒为假的查询条件,列表必然为空,排错时会一脸懵。
完成查询后,列表排序仍然沿用原有的-created_at,分页用Django的PageNumberPagination或者DRF的内置分页。多状态筛选和分页是正交的,filter之后照常order_by和切片即可。如果你使用DRF,可以在filter_backends里扩展自定义过滤器,也可以直接在视图里调用上面的解析函数,然后赋值给queryset。两种方式都行,核心逻辑一致。
3. 向后兼容策略:老版本小程序不能挂
3.1 兼容而不是覆盖
“兼容”这两个字说起来容易,做起来要非常克制。我们在改造时最容易犯的错误是直接把旧参数status改成多值,让前端都走status=1,2。这会带来两个问题:一是旧版小程序已经发布出去,它传的永远只是单个数字,代码层面不会感知新语义,虽然单个数字也意外兼容,但一旦旧端在某个页面上同时传了多个status参数,后端解析如果只取第一个,就会漏数据;二是后端日志、监控、告警都围绕status,突然改语义会让历史数据对比失效。
所以我们选择“新参数优先,旧参数兜底”,而不是“修改旧参数”。在代码里,两条路径实际上都会汇聚到同一个解析函数,只是优先级不同。上面parse_status_filter已经实现了这一点,逻辑上非常干净:先看新参数,新参数没有东西就看旧参数,两者都没有就不过滤。
3.2 回归测试用例
兼容改造最怕的就是新功能没问题,老功能悄悄崩了。我建议不管后端有没有专门的测试团队,自己先按下面表格手动回归一遍。我们团队当时就靠这张表发现了一个老参数被网关截断的问题。
| 场景 | 请求参数 | 期望结果 |
|---|---|---|
| 老版单状态 | status=1 | 只返回进行中任务 |
| 新版多状态 | statuses=1,2 | 返回进行中+已暂停 |
| 新老同时存在 | status=3&statuses=1,2 | statuses优先,返回1和2 |
| statuses为空串 | statuses= | 按不筛选处理 |
| statuses含非法值 | statuses=1,abc,2 | 丢弃非法值,返回1和2 |
| statuses含中文逗号 | statuses=1,2 | 解析后可能只有一个元素,需要前端避免 |
| 不传筛选 | 无 | 全部任务 |
| 传page和pageSize | statuses=1,2&page=2&pageSize=10 | 第二页正确 |
这张表看起来简单,但每一行都可能对应一个线上事故。比如中文逗号那行,我们前端有个同学从UI组件里复制了带逗号的内容进去,代码又没做防呆,后端如果直接int()解析就会抛invalid literal,被自己的异常处理吞掉后返回空数组,整页空白。后来后端加了item.isdigit()判断,这类非法输入直接被过滤掉。
3.3 兼容期间的日志与灰度观察
兼容期不是无限期的。旧版本小程序可以通过发布新版本逐渐升级,但用户如果不主动更新,旧端会一直存在。为了知道什么时候可以下线老参数,我们在后端每一层都加了结构化日志,记录实际生效的过滤类型是status还是statuses,再配合统计看板统计两种参数的使用占比。当新端覆盖率足够高后,向下兼容的负担就纯粹是维护成本,可以排期下线status参数。
这个思路和前后端灰度发布很像,接口兼容本质上也是服务层的一次灰度,只不过客户端升级相对不可控,所以日志观察比强制下线更可靠。我见过很多团队图省事,上线新接口当天就把旧参数删掉,结果用户更新App不及时,线上列表白屏几小时,最终只能紧急回滚。所以向后兼容设计里,最重要的不是“改得漂亮”,而是“退得从容”。
4. uni-app前端页面:筛选交互与请求适配
4.1 筛选组件选型:底部弹窗多选
前端部分我们用的技术栈是uni-app + vue3 + setup语法。页面定义在pages/task/list.vue,顶部是一排筛选钮:状态、负责人、时间范围,其中状态这次改造成了多选。
一开始我们打算用uni-app内置的picker组件,结果发现picker的mode="selector"只支持单选,多选必须自己写。后来用了uni-ui的uni-data-checkbox包一个底部弹层,效果还可以。如果后端工期紧,也可以直接用view加checkbox模拟,反正核心是把用户选中的状态集合保存到一个响应式数组里。这里提一个uni-app的细节:vue3环境下用ref包裹数组,操作selectedStatuses.value.push(...)或者直接selectedStatuses.value = newList都是响应式的。不要再用this.selectedStatuses的老写法,容易在自定义组件里丢响应式。
弹窗面板的确认按钮逻辑建议合并去重并排序后再存,比如用户勾选顺序是“已完成、进行中”,但保存时统一为[1, 3],这样后续生成请求参数时不会因为勾选顺序不同而产生不同的请求URL,方便后端缓存命中。
4.2 参数组装:数组如何变成后端认识的statuses
这是整个联调过程中最容易出问题的一步。小程序端在使用uni.request时,如果直接把数组放到data里,很多传输层会把它序列化成statuses[]=1&statuses[]=2,或者是statuses=1&statuses=2。这两种格式都不是我们约定的逗号分隔字符串,后端要么取不到,要么取到第一个,表现成“筛选完全没生效”。
所以我们统一在请求前把状态数组转成字符串:
const buildListParams = () => { const params = { page: currentPage.value, pageSize: pageSize.value, } if (selectedStatuses.value.length > 0) { params.statuses = [...selectedStatuses.value] .sort((a, b) => a - b) .join(',') } else { params.status = undefined params.statuses = undefined } return params }这里故意在未选中任何状态时不传statuses,也不传status,让后端返回全部数据。有的团队习惯把“全部”设计成statuses=0,1,2,3,4,这样也行,但SQL会多一个无效的IN条件,状态枚举一旦扩展就要前端跟着改,所以我更推荐“不筛选就不传参”。
为什么排序?因为状态集合[1,3]和[3,1]本质是同一个筛选条件,如果前端不排序,同一个条件会生成两个不同字符串,后端做缓存或者日志归并时都会困难。排序后URL可以变成固定格式,上下游排查都方便。
4.3 请求封装:loading、刷新与加载更多
列表请求我们封装成fetchTaskList,内部处理loading、错误提示和响应解析。关键点是筛选状态变化时要重置分页。最典型的问题是用户第一页看了一部分,然后点了新的状态筛选,如果你没有把currentPage.value = 1,前端会继续请求第二页,结果出现“筛选后的列表只有几行,但加载更多还在继续”。所以监听筛选变化时,要做三件事:重置page为1、清空旧列表、重新请求。
代码大致如下:
const loadList = async (isRefresh = false) => { if (isRefresh) { currentPage.value = 1 taskList.value = [] } if (loading.value) return loading.value = true errorMsg.value = '' try { const params = buildListParams() const res = await request.get('/api/tasks/', { params }) taskList.value.push(...res.data.results) hasMore.value = res.data.next != null currentPage.value += 1 } catch (e) { errorMsg.value = '加载失败,请稍后重试' } finally { loading.value = false } }下拉刷新用onPullDownRefresh,刷新结束后记得调用uni.stopPullDownRefresh()。上拉加载更多用onReachBottom,只要hasMore为true且loading为false就可以再次调用loadList(false)。这里的互斥很关键,否则用户一边下拉刷新一边触发上拉,会产生并发请求,列表顺序就乱了。
5. 联调实战:抓包确认参数,定位兼容问题
5.1 联调前的对齐会议
前后端联调不是从接口写完才开始的,我们在后端参数设计好之后就先拉了一次对齐会,把请求示例、返回结构、错误处理都过了一遍。重点确认下面几个问题:
- 参数名:
statuses,不是stateList也不是status[] - 参数值:半角逗号分隔的数字字符串,例如
statuses=1,2 - 枚举值:前端展示的“进行中”对应后端
1 - 返回结构:DRF默认的
{count, next, previous, results}结构 - 不传表示不筛选,传空串也按不筛选处理
这些看起来都是基础,但如果不写下来,前端和后端很容易各自理解。我们之前就发生过前端传statuses: "1,2",后端文档写着statuses: "1,2",但中间有个API网关把逗号当成分隔符做了重写,到后端变成statuses: "1"和2两个query参数,导致筛选不生效。没有对齐会议的话,这个问题会被当成前端bug查半天。
5.2 用抓包工具看实际请求,比看代码更直接
联调阶段我们遇到最诡异的一次问题是:新版本小程序在自己手机上筛选“进行中+已暂停”,列表返回的却一直是全部任务。前端代码review了三遍没发现问题,后端日志也显示statuses参数根本没到后端。最后我在电脑上打开Charles抓包,看了一眼实际发出的请求,发现查询字符串并不是后端要求的statuses=1,2,而是statuses%5B%5D=1&statuses%5B%5D=2。
原因出在我们自己写的请求拦截器里。之前为了兼容另一个老接口,拦截器对所有数组类型的参数都做了[]展开处理,结果新接口也吃了这个逻辑。用抓包工具的好处是直接看到HTTP层实际内容,绕过所有中间层,能最快分清是前端拼参问题、后端解析问题还是中间网络改写问题。
当时排查步骤大致是这样:
- 在电脑上启动抓包工具,手机连到同一局域网,打开小程序的request请求。
- 复现筛选操作,定位
/api/tasks/这条请求。 - 查看Query String,发现参数是
statuses[]=1&statuses[]=2。 - 对照接口文档,确定格式不一致。
- 回到拦截器,把数组转字符串的逻辑提前,避开通用数组展开。
- 再次抓包,确认URL变成
statuses=1,2,后端响应正确。
这里不需要把抓包工具当成什么高深技术,它就是看HTTP请求的“放大镜”。联调排错时先抓包后猜代码,能省很多时间。如果你用的是其他抓包工具,原理一样,关键是学会看URL Query和响应体。
5.3 状态映射不一致的排查
除了参数格式,前后端状态映射不一致也是多状态筛选的高发问题。我们项目里任务状态枚举是后端维护的,前端为了保证中文展示,自己写了一份映射表。某天前端加了一个“已暂停”状态,映射表里写成state: 2,而后端枚举是status: 2。页面筛选弹窗传的是前端映射的2,这没问题,等评审时发现前端把“已取消”写成4,后端枚举里4表示“已完成”,结果筛选结果和预期完全对不上。
排查时最有效的方式是让后端在响应里回传status值,前端直接拿status_name展示,而不是前端再映射一遍。列表数据里每一项都应该有:
{ "id": 101, "title": "修复登录bug", "status": 1, "status_name": "进行中" }前端展示直接使用status_name,筛选时仍传状态数字。这样一个状态枚举只在后端定义,前端和测试都只看返回语义,就不容易出现两边各自维护映射表导致对不上的问题。这个改动虽然简单,却能砍掉大量联调成本。
6. 分页、排序与多状态筛选的组合问题
6.1 固定排序是翻页不出错的前提
多状态筛选本身不会影响数据唯一性,但分页要正确,后端排序必须固定。我们用的是-created_at,也就是按创建时间倒序。如果只按id排序,理论上也没问题,但要注意保证每次查询的排序字段唯一且确定。
这里为什么反复强调排序要固定?在并发或快速翻页场景下,如果排序反复变动,同一批数据可能在不同页之间跳动,出现重复或遗漏。以前我们有过一个列表用updated_at排序,而批量更新任务时多个任务会被同时修改成相同时间戳,导致后端的排序结果在临界情况下不唯一,用户快速翻页时看到数据重复。后来改成-created_at, -id双字段排序,问题才消失。这段经验虽然是老生常谈,但配合多状态筛选时尤其重要,因为IN条件筛选出的数据往往比单状态更多,更容易暴露排序不唯一问题。
6.2 加载更多必须重新读当前筛选条件
我在多个项目里都遇到过同一个bug:第一页请求带了statuses=1,2,加载更多时因为代码里复用了上一次请求的params对象,而对象被前端某个操作修改了,导致第二次请求里statuses丢失,列表变成全部任务。排查起来很像玄学。
解决思路很简单:加载更多时不要缓存上一次的请求参数,而是每次都从当前筛选状态重新组装。在我们的代码里,loadList方法每次都会调用buildListParams(),这个方法读的是selectedStatuses.value,所以只要筛选条件变化,参数就能跟着变。你可能会问,每次加载多一次排序和去重的开销,其实几毫秒的事情,完全可接受。
6.3 快速切换筛选条件的竞态处理
还有一个必须处理的坑:用户快速切换状态筛选时,可能第一次请求还没返回,第二次请求已经发出。如果第一次请求后返回,会把旧列表数据覆盖到新筛选条件下,造成列表展示错乱。在uni-app小程序环境里,uni.request返回的对象虽然也有取消请求的方法,但在不同平台的兼容性不太一样,所以我更推荐用“请求序号”来做逻辑丢弃。
let latestRequestId = 0 const fetchTaskList = async () => { const currentRequestId = ++latestRequestId const params = buildListParams() try { const res = await request.get('/api/tasks/', { params }) if (currentRequestId !== latestRequestId) { // 说明这次请求已经过期,丢弃结果 return } // 正常渲染 } catch (e) { if (currentRequestId !== latestRequestId) return // 错误处理 } }这样每次新请求都会让旧请求的currentRequestId落后于latestRequestId,从而自动忽略。不管是下拉刷新、加载更多还是切筛选条件,这个机制都能防止旧响应覆盖新状态。
7. 从一次联调扩展到通用多值筛选设计
7.1 沉淀一套通用的多值参数解析
这次改造后,我把后端的多值参数解析逻辑抽成了一个通用函数,后续凡是需要多选筛选的接口都用它。函数签名大致如下:
def parse_multi_int_param(params, key, whitelist=None): raw = params.get(key, '') if not raw: return None values = [] for item in raw.split(','): item = item.strip() if item.lstrip('-').isdigit(): value = int(item) if whitelist is None or value in whitelist: values.append(value) values = sorted(set(values)) return values if values else None这里的whitelist参数很关键,它不仅是校验,也是接口文档的一种代码化表达。比如任务状态的白名单是{0,1,2,3,4},标签ID就没有白名单,用None表示不限制。这样新增一个多值筛选字段时,前端只需要传多值,后端无需重写解析逻辑。
7.2 向后兼容设计的工程经验
最后想分享一点工程层面的体会。接口的向后兼容,本质上不是“不能改”,而是“旧的调用方不需要改”。要做到这一点,最可靠的方式是新增参数而不是改旧参数语义。像这次我们从status平滑扩展出statuses,老版本小程序完全无感,新版本前端可以成套使用,整个切换周期内不需要强制用户升级App。
这套做法还可以推广到其他字段,比如批量删除接口的ids=1,2,3、多分类筛选的category_ids=1,2、多标签筛选的tag_ids=1,2。只要遵循“复数参数名+逗号分隔+服务端白名单校验+新参数优先”这几个原则,列表类功能的迭代会顺畅很多。
我个人的习惯是,在新项目里从第一天起就把可能多选的筛选字段定义成多值格式,而不是等到需求来了再补兼容层。但如果你面对的是已经上线的老接口,也别急着推翻重来,用“新参数优先、旧参数兜底”的兼容策略,配合日志灰度观察,等新端覆盖率上来后再下线老参数。这个过程需要前后端充分对齐,也需要靠抓包工具和接口文档来兜底,但只要设计清楚,列表多状态筛选从单状态到IN查询的改造,完全可以做到上线当天零事故。