我最近改一个 Taro 小程序的列表页。页面本来就有分页:首次请求 20 条,滚动到底部后继续请求下一页。问题出在“还有没有下一页”的判断上。
最初的代码只看本次返回数量:
const hasMore = pageItems.length === pageSize;这段判断大多数时候能用,但边界不准。如果数据总数正好是 20、40 或 60,最后一页仍然返回完整的 20 条,客户端会认为后面还有一页。用户再次滚动时,才会发出一次注定返回空数组的请求。
页面上的“共多少条”也有同样的问题。用当前已加载数量代替总数,第一屏只能显示 20,翻页后又变成 40。它表达的是加载进度,不是数据总量。
第一反应:让列表接口返回 total
很自然的改法是把列表响应改成:
{
"items": [],
"total": 137
}客户端拿到 total 后,判断就准确了:
const hasMore = loadedItems.length < total;这样确实消除了额外的空页请求,也能显示真实总数。不过我很快发现,这个设计把另一个成本藏进了每次翻页。
为了返回 total,服务端通常要执行一次 COUNT。列表请求第一页时查一次,第二页再查一次,之后每一页都要重新查。后台管理系统的访问量不大,这点开销常常可以接受;面向大量用户的前台列表则不同。用户只是继续取下一批数据,总数并没有因为排序或页码发生变化,却仍然为同一个筛选条件重复计数。
省掉一次偶发的空页请求,不应该换来每页一次固定的 COUNT。
把列表和总数拆成两个接口
最后我保留原来的分页列表接口,另外增加一个只返回总数的接口:
GET /api/items?page=1&limit=20&type=SELL&keyword=camera&sortBy=NEWEST
GET /api/items/count?type=SELL&keyword=camera列表接口只负责取当前页:
app.get("/api/items", async (context) => {
const { page, limit, type, keyword, sortBy } = context.req.valid("query");
const offset = (page - 1) * limit;
const conditions = getActiveItemConditions(type, keyword);
const rows = await db.query.items.findMany({
where: and(...conditions),
orderBy: sortBy === "OLDEST"
? asc(itemTable.createdAt)
: desc(itemTable.createdAt),
limit,
offset,
});
return context.json(rows);
});总数接口复用完全相同的筛选条件,但不接收页码和排序:
app.get("/api/items/count", async (context) => {
const { type, keyword } = context.req.valid("query");
const conditions = getActiveItemConditions(type, keyword);
const [{ total }] = await db
.select({ total: count(itemTable.id) })
.from(itemTable)
.where(and(...conditions));
return context.json({ total });
});这里最容易出错的是筛选条件。如果列表和计数各写一套 where,后面新增状态、分类或关键词规则时,很容易只改其中一个。最终就会出现“页面能看到 18 条,标题却写着 21 条”的情况。将筛选条件提成共享函数,比在两个路由里复制判断稳妥得多。
客户端什么时候请求总数
拆开接口后,客户端不应在每次列表请求旁边顺手调用 count。总数的请求时机由筛选条件决定,而不是由页码决定。
例如一个列表有分类、关键词和排序,可以把缓存键分成两层:
const filterKey = `${activeType}_${appliedKeyword}`;
const listKey = `${filterKey}_${sortBy}`;filterKey 对应数据集合,listKey 对应这个集合的排列方式。改变排序只会调整顺序,不会改变总数,因此可以继续复用同一份计数。
useEffect(() => {
let cancelled = false;
async function loadTotal() {
const response = await api.getItemCount({
type: activeType,
keyword: appliedKeyword,
});
if (!cancelled) {
setTotalMap((current) => ({
...current,
[filterKey]: response.total,
}));
}
}
void loadTotal();
return () => {
cancelled = true;
};
}, [activeType, appliedKeyword, filterKey]);翻页只请求列表。切换新旧排序也只刷新列表。分类或实际生效的搜索词改变时,才为新的数据集合请求一次总数。
有了总数以后,加载更多的判断也不再依赖“这一页是否刚好装满”:
const total = totalMap[filterKey];
const hasMore = total == null
? pageItems.length === pageSize
: loadedItems.length < total;如果计数请求尚未完成,可以暂时沿用旧判断,不必阻塞首屏列表。计数返回后,再切换到准确判断。
为什么排序不应该触发 COUNT
这点看似简单,写状态时却容易混在一起。假设用户查看同一分类下的商品:
- 从“最新发布”切换到“最早发布”,集合没变,只是顺序变了。
- 从“出售”切换到“求购”,集合变了,需要重新计数。
- 修改搜索词,集合也变了,需要重新计数。
- 从第一页滚动到第二页,集合没变,不需要重新计数。
因此,列表缓存可以包含 sortBy,总数缓存不要包含。把两种键分开以后,请求时机也会清楚很多。
接口兼容与发布顺序
给已有列表接口增加字段,有时会迫使服务端同时保留数组响应和对象响应,客户端也要判断两种结构。独立的计数接口是新增能力,不改变旧列表响应,老客户端仍然按原方式分页。
这类改动适合先发布服务端,再发布客户端:
- 新服务端上线,旧客户端继续调用原列表接口。
- 新客户端上线,开始额外调用计数接口。
- 列表接口始终保持单一响应结构,不需要临时分支。
兼容代码如果确实不可避免,我会在代码旁留下清理 TODO。以后再次修改这个接口时,只要确认兼容分支不是紧邻的上一次更新新增,就应删除它。用固定版本号做清理条件,往往会因为发布节奏变化而失效。
COUNT 仍然不是免费的
拆成单独接口只能避免重复计算,不能让计数本身变快。数据量继续增长后,还要看真实查询:
- 状态、分类等固定筛选字段是否有合适索引。
- 关键词是否使用了前后通配符,导致普通索引无法发挥作用。
- 同一筛选条件是否值得做短期缓存。
- 产品是否真的需要精确总数,还是“还有更多”已经足够。
如果列表数据变化很频繁,首次取得的总数也可能在用户浏览期间过期。对大多数前台页面来说,这种短暂误差比每次翻页重新 COUNT 更容易接受。如果业务要求实时准确,可以在主动刷新时更新总数,或者给计数缓存加较短的有效期。
这次改造让我重新区分了两件事:分页是在取一段数据,总数是在描述整个集合。两者使用相同筛选条件,但请求频率并不相同。把它们绑在同一个接口里很方便,拆开以后才更符合前台流量的实际成本。