Ponytail 实战:用 IntersectionObserver 哨兵把无限滚动依赖降到零
本文以 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.
逐段拆解这段代码的机制:
- 哨兵元素:列表末尾的
<div ref={sentinel} />本身没有任何可见内容,它的唯一职责是占据列表末尾的位置,作为"是否已滚到底"的探针。 IntersectionObserver只关心相交,不关心滚动事件:回调仅在哨兵进入/离开视口时被浏览器调用,不需要scroll监听、不需要节流(throttling)、不存在高频事件带来的卡顿。这正是文档给出的三个否定式理由——"no scroll event, no throttling, no jank"。- 触发条件:
entry.isIntersecting && hasMore保证只有哨兵可见且确实还有数据时才调用fetchMore()。 - 生命周期管理:
useEffect返回observer.disconnect()作为清理函数,组件卸载时断开观察,避免泄漏。 - 依赖数组
[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,滚动监听从有到无——"最好的代码是你从未写出的代码",在这里具体化为"最好的依赖是你从未安装的依赖"。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00