# 无限滚动 Infinite Scroll > 滑到底自动接着加载下一批,像刷短视频。 分类:导航 [全部组件和流程](https://www.dachuiai.com/llms.txt) · [导航选型规则](https://www.dachuiai.com/guides/navigation.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[导航选型规则](https://www.dachuiai.com/guides/navigation.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:随便刷着看的内容流:灵感图、动态、短内容,手机为主。 - 不适合:要对比、要回头找的列表(用分页);页面底部有重要内容(页脚永远到不了)。 - 适合的网站:内容站、电商、作品集 - 放在:列表 - 手机上:好用:手机上最顺手。 - 性能代价:低:列表最后放一个看不见的哨兵判断到没到底;几百项以上要配虚拟列表。 - 常搭配:[流光 Shimmer](https://www.dachuiai.com/c/shimmer.md)、[返回顶部 Back to Top](https://www.dachuiai.com/c/back-to-top.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react`(lucide-react 只有最后的演示示例用到;不留示例的话,删掉对应的 import 就不用装) - 文件:原样存成 `infinite-scroll.tsx`(比如 `src/components/infinite-scroll.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/infinite-scroll.tsx),比手抄准:`curl -fsSL -o src/components/infinite-scroll.tsx https://www.dachuiai.com/code/infinite-scroll.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useInfinite`,是做这个效果用的工具(hook、函数或常量);页面结构在最后的 `export default` 示例里,照着示例搭。 - 图片:示例用了本站的 [/tex/s1.jpg](https://www.dachuiai.com/tex/s1.jpg)、[/tex/s2.jpg](https://www.dachuiai.com/tex/s2.jpg)、[/tex/s3.jpg](https://www.dachuiai.com/tex/s3.jpg)、[/tex/s4.jpg](https://www.dachuiai.com/tex/s4.jpg)、[/tex/s5.jpg](https://www.dachuiai.com/tex/s5.jpg)、[/tex/s6.jpg](https://www.dachuiai.com/tex/s6.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:useInfinite(加载函数, {root, enabled, count}) 返回哨兵的 ref,把它挂在列表最后一个看不见的元素上;加载中或全部加载完时 enabled 传 false。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `load` | | 加载下一批的函数 | | `root` | | 滚动盒子的 ref;整页滚动时不传 | | `enabled` | `true` | 为 false 时暂停(加载中、全部加载完时传 false) | | `count` | `0` | 数量 | ## 你可以说 > 发现页做成 Infinite Scroll 无限滚动:滑到底自动加载下一批,加载时显示占位,全部加载完显示「没有更多了」。 ## 完整代码 ```tsx import {useEffect, useRef, useState, type RefObject} from 'react'; import {Heart} from 'lucide-react'; /* --------------------------------------------------------------- * InfiniteScroll 无限滚动 * 滑到底自动接着加载,像刷短视频。随便刷着看的内容用它; * 要对比、要回头找的列表别用(用分页)。 * 原理:列表最后放一个看不见的"哨兵",它一进入视野就加载下一批。 * 整页用时 root 不传;放在可滚动的盒子里时,传这个盒子。 * ------------------------------------------------------------- */ export function useInfinite(load: () => void, {root, enabled = true, count = 0}: {root?: RefObject; enabled?: boolean; count?: number} = {}) { const sentinel = useRef(null); const cb = useRef(load); cb.current = load; useEffect(() => { if (!enabled) return; const io = new IntersectionObserver(([e]) => e.isIntersecting && cb.current(), {root: root?.current ?? null, rootMargin: '0px 0px 200px 0px'}); if (sentinel.current) io.observe(sentinel.current); return () => io.disconnect(); }, [enabled, root, count]); // 每加载一批重新观察:哨兵仍在视野里就继续加载 return sentinel; } /* ---------------- 示例:「山野」App 的发现页 ---------------- */ const IMGS = ['/tex/s1.jpg', '/tex/s2.jpg', '/tex/s3.jpg', '/tex/s4.jpg', '/tex/s5.jpg', '/tex/s6.jpg']; const TITLES = ['湖边的清晨', '第一次搭天幕', '山顶看日出', '营地晚餐', '雨后的森林', '秋天的草甸', '带狗狗露营', '星空延时', '溪边钓鱼', '徒步 12 公里']; const NAMES = ['小林', 'Mia', '老周', 'Rita', '大熊', '阿杰']; const MAX = 30, BATCH = 6; const post = (i: number) => ({id: i, title: TITLES[i % TITLES.length], name: NAMES[i % NAMES.length], img: IMGS[(i * 5) % 6], likes: 80 + ((i * 37) % 400), tall: i % 3 === 0}); export default function InfiniteScrollDemo() { const box = useRef(null); const [items, setItems] = useState(() => Array.from({length: BATCH}, (_, i) => post(i))); const [loading, setLoading] = useState(false); const done = items.length >= MAX; const load = () => { if (loading || done) return; setLoading(true); setTimeout(() => { setItems((it) => [...it, ...Array.from({length: BATCH}, (_, k) => post(it.length + k))]); setLoading(false); }, 700); }; const sentinel = useInfinite(load, {root: box, enabled: !done && !loading, count: items.length}); const cols = [items.filter((_, i) => i % 2 === 0), items.filter((_, i) => i % 2 === 1)]; return (

发现

{cols.map((col, c) => (
{col.map((p) => (

{p.title}

{p.name} {p.likes}
))} {loading &&
}
))}

{done ? '没有更多了' : loading ? '加载中…' : ''}

); } ```