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.

Question 3: Handcrafting an Infinite Scroll List (手写无限滚动列表)

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 flag hasMore.

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 / 初始硬性限制:

  1. No Third-Party Libraries: You cannot install packages like react-infinite-scroll-component or react-window.
  2. Performance Rule: You are strictly forbidden from listening to the high-frequency global window.onscroll event, as it severely damages frame rates and causes mobile browser jank. You must use the modern browser Intersection Observer API.

 

Think Out Loud / 中英双语思考提示:

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 page number counter and a loading flag. I will map through the items to render them. At the bottom of the list, I will insert a div element acting as our 'sentinel'. I will create a useEffect that instantiates an Intersection Observer. I'll pass a callback to it: if entries[0].isIntersecting is 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),防止内存泄漏。”

核心API

IntersectionObserver(交叉观察器) 是浏览器原生提供的一个 API,用来监听某个元素是否进入了用户的可视区域(或者另一个父元素的区域)

简单来说,它就像一个“看门狗”,当被监视的元素在屏幕上“露头”或者“完全消失”时,它就会立刻通知你。

1. 为什么需要它?(解决什么痛点)

在它出现之前,如果你想判断一个元素是否可见(比如滚动到下面才加载图片),必须去监听页面的 scroll 事件,然后频繁调用 getBoundingClientRect() 去计算位置。

2. 最常用的 3 大应用场景

3. 最简核心代码示例

它的用法非常固定,只需三步:

总结

面试时一句话概括:

IntersectionObserver 是浏览器原生的元素可见性监听 API。它彻底替代了传统靠监听 scroll 事件和计算位置的低效做法,主要用于图片懒加载、数据无限滚动和广告曝光统计,性能非常好。”

Over to You!

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.

 

Step 1: Set Up the Component State & List (设置组件状态与列表渲染)

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), and hasMore (true). I'll map over the items to render them on screen, and append a specific empty div at the very bottom with a React useRef attached to it. This div will be our sentinel.

中文思考路径:

“第一步,我需要定义几个核心状态:items(初始化为空数组)、page(从第1页开始)、loading(加载状态)以及 hasMore(用来判断后端是不是还有数据)。我用 .map() 渲染产品列表,并在列表的最末尾放一个空的 div,用 useRef 钩子绑住它。这个 div 就是我们的‘哨兵’。”

 

Step 2: Connect the Intersection Observer (挂载浏览器哨兵监听器)

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 useEffect to instantiate the IntersectionObserver. In its callback, if entry.isIntersecting is true, it means the sentinel is visible. If we aren't loading and hasMore is true, I will trigger setPage(prev => prev + 1). Crucially, I will run observer.disconnect() in the cleanup to avoid memory leaks."

中文思考路径:

“第二步,写一个 useEffect 来实例化原生的 IntersectionObserver。在回调函数里判断:如果 entry.isIntersecting 为真,说明用户已经滚到底部看到哨兵了。只要当前没有在加载,且后端还有数据,我就让页码自动加一:setPage(prev => prev + 1)。最关键的是,在清除函数里执行 observer.disconnect() 断开监听,防止页面卡死或内存泄漏。”

 

Step 3: Fetch and Aggregate the Data (网络请求与数据拼接)

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 useEffect dependent on page. Inside, I will turn loading to true, fetch the next page from our simulated API, append the new items onto the existing ones using the spread operator (...), and update hasMore based on the backend flag."

中文思考与解释:

“最后,添加一个专门监听 page 改变的 useEffect。一旦页码加一,就把 loading 设为 true,去后端请求下一页的数据。拿到新数据后,用展开运算符 ... 把它追加到原有数据的屁股后面,并用后端传回的 hasMore 更新我们的边界标志。”

 

The Final Self-Contained Implementation:

效果:

可以看到,第一次渲染的时候,fetchData执行了两次,这是为什么呢?

这个mockdata很具有欺骗性,搞得我连有没有bug,bug在哪里都没有定位到,后来我使用真实接口了,才发现,这根本不是问题。

fetchData是执行了两次,而且结果是一样的。但是这是mockdata的锅,在setPage之后,因为sentinel是在视口里面的,所以又触发了一次,而此时page还没有真正改完,所以返回的mockdata是一样的。

我使用真实接口来做,也是执行了两次,但是返回的数据不一样,我这才发现,原来是因为页面比较高,即使返回了10条数据,但是sentinel还处于视口内,会接着触发一次,而当我把页面高度缩小之后,就只返回10条数据了。

 

 

疑问:如果想在这里使用useFetch怎么做?

刚开始我还想在useEffect监听page的改变,然后使用useFetch,报错了。幸好提前发现了自己的错误。

在 React 中使用自定义 Hook(如你的 useFetch)有一个非常铁的纪律:绝对不能在 useEffect、条件语句(if)或者普通函数内部去调用 Hook。 Hook 必须写在组件的最顶层。

 

怎么做呢?其实我还是没有搞清楚整个渲染的流程。是这样的,组件初始化时,useFetch会执行一次,useEffect里面的observer会创建。当用户滚动到让sentinel出现时,会触发observer里面的代码→setPage。那么page改变了,组件就要重新渲染一次(但是useState只会在初始化时执行,所以这一点不用担心),那么useFetch里面的url就是新的,就会触发里面的监听,请求数据,就会得到新的data。

那么通过监听data,将data加到本地的state上面去,就实现了数据的拼接。

 

追问

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.

有了loading其实问题就已经解决了

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 回调。这时候该怎么办?”

The Solution: The "Strict Gatekeeper" Pattern (严格守门员模式)

The Elegant Fix (优雅的修复方案): 在局部使用 Ref 充当“同步锁”

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.

 

Code Adjustment (代码微调)

Let's upgrade your component with this foolproof synchronization layer:

English Thought Process & Code:

To eliminate the double-trigger bug, I will introduce a isFetchingRef using useRef(false). The very instant the observer sees an intersection, I will flip isFetchingRef.current = true synchronously. This completely blocks any subsequent triggers until the async network operation finishes and flips it back to false.

中文思考与代码:

“为了彻底杜绝极速滚动导致的重复触发 Bug,我会引入一个 isFetchingRef 钩子,初始值为 false。就在监听器发现‘哨兵’露面的那一瞬间,我同步、立刻isFetchingRef.current 设为 true。这会像一把铁锁一样死死卡住后续的任何多余触发,直到整个异步网络请求彻底完成并返回数据后,我们再把它放开重置为 false。”

 

继续追问: The "DOM Node Melting" Crash (终极架构追问:内存暴涨崩溃)

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 的做法)”

The Conceptual Answer (双语大局观解答):

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-virtual or react-window to 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 个,性能永远是满分。”

img

 

思路

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

image-20260707223705993

 

重点:

① 最外层容器设置了 overflow-y: auto ,意义重大:

② 最里面的容器设置下面的样式,就能确保visibleItems处于可视范围内

③ 不加上缓冲区是可以的,但是会造成空白一闪的bug。为什么呢?

因为当用户快速滚动的时候,如果不加上缓冲区,就会直接显示下面的空白内容(react计算需要时间,无论多短的时间都会有一种闪动感)。如果加上了缓冲区,就直接显示缓冲区的内容。

人类手指滑动的极限(惯性滚动):在屏幕上飞快一划,一帧(约 16 毫秒)内滚动条顶多向下移动 200px400px(相当于 2 到 4 个卡片的高度)。而react重新计算只需要3ms左右,所以用户会先看到缓冲区,然后继续滚动就会看到已经准备好的数据了。

当然,如果你想更好的体验,可以加上<Suspense fallback={<Skeleton />}>

setScrollTop现在并没有加上防抖,但是快速、多次滚动并没有性能问题,为什么呢?

因为react帮助我们做了很多事情。

顺便提一下,这里不能使用防抖,要使用节流。因为防抖是用户操作结束之后才会执行,如果用户一直在滚动,岂不是一直都没有数据显示出来?所以这里必须使用节流。

 

详细思路

  1. 结构层:引入“总高度容器”与“绝对定位”

我们不能再让商品列表自然往下撑开,而是要人为制造一个“大盒子”来欺骗浏览器的滚动条:

  1. 计算层:实时计算索引(StartIndex / EndIndex)

我们需要监听视口的滚动事件(onScroll),在用户滚动时算两件事:

  1. startIndex(第一个该显示的商品): Math.floor(当前滚动距离 scrollTop / 单行高度)
  2. endIndex(最后一个该显示的商品): startIndex + (视口高度 / 单行高度) + 缓冲区数量

然后,页面上的渲染逻辑从 items.map(...) 升级为:

  1. 数据层:无限滚动与虚拟列表双剑合璧

代码

亮点 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 (元素) 面板,展开你的滚动容器。

随着你的鼠标快速向下滚动,你需要观察以下两个现象:

  1. 检查 DOM 节点的数量

你会发现,无论你滚到第几千条,.space-y-0 容器内部的 <div> 标签永远只有 12 个左右)。

  1. 检查 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.

Advanced Follow-Up 1: The "Scroll Position Jump" Layout Shift (无尽滚动的布局抖动)

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 0px before popping open to 300px.

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(累积布局偏移)性能指标。你怎么在动态追加高度不固定的内容时,防止这种布局抖动?

The Solution / 解决方案:

We must implement structural placeholders and enforce strict aspect ratios. We should never let an image render without an explicit or container-bounded size.

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-squareaspect-video),并在加载时渲染一个带有渐变动画的骨架屏(Skeleton Shimmer)。这能在数据返回之前就在 DOM 树里提前锁死它应有的像素高度,从而将布局偏移降到绝对的零。”

 

Advanced Follow-Up 2: Adaptive Error Recovery / Network Failures (断网容错与自动恢复机制)

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 fetchNewProducts triggers, hits a network error, and sets isFetchingRef.current = false.

Now the user exits the tunnel and regains internet. But because the sentinel is already sitting inside the viewport, the IntersectionObserver won'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 不会再次触发(因为它只有在元素‘由外向内进入’的刹那才会触发)。无尽滚动此时彻底卡死。你该怎么实现一个优雅的错误降级与重试恢复机制?

The Solution / 解决方案:

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.

Question 3 Is Now 100% Completed!

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.