搜索结果页的状态比结果本身更容易丢
搜索页看起来是一个列表,实际上是一组必须同步变化的条件:查询词、内容类型、排序、时间范围、标签,再加上「已经加载到哪一页」。任何一项变化,其余几项都可能失效。线上最常见的搜索 bug 不是接口返回错,而是条件已经变了,客户端还在用上一组条件的游标继续翻页。
本节只讨论客户端如何持有和迁移这组状态:条件变化时游标怎么办、旧响应如何丢弃、筛选状态放在哪里、返回页面时恢复什么、空结果和「没有更多」如何区分。它不讨论查询语法、分词与相关性排序,也不讨论联想框的防抖与竞态(12.1),更不讨论发现页的多来源组合(12.3)。我们假定服务端已经按游标正确返回一段结果。
一个搜索会话至少包含五个字段
| 字段 | 作用 | 变化时的影响 |
|---|---|---|
query | 当前查询词 | 新会话,游标必须清空 |
filters | 类型、排序、时间、标签 | 新会话,游标必须清空 |
revision | 条件版本,用于丢弃旧响应 | 每次条件变化自增 |
itemCount | 当前已加载条目数 | 决定「空」与「没有更多」的文案 |
nextCursor | 下一页游标,null 表示没有下一页 | 只对当前条件有效 |
很多实现把 cursor 和 items 放在组件状态里,把 query 和 filters 放在另一个 Hook 里,两者靠 useEffect 同步。同步一旦漏掉一条路径,就会出现「筛选变了、游标没变」的组合。把它们收进一个 reducer,条件变化和游标重置发生在同一个状态迁移里,才不容易漏。
条件变化不是翻页,是新会话
游标不是「第几页」,而是服务端某次查询快照上的位置。它通常编码了排序键、过滤条件和快照时间。拿「相机」的游标去请求「相机 + 全新」的下一页,服务端要么返回与筛选无关的内容,要么返回重复项,要么直接报游标失效。三种结果在端上看起来都像「搜索坏了」。
因此规则要写得足够硬:查询词或任一筛选条件变化时,nextCursor 立即置空,itemCount 归零,状态回到待加载。不是等新请求返回再重置,而是在条件变化的同一个动作里重置。否则用户在新条件加载期间点击「加载更多」,会拿着旧游标发请求。
重置的时机也要包括「相同条件重新提交」。用户点搜索按钮重试,应该视为新会话:版本号自增,旧请求作废,从第一页重新开始。只有「加载更多」是同一个会话内的延续。
用 revision 丢弃旧条件的响应
条件变化后,旧请求可能还在路上。它带回的是上一组条件的结果,直接追加会把两个条件的数据混在一起。解决办法与联想框的 seq 相同:给每个搜索会话一个自增的 revision,请求发出时带上当时的版本,响应回来先比对。
interface FirstPage {
revision: number;
items: number;
nextCursor: string | null;
}
function applyFirst(state: SearchState, page: FirstPage): SearchState {
if (page.revision !== state.revision) return state;
if (page.items === 0) return { ...state, status: "empty", nextCursor: null };
return { ...state, status: "ready", itemCount: page.items, nextCursor: page.nextCursor };
}
这里不需要比较服务端时间,也不依赖 abort 是否生效。revision 是客户端自己的因果顺序:后一次条件变化一定比前一次大,旧响应一定对不上。abort 仍然要做,但它只是节省资源,不是正确性判据。
「加载更多」的响应也要带 revision。用户在第 3 页加载中途切换筛选,这个翻页响应即使成功返回,也属于旧会话,必须丢弃。只在首页请求上做版本校验,是这类 bug 最常见的漏点。
筛选状态放进 URL
搜索条件应该进 URL,原因有三个:分享、刷新、返回。用户把「相机 + 全新 + 最新」的链接发给朋友,对方打开应该看到同一组条件;用户刷新页面,条件不应该回到默认;用户从详情页返回,筛选 chips 不应该全部消失。
URL 参数只放「可复现的条件」,不放游标和滚动位置:
| 参数 | 示例 | 说明 |
|---|---|---|
q | q=相机 | 空查询不写 |
type | type=post | 默认 all 不写 |
sort | sort=latest | 默认 relevance 不写 |
time | time=week | 默认 all 不写 |
tags | tags=android,jvm | 排序、去重后写 |
默认值省略不是省字符,而是让 URL 稳定:默认条件不应该因为代码里参数的插入顺序不同而产生两个看起来不同的链接。标签排序去重也是同理,tags=jvm,android 与 tags=android,jvm 应该被视为同一个筛选。
游标为什么不进 URL?它是服务端快照上的不透明位置,可能过期、可能绑定账号、可能包含服务端内部排序键。把游标分享出去,对方未必能复现同一页;刷新后游标失效,页面还会显示一个空列表。正确做法是 URL 只恢复条件,进入页面后重新请求第一页。
返回时恢复什么、不恢复什么
从结果页点进详情再返回,用户期望看到原来的条件和原来的位置。可恢复的应该是三层:
- 条件层:查询词与筛选从 URL 恢复,这是权威来源。
- 数据层:可以先用内存里的第一页结果占位,避免白屏,但要标记为旧数据并在后台刷新。
- 位置层:滚动位置按第 2 章的锚点 id 恢复,不能用像素值硬记。
不应该恢复的是「旧游标继续翻页」。返回后条件可能已经被其他端修改,服务端快照也可能过期。把旧游标当成还能用,用户点「加载更多」时就会遇到游标失效。更稳的做法是回到第一页,滚动到锚点,再让用户自己决定是否继续往下。
筛选 chips 的选中态必须从状态派生,不能存在组件局部变量里。局部变量在返回、刷新、分享三条路径上都会丢,而 URL 不会。
空结果与「没有更多」是两件事
这两个状态在界面上都可能显示成一句灰字,但语义完全不同:
| 状态 | 触发条件 | 界面应该做什么 |
|---|---|---|
| 空结果 | 第一页返回 0 条 | 展示「没有找到相关内容」,给修改条件的入口 |
| 没有更多 | 已有内容,nextCursor 为 null | 在列表底部展示「没有更多了」 |
| 加载更多失败 | 已有内容,翻页请求失败 | 保留列表,底部展示重试按钮 |
把「空结果」和「没有更多」都判断成 items.length === 0,会导致两个问题:首屏空结果时列表底部还挂着「没有更多了」,用户以为上面还有内容没加载;翻页失败时列表被清空,用户已经读到的内容消失。
正确判据是分开的:status === "empty" 表示第一页就没有结果;itemCount > 0 && nextCursor === null 表示确实到底了;error !== null && itemCount > 0 表示还有机会重试。三个条件不要互相替代。
一个可运行的状态 reducer
下面的 search-state.ts 属于 L1:全部是纯函数,没有网络、时钟与组件依赖。查询词和筛选变化会在同一个迁移里重置游标,URL 编解码只处理可复现条件,失败时根据是否已有内容决定清空还是保留。
// file: src/search/search-state.ts
// tier: L1
// verify: node --test test/search/search-state.test.ts
export type SearchType = "all" | "post" | "user" | "topic";
export type SearchSort = "relevance" | "latest";
export type SearchTimeRange = "all" | "day" | "week" | "year";
export type SearchStatus = "idle" | "loading" | "loadingMore" | "ready" | "empty" | "error";
export interface SearchFilters {
type?: SearchType;
sort?: SearchSort;
timeRange?: SearchTimeRange;
tags?: string[];
}
export interface NormalizedFilters {
type: SearchType;
sort: SearchSort;
timeRange: SearchTimeRange;
tags: string[];
}
export interface SearchState {
query: string;
filters: NormalizedFilters;
revision: number;
status: SearchStatus;
itemCount: number;
nextCursor: string | null;
error: string | null;
}
export type SearchAction =
| { type: "query"; value: string }
| { type: "filter"; patch: SearchFilters }
| { type: "submit" }
| { type: "moreStart" }
| { type: "first"; revision: number; items: number; nextCursor: string | null }
| { type: "more"; revision: number; items: number; nextCursor: string | null }
| { type: "fail"; revision: number; message: string }
| { type: "clear" };
export const DEFAULT_FILTERS: NormalizedFilters = {
type: "all",
sort: "relevance",
timeRange: "all",
tags: [],
};
const TYPES: SearchType[] = ["all", "post", "user", "topic"];
const SORTS: SearchSort[] = ["relevance", "latest"];
const TIMES: SearchTimeRange[] = ["all", "day", "week", "year"];
export function normalizeQuery(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export function normalizeTags(tags: string[]): string[] {
return [...new Set(tags.map((tag) => tag.trim()).filter((tag) => tag !== ""))].sort();
}
export function normalizeFilters(
patch: SearchFilters = {},
base: NormalizedFilters = DEFAULT_FILTERS,
): NormalizedFilters {
return {
type: patch.type ?? base.type,
sort: patch.sort ?? base.sort,
timeRange: patch.timeRange ?? base.timeRange,
tags: normalizeTags(patch.tags ?? base.tags),
};
}
export function sameFilters(a: NormalizedFilters, b: NormalizedFilters): boolean {
return (
a.type === b.type &&
a.sort === b.sort &&
a.timeRange === b.timeRange &&
a.tags.length === b.tags.length &&
a.tags.every((tag, index) => tag === b.tags[index])
);
}
export function createSearchState(query = "", filters: SearchFilters = {}): SearchState {
return {
query: normalizeQuery(query),
filters: normalizeFilters(filters),
revision: 1,
status: "idle",
itemCount: 0,
nextCursor: null,
error: null,
};
}
export function reduce(state: SearchState, action: SearchAction): SearchState {
switch (action.type) {
case "query": {
const query = normalizeQuery(action.value);
if (query === state.query) return state;
return { ...state, query, revision: state.revision + 1, status: "idle", itemCount: 0, nextCursor: null, error: null };
}
case "filter": {
const filters = normalizeFilters(action.patch, state.filters);
if (sameFilters(filters, state.filters)) return state;
return {
...state,
filters,
revision: state.revision + 1,
status: "idle",
itemCount: 0,
nextCursor: null,
error: null,
};
}
case "submit":
return {
...state,
revision: state.revision + 1,
status: "loading",
itemCount: 0,
nextCursor: null,
error: null,
};
case "moreStart":
if (state.status !== "ready" || state.nextCursor === null) return state;
return { ...state, status: "loadingMore", error: null };
case "first": {
if (action.revision !== state.revision) return state;
if (action.items === 0) {
return { ...state, status: "empty", itemCount: 0, nextCursor: null, error: null };
}
return { ...state, status: "ready", itemCount: action.items, nextCursor: action.nextCursor, error: null };
}
case "more": {
if (action.revision !== state.revision) return state;
if (state.status !== "loadingMore") return state;
const itemCount = state.itemCount + action.items;
// 服务端原地返回同一个游标说明分页没有推进,再请求只会死循环。
const repeated = state.nextCursor !== null && action.nextCursor === state.nextCursor;
const nextCursor = repeated ? null : action.nextCursor;
return { ...state, status: itemCount === 0 ? "empty" : "ready", itemCount, nextCursor, error: null };
}
case "fail": {
if (action.revision !== state.revision) return state;
// 已有内容时加载更多失败不应清空列表,只把错误挂出来等重试。
return { ...state, status: state.itemCount > 0 ? "ready" : "error", error: action.message };
}
case "clear":
return createSearchState();
}
}
export function hasMore(state: SearchState): boolean {
return state.status === "ready" && state.nextCursor !== null;
}
export function isExhausted(state: SearchState): boolean {
return state.status === "ready" && state.itemCount > 0 && state.nextCursor === null;
}
export function isEmpty(state: SearchState): boolean {
return state.status === "empty";
}
export function retryAction(state: SearchState): SearchAction {
if (state.itemCount > 0 && state.nextCursor !== null) return { type: "moreStart" };
return { type: "submit" };
}
export function encodeSearch(state: Pick<SearchState, "query" | "filters">): string {
const params = new URLSearchParams();
if (state.query !== "") params.set("q", state.query);
if (state.filters.type !== "all") params.set("type", state.filters.type);
if (state.filters.sort !== "relevance") params.set("sort", state.filters.sort);
if (state.filters.timeRange !== "all") params.set("time", state.filters.timeRange);
if (state.filters.tags.length > 0) params.set("tags", [...state.filters.tags].sort().join(","));
return params.toString();
}
export function decodeSearch(search: string): Pick<SearchState, "query" | "filters"> {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const tags = (params.get("tags") ?? "")
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag !== "");
return {
query: normalizeQuery(params.get("q") ?? ""),
filters: normalizeFilters({
type: pick(TYPES, params.get("type"), "all"),
sort: pick(SORTS, params.get("sort"), "relevance"),
timeRange: pick(TIMES, params.get("time"), "all"),
tags,
}),
};
}
export function restoreSearch(search: string): SearchState {
const { query, filters } = decodeSearch(search);
return createSearchState(query, filters);
}
function pick<T extends string>(allowed: T[], value: string | null, fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}
四个关键决策
条件迁移与游标重置写在同一个 case 里
query 和 filter 两个 case 都同时更新条件、revision、status、itemCount 与 nextCursor。这不是重复代码,而是把「新会话」定义成一个原子迁移。拆成两个 action 再由调用方依次 dispatch,中间任何一次渲染或副作用都可能看到「新条件 + 旧游标」的非法组合。
revision 在提交时也自增
用户点搜索按钮重试,条件没变,但仍应视为新会话。submit 自增 revision,旧请求立即作废。如果只在条件变化时自增,重试会与上一次请求共用一个版本,先发的慢响应仍可能覆盖重试结果。
URL 只放能重建第一页的条件
encodeSearch 不读 nextCursor,decodeSearch 也不产出游标。这不是遗漏,而是边界:URL 是分享与刷新用的,必须能在任何设备上从第一页重建。游标是会话内的临时位置,出了这个会话就不该被信任。
加载更多失败保留已有内容
fail 根据 itemCount 分流:首屏失败进入 error,已有内容时回到 ready 并把错误挂出来。列表不应该因为一次翻页失败而清空。重试动作也据此区分:有内容且还有游标时重试「加载更多」,否则重试首屏。
测试与真实输出
本机运行结果如下:
$ node --test --test-concurrency=1 test/search/search-state.test.ts
✔ 查询词变化重置游标与已加载条目 (0.677ms)
✔ 筛选变化重置状态,相同筛选不产生新版本 (0.856708ms)
✔ 旧版本的首页与翻页响应都被丢弃 (0.090709ms)
✔ 空结果与「没有更多」是两种状态 (0.167792ms)
✔ 加载更多累加条目,重复游标判定为耗尽 (0.078917ms)
✔ URL 只编码查询条件,不包含游标或位置 (0.161ms)
✔ URL 解码校验非法值,标签去重排序并可往返 (0.229375ms)
✔ 失败保留已有内容并给出正确的重试动作 (0.097375ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 150.054042
八条用例覆盖了条件重置、筛选幂等、旧版本丢弃、空态与耗尽、重复游标、URL 往返和失败分流。它们没有覆盖真实后端游标的过期行为,也没有覆盖浏览器前进后退;前者要靠接口契约,后者要在真实路由里验证。纯函数能证明状态迁移自洽,不能证明服务端快照一定按预期失效。
失败复盘:筛选之后仍在翻旧游标
「回声」的搜索页最初把筛选条件放在组件局部状态里,游标放在请求 Hook 里。用户搜索「相机」,加载了三页,然后勾选「全新」。筛选条件立刻变了,列表也重新请求了第一页,但 nextCursor 没有重置,仍然是第三页的游标。
用户继续下滑,客户端拿旧游标请求下一页。服务端的游标绑定的是「相机」这个查询快照,不包含「全新」条件,于是返回了一批不满足筛选的内容。用户看到列表里混进了旧货,以为筛选坏了。更隐蔽的是,重新请求第一页的响应和旧游标的翻页响应几乎同时回来,谁后到谁覆盖,列表内容在两种条件之间来回跳。
修复分三步。第一,把查询词、筛选、游标、版本号收进同一个 reducer,条件变化时原子重置。第二,首页与翻页响应都带 revision,旧会话的响应直接丢弃。第三,URL 只保留条件,返回页面时从第一页重建,不再复用旧游标。
第二个事故来自分享链接。早期 URL 里带了 cursor,用户把链接发给同事,对方打开时游标已经过期,页面显示空列表,但筛选 chips 还在。修复后 URL 只保留 q 与筛选参数,任何人打开都从第一页开始。分享的是「查询」,不是「某一次查询的中间位置」。
小结
搜索状态要按「会话」管理,而不是按「列表」管理。条件变化是新的会话,游标、条目数和状态一起重置;revision 决定响应属于哪个会话;URL 只保存能重建第一页的条件;返回时恢复条件与位置,不复用旧游标;空结果、没有更多、翻页失败三种状态分开表达。
搜索页解决了「给定条件如何稳定地翻」,但还有一类页面根本不从查询词出发:发现页把运营位、榜单、专题和普通信息流拼在一起。下一节讨论这些来源的差异,以及端上如何组合而不串位。
阅读导航:上一节:12.1 输入联想:防抖、竞态与缓存 · 下一节:12.3 发现页的推荐位与内容流的差异 。
常见错误与边界
- 查询词或筛选变化后保留旧游标,下一页混入不满足条件的内容。
- 只在首屏请求上校验版本,翻页响应绕过 revision 覆盖新条件。
- 把游标写进分享 URL,对方打开时游标过期,页面空白。
- 筛选 chips 存在组件局部状态,返回或刷新后选中态丢失。
- 用
items.length === 0同时判断空结果与没有更多,文案互相打架。 - 加载更多失败时清空列表,用户已读内容消失。
- 相同条件重试不自增版本,慢的旧响应覆盖重试结果。
- 返回页面时复用旧游标继续翻页,服务端快照已失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。