AI 能把页面写出来,为什么改个需求就开始崩?

一个列表页,能搜索、能翻页、能勾选。拿固定数据点一遍,没什么问题。

然后加一句需求:"勾选要跨页保留,切换筛选条件时清空。"

事情开始变复杂:第二页的全选框亮了,实际一条也没选;搜索结果已经换了,批量操作按钮还带着旧数据;连续切换条件,页面最后显示的却是上一次的结果。

这些问题不需要复杂业务才能出现。下面用一个简化的 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. 最后发出的请求,不一定最后返回

固定数据的演示,很难暴露这个问题:

  1. 查询 A 发出。
  2. 用户切换条件,查询 B 发出。
  3. B 先返回,页面显示新结果。
  4. 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 能快速写出第一版页面,也能帮忙改好第二版。但"页面能显示"只覆盖了某个时刻,"需求能继续改"还取决于状态在操作之间怎样变化。

下一次看到"加个批量选择",先把跨页、全选、筛选重置这几句话定下来。否则每个人都以为自己在加一个按钮,最后加进来的却是几套互相冲突的行为。


参考资料:

相关推荐
锋行天下10 分钟前
LangGraph HITL 实战:从 interrupt 到 useStream 的最小闭环
人工智能
OpsEye14 分钟前
同一套用例跑两个大模型接口,差异到底出在哪
javascript·ai编程
X1A0RAN1 小时前
PPT 图片批量处理教程:导出全部幻灯片 · 提取全部素材
开发语言·javascript·powerpoint
张彦峰ZYF1 小时前
浏览器调试接口的官方化:从 chrome-devtools-mcp 看 Agent 原生工具层的形成
人工智能·chrome·agent·mcp·apache-2.0许可·devtools-mcp·调试闭环
涛涛ing1 小时前
2026年最值得关注的前端变化,藏在“默认值”里
前端
Dawson Zhu1 小时前
Queen Planner深度解析:Agent竞争从模型时代进入系统时代
人工智能·语言模型·架构·aigc·agi
甲维斯1 小时前
GPT6.2Astra也很强啊,国模...!
人工智能
量化分析码农2 小时前
股票历史数据的复权参数对照:多级别返回的字段与数值差异
java·javascript·后端
承渊政道2 小时前
Fooocus本地AI绘图教程:Windows部署、低门槛生图与远程访问
人工智能·windows·cpolar·fooocus·生图