跳转至

无限滚动(游标加载列表)

信息流的形状:不给页码,让可见窗口一页页往下长 —— 游标记着上一页停在哪,「加载更多」按钮和滚动近底自动触发各走一条路。数据只管顺着看、页码没有意义(动态、日志、消息),就是它。

试这几下:

  • 加载更多,或直接把列表滚到近底 —— 两条路都翻页,底部先出骨架行(模拟 400ms 延迟)
  • 看「已加载 x / 共 y」一路涨到头 —— 到底出现明确的没有更多了
  • 搜索姓名或部门 —— 游标重置、从头加载;快速改词也不乱序(过期响应按请求序号丢弃)
  • 勾上模拟下次加载失败再翻页 —— 自动触发暂停,出现错误提示和重试按钮

规矩

  • 「加载更多」按钮永远是主路径:滚动近底自动触发(IntersectionObserver 观察底部哨兵)只是增强,坏了也能点。demo 里两者共用同一个 loadMore 入口,加载中/出错/到底时统一拦截
  • 进度与终点要说清:始终显示「已加载 x / 共 y」,到底给明确完结态,不能让按钮悄悄消失
  • 远程形态的 loading 和 error 都要显式:下一页加载给骨架行 + 按钮置灰;失败暂停自动触发、给重试按钮(否则滚动会无限重试)。demo 用 400ms 定时器模拟,真实版换 TanStack Query 的 useInfiniteQueryisFetchingNextPage / error + refetch
  • 游标保证不重不漏:demo 的游标 = 上一页最后一行 id,「服务端」恒按 id 升序切片,客户端追加前再按 id 去重兜底;真实版把游标交给 getNextPageParam,搜索/筛选放 URL,一变就重置游标从第一页拉起

蓝本:add-infinite-list.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-infinite-list.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:无限滚动</title>
<!--
  demo-infinite-list.html —— 「无限滚动」形状的最小可玩实现(蓝本:open-dashboard 的 add-infinite-list + list-infinite.tsx 模板)。
  复现的不变量:
  - 「加载更多」按钮永远可用——滚动近底自动触发(IntersectionObserver 观察底部哨兵)只是增强,不是唯一路径
  - 始终显示「已加载 x / 共 y」,到底后有明确的完结态
  - 远程形态的 loading(下一页骨架行)与 error(暂停自动触发 + 重试按钮)都显式处理
  - 游标 = 上一页最后一行 id,顺序恒按 id 升序、追加前去重;搜索变化即重置游标从头加载,过期响应按请求序号丢弃
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .feed { height: 350px; overflow: auto; }
  .rowitem { display: flex; align-items: center; gap: 10px; padding: 9px 12px;
             border-bottom: 1px solid var(--border); }
  .avatar { flex: none; width: 30px; height: 30px; border-radius: 999px;
            background: var(--chip-off-bg); color: var(--chip-off-fg);
            display: flex; align-items: center; justify-content: center; font-size: 13px; }
  .meta { min-width: 0; flex: 1; }
  .meta p { margin: 0; }
  .meta .n { font-weight: 550; font-size: 13px; }
  .meta .e { color: var(--muted); font-size: 12px; }
  .idno { color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; }
  .loadrow { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; }
  .errwrap { display: inline-flex; align-items: center; gap: 10px; color: var(--danger); font-size: 13px; }
  .endtext { color: var(--muted); font-size: 13px; }
  .sentinel { height: 1px; }
  .failbox { display: flex; align-items: center; gap: 6px; font-size: 12px;
             color: var(--muted); white-space: nowrap; cursor: pointer; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useEffect, useRef } = React;
const html = htm.bind(React.createElement);

const PAGE = 10;      // 每页条数
const LATENCY = 400;  // 模拟网络延迟(ms)

const FAMILY = ["林", "沈", "顾", "陆", "叶", "秦", "江", "苏", "孟", "裴", "温", "霍"];
const FAMILY_PY = ["lin", "shen", "gu", "lu", "ye", "qin", "jiang", "su", "meng", "pei", "wen", "huo"];
const GIVEN = ["之远", "照澜", "轻舟", "望舒", "南絮", "知微", "晚晴", "既白", "疏影", "青梧"];
const GIVEN_PY = ["zhiyuan", "zhaolan", "qingzhou", "wangshu", "nanxu", "zhiwei", "wanqing", "jibai", "shuying", "qingwu"];
const DEPTS = ["研发", "设计", "运营", "市场", "数据", "客服"];

// 确定性的 57 条数据:id 即插入顺序,也是排序与游标的依据
const DB = Array.from({ length: 57 }, (_, i) => ({
  id: i + 1,
  name: FAMILY[i % FAMILY.length] + GIVEN[i % GIVEN.length],
  email: FAMILY_PY[i % 12] + "." + GIVEN_PY[i % 10] + (i + 1) + "@corp.cn",
  dept: DEPTS[i % DEPTS.length],
}));

// —— 模拟「服务端」:游标式列表接口 ——
// 游标 = 上一页最后一行的 id;数据恒按 id 升序,同一 (search, cursor) 永远返回
// 同一页,翻页天然不重复、不漏行。真实版把这段换成服务端查询:
//   WHERE id > :cursor ORDER BY id LIMIT :pageSize
function fetchPage({ cursor, search, fail }) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (fail) { reject(new Error("网络错误")); return; }
      const raw = search.trim();
      const kw = raw.toLowerCase();
      const matched = DB.filter(m =>
        !raw || m.name.includes(raw) || m.dept.includes(raw) || m.email.includes(kw));
      const start = cursor == null ? 0 : matched.findIndex(m => m.id > cursor);
      const items = start === -1 ? [] : matched.slice(start, start + PAGE);
      resolve({
        items,
        nextCursor: items.length ? items[items.length - 1].id : cursor,
        hasMore: start !== -1 && start + items.length < matched.length,
        total: matched.length,
      });
    }, LATENCY);
  });
}

const SkeletonRows = () => html`
  <div>
    ${[0, 1, 2].map(i => html`<div key=${i} class="skel" style=${{ width: (78 - i * 14) + "%" }}></div>`)}
  </div>`;

function App() {
  const [q, setQ] = useState("");
  const [items, setItems] = useState([]);
  const [cursor, setCursor] = useState(null);
  const [total, setTotal] = useState(null);
  const [hasMore, setHasMore] = useState(true);
  const [status, setStatus] = useState("loading"); // "loading" | "idle" | "error"
  const [failNext, setFailNext] = useState(false);
  const reqSeq = useRef(0);
  const firstRun = useRef(true);
  const listRef = useRef(null);
  const sentinelRef = useRef(null);

  // 发起一页请求;reset=true 表示(搜索变化后)重置游标从头加载
  function loadPage(cur, reset) {
    const seq = ++reqSeq.current;   // 请求序号:旧响应晚到直接丢弃,列表顺序不被打乱
    const fail = failNext;
    if (fail) setFailNext(false);   // 失败开关一次性消费,点「重试」即可成功
    setStatus("loading");
    if (reset) { setItems([]); setCursor(null); setHasMore(true); setTotal(null); }
    fetchPage({ cursor: cur, search: q, fail })
      .then(res => {
        if (seq !== reqSeq.current) return; // 过期响应,丢弃
        setItems(prev => {
          const base = reset ? [] : prev;
          const seen = new Set(base.map(m => m.id)); // 追加前按 id 去重兜底
          return [...base, ...res.items.filter(m => !seen.has(m.id))];
        });
        setCursor(res.nextCursor);
        setHasMore(res.hasMore);
        setTotal(res.total);
        setStatus("idle");
      })
      .catch(() => { if (seq === reqSeq.current) setStatus("error"); });
  }

  // 「加载更多」:按钮和滚动触发共用的唯一入口;出错时暂停自动触发,只留重试
  function loadMore() {
    if (status !== "idle" || !hasMore) return;
    loadPage(cursor, false);
  }

  // 搜索:重置游标、从头加载;300ms 防抖,首帧立即加载
  const loadRef = useRef(null);
  loadRef.current = loadPage;
  useEffect(() => {
    const delay = firstRun.current ? 0 : 300;
    firstRun.current = false;
    const t = setTimeout(() => loadRef.current(null, true), delay);
    return () => clearTimeout(t);
  }, [q]);

  // 滚动近底自动触发(增强路径):观察列表底部哨兵,提前 120px 预取
  const moreRef = useRef(null);
  moreRef.current = loadMore;
  useEffect(() => {
    const io = new IntersectionObserver(
      entries => { if (entries[0].isIntersecting) moreRef.current(); },
      { root: listRef.current, rootMargin: "0px 0px 120px 0px" });
    io.observe(sentinelRef.current);
    return () => io.disconnect();
  }, []);

  const showEmpty = status === "idle" && items.length === 0;
  const showEnd = status === "idle" && !hasMore && items.length > 0;

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>成员目录(无限滚动)</h1>
          <p class="sub">点「加载更多」或滚到列表近底自动取下一页;搜索会重置游标从头加载</p>
        </div>
      </header>

      <div class="toolbar">
        <input class="grow" placeholder="搜索姓名、部门或邮箱…" value=${q}
               onChange=${e => setQ(e.target.value)} />
        <label class="failbox">
          <input type="checkbox" checked=${failNext}
                 onChange=${e => setFailNext(e.target.checked)} />
          模拟下次加载失败
        </label>
      </div>

      <div class="card feed" ref=${listRef}>
        ${items.map(m => html`
          <div key=${m.id} class="rowitem">
            <div class="avatar">${m.name[0]}</div>
            <div class="meta">
              <p class="n"><span class="idno">#${m.id}</span> ${m.name}</p>
              <p class="e">${m.email}</p>
            </div>
            <span class="chip chip-off">${m.dept}</span>
          </div>`)}

        ${status === "loading" && html`<${SkeletonRows} />`}
        ${showEmpty && html`<div class="empty">没有匹配的成员</div>`}

        <div class="loadrow">
          ${status === "error" && html`
            <span class="errwrap">
              加载失败:网络错误
              <button class="btn sm" onClick=${() => loadPage(cursor, false)}>重试</button>
            </span>`}
          ${status !== "error" && hasMore && items.length > 0 && html`
            <button class="btn" disabled=${status === "loading"} onClick=${loadMore}>
              ${status === "loading" ? "加载中…" : "加载更多"}
            </button>`}
          ${showEnd && html`<span class="endtext">✓ 没有更多了 · 共 ${total} 条已全部加载</span>`}
        </div>
        <div class="sentinel" ref=${sentinelRef}></div>
      </div>

      <footer class="pager">
        <span>已加载 ${items.length} / 共 ${total == null ? "…" : total} 条</span>
        <span>游标 = 最后一行 id:${cursor == null ? "—" : "#" + cursor}</span>
      </footer>
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>