首页
/ Ponytail 实战:用 IntersectionObserver 哨兵把无限滚动依赖降到零

Ponytail 实战:用 IntersectionObserver 哨兵把无限滚动依赖降到零

2026-09-04 15:59:32作者:瞿蔚英Wynne

本文以 examples/infinite-scroll.md 为例,拆解 ponytail 技能面对「用户滚动到底部时加载更多数据」这一常见 React 需求时的完整决策过程:基线方案为何引入 react-infinite-scroll-component,ponytail 版本如何用浏览器原生的 IntersectionObserver + 哨兵元素实现同样效果,以及这一选择背后所依据的「决策阶梯」规则(定义在 skills/ponytail/SKILL.md)。读完后,你既能直接拿走一段可运行的零依赖无限滚动组件,也能理解 ponytail 判定「原生平台能力优先于第三方依赖」的规则来源与适用边界。

任务来源与对照方法

该示例对应的原始任务只有一句话:

"Load more items when the user scrolls to the bottom."(用户滚动到底部时加载更多条目。)

examples/ 目录下的所有示例都不是手写的,而是基准测试中同一模型、同一提示词、两种配置下的逐字输出对照:无技能(## Without Ponytail)与启用 ponytail(## With Ponytail)。按 examples/README.md 的说明,模型为 Claude Haiku 4.5、temperature 1,数据来源是 benchmarks/output.json;这些文件由 benchmarks/generate-examples.mjs 从真实基准运行结果中自动生成。复现命令(来自 benchmarks/README.md,需要 Anthropic API key 与 Node.js ≥ 22.22.0):

npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml

下文两节代码均为该次基准运行的模型原话,未做改写。

基线方案:安装一个监听滚动位置的依赖

无 ponytail 时的输出:

npm install react-infinite-scroll-component
import InfiniteScroll from "react-infinite-scroll-component";

export function Feed({ items, fetchMore, hasMore }) {
  return (
    <InfiniteScroll
      dataLength={items.length}
      next={fetchMore}
      hasMore={hasMore}
      loader={<Spinner />}
      endMessage={<p>No more items</p>}
      scrollThreshold={0.9}
    >
      {items.map(item => <Card key={item.id} item={item} />)}
    </InfiniteScroll>
  );
}

文档对基线方案的定性很直接:"A dependency to watch scroll position and fire a callback."(一个用来监听滚动位置并触发回调的依赖。)

这个组件的功能确实覆盖了任务需求:dataLength 传入当前数据量、next 指定加载函数、scrollThreshold={0.9} 表示滚动到 90% 处即触发。但代价是:为「滚动到某位置执行一个回调」这一件事引入一个包,以及它背后的维护、体积与版本成本。

Ponytail 版本:IntersectionObserver + 哨兵元素

启用 ponytail 后的完整输出:

// ponytail: IntersectionObserver does this, no scroll listener needed
import { useEffect, useRef } from "react";

export function Feed({ items, fetchMore, hasMore }) {
  const sentinel = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && hasMore) fetchMore();
    });
    if (sentinel.current) observer.observe(sentinel.current);
    return () => observer.disconnect();
  }, [hasMore, fetchMore]);

  return (
    <>
      {items.map(item => <Card key={item.id} item={item} />)}
      <div ref={sentinel} />
    </>
  );
}

结论一句话:1 dependency → 0 dependencies.

逐段拆解这段代码的机制:

  1. 哨兵元素:列表末尾的 <div ref={sentinel} /> 本身没有任何可见内容,它的唯一职责是占据列表末尾的位置,作为"是否已滚到底"的探针。
  2. IntersectionObserver 只关心相交,不关心滚动事件:回调仅在哨兵进入/离开视口时被浏览器调用,不需要 scroll 监听、不需要节流(throttling)、不存在高频事件带来的卡顿。这正是文档给出的三个否定式理由——"no scroll event, no throttling, no jank"。
  3. 触发条件:entry.isIntersecting && hasMore 保证只有哨兵可见且确实还有数据时才调用 fetchMore()
  4. 生命周期管理:useEffect 返回 observer.disconnect() 作为清理函数,组件卸载时断开观察,避免泄漏。
  5. 依赖数组 [hasMore, fetchMore]:两者任一变化时重建观察者,保证回调闭包拿到最新的 hasMore 值。

文档还点破了依赖库的本质:"The library wraps exactly this API."——react-infinite-scroll-component 内部封装的正是 IntersectionObserver 这套能力,包的价值主要是 API 糖,而不是额外功能。

为什么这条路径成立:从 scroll 监听说起

对比两条技术路线,可以更清楚 ponytail 方案的工程优势:

维度 scroll 监听方案 IntersectionObserver 方案
触发机制 每次滚动都派发事件,需自行节流/防抖 仅在目标元素与视口相交状态变化时回调
性能特征 高频事件,JS 层承担调度成本 浏览器底层批处理,与渲染管线协同
代码量 需记录滚动位置、计算阈值、防抖计时器 声明式观察,一个回调
跨容器场景 需区分窗口滚动与容器滚动 通过 root 选项直接指定滚动容器

此外,基线方案里的 scrollThreshold={0.9}(提前 10% 触发)在 IntersectionObserver 中有对应的原生写法:构造参数里的 rootMargin(例如 rootMargin: "200px 0px 0px 0px" 表示哨兵距视口底部还有 200px 时就判定为"相交")。也就是说,第三方组件暴露的每个常用参数,在原生 API 层都有等价物——这也是"库只是在这套 API 上包了一层"这一判断的由来。

需要说明的适用前提:该方案依赖 IntersectionObserver 的浏览器支持,文档表述为 "Ships in every browser"(所有浏览器均已内置)。哨兵方案要求列表本身可滚动或被观察元素能进入视口;如果整个页面不可滚动(例如数据一次性全部渲染),哨兵会一直处于相交状态,触发逻辑就退化为"挂载即触发"。

规则溯源:ponytail 的决策阶梯

这段代码不是模型随机挑了个"更短"的写法,而是 skills/ponytail/SKILL.md 中定义的阶梯(ladder)跑完后的产物。该技能的核心设定是"lazy senior dev"——lazy 指高效而非草率,信条是 "The best code is the code never written"(最好的代码是你从未写出的代码)。写代码前,agent 停在第一个成立的梯级:

1. 这件事需要存在吗?        → 不需要就跳过(YAGNI)
2. 本代码库已有?           → 复用,不要重写
3. 标准库能做?             → 用它
4. 原生平台能力能覆盖?      → 用它
5. 已安装的依赖能解决?      → 用它,绝不为几行代码新增依赖
6. 能一行解决?             → 一行
7. 然后才是:能工作的最小代码

对照本例:无限滚动这个功能显然需要存在(第 1 级不成立),而 IntersectionObserver 恰好命中第 4 级"原生平台能力"(与技能文档中 <input type="date"> 优先于日期选择器库、CSS 优先于 JS 属同一类判断),于是流程直接停在第 4 级,不再进入"写最小自定义代码"。技能文档同时明确:阶梯是在理解问题之后运行的,不是用来跳过读代码的——"Lazy about the solution, never about reading"(对方案可以懒,对理解绝不懒)。

ponytail: 注释:刻意的简化要留下记号

With 版本代码的第一行 // ponytail: IntersectionObserver does this, no scroll listener needed 并非装饰。技能规则要求:凡是有意识地砍掉了一个真实边角、存在已知上限的简化,必须用 ponytail: 注释标出上限与升级路径(例如 # ponytail: global lock, per-account locks if throughput matters)。本例中它的作用是向未来的读者交代:这里选择原生 API 是刻意为之,以及当初为什么不需要那个监听库——日后若产品提出"回到顶部""加载失败重试"等超出哨兵模式的需求,维护者能立刻知道当初的决策边界在哪里。仓库中同系列的 examples/url-params.md(URLSearchParams 替代 query-string)和 examples/modal-dialog.md(原生 <dialog> 替代 Radix Dialog)都遵循同一模式:原生能力命中阶梯第 4 级,依赖归零,并留下 ponytail: 注释。

真实接入时的补充边界

原示例是基准任务的直接输出,面向的是最小可用形态。从源码结构看,若把它搬进真实项目,有两处值得注意(这是基于该实现的工程推断,而非文档承诺):

  • 在途请求保护:IntersectionObserver 回调可能在 fetchMore() 尚未返回时再次触发(哨兵未离开视口)。hasMore 只能在数据耗尽后拦截,无法拦截"上一批还在飞行中"的重复请求;真实列表通常再加一个 loading 状态位。
  • 回调身份:fetchMore 若每次渲染都重新创建,依赖数组 [hasMore, fetchMore] 会导致观察者在每次渲染后重建。稳定引用(useCallback)或把 fetchMore 放进 ref 都可以避免。

这两点不改变"0 依赖"的结论,它们属于 ponytail 阶梯第 7 级"能工作的最小代码"内部的质量细节,与是否引入第三方库无关。

如何验证:复现这份对照

想亲眼看到"同一模型、有无技能"的差异,而不是读二手描述,可按 benchmarks/README.md 的流程执行:

cp ../.env.example .env      # 填入 ANTHROPIC_API_KEY
npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml --env-file ../.env --repeat 10
npx promptfoo@latest view

--env-file ../.env 是必需的,因为 promptfoo 从 benchmarks/ 当前目录读取 .env,而不是仓库根目录。基准还内置正确性门禁 correctness.js(运行生成代码并断言)与行数度量 loc.js,即"行数漂亮但代码跑不通"的输出会被门禁拦下;React 类任务由于只做结构性检查而非运行时执行,验证强度弱于可执行类任务——引用该基准数字时应带上这层前提。

小结

infinite-scroll.md 的价值不在于那 20 行 IntersectionObserver 代码本身,而在于它完整呈现了 ponytail 的工作方式:先用决策阶梯排除第 1~3 级,命中第 4 级"原生平台能力",用一个哨兵元素和一段浏览器内置 API 替换整个 react-infinite-scroll-component 依赖,并用 ponytail: 注释把简化决策的边界留给未来的维护者。依赖从 1 到 0,滚动监听从有到无——"最好的代码是你从未写出的代码",在这里具体化为"最好的依赖是你从未安装的依赖"。

登录后查看全文
热门项目推荐
相关项目推荐