一个列表页,能搜索、能翻页、能勾选。拿固定数据点一遍,没什么问题。
然后加一句需求:"勾选要跨页保留,切换筛选条件时清空。"
事情开始变复杂:第二页的全选框亮了,实际一条也没选;搜索结果已经换了,批量操作按钮还带着旧数据;连续切换条件,页面最后显示的却是上一次的结果。
这些问题不需要复杂业务才能出现。下面用一个简化的 React 列表推演:第一版验证了每个功能能用,需求变更开始验证它们能不能一起用。
1. "加个批量选择",先加出来的是一组规则
假设第一版只支持当前页勾选,翻页就清空。这个实现并不一定错,它可能完全符合当时的要求。
现在要跨页保留,至少要回答四个问题:
- 第一页选了两条,翻到第二页,这两条还算不算选中?
- 第二页点击全选,是选当前页,还是选整个搜索结果?
- 换了筛选条件,旧选择保留还是清空?
- 批量提交时,用当前页的数据,还是所有已选记录的 ID?
本文采用一套明确的约定:翻页保留选择;全选只操作当前页;筛选条件改变时清空选择并回到第一页;批量提交所有已选 ID。
这些约定决定代码怎么写。
如果只把"支持跨页选择"丢给编程工具,它可能保留 ID,也可能缓存完整行对象,还可能顺手把全选改成全部结果。几种方案都能做出一个会动的页面,但业务含义不一样。
需求没说清的地方,代码也会给出一个答案。这个答案未必是你想要的。
这里的问题既可能是实现漏掉了已有约定,也可能是新需求改变了原先的约定。不能看到第二版出问题,就倒推第一版一定写错了。
2. 全选框亮不亮,不能看"有没有选中项"
下面这种写法很容易通过第一页的演示:
tsx
const allChecked = selectedIds.length === rows.length;
当前页有两条,选了两条,全选框亮了。
但翻页之后呢?第一页选中的两个 ID 还在,第二页也刚好两条。数量相等,全选框依然亮着,可第二页可能一条都没选。
甚至在空列表里,0 === 0 也会把它算成全选。
它把"当前页全部选中"简化成了"两个数组一样长"。在单页场景下,这个简化不容易暴露;跨页之后,前提变了。
正确判断要看当前页的每一个 ID:
tsx
const selectedSet = new Set(selectedIds);
const selectedOnPage = rows.filter(row => selectedSet.has(row.id)).length;
const allChecked = rows.length > 0 && selectedOnPage === rows.length;
const indeterminate = selectedOnPage > 0 && !allChecked;
点击当前页全选,也只修改当前页的 ID:
tsx
function togglePage(checked: boolean) {
setSelectedIds(previous => {
const next = new Set(previous);
for (const row of rows) {
if (checked) next.add(row.id);
else next.delete(row.id);
}
return [...next];
});
}
第二页取消全选,不会把第一页的选择一起删掉。
这个例子里的记录用唯一、稳定的字符串 ID。批量按钮的数量来自 selectedIds.length,提交参数也来自 selectedIds,不从当前页 rows 里反推。
当前页展示的是一页记录,用户选择的是一组记录。它们是两个范围。
如果业务后来要求"选择所有符合筛选条件的记录",这套 ID 集合就不够用了。那需要另一个明确的设计,例如查询条件加排除 ID,并配套服务端接口。不能只把全选按钮的文案换掉。
3. 真正难改的,是同一个事实存了好几份
另一个常见问题,是给每个展示结果都加一份 state:
tsx
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedCount, setSelectedCount] = useState(0);
const [allChecked, setAllChecked] = useState(false);
const [canSubmit, setCanSubmit] = useState(false);
于是每个事件处理函数,都得记得同步它们。
单条勾选同步一次,全选同步一次,翻页同步一次,筛选清空再同步一次。漏掉一处,就出现"按钮显示已选 3 条,提交参数却只有 2 个 ID"。
这时候继续修,通常是在漏掉的地方再补几个 setState。眼前的 bug 修好了,下一次需求变更仍然要找到所有同步入口。
在本文的约定下,保存 ID 就够了:
tsx
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const selectedCount = selectedIds.length;
const canSubmit = selectedCount > 0;
// 当前页的 allChecked、indeterminate 从 rows 和 selectedIds 计算
React 文档建议避免冗余状态和重复状态,理由正是它们很难保持同步。这里不需要再套一层状态库,也不需要先抽一个万能 Hook。把能计算出来的值移出 state,就少了几个会失去同步的地方。
同样的判断还适用于列表数据。
如果全部数据都在客户端,筛选后的列表可以根据原始数据和条件计算;如果接口已经负责筛选、分页,客户端就应使用该次查询的响应。先确认谁负责筛选,再决定存什么。不要把客户端全量列表和服务端分页的两套逻辑混在一起。
4. 改筛选条件,是一次完整的状态变化
筛选框的 onChange 如果只更新关键词,剩下的状态不会自动理解新需求:
tsx
setKeyword(nextKeyword);
用户原来在第八页,换了条件,新结果只有一页,接口仍然请求第八页。屏幕显示"暂无数据",用户以为搜不到。
已选 ID 如果继续保留,批量操作还可能带上上一组查询里的记录。
按照前面定下的规则,可以把查询条件放在一起,并在同一个事件入口里完成重置。下面的关键词指实际提交给查询的条件,不区分输入框草稿和已提交值:
tsx
const [query, setQuery] = useState({ keyword: '', page: 1 });
function changeKeyword(keyword: string) {
setQuery(previous => {
if (previous.keyword === keyword) return previous;
return { ...previous, keyword, page: 1 };
});
if (keyword !== query.keyword) setSelectedIds([]);
}
function changePage(page: number) {
setQuery(previous => ({ ...previous, page }));
// 翻页保留 selectedIds
}
这里刻意把翻页和筛选分开,因为它们对选择的影响不同。
以后加"状态筛选",要检查它是否属于改变结果集的条件;如果属于,就沿用回到第一页、清空选择的规则。不是到哪个控件里临时发现 bug,就在那里补一个清空。
项目很小时,这几个函数就够了。等更新入口变多、同一条规则要重复写,再考虑用 reducer 集中处理。代码组织服务于已经存在的规则,没有必要提前造一个列表引擎。
5. 最后发出的请求,不一定最后返回
固定数据的演示,很难暴露这个问题:
- 查询 A 发出。
- 用户切换条件,查询 B 发出。
- B 先返回,页面显示新结果。
- A 后返回,把页面覆盖成旧结果。
过滤条件显示 B,记录却来自 A。勾选、全选和批量操作再正确,也是在错误的数据上操作。
手写 Effect 请求时,至少要让过期请求失去更新状态的资格。下面是独立的请求逻辑示意,fetchRows 是项目提供的接口函数,返回 Promise<Row[]>:
tsx
type Row = { id: string; name: string };
const requestKey = JSON.stringify([query.keyword, query.page]);
const [result, setResult] = useState<{
key: string;
rows: Row[];
} | null>(null);
const [error, setError] = useState<{
key: string;
message: string;
} | null>(null);
useEffect(() => {
let ignore = false;
setError(null);
fetchRows(query).then(
rows => {
if (!ignore) setResult({ key: requestKey, rows });
},
() => {
if (!ignore) {
setError({ key: requestKey, message: '列表加载失败,请重试' });
}
},
);
return () => {
ignore = true;
};
}, [query, requestKey]);
const rows = result?.key === requestKey ? result.rows : [];
const currentError = error?.key === requestKey ? error.message : null;
const loading = result?.key !== requestKey && !currentError;
这里做了两件不同的事。
ignore 让旧 Effect 的成功、失败回调都不能更新当前结果。结果上的 key 则让渲染只使用匹配当前查询的数据:条件刚切换、新结果还没回来时,不把上一页记录当作当前页继续展示和勾选。
界面应根据 loading 展示加载态,根据 currentError 展示失败态和重试入口,加载时禁用当前页的全选操作。上面省略了这些 JSX,也没有涵盖缓存、刷新和分页总数。
ignore 不会取消网络传输。如果接口支持 AbortSignal,可以进一步取消旧请求;项目已有请求库时,优先使用现有的查询键、取消和加载状态机制。没有必要为了这个例子再引入另一套方案。
关键是:只有当前查询的结果可以驱动当前列表交互。
6. 怎么验收,比怎么生成更值得提前写
"支持筛选、分页、批量选择"只是功能清单。把操作顺序写出来,才知道这些功能有没有接上:
| 操作 | 应当观察到的结果 |
|---|---|
| 第一页选 A,翻到第二页选 B | 已选 2 条,提交包含 A、B |
| 两页条数相同,第二页没有选中项 | 第二页全选框不亮 |
| 第二页全选,再取消当前页全选 | 第一页的 A 仍然选中 |
| 在后面的页码切换筛选条件 | 回到第一页,选择清空 |
| 连续切换条件,让旧请求后返回 | 旧响应不能覆盖当前结果 |
| 当前查询失败 | 显示失败状态,不把旧数据当作新结果 |
不需要一次列出所有想象中的边界条件。先把这次需求改变的行为验证掉,尤其是跨页、跨条件、跨请求的操作。
给 AI 的修改说明,也可以直接使用这些约定:
在现有列表中支持跨页选择。翻页保留 ID;全选仅操作当前页;筛选条件改变时回到第一页并清空选择;批量提交全部已选 ID。检查全选判断、重复状态和过期响应处理,沿用项目已有请求方案。完成后按上述操作顺序验证。
这段话有用,因为它明确了哪些旧行为要改变,哪些必须保留。写完之后仍然要看实现、跑验证,它不会自动保证生成结果正确。
AI 能快速写出第一版页面,也能帮忙改好第二版。但"页面能显示"只覆盖了某个时刻,"需求能继续改"还取决于状态在操作之间怎样变化。
下一次看到"加个批量选择",先把跨页、全选、筛选重置这几句话定下来。否则每个人都以为自己在加一个按钮,最后加进来的却是几套互相冲突的行为。
参考资料:
- React:Choosing the State Structure------冗余状态、重复状态及同步问题。
- React:useEffect------请求响应乱序与 cleanup 中的
ignore示例。