Let’s charge into Question 3, which is a heavy favorite for remote teams dealing with data-heavy dashboards, social media feeds, or e-commerce storefronts.
It tests your ability to handle large lists of data efficiently without melting the user's browser, and it deeply examines your mastery of modern browser utility Web APIs instead of relying on heavy third-party npm packages.
Interviewer Statement of Requirements:
Imagine we are building a product discovery page for an e-commerce platform. The backend provides a paginated API endpoint:
/api/products?page=[number], which returns an array of 10 items per page, alongside a boolean flaghasMore.Please write a React client component that renders this list of products. As the user scrolls down the page and approaches the bottom, the component must automatically fetch and append the next page of data seamlessly without making the user click a 'Load More' button.
The Initial Constraints / 初始硬性限制:
react-infinite-scroll-component or react-window.window.onscroll event, as it severely damages frame rates and causes mobile browser jank. You must use the modern browser Intersection Observer API.
Before looking at the syntax, how does an Intersection Observer infinite scroll actually work conceptually?
You place a tiny, invisible "sentinel" element (like a spinner or a blank <div>) at the very bottom of your list, right below your last product card. You tell the browser to watch that element. When that element intersects with the viewport (meaning the user scrolled far enough to see it), you fire the function to load the next page.
English Thought Process:
I need to maintain an array of items in state, alongside a
pagenumber counter and aloadingflag. I will map through the items to render them. At the bottom of the list, I will insert adivelement acting as our 'sentinel'. I will create auseEffectthat instantiates anIntersection Observer. I'll pass a callback to it: ifentries[0].isIntersectingis true and we aren't already loading data, I will increment the page count to trigger a fetch. Finally, I must ensure I disconnect the observer in the cleanup function.
sentinel:/ˈsentənəl/,哨兵。
中文思考路径:
“首先我需要在 state 里维护一个数据列表数组、一个当前页码
page计数器以及一个加载状态loading标志。我用.map()渲染所有商品数据。在列表的最底部,我塞入一个空<div>作为我们的‘哨兵(Sentinel)’。” “接着我写一个useEffect来实例化浏览器的IntersectionObserver。给它传一个回调函数:如果发现entries[0].isIntersecting为真(代表哨兵出现在屏幕中了),且当前没有处于加载状态,我就把页码加一,从而触发网络请求。最后,必须在清除函数里把这个监听断开(disconnect),防止内存泄漏。”
IntersectionObserver(交叉观察器) 是浏览器原生提供的一个 API,用来监听某个元素是否进入了用户的可视区域(或者另一个父元素的区域)。
简单来说,它就像一个“看门狗”,当被监视的元素在屏幕上“露头”或者“完全消失”时,它就会立刻通知你。
1. 为什么需要它?(解决什么痛点)
在它出现之前,如果你想判断一个元素是否可见(比如滚动到下面才加载图片),必须去监听页面的
scroll事件,然后频繁调用getBoundingClientRect()去计算位置。
- 旧做法的缺点:
scroll事件触发得极快(一秒几十次),每次计算位置都会强迫浏览器重新计算布局(导致重排/回流 Reflow),非常消耗性能,极易导致页面卡顿。- IntersectionObserver 的优势:它是异步且由浏览器底层优化的。只有当目标元素真正穿过可视区域的边界时才会触发回调,性能极高,完全不卡顿。
2. 最常用的 3 大应用场景
- 图片懒加载 (Lazy Loading):页面刚打开时,只加载屏幕内的图片。当底部的图片滚动到快要显示时,再给它赋真实的
src地址。- 无限滚动 / 瀑布流 (Infinite Scroll):在列表最底部放一个透明的“加载中”元素。一旦这个元素滚进屏幕,就说明用户滑到底了,立刻自动发起网络请求加载下一页数据。
- 触底埋点 / 广告曝光统计:用来统计某个广告位或者内容是否真正被用户看到了(比如在屏幕中停留超过 50% 的面积,就触发一次曝光埋点上报)。
3. 最简核心代码示例
它的用法非常固定,只需三步:
x// 这个应该是 图片懒加载 的代码,因为使用了 entries.forEach 这样的代码,让图片都加上 id="loading-more",然后探测到图片进入屏幕之后,就给 src 赋值。// 1. 定义当“看到”或“看不到”元素时的动作const observer = new IntersectionObserver((entries) => {entries.forEach(entry => {if (entry.isIntersecting) {console.log('目标元素进入视野了!');// 可以在这里加载图片、或者加载下一页// 如果是一次性动作(如懒加载),完事后可以取消观察// observer.unobserve(entry.target);} else {console.log('目标元素离开视野了!');}});});// 2. 获取你想要监视的 DOM 元素const target = document.querySelector('#loading-more');// 3. 开始观察observer.observe(target);总结
面试时一句话概括:
“IntersectionObserver 是浏览器原生的元素可见性监听 API。它彻底替代了传统靠监听
scroll事件和计算位置的低效做法,主要用于图片懒加载、数据无限滚动和广告曝光统计,性能非常好。”
Don't let the native API scare you. Try to lay down the state architecture and the list mapping first.
Show me your first-pass code structure or talk me through your implementation steps in English or Chinese!
No worries at all! Handcrafting an infinite scroll list with the Intersection Observer API is one of those tasks that sounds intimidating at first, but becomes incredibly elegant once you see the pattern.
Let's build this together step-by-step using our Bilingual (中英双语) approach.
First, we need to declare our states to track the list data, the current page number, and whether we are currently fetching data or if we have reached the absolute end of the backend database.
English Thought Process:
First, I need states for
items(an empty array initially),page(starting at 1),loading(false), andhasMore(true). I'll map over the items to render them on screen, and append a specific emptydivat the very bottom with a ReactuseRefattached to it. Thisdivwill be our sentinel.
中文思考路径:
“第一步,我需要定义几个核心状态:
items(初始化为空数组)、page(从第1页开始)、loading(加载状态)以及hasMore(用来判断后端是不是还有数据)。我用.map()渲染产品列表,并在列表的最末尾放一个空的div,用useRef钩子绑住它。这个div就是我们的‘哨兵’。”
xxxxxxxxxx'use client';import { useState, useEffect, useRef } from 'react';export default function InfiniteScrollList() { const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); // 🌟 The pointer to our bottom element / 绑定底部哨兵元素的指针 const sentinelRef = useRef(null); // Todo: Fetch data when "page" changes / 稍后在这里监听页码发请求 return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-xl font-bold mb-4">Product Catalog</h1> {/* Render current list items / 渲染当前的商品列表 */} <div className="space-y-3"> {items.map((item, index) => ( <div key={index} className="p-4 bg-white border rounded shadow text-black"> 📦 {item.name} (ID: {item.id}) </div> ))} </div> {/* 🌟 THE SENTINEL ELEMENT: The observer watches this! */} {/* 哨兵元素:浏览器看的就是它!只要它露面,就代表该加载下一页了 */} <div ref={sentinelRef} className="h-10 flex items-center justify-center mt-4"> {loading && <p className="text-gray-500">Loading more products</p>} {!hasMore && <p className="text-gray-400 text-sm">No more products to show.</p>} </div> </div> );}
Now, we introduce the IntersectionObserver. We tell the browser: "Watch my sentinelRef. If the sentinel enters the screen, and we aren't currently loading anything, and there's more data to fetch, increment the page number by 1."
English Thought Process:
"Next, I'll write a
useEffectto instantiate theIntersectionObserver. In its callback, ifentry.isIntersectingis true, it means the sentinel is visible. If we aren't loading andhasMoreis true, I will triggersetPage(prev => prev + 1). Crucially, I will runobserver.disconnect()in the cleanup to avoid memory leaks."
中文思考路径:
“第二步,写一个
useEffect来实例化原生的IntersectionObserver。在回调函数里判断:如果entry.isIntersecting为真,说明用户已经滚到底部看到哨兵了。只要当前没有在加载,且后端还有数据,我就让页码自动加一:setPage(prev => prev + 1)。最关键的是,在清除函数里执行observer.disconnect()断开监听,防止页面卡死或内存泄漏。”
xxxxxxxxxx // Synchronous gatekeeper to completely eliminate multi-trigger bugs // 引入毫秒级同步锁,彻底卡死并发和极速滚动导致的页码错乱 const isFetchingRef = useRef(false); // Inside your component / 在组件内部:Full-time automated environment tracker // 监听器副作用必须保持【全天候存活】,移除外层的 loading 拦截 useEffect(() => { // If we already hit the end of database, don't observe anymore // 如果已经没有更多数据了,直接返回,不再启动监听 if (!hasMore) return; // 1. Create the observer instance / 创建监听器实例 const observer = new IntersectionObserver((entries) => { const firstEntry = entries[0]; // If sentinel enters the screen and isFetchingRef is false // 如果哨兵露面了 // Read directly from the synchronous Ref lock instead of lagging state // 关键改动:在监听到相交的瞬间,去读绝对同步的 Ref 锁,而不是看有渲染延迟的 state if (firstEntry.isIntersecting && !isFetchingRef.current) { console.log('👀 Sentinel triggered! Loading next page...'); isFetchingRef.current = true; setPage((prevPage) => prevPage + 1); // Increment page counter / 递增页码 } }, { threshold: 1.0 }); // 1.0 means the entire 10px element must be fully visible / 代表元素100%露面时触发 // 2. Start tracking our sentinel element / 让监听器绑定并追踪我们的哨兵 if (sentinelRef.current) { observer.observe(sentinelRef.current); } // 3. CLEANUP: Disconnect the observer when component re-renders or unmounts // 清除函数:当组件重新渲染或销毁时,切断监听,避免造成严重的性能垃圾 return () => { observer.disconnect(); }; }, [hasMore]); // Must re-sync when hasMore changes / 状态改变时必须重新计算边界
Finally, we need a separate useEffect that fires whenever the page count changes. It fetches the raw data and concatenates (appends) it to our existing list.
English Thought Process & Explanation:
"Lastly, I'll add another
useEffectdependent onpage. Inside, I will turnloadingto true, fetch the next page from our simulated API, append the new items onto the existing ones using the spread operator (...), and updatehasMorebased on the backend flag."
中文思考与解释:
“最后,添加一个专门监听
page改变的useEffect。一旦页码加一,就把loading设为 true,去后端请求下一页的数据。拿到新数据后,用展开运算符...把它追加到原有数据的屁股后面,并用后端传回的hasMore更新我们的边界标志。”
xxxxxxxxxx // Inside your component / 在组件内部: useEffect(() => { // Acknowledge page 1 initial run / 确保第一页加载时锁也是闭合的 isFetchingRef.current = true; const fetchNewProducts = async () => { setLoading(true); try { // Simulating API call / 模拟调用分页接口 `/api/products?page=X` // In reality: const res = await fetch(`/api/products?page=${page}`); await new Promise(resolve => setTimeout(resolve, 800)); // Simulate network lag const mockNewItems = Array.from({ length: 10 }, (_, i) => ({ id: (page - 1) * 10 + i + 1, name: `Premium Product ${(page - 1) * 10 + i + 1}` })); // Append items to the end / 使用解构赋值把新数据追加到旧数据列表后面 setItems((prevItems) => [prevItems, mockNewItems]); // Stop after page 4 for testing / 模拟在第 4 页时后端告知全部加载完毕 if (page >= 4) { setHasMore(false); } } catch (error) { console.error("Failed to load products", error); } finally { setLoading(false); // Open the gate ONLY after DOM elements have fully appended // 只有当数据成功追加、DOM 渲染完毕后,才重新解开这把锁! console.log('🔓 Data pipeline settled. Opening gate.'); isFetchingRef.current = false; } }; fetchNewProducts(); }, [page]); // Runs whenever 'page' increments / 只要页码一变就自动发请求效果:

可以看到,第一次渲染的时候,fetchData执行了两次,这是为什么呢?
这个mockdata很具有欺骗性,搞得我连有没有bug,bug在哪里都没有定位到,后来我使用真实接口了,才发现,这根本不是问题。
fetchData是执行了两次,而且结果是一样的。但是这是mockdata的锅,在setPage之后,因为sentinel是在视口里面的,所以又触发了一次,而此时page还没有真正改完,所以返回的mockdata是一样的。
我使用真实接口来做,也是执行了两次,但是返回的数据不一样,我这才发现,原来是因为页面比较高,即使返回了10条数据,但是sentinel还处于视口内,会接着触发一次,而当我把页面高度缩小之后,就只返回10条数据了。
刚开始我还想在useEffect监听page的改变,然后使用useFetch,报错了。幸好提前发现了自己的错误。
在 React 中使用自定义 Hook(如你的 useFetch)有一个非常铁的纪律:绝对不能在 useEffect、条件语句(if)或者普通函数内部去调用 Hook。 Hook 必须写在组件的最顶层。
怎么做呢?其实我还是没有搞清楚整个渲染的流程。是这样的,组件初始化时,useFetch会执行一次,useEffect里面的observer会创建。当用户滚动到让sentinel出现时,会触发observer里面的代码→setPage。那么page改变了,组件就要重新渲染一次(但是useState只会在初始化时执行,所以这一点不用担心),那么useFetch里面的url就是新的,就会触发里面的监听,请求数据,就会得到新的data。
那么通过监听data,将data加到本地的state上面去,就实现了数据的拼接。
xxxxxxxxxx"use client";import useFetch from "@/app/hooks/useFetch";import { useEffect, useRef, useState } from "react";interface Product { id: number; title: string;}export default function InfiniteLoopPage() { const [items, setItems] = useState<Product[]>([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const { data, loading, error } = useFetch<Product[]>( `https://dummyjson.com/products?limit=10&skip=${(page - 1) * 10}`, ); const sentinelRef = useRef(null); useEffect(() => { if (!hasMore || loading) return; const observer = new IntersectionObserver( (entries) => { const firstEntry = entries[0]; if (firstEntry.isIntersecting) { setPage((prev) => prev + 1); } }, { threshold: 1.0 }, ); if (sentinelRef.current) { observer.observe(sentinelRef.current); } return () => { observer.disconnect(); }; }, [hasMore, loading]); useEffect(() => { if (data) { if (data.products.length === 0) { setHasMore(false); } else { setItems((prev) => [prev, data.products]); } } }, [data]); return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-3xl font-bold mb-4">Products List</h1> <div className="space-y-3"> {items.map((item) => ( <div key={item.id} className="p-4 bg-white border rounded-md shadow text-black"> {item.id} - {item.title} </div> ))} </div> <div ref={sentinelRef} className="h-10 flex items-center justify-center mt-4"> {loading && <p className="text-gray-500">Loading more products...</p>} {!hasMore && ( <p className="text-gray-400 text-sm">No more products to show.</p> )} </div> </div> );}
Brilliant! You successfully built a highly efficient, custom native infinite scroll list without loading a single mega-byte of npm packages.
But as you know by now, a senior remote interviewer is about to put on a strict performance audit face. Here is the follow-up question coming straight at you:
Your implementation is functionally clean. However, consider the Fast Scrolling / Double-Trigger Bug. If a user has a very fast fiber connection and scrolls down incredibly quickly, the sentinel might trigger page 2. Before the network response for page 2 returns, the sentinel is STILL visible on screen, meaning the observer will fire AGAIN and trigger page 3 immediately! How do you guarantee that you prevent this multi-trigger duplication while a network request is still pending?
中文翻译提示:
“你的实现功能很清晰。但考虑一下 ‘极速滚动/二次重复触发 Bug’。如果用户的网速极快,并且疯狂向下滚动,哨兵会触发加载第 2 页。但在第 2 页的网络响应还没返回的这几百毫秒内,哨兵依然暴露在屏幕上,这意味着监听器会再次触发并直接去加载第 3 页!你怎么绝对保证,在一个网络请求还挂起(Pending)时,绝不会发生这样重复触发页码加载的 Bug?”
This is an incredibly common edge-case glitch in real-world frontend apps.
This is an excellent catch by the interviewer, and it happens all the time in real-world products. If you don't block this, your app will accidentally fetch page 2, page 3, and page 4 all at the exact same time, causing a messy UI overlap.
Let’s solve this Double-Trigger Bug using our Bilingual (中英双语) strategy.
We‘ve added the loading check, this bug is already prevented.
When the sentinel is triggered for the first time, loading instantly becomes true. Because loading is a dependency of our useEffect, the previous observer is destroyed. In the newly created effect, the line if (loading) return; acts as a hard stop. It completely blocks the creation of a new observer while the network request is still pending. No matter how fast the user scrolls, the sensor is temporarily disabled until the data arrives.
中文: 当哨兵第一次被触发时,loading 会立刻变为 true。因为 loading 是我们 useEffect 的依赖项,旧的监听器会被瞬间销毁。而在新一轮执行中,if (loading) return; 这行代码是一个强力卡点——它在请求悬空期间,彻底阻止了新监听器的创建。此时无论用户滚动得多快,哨兵都处于“断电”状态,直到数据返回。
“虽然你在组件层通过 loading 锁住了 setPage,但由于 React 的状态更新(State Batching)和自定义 Hook 的数据传递是有时间差(微秒级延迟)的。 在 setPage(2) 执行完,到 useFetch 真正把 loading 变为 true 并在组件层生效的这极短的几十毫秒内,如果刚好碰上高刷屏(120Hz/144Hz)或者极为极端的急速滚动,视图层依然可能在 loading 还没变成 true 之前,连续触发两次 isIntersecting 回调。这时候该怎么办?”
Instead of relying purely on a asynchronous React state variable (loading), we can introduce a mutable instance variable using a React useRef (e.g., isFetchingRef.current).
Unlike state, updating a useRef value happens instantaneously and synchronously. We change it to true the exact millisecond the sentinel is crossed, creating an un-bypassable iron gate.
Let's upgrade your component with this foolproof synchronization layer:
English Thought Process & Code:
To eliminate the double-trigger bug, I will introduce a
isFetchingRefusinguseRef(false). The very instant the observer sees an intersection, I will flipisFetchingRef.current = truesynchronously. This completely blocks any subsequent triggers until the async network operation finishes and flips it back tofalse.
中文思考与代码:
“为了彻底杜绝极速滚动导致的重复触发 Bug,我会引入一个
isFetchingRef钩子,初始值为false。就在监听器发现‘哨兵’露面的那一瞬间,我同步、立刻将isFetchingRef.current设为true。这会像一把铁锁一样死死卡住后续的任何多余触发,直到整个异步网络请求彻底完成并返回数据后,我们再把它放开重置为false。”
xxxxxxxxxx'use client';import { useState, useEffect, useRef } from 'react';export default function PerfectInfiniteScroll() { const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const sentinelRef = useRef(null); // 🌟 FIX 1: The Synchronous Iron Gate / 同步锁守门员 const isFetchingRef = useRef(false); // Observer Effect / 监听器副作用 useEffect(() => { if (!hasMore) return; const observer = new IntersectionObserver((entries) => { const firstEntry = entries[0]; // 🌟 FIX 2: Read directly from the synchronous Ref instead of state // 直接读取秒级同步的 Ref 锁,而不是等待有渲染延迟的 state if (firstEntry.isIntersecting && !isFetchingRef.current) { console.log('🔒 Gate closed! Lock triggered.'); isFetchingRef.current = true; // Instantly lock / 立刻上锁 setPage((prev) => prev + 1); } }, { threshold: 1.0 }); if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [hasMore]); // Removed loading from dependency! / 减少依赖项,让逻辑更简单 // Fetching Effect / 网络请求副作用 useEffect(() => { const fetchNewProducts = async () => { try { // In reality: const res = await fetch(`/api/products?page=${page}`); await new Promise(resolve => setTimeout(resolve, 800)); // Network latency const mockNewItems = Array.from({ length: 10 }, (_, i) => ({ id: (page - 1) * 10 + i + 1, name: `Premium Product ${(page - 1) * 10 + i + 1}` })); setItems((prevItems) => [prevItems, mockNewItems]); if (page >= 4) setHasMore(false); } catch (error) { console.error(error); } finally { // 🌟 FIX 3: Open the gate only after data has finished appending to the DOM // 只有当数据已经拿到、渲染成功后,才把这把锁重新解开 console.log('🔓 Gate opened! Ready for next page.'); isFetchingRef.current = false; } }; // Skip the very first initial evaluation if needed, or run it for page 1 fetchNewProducts(); }, [page]); return ( <div> {/* ... mapping items ... */} <div ref={sentinelRef} className="h-10 text-center text-gray-400"> {isFetchingRef.current ? "Loading..." : "Scroll down to see more"} </div> </div> );}
If you solve the double-trigger bug, the interviewer will give you a massive nod of approval. But if they want to see if you can handle Staff / Architect level complexity, they will ask one final question before moving on:
Interviewer:
Your scrolling loop is now perfectly safe. But imagine the user scrolls for 20 minutes and loads 5,000 product cards into the list. The browser DOM tree now contains tens of thousands of active image and text nodes. The page will become extremely laggy, skip frames, and eventually crash the smartphone's browser tab due to severe memory pressure.
How would you conceptually upgrade this architecture to prevent 'DOM melting' when handling a near-infinite amount of records? (Hint: Think about what Twitter or Instagram does).
中文翻译提示:
“你的滚动循环现在完美无瑕了。但想象一下,如果用户往下划拉了20分钟,加载了 5000 个商品卡片。此时浏览器的 DOM 树里会塞满几万个活跃的图片和文字节点。页面会变得极度卡顿、疯狂掉帧,最后让手机浏览器直接因为内存内存耗尽而崩溃。”
“你该如何在概念上升级这个架构,来应对这种数据量无限增长的场景,防止 DOM 结构过载崩溃?(提示:想想 Twitter 或者 Instagram 的做法)”
To pass this final boss question, you must introduce the concept of "Virtual Scrolling" (虚拟滚动) or "Windowing" (窗口化化技术).
解决这个问题的根本哲学是:不要诚实地把所有数据都塞进 DOM 树里。 哪怕用户加载了 5,000 条数据,用户的手机屏幕一次也只能看到 5~10 个商品卡片。因此,我们只需要在 DOM 中保持 “可视区域(Window)” 及其上下少量“缓冲区(Buffer)”的节点,其余看不见的数据,全部用纯粹的 JavaScript 对象留在内存里,不变成真正的 DOM 节点。
To handle massive lists without melting the DOM, we must implement Virtualization.
In product, I will use
@tanstack/react-virtualorreact-windowto solve this problem.Here I will first calculate the wrapper's height and only render the 10 or 15 items currently visible within the viewport, plus a tiny buffer above and below. As the user scrolls, we recycle the DOM nodes dynamically, replacing their inner text and data while using CSS transforms to position them correctly.
This keeps the total DOM count perfectly flat at 15 elements, whether the database has 100 items or 1,000,000 items.
中文解释给面试官:
“为了处理海量列表而不至于撑爆 DOM 树,我们需要引入‘虚拟列表技术(Virtualization)’。我们不渲染全部的 5000 个卡片,而是根据视口高度计算,只渲染当前处于屏幕可视区域内的 10 到 15 个组件(外加头部和尾部的一点点缓存缓冲区)。随着用户滚动,我们动态复用这些 DOM 节点,仅仅替换里面的文字和图片数据,同时利用 CSS transform 撑开高度定位。这样无论列表里有 100 条还是 100 万条数据,浏览器里真实的 DOM 节点永远只有 15 个,性能永远是满分。”

先不要管js代码里面怎么计算,先从UI理解。

重点:
① 最外层容器设置了 overflow-y: auto ,意义重大:
overflow-y: auto(或者 scroll)并且给定了固定高度(如 600px)时,浏览器一旦发现内部子元素的总高度(也就是那个 totalHeight 撑开的垫片,比如 500,000px)超过了 600px,就会自动在右侧生成一个符合比例的原生滚动条。onScroll 事件:只有当容器允许滚动时,用户拖动滚动条或拨动鼠标滚轮,才能触发组件上的 onScroll={handleScroll} 监听函数。scrollTop 基础数据:整个虚拟列表的算法核心,全靠从 e.currentTarget.scrollTop 实时获取当前滚动了多少像素。没有 auto,就没有滚动,scrollTop 永远是 0,算法直接失效。② 最里面的容器设置下面的样式,就能确保visibleItems处于可视范围内
x
position: absolute;transform: translateY `${translateY}px`;③ 不加上缓冲区是可以的,但是会造成空白一闪的bug。为什么呢?
因为当用户快速滚动的时候,如果不加上缓冲区,就会直接显示下面的空白内容(react计算需要时间,无论多短的时间都会有一种闪动感)。如果加上了缓冲区,就直接显示缓冲区的内容。
人类手指滑动的极限(惯性滚动):在屏幕上飞快一划,一帧(约 16 毫秒)内滚动条顶多向下移动 200px 到 400px(相当于 2 到 4 个卡片的高度)。而react重新计算只需要3ms左右,所以用户会先看到缓冲区,然后继续滚动就会看到已经准备好的数据了。
当然,如果你想更好的体验,可以加上<Suspense fallback={<Skeleton />}>。
④ setScrollTop现在并没有加上防抖,但是快速、多次滚动并没有性能问题,为什么呢?
因为react帮助我们做了很多事情。
React 18+ 的自动批处理 (Automatic Batching),极短时间内触发多次 onScroll 事件,产生一连串的 setScrollTop 调用。react 会自动批处理。
浏览器的滚动事件触发频率,通常会自动与显示器的刷新率(如 60Hz 或 120Hz)进行对齐。也就是说,在 60Hz 的屏幕上,浏览器最多每 16.7 毫秒才扔给你一个 onScroll 信号。
我们写的是 的纯 JS 计算
const computedStartIndex = Math.floor(scrollTop / ITEM_HEIGHT);const endIndex = Math.min(items.length, );const visibleItems = items.slice(startIndex, endIndex);slice 只有 12 个元素)。在 V8 引擎中,执行这几行 JS 代码只需要 不到 0.1 毫秒。translateY 即可。顺便提一下,这里不能使用防抖,要使用节流。因为防抖是用户操作结束之后才会执行,如果用户一直在滚动,岂不是一直都没有数据显示出来?所以这里必须使用节流。
我们不能再让商品列表自然往下撑开,而是要人为制造一个“大盒子”来欺骗浏览器的滚动条:
800px,超出部分隐藏 overflow-y: auto。单行高度 * 5000条数据。这样浏览器的滚动条就能完美还原出 5,000 条数据的真实长度和滚动体验。position: absolute。随着用户滚动,系统实时计算出当前应该显示第几条到第几条数据,并动态改变它们的 top 值,把它们精准挪到用户眼前。我们需要监听视口的滚动事件(onScroll),在用户滚动时算两件事:
startIndex(第一个该显示的商品): Math.floor(当前滚动距离 scrollTop / 单行高度)endIndex(最后一个该显示的商品): startIndex + (视口高度 / 单行高度) + 缓冲区数量然后,页面上的渲染逻辑从 items.map(...) 升级为:
xxxxxxxxxx// 永远只切片渲染一小部分 DOM 节点,数量恒定(比如永远只有 15 个)const visibleItems = items.slice(startIndex, endIndex);useFetch 和 page 逻辑保持完全不变:当用户快滚动到总高度的底部时,依然触发 setPage(prev => prev + 1) 去请求新数据。items 数组里继续追加(此时 items 数组长度变成了 5100, 5200...)。items 的数字长到多大,由于虚拟列表的动态裁剪,DOM 树里的节点数量永远死死固定在 15 个左右。x
"use client";import useFetch from "@/app/hooks/useFetch";import { useEffect, useRef, useState, UIEvent } from "react";interface Product { id: number; title: string;}// 1. 定义固定高度常量(面试时明确告诉面试官先按固定高度实现)const ITEM_HEIGHT = 100; // 每行高度 100pxconst CONTAINER_HEIGHT = 600; // 容器高度 600pxconst BUFFER_COUNT = 3; // 上下缓冲区保留的节点数const MOCK_PRODUCTS = Array.from({ length: 10000 }, (_, index) => ({ id: index + 1, title: `模拟商品卡片 - 这是一个超长的标题测试 #${index + 1}`,}));export default function InfiniteLoopPage() { const [items, setItems] = useState<Product[]>(MOCK_PRODUCTS); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); // 滚动状态:记录当前的 scrollTop const [scrollTop, setScrollTop] = useState(0); const { data, loading, error } = useFetch<{ products: Product[] }>( `https://dummyjson.com/products?limit=10&skip=${(page - 1) * 10}`, ); // --- 🌟 核心算法计算开始 🌟 --- // A. 计算当前视口里,第一条数据的索引 const computedStartIndex = Math.floor(scrollTop / ITEM_HEIGHT); // 加上缓冲区,防止往上滚时白屏,但不能小于 0 const startIndex = Math.max(0, computedStartIndex - BUFFER_COUNT); // B. 计算当前视口里,最后一条数据的索引 const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT); // 加上缓冲区,但不能超过数组最大长度 const endIndex = Math.min(items.length, computedStartIndex + visibleCount + BUFFER_COUNT); // C. 动态计算渲染出来的这一小段列表,需要向下偏移多少像素 // 偏移量 = 隐藏在视口上方的节点数 * 单个高度 const translateY = startIndex * ITEM_HEIGHT; // D. 计算总高度垫片,把滚动条撑起来 const totalHeight = items.length * ITEM_HEIGHT; // 截取当前需要真实渲染的数据切片 const visibleItems = items.slice(startIndex, endIndex); // --- 🌟 核心算法计算结束 🌟 --- // 监听容器滚动事件 const handleScroll = (e: UIEvent<HTMLDivElement>) => { const target = e.currentTarget; setScrollTop(target.scrollTop); // 2. 顺便在这里实现无限滚动加载: // 滚动条触底公式:scrollTop + clientHeight >= scrollHeight - 阈值 if (target.scrollTop + target.clientHeight >= target.scrollHeight - 50) { if (hasMore && !loading) { setPage((prev) => prev + 1); } } }; useEffect(() => { if (data && data.products) { if (data.products.length === 0) { setHasMore(false); } else { setItems((prev) => [prev, data.products]); } } }, [data]); return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-3xl font-bold mb-4">Products List</h1> {/* 外部可滚动容器:必须给固定高度和 overflow-y-auto */} <div onScroll={handleScroll} style={{ height: `${CONTAINER_HEIGHT}px`, overflowY: "auto" }} className="border border-gray-300 relative rounded-md" > {/* 内部高度垫片:唯一作用就是撑开滚动条 */} <div style={{ height: `${totalHeight}px`, width: "100%", position: "relative" }}> {/* 真正渲染的可见列表:通过 transform 撑到正确的可见位置 */} <div style={{ transform: `translateY(${translateY}px)`, position: "absolute", left: 0, right: 0, top: 0, }} className="space-y-0" // 建议在虚拟列表中将外部间距设为0,用 item 的 padding 调整 > {visibleItems.map((item, index) => { // 关键点:因为 slice 之后索引变了,我们要还原它在全局的真实索引 const globalIndex = startIndex + index; return ( <div key={item.id} style={{ height: `${ITEM_HEIGHT}px` }} className="p-4 bg-white border-b flex items-center text-black" > {item.id} - {item.title} (Index: {globalIndex}) </div> ); })} </div> </div> </div> {/* 底部状态提示 */} <div className="text-center mt-2"> {loading && <p className="text-gray-500">Loading more products...</p>} {!hasMore && <p className="text-gray-400 text-sm">No more products to show.</p>} </div> </div> );}亮点 1:告别了 IntersectionObserver 和 Sentinel 节点。 因为我们有了 handleScroll,所以直接通过 target.scrollTop + target.clientHeight >= target.scrollHeight - 阈值 就能判断是否需要加载下一页。去掉了原先的 sentinelRef 节点,代码更纯粹。
亮点 2:Key 的处理。 在虚拟列表中,key 的选择要很小心。虽然我们频繁切换数组切片,但因为卡片里面有具体的 item.id(唯一标识),用 key={item.id} 可以让 React 进行精确的 DOM 复用,而不是粗暴地销毁重建。
亮点 3:节流优化(Throttling)。 主动跟面试官说:“在生产环境中,由于 onScroll 触发频率极高(每秒 60 次以上),我们可以对 handleScroll 加上一个简单的节流函数(如 requestAnimationFrame),进一步提升极致滚动下的性能。”
怎么验证它成功了?
把数据改大并运行页面后,打开浏览器的 开发者工具 (F12),切换到 Elements (元素) 面板,展开你的滚动容器。
随着你的鼠标快速向下滚动,你需要观察以下两个现象:
你会发现,无论你滚到第几千条,.space-y-0 容器内部的 <div> 标签永远只有 12 个左右()。
<div> 随着滚动变得越来越多,说明你的 slice(startIndex, endIndex) 没生效,依然在往 DOM 里塞节点。translateY 和数据的动态变化当你滚动时,你会看到这一十几个 DOM 节点里的文字(如 Index: 450)在疯狂高频变化,同时最外层的 transform: translateY(XXXXpx) 也在实时变大。
效果,可以看到即使滚动到几千个甚至1万多个,滚动还是很丝滑的,没有卡顿:

Yes, we can push this into senior production engineering territory.
Now that you have mastered the useRef sync lock and explained DOM Virtualization, an elite interviewer will test your ability to handle real-world user interface glitches and network instability.
Interviewer:
Imagine each product card contains an image. When the user scrolls down, the sentinel triggers, and you append 10 new items. However, because the images take a fraction of a second to load, the new cards initially have a height of
0pxbefore popping open to300px.This causes the page layout to violently shake and jump, which ruins the user experience and ruins our Google Cumulative Layout Shift (CLS) web vital score. How do you prevent this layout shift when dynamically appending uneven content?
中文翻译提示:
“想象一下,每个商品卡片里面都包含一张大图。当用户滚到底部,哨兵触发,你追加了 10 个新商品。然而,由于图片需要零点几秒来加载,新卡片在图片加载出来之前高度是
0px,随后突然撑大到300px。”“这会导致整个页面布局发生剧烈的抖动和跳跃,严重破坏用户体验,还会砸掉我们谷歌的 CLS(累积布局偏移)性能指标。你怎么在动态追加高度不固定的内容时,防止这种布局抖动?”
We must implement structural placeholders and enforce strict aspect ratios. We should never let an image render without an explicit or container-bounded size.
xxxxxxxxxx<!-- 🌟 Enforce Aspect Ratio and Shimmer UI / 强制固定宽高比与骨架屏 --><div class="animate-pulse bg-gray-200 aspect-video w-full rounded-md"> <!-- Next.js <Image /> component handles this natively by requiring structural width/height layout definitions --> <img src={src} className="object-cover w-full h-full" onLoad={...} /></div>English Answer: I will assign a fixed structural aspect-ratio to the product card image containers (e.g., using Tailwind's aspect-square or aspect-video) and render a Skeleton Shimmer placeholder while loading. This reserves the exact layout pixel coordinates in the DOM tree before the network data arrives, dropping layout shifts to absolute zero.
中文回答: “我会给商品卡片的图片容器指定一个固定的物理宽高比(例如使用 Tailwind CSS 的 aspect-square 或 aspect-video),并在加载时渲染一个带有渐变动画的骨架屏(Skeleton Shimmer)。这能在数据返回之前就在 DOM 树里提前锁死它应有的像素高度,从而将布局偏移降到绝对的零。”
Interviewer:
Suppose the user is scrolling on a train, enters a tunnel, and loses their internet connection right when the sentinel crosses the viewport. Your
fetchNewProductstriggers, hits a network error, and setsisFetchingRef.current = false.Now the user exits the tunnel and regains internet. But because the sentinel is already sitting inside the viewport, the
IntersectionObserverwon't fire again because it only triggers on the transition of entering the screen. The infinite scroll is now completely frozen. How do you implement an elegant error fallback and recovery state?
中文翻译提示:
“假设用户正在火车上刷手机,突然进了解一个隧道,正好在哨兵划进屏幕的那一刻断网了。你的请求触发,遭遇网络错误挂掉,并重置了锁
isFetchingRef.current = false。”“现在用户出了隧道,网络恢复了。但由于此时哨兵已经静静地躺在屏幕里了,
IntersectionObserver不会再次触发(因为它只有在元素‘由外向内进入’的刹那才会触发)。无尽滚动此时彻底卡死。你该怎么实现一个优雅的错误降级与重试恢复机制?”
We introduce a local error state. If a network request fails, we conditional-render a manual "Retry Button" instead of the sentinel. Clicking the retry button manually runs the fetch again, clears the error, and brings back the automated observer tracking.
xxxxxxxxxx// 🌟 Inside the JSX Render Block / 在组件渲染区域<div ref={sentinelRef} className="py-4"> {error ? ( <div className="text-center"> <p className="text-red-500 mb-2">Network error. Failed to load items.</p> {/* 🌟 Manual click overrides the frozen observer state / 手动点击打破卡死的监听状态 */} <button onClick={() => retryLastFetch()} className="px-4 py-2 bg-blue-500 text-white rounded"> Tap to Retry </button> </div> ) : ( loading && <p>Loading more</p> )}</div>We have successfully battle-tested this Infinite Scroll system from a basic implementation to production-level reliability. You have fully proved you can control memory, bypass browser limits, protect SEO performance layout scores, and handle real-world mobile internet disconnections.
We can now cleanly seal this topic away.