前台分页接口为什么要拆开列表与总数

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
TaroAPI 设计分页性能优化

我最近改一个 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,总数缓存不要包含。把两种键分开以后,请求时机也会清楚很多。

接口兼容与发布顺序

给已有列表接口增加字段,有时会迫使服务端同时保留数组响应和对象响应,客户端也要判断两种结构。独立的计数接口是新增能力,不改变旧列表响应,老客户端仍然按原方式分页。

这类改动适合先发布服务端,再发布客户端:

  1. 新服务端上线,旧客户端继续调用原列表接口。
  2. 新客户端上线,开始额外调用计数接口。
  3. 列表接口始终保持单一响应结构,不需要临时分支。

兼容代码如果确实不可避免,我会在代码旁留下清理 TODO。以后再次修改这个接口时,只要确认兼容分支不是紧邻的上一次更新新增,就应删除它。用固定版本号做清理条件,往往会因为发布节奏变化而失效。

COUNT 仍然不是免费的

拆成单独接口只能避免重复计算,不能让计数本身变快。数据量继续增长后,还要看真实查询:

  • 状态、分类等固定筛选字段是否有合适索引。
  • 关键词是否使用了前后通配符,导致普通索引无法发挥作用。
  • 同一筛选条件是否值得做短期缓存。
  • 产品是否真的需要精确总数,还是“还有更多”已经足够。

如果列表数据变化很频繁,首次取得的总数也可能在用户浏览期间过期。对大多数前台页面来说,这种短暂误差比每次翻页重新 COUNT 更容易接受。如果业务要求实时准确,可以在主动刷新时更新总数,或者给计数缓存加较短的有效期。

这次改造让我重新区分了两件事:分页是在取一段数据,总数是在描述整个集合。两者使用相同筛选条件,但请求频率并不相同。把它们绑在同一个接口里很方便,拆开以后才更符合前台流量的实际成本。