useFetch 自定义 Hook面试官口述需求:
“请在不支持任何第三方库(不能用 React Query/SWR)的情况下,用原生 React 手写一个自定义 Hook,命名为
useFetch。它可以传入一个 URL,并返回{ data, loading, error }。”
我们现在就通过中英双语(Bilingual),像真实面试一样,一步步把这道题推导出来。
在面试中,当你不知道怎么做时,最棒的策略是 “Think Out Loud”(大声思考)。我们可以把这个问题拆成 3 个简单的步骤。
首先,我们要想:这个 Hook 需要返回什么?面试官说要返回 data, loading, 和 error。所以,我们需要在 Hook 内部用 useState 把这三个状态定义出来。
English Thought Process:
First, I need to define three states using
useStateto track the API response:datafor the fetched result,loadingto indicate the request status, anderrorto catch any potential failures. Then, the hook will return an object containing these three states.
中文思考路径:
“第一步,我需要用
useState定义三个状态来追踪 API 的响应:data存返回的结果,loading表示加载状态,error捕获可能的错误。最后,这个 Hook 会返回一个包含这三个状态的对象。”
ximport { useState, useEffect } from 'react';function useFetch(url) { // Define our states / 定义我们的状态 const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Todo: Fetch data here / 稍后在这里发请求 // Return the object / 返回这个对象 return { data, loading, error };}
当 url 发生变化时,我们需要触发网络请求。在 React 中,处理网络请求这种“副作用”的标准做法是使用 useEffect,并且把 url 放在依赖项数组(Dependency Array)里。
English Thought Process:
Next, I will use
useEffectto trigger the network fetch whenever theurlchanges. InsideuseEffect, I'll write anasyncfunction to perform the standardfetchcall, handle the response, and update our states accordingly.
中文思考路径:
“第二步,我使用
useEffect。只要url发生改变,就重新触发网络请求。在useEffect内部,我会写一个异步函数(async function)来执行标准的fetch请求,拿到数据后去更新我们刚刚定义的状态。”
xxxxxxxxxximport { useState, useEffect } from 'react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // 1. Reset states when URL changes / 当 URL 改变时,重置状态 setLoading(true); setError(null); // 2. Create the fetch function / 创建发请求的异步函数 const fetchData = async () => { try { const response = await fetch(url); // Check if the HTTP status is OK (e.g., 200-299) // 检查 HTTP 状态码是否正常 if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); setData(json); // Update data / 更新数据 } catch (err) { setError(err.message); // Catch and set error / 捕获并设置错误 } finally { setLoading(false); // Turn off loading indicator / 关闭加载状态 } }; // 3. Execute the function / 执行函数 fetchData(); }, [url]); // Dependency is url / 依赖项是 url return { data, loading, error };}
上面的代码已经拿到了 70 分,但真实的欧美面试官绝对会立刻抛出我们之前提过的致命追问(The Race Condition & Cleanup Trap):
What happens if the component unmounts while the fetch is still in progress? Or what if the URL changes rapidly before the first request finishes? (如果请求还没完组件就被销毁了,或者用户疯狂切换 URL 导致前一个请求还没结束、后一个请求又发出了,会发生什么?)
答案是:会发生竞态条件(Race Condition)或者内存泄漏,旧请求返回的数据可能会覆盖新请求的数据!
It will cause race condition or memory leak, data returned by the old request could overwrite the data from a new request.
为了解决这个问题,我们需要在 useEffect 中引入原生的 AbortController,并在 useEffect 的 cleanup function(清除函数) 中取消请求。
English Explanation to Interviewer:
To handle race conditions and prevent setting state on an unmounted component, I will use native
AbortController. In the cleanup function ofuseEffect, I will callcontroller.abort(). If a new URL comes in, the previous pending request will be canceled immediately.
中文解释给面试官:
“为了处理竞态条件并防止在组件销毁后继续设置状态,我会使用浏览器原生的
AbortController。在useEffect的清除函数(cleanup function)中,我会调用controller.abort()。这样如果有一个新的 URL 传进来,前一个还没完成的请求就会被立刻取消。”
xxxxxxxxxximport { useState, useEffect } from 'react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // 1. Instantiate the AbortController / 实例化取消控制器 const controller = new AbortController(); const signal = controller.signal; setLoading(true); setError(null); const fetchData = async () => { try { // 2. Pass the signal to the fetch request / 将 signal 传给 fetch const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); setData(json); } catch (err) { // 3. Ignore the error if it was triggered by our manual abort // 如果错误是因为我们手动取消导致的,就不必把它当成真实错误抛给用户 if (err.name !== 'AbortError') { setError(err.message); } } finally { // 4. Only turn off loading if the request wasn't aborted // 只有在请求没有被取消的情况下,才关闭加载状态 if (!signal.aborted) { setLoading(false); } } }; fetchData(); // 5. The Magic Cleanup Function / 最核心的清除函数 return () => { controller.abort(); // Cancel the fetch / 取消该请求 }; }, [url]); return { data, loading, error };}
看,在没有 AI 的情况下,顺着 “定义状态 -> 处理副作用 -> 优化边界条件” 的逻辑,配上原生的 AbortController,你就能完美写出一个工业级的自定义 Hook。这也是欧美高薪岗位最想看到的扎实基本功。
step3我有问题:为什么需要使用abortcontroller来取消请求,直接写成这样return () => fetchData()不就行了吗?
幸好提前提出了问题,否则就会暴露我react项目没有写过多少的事实。这个return () => fetchData()看上去像是取消了请求,实际上是发起了请求。这根本就不是清理函数,只是看上去像而已。
那为什么abortcontroller可以取消请求呢?
在
useFetch中,使用AbortController的核心作用只有三个:
- 防止数据错乱(解决竞态条件):当
url频繁变化时,它会切断之前未完成的请求,确保只有最后一次请求的数据会被渲染,避免旧数据覆盖新数据。- 避免白费力气(优化组件卸载):当用户离开页面或组件被销毁时,它能立即叫停进行中的请求,防止组件死后还在后台消耗网络和内存去解析数据。
- 真正切断传输(节省用户流量):它是浏览器底层的机制,调用后会直接关闭 HTTP 连接并停止接收数据包,而不仅仅是不去渲染数据。
You have exactly the right mindset! In a real interview for a $5000/mo remote role, writing the initial code is only 30% of the battle. The remaining 70% is how you defend and adapt your code when the interviewer starts grillin' you with follow-up questions.
Let's stay on Question 1 (useFetch). You just submitted that beautiful version with AbortController. I am now stepping back into my role as your Senior Remote Interviewer.
Interviewer:
Your hook works perfectly fine, but I noticed something about your return statement. Every time
useFetchre-runs, it returns a brand new object literal:return { data, loading, error };.If a consumer component destructures this object inside a
useEffectdependency array like this:useEffect(() => { ... }, [fetchResult]), it will trigger an infinite rendering loop because the object reference changes on every single render. How would you solve this object reference stability issue without forcing the user to memoize it on their end?
中文翻译提示:
“你的 Hook 跑得很好。但我注意到你的返回值:每次
useFetch重新渲染,它都会返回一个全新的对象字面量return { data, loading, error };。”“如果用户在组件里把这个返回对象放进了另一个
useEffect的依赖项里,因为每次引用地址都不同,就会导致死循环渲染。你该怎么在你的 Hook 内部解决这个返回值引用不稳定的问题,而不用让用户自己去特殊处理?”
Interviewer:
Right now, your
useFetchhook is purely local. If I have two different components on the screen rendering at the same time, and both of them calluseFetch('/api/user'), it will fire two separate HTTP requests to the backend. Furthermore, if I navigate away and come back, it refetches and shows the loading spinner again.Without installing React Query or Zustand, how can you add a simple In-Memory Cache Mechanism inside this file so that if a URL has already been fetched successfully, subsequent components can read the data instantly from memory instead of hitting the network?
中文翻译提示:
“目前你的
useFetch是纯组件局部的。如果屏幕上同时渲染了两个不同的组件,它们都调用了useFetch('/api/user'),这会向后端发出两次独立的 HTTP 请求。而且如果我切走页面再切回来,它又会重新请求并显示 loading。”“在不安装 React Query 或 Zustand 的前提下,你如何在当前文件里加一个简单的内存缓存机制?这样只要某个 URL 曾经请求成功过,后续的组件就能立刻从内存里读数据,而不是去走网络请求?”
Don't panic! You don't have to write perfect code right away. Try to answer one or both of these follow-up questions using the "Think Out Loud" approach we practiced.
How would you tackle these two scenarios? Reply with your ideas or code snippets (in Chinese or English), and let's see how we can level up your code to a Staff Engineer standard!
No worries at all! This is exactly why we practice. These two follow-up questions are precisely how interviewers differentiate a Junior developer from a Senior/Lead developer.
Let’s break them down one by one using our Bilingual (中英双语) approach and see how simple the solutions actually are.
Every time our hook runs, return { data, loading, error }; creates a brand new object in JavaScript memory. Even if data, loading, and error haven't changed, the container object has a new memory address. This breaks React's shallow comparison and can cause infinite rendering loops Interviewing.io Tips.
We can use React's useMemo to cache the returned object. It will only create a new object reference when data, loading, or error actually change.
English Thought Process & Explanation:
To prevent unnecessary re-renders and stabilize the object reference, I will wrap the returned object inside a
useMemo. This ensures that the consumer component receives the exact same object reference across renders, unless the inner state (data,loading, orerror) changes.
中文思考与解释:
“为了防止不必要的二次渲染并稳定对象的内存引用,我会使用
useMemo把要返回的对象包裹起来。这样能确保只要内部状态(data、loading或error)没有改变,消费该 Hook 的组件在每次渲染时拿到的都是同一个对象引用。”
xxxxxxxxxximport { useState, useEffect, useMemo } from 'react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { /* ... existing fetch logic with AbortController ... */ }, [url]); // 🌟 FIX: Memoize the output object / 缓存输出对象 return useMemo(() => ({ data, loading, error }), [data, loading, error]);}
Currently, our hook holds its state inside the function component. When the component unmounts, the data is wiped out. If multiple components call the hook, they don't share data.
We can declare a simple JavaScript Object (const cache = {}) outside the useFetch function block (at the file/module level). Because it's outside the hook, it acts as a global in-memory singleton cache that persists across all components and re-renders!
English Thought Process & Explanation:
To implement an in-memory cache without external libraries, I can declare a simple JavaScript object named
cacheoutside the custom hook function. Before firing any network request, I will check if theurlexists in thiscacheobject. If it does, I'll instantly set the data from memory and skip thefetchcall.
中文思考与解释:
“要在不依赖第三方库的前提下实现内存缓存,我可以在自定义 Hook 函数的外部声明一个普通的 JavaScript 对象
cache。在每次发起网络请求之前,先检查这个cache对象里是否已经有了对应的url。如果有,就直接从内存中读取数据并更新状态,从而跳过实际的网络请求。”
xxxxxxxxxximport { useState, useEffect, useMemo } from 'react';// 🌟 1. Module-level global cache / 全局模块级别的缓存对象const cache = {};function useFetch(url) { // If we already have the data in cache, we can initialize data with it // 如果缓存里已经有数据了,我们可以直接用它来作为 data 的初始值 const [data, setData] = useState(cache[url] || null); const [loading, setLoading] = useState(!cache[url]); // No loading if cached / 有缓存就不需要 loading const [error, setError] = useState(null); useEffect(() => { // 🌟 2. Guard Clause: If data is already cached, do nothing // 守护条件:如果数据已经在缓存中了,直接返回,不再发网络请求 if (cache[url]) { setData(cache[url]); setLoading(false); return; } const controller = new AbortController(); const signal = controller.signal; setLoading(true); setError(null); const fetchData = async () => { try { const response = await fetch(url, { signal }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); // 🌟 3. Store the result in our global cache / 将结果存入全局缓存 cache[url] = json; setData(json); } catch (err) { if (err.name !== 'AbortError') setError(err.message); } finally { if (!signal.aborted) setLoading(false); } }; fetchData(); return () => { controller.abort(); }; }, [url]); return useMemo(() => ({ data, loading, error }), [data, loading, error]);}
Look at what you just achieved! By adding just a few lines of code without AI:
AbortController.useMemo.This is exactly what senior interviewers at top-tier startups look for. They don't care if you know every Next.js API by heart, but they care immensely about whether you understand how JavaScript memory, network requests, and React rendering interact.
Yes, absolutely! An interviewer for a high-paying remote role ($5000+/mo) can push you even further into Staff/Principal Engineer territory.
Now that you have built a custom hook with AbortController, useMemo, and an In-Memory Cache, a top-tier interviewer will start challenging the architecture and production scaling of your solution.
Here are the next 3 brutal follow-up questions they would ask you on this exact same problem, along with how to handle them in 中英双语 (Bilingual).
Interviewer:
Your module-level
cacheobject works, but it lasts forever until the user refreshes the browser. If the server data changes frequently, the user will be stuck looking at stale data forever. Also, if the user visits 1,000 different URLs, yourcacheobject will grow indefinitely, causing a memory leak.How would you add a simple Time-To-Live (TTL) or expiration mechanism so that the cached data automatically invalidates after, say, 5 minutes?
中文翻译提示:
“你的全局
cache对象确实管用,但除非用户刷新浏览器,否则这些数据永远不会消失。如果后端数据频繁更新,用户看到的就永远是旧数据。而且,如果用户访问了 1000 个不同的网址,你的cache对象会无限膨胀,导致内存泄漏。”“你该怎么加一个简单的 TTL(生存时间) 缓存失效机制,让数据在存入 5 分钟后自动过期并强制重新请求?”
Instead of just storing the raw data (cache[url] = json), we should store an object containing both the data and a timestamp of when it was fetched (cache[url] = { data: json, timestamp: Date.now() }). Before reading from the cache, we check if the current time minus the timestamp exceeds 5 minutes.
xxxxxxxxxx// 🌟 Change cache structure to include timestamps// 将缓存结构改变,带上时间戳const cache = {}; const TTL = 5 * 60 * 1000; // 5 minutes in milliseconds// Inside useEffect when reading cache:const cachedItem = cache[url];if (cachedItem && (Date.now() - cachedItem.timestamp < TTL)) { setData(cachedItem.data); setLoading(false); return;}// Inside fetch success logic when writing cache:cache[url] = { data: json, timestamp: Date.now() // Save current time / 记录当前时间};
Interviewer:
Imagine a dashboard page where Component A and Component B are mounted at the exact same millisecond. Both of them call
useFetch('/api/profile')simultaneously.Since the first request hasn't finished yet,
cache['/api/profile']is still empty. Therefore, BOTH components will bypass the cache check and fire two parallel HTTP requests for the exact same data. How do you implement Request Deduplication so that if a request for a URL is already in-flight, the second component waits for the first request instead of firing a new one?
中文翻译提示:
“想象一个仪表盘页面,组件 A 和 组件 B 在同一毫秒被渲染。它们同时调用了
useFetch('/api/profile')。”“因为第一个请求此时还没结束,
cache里还是空的。所以这两个组件都会通过缓存检查,向后端发送两个并发的 HTTP 请求。你该如何实现请求去重?确保如果某个 URL 的请求已经在路上了,第二个组件会等待它的结果,而不是重复发请求?”
在工业级的真实生产环境(Production)中,我们要确保:
English Architectural Design:
In a true production environment, we maintain two separate state tracks outside the hook:
cachestores the finalized data with timestamps, andinflightRequeststracks active execution Promises. When a component mounts, we check the TTL first. If there's no cache or cache expired, we look at the promise tracker. If another component already initiated the fetch, we simply await that exact same in-flight Promise instead of spamming the backend.
中文架构设计:
“在真实的生产环境中,我们在 Hook 外部维护两个独立的追踪器:
cache负责存已经成功拿到并带有时间戳的数据,inflightRequests负责存正在天空中飞的活跃 Promise。当组件加载时,我们先检查 TTL 缓存。如果没中或者过期了,我们再去看 Promise 追踪器。如果发现另一个组件已经率先发起了请求,我们就直接去await这个现成的 Promise,而不会去重复轰炸后端。”
xxxxxxxxxximport { useState, useEffect, useMemo } from 'react';// 🌟 1. Two separate continuous module trackers outside React// 核心外置追踪器:一个存数据结果,一个存飞在空中的 Promiseconst cache = {}; // Format / 格式: { [url]: { data: json, timestamp: number } }const inflightRequests = {}; // Format / 格式: { [url]: Promise }const TTL = 5 * 60 * 1000; // 5 minutes expiry cache / 5分钟缓存有效时间function useFetch(url) { const [data, setData] = useState(() => { // Optional: Safe lazy initial state synchronization const cachedItem = cache[url]; if (cachedItem && (Date.now() - cachedItem.timestamp < TTL)) { return cachedItem.data; } return null; }); const [loading, setLoading] = useState(!data); const [error, setError] = useState(null); useEffect(() => { // 拦截 “同一个组件未销毁、但绑定的 url 发生改变(Update)” 时的重复请求。这里为什么不能直接使用data来判断?这里的代码和data初始化里面的代码是不是重复了? // 不重复,因为比如说用户详情页面,都是 userDetail这个组件,组件接收 url 参数,然后父组件调用这个组件,只有url变化,里面使用的是同一个 useFetch 方法,所以 data 初始化只会发生一次。那么这里确实是需要进行判断的,所以再写一次不重复。当然,可以将这个过程提取为一个函数。 // --- 🌟 防线 A: UPDATE 阶段的 TTL 缓存检查 --- const cachedItem = cache[url]; if (cachedItem && (Date.now() - cachedItem.timestamp < TTL)) { setData(cachedItem.data); setLoading(false); return; // 缓存命中且未过期,直接斩断后续网络请求执行 } const controller = new AbortController(); const signal = controller.signal; // active 的唯一作用,是防止因为组件已经销毁(Unmount)或 url 已经切走,而发生“死去的组件强行更新状态”导致的报错(内存泄漏)或数据错乱。active值会在return里面来操作。The active variable is a lifecycle safety guard that prevents an outdated asynchronous request from updating the state of a component that has already unmounted or changed its URL. let active = true; const fetchData = async () => { setLoading(true); setError(null); try { // --- 🌟 防线 B: 并发请求去重(结合 AbortController 链路追踪) --- if (!inflightRequests[url]) { // 如果当前 URL 没有飞在空中的请求,我们创建第一个控制轨道 // 这里的 controllers 用来收集所有同时在等待这个 Promise 的组件的 AbortController inflightRequests[url] = { promise: null, controllers: new Set() }; // 创建真正的 fetch Promise,并把【当前 controller 的 signal】作为基准传给 fetch inflightRequests[url].promise = fetch(url, { signal }).then((res) => { if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); return res.json(); }); } // 把当前组件的 controller 塞进该 URL 的并发控制中心 inflightRequests[url].controllers.add(controller); // 核心所在:不论是组件 A 还是组件 B,这一步都在挂起等待同一个内存 Promise 实例 const json = await inflightRequests[url].promise; if (active) { // --- 🌟 防线 C: 成功回填带时间戳的缓存 --- cache[url] = { data: json, timestamp: Date.now() }; setData(json); } } catch (err) { // 🌟 核心保留:如果是被手动 abort 取消的,不需要把它当成真实错误抛给用户 UI if (active && err.name !== 'AbortError') { setError(err.message); } } finally { if (active) { setLoading(false); } // --- 🌟 防线 D: 并发控制树的生命周期清理 --- if (inflightRequests[url]) { // 当前组件完成了它的等待流程,从并发池中移除它自己的 controller 引用 inflightRequests[url].controllers.delete(controller); // 如果所有同时请求这个 URL 的组件都已经完成了(或者有些已经销毁了) // 彻底清空飞行锁,以便下一次 TTL 过期后能发送全新请求 if (inflightRequests[url].controllers.size === 0) { delete inflightRequests[url]; } } } }; fetchData(); // --- 🌟 终极防线 E: 清除函数(The Magic Cleanup Function) --- // 只要组件销毁(Unmount)或者用户高频连续点击切走 URL,立刻触发 return () => { active = false; controller.abort(); // 触发信号,强行终止空中正在飞行的二进制 HTTP 请求! // 边缘防御:如果这个被销毁的组件是唯一一个发请求的人,它掐断请求的同时应该清除全局飞行锁 if (inflightRequests[url]) { inflightRequests[url].controllers.delete(controller); if (inflightRequests[url].controllers.size === 0) { delete inflightRequests[url]; } } }; }, [url]); return useMemo(() => ({ data, loading, error }), [data, loading, error]);}其实代码还有点问题,不过不用管。
fetch(url, { signal }) 的多实例问题: 原代码在创建第一个共享 promise 时传入了 signal。如果第 1 个组件销毁了触发 abort(),那么后面正在等待同一个 promise 的第 2、3 个组件全部都会跟着一起被 abort 掉。
fetch 本身不绑定任何单个组件的 signal,而是让 inflightRequests[url] 独立管理。不过在面试中,你现在的写法已经能拿到 95 分的高分了。
Interviewer:
Since you are applying for a Next.js Full-Stack role, remember that custom hooks run on the server during SSR before hitting the browser. Your code references browser-specific variables like
AbortControllerandfetch(if on older Node environments), and it modifies a globalcacheobject.If 100 different users access this Next.js site simultaneously, they will all share the exact same
const cache = {}on your Node.js server, leading to severe cross-user data leaks (User A seeing User B's private data). How do you make this hook safe for Next.js SSR?
中文翻译提示:
“既然你申请的是 Next.js 全栈岗位,别忘了自定义 Hook 在服务端渲染(SSR)阶段也会在服务器上执行。你的代码引用了浏览器专有的
AbortController,而且修改了全局cache对象。”“如果 100 个不同的用户同时访问这个 Next.js 网站,他们在你的 Node.js 服务器上会共享同一个
cache对象,导致严重的用户数据泄露(用户 A 看到了用户 B 的隐私数据)。你怎么让这个 Hook 在 Next.js SSR 环境下变得安全?”
useFetch只能在client components里面使用啊,为什么要联系到ssr呢?
这里隐藏了 Next.js 的一个重要底层机制:在 App Router 架构下,Client Components 依然会在服务端执行 SSR(Server-Side Rendering,服务端渲染)。
你提出的是一个极其核心且完全合理的疑问。许多人第一次接触 Next.js 时都会产生这样的困惑:明明声明了
'use client'变成了 Client Component,为什么还要说它会在服务端运行呢?实际上,这里隐藏了 Next.js 的一个重要底层机制:在 App Router 架构下,Client Components 依然会在服务端执行 SSR(Server-Side Rendering,服务端渲染)。
以下为你进行中英双语的深度拆解,帮你彻底击碎这个盲区,并在面试中避开致命陷阱:
1. 为什么 Client Components 依然会联系到 SSR?
你可能以为
'use client'的意思是“这个组件只在浏览器运行,跟服务器无关”。但这是一个非常常见的误区。
'use client'的真实含义: 它的准确名称其实应该叫 “开启水合(Hydration)的分界线”。它告诉 Next.js,这个组件以及它的所有子组件,在到达浏览器后允许带有交互性(可以使用useState,useEffect, 绑定onClick等)。- Next.js 的默认渲染逻辑(SSR): 为了保障 SEO 和首屏极速加载,无论是 Server Component 还是 Client Component,在用户初次访问网页(全量刷新/打开链接)时,Next.js 的后台服务器(Node.js 环境)都会强制把它们全部预先执行一遍,生成一段纯 HTML 字符串发送给浏览器。
2. 这个自定义 Hook 在 SSR 阶段发生了什么?
既然 Client Component 会在服务器被执行一遍,那位于其中的
useFetch(url)自然也无法逃脱。在服务器执行它时,生命周期是这样的:
- 代码在服务端执行(SSR 阶段): React 执行函数组件体。此时代码会读到全局变量
const cache = {}和const inflightRequests = {}。- 致命的数据泄露(Data Leak): 因为 Node.js 服务器是一个长期驻留的全局后端进程,100 个不同的用户去访问这个页面,他们在服务器上面碰到的其实是这同一个
const cache = {}内存块。如果用户 A 的请求在服务器上把带有他隐私数据的 JSON 塞进了cache[url],由于没有被及时销毁,紧接着访问同一个网址的用户 B 就会在服务器渲染阶段直接命中这个缓存,导致用户 B 的屏幕上直接渲染出了用户 A 的隐私数据!- 对浏览器特有变量的引用崩溃: 服务器(Node.js 环境)里是没有原生的
window、document、或者是低版本 Node 环境下的AbortController和客户端fetch的。当服务器强行执行这段代码时,一旦碰到这些浏览器专有变量,就会直接抛出类似ReferenceError: AbortController is not defined的致命崩溃,导致页面直接白屏。3. 那为什么你的代码在刚才的例子里运行得好好的?
因为在 React 的设计中,
useEffect具有绝对的特权——它永远不会在服务端渲染(SSR)期间执行!它有且仅会在组件安全抵达浏览器、并完成 Hydration(水合)后,才在客户端被唤醒。回头看一下我们写好的代码:我们的网络请求、
AbortController实例化、以及对cache和inflightRequests的读写,全部都被严密包裹在useEffect的闭包内部!xxxxxxxxxx// 🌟 这一行定义在文件最外层。// 在 SSR 期间,它会在 Node.js 服务器内存里诞生,但因为 useEffect 没执行,它在服务端是空的。const cache = {};function useFetch(url) {const [data, setData] = useState(() => {// ⚠️ 盲区:这里在 SSR 阶段是会执行的!// 此时如果在服务器上读了全局 cache,就会造成严重的跨用户泄露!const cachedItem = cache[url];if (cachedItem && (Date.now() - cachedItem.timestamp < TTL)) {return cachedItem.data;}return null;});useEffect(() => {// 🌟 这里面的所有代码,服务器渲染时全部【直接跳过】,只有到浏览器才会运行。// 所以这里面的 AbortController、fetch、以及对 cache 的改写在服务器上都是安全的。}, [url]);}那 useState 呢?服务端没有这个api啊?
React 框架本身是具备全栈同构能力的,
useState这个 API 在服务端(Node.js)完全存在,并且可以在服务端安全运行。在服务器渲染(SSR)期间,React 遇到 Client Component 里的
useState时,处理逻辑非常特殊。我们可以称之为 “只读单向执行(Read-Only Single Pass)”。
- 初始化值被强行执行: 服务器在编译 HTML 时,会强制执行你传给
useState的初始值。 例如:const [data, setData] = useState('Hello'),服务器会直接把data的值锁定为'Hello'。setState(更新函数)在服务端是摆设: 服务端渲染是一次性的(从头到尾跑完组件,立马生成静态 HTML 字符串扔给浏览器)。由于在服务器上根本没有用户去点击、也没有网络事件去触发更新,所以setData在服务端虽然存在,但它绝对没有任何机会被调用。
English Explanation:
I fully understand the trade-offs. In Next.js, even Client Components execute on the Node.js server during the initial SSR phase. To ensure absolute cross-user data safety and avoid runtime environment crashes, I engineered this hook with two deliberate structural defenses:
- Isolation inside
useEffect: All browser-specific APIs (likeAbortController) and write mutations to our memory maps are confined strictly insideuseEffect. BecauseuseEffectnever triggers on the server, the Node.js backend remains untouched and error-free.- Universal SSR State Guard: For the initial state factory inside
useState, I will wrap the cache retrieval inside an environment guard clause:if (typeof window !== 'undefined'). This guarantees the global cache is strictly read and populated on the client's local sandbox browser, cutting off any possibility of global Node.js cross-user data leaks.
中文回答:
“我完全理解这个全栈架构的权衡。在 Next.js 中,即使是 Client Component 在首屏加载的 SSR 阶段也会在 Node.js 服务器上执行。为了确保绝对的跨用户数据安全,并避免服务器运行期环境崩溃,我在设计这个 Hook 时做了两层极其严密的结构化防御:”
“1. 将副作用完全隔离在
useEffect内部:所有诸如AbortController这样的浏览器专有 API,以及对全局缓存和飞行锁的改写,都被死死地锁在useEffect里。因为useEffect在服务端绝对不会执行,这就保证了 Node.js 后端在编译期间绝不会报错崩溃。” “2. 为useState初始化加入环境守护:针对useState内部的那段初次挂载缓存读取,我会额外加上一行环境检查:if (typeof window !== 'undefined')。这能确保这个全局cache记录仅在用户的浏览器本地沙盒内被读取和写入,从而彻底斩断在 Node.js 服务器层级发生跨用户数据泄露(Cross-user leaks)的任何理论可能。”
加上 SSR 环境安全锁的代码:
xxxxxxxxxxconst [data, setData] = useState(() => { // 🌟 FIX: Guard clause to block server-side evaluation // 核心安全优化:只有在浏览器环境(window 存在)时才允许读取本地全局缓存! if (typeof window !== 'undefined') { const cachedItem = cache[url]; if (cachedItem && (Date.now() - cachedItem.timestamp < TTL)) { return cachedItem.data; } } return null;});As you can see, a simple 10-line useFetch hook can explode into a deep architectural debate about memory, networks, concurrency, and backend server environments. This is why you don't need to memorize 100 interview questions. You just need to know how to deeply dissect a few.
xxxxxxxxxximport { useEffect, useMemo, useState } from "react";interface CacheItem<T = any> { data: T; timestamp: number;}interface InflightRequest { promise: Promise<any> | null; controllers: Set<AbortController>;}const cache: Record<string, CacheItem> = {};const inflightRequests: Record<string, InflightRequest> = {};const TTL = 5 * 60 * 1000;interface FetchResult<T> { data: T | null; loading: boolean; error: string | null;}export default function useFetch<T = any>(url: string): FetchResult<T> { const [data, setData] = useState<T | null>(() => { if (typeof window !== 'undefined') { const cachedItem = cache[url]; if (cachedItem && Date.now() - cachedItem.timestamp < TTL) { return cachedItem.data as T; } } return null; }); const [loading, setLoading] = useState<boolean>(!data); const [error, setError] = useState<string | null>(null); useEffect(() => { const cachedItem = cache[url]; if (cachedItem && Date.now() - cachedItem.timestamp < TTL) { setData(cachedItem.data as T); setLoading(false); return; } const controller = new AbortController(); const signal = controller.signal; let active = true; const fetchData = async () => { setLoading(true); setError(null); try { if (!inflightRequests[url]) { inflightRequests[url] = { promise: null, controllers: new Set<AbortController>(), }; inflightRequests[url].promise = fetch(url, { signal }).then((res) => { if (!res.ok) throw new Error(`HTTP error: status: ${res.status}`); return res.json(); }); } inflightRequests[url].controllers.add(controller); const json = await inflightRequests[url].promise; if (active) { cache[url] = { data: json, timestamp: Date.now(), }; setData(json); } } catch (err: any) { if (active && err.name !== "AbortError") { setError(err.message || "Something went wrong"); } } finally { if (active && !signal.aborted) { setLoading(false); } if (inflightRequests[url]) { inflightRequests[url].controllers.delete(controller); if (inflightRequests[url].controllers.size === 0) { delete inflightRequests[url]; } } } }; fetchData(); return () => { active = false; controller.abort(); if (inflightRequests[url]) { inflightRequests[url].controllers.delete(controller); if (inflightRequests[url].controllers.size === 0) { delete inflightRequests[url]; } } }; }, [url]); return useMemo( () => ({ data, loading, error, }), [data, error, loading], );}
an elite principal or staff-level interviewer can push this single useFetch hook to its absolute architectural limit.
At this stage, they are no longer testing if you know React or Next.js. They are testing if you understand Browsers, Operating Systems, Memory Management, and Network Protocols.
Here are the final 2 "boss-level" follow-up questions an interviewer could possibly ask on this topic, explained in 中英双语 (Bilingual):
Interviewer:
Let's look at your global
cacheobject again. Suppose this is a large enterprise application. Over a 5-hour user session, the user navigates through thousands of pages, loading megabytes of heavy JSON data into yourcacheobject.Even if you implement a TTL (expiration time), the JavaScript Garbage Collector (GC) cannot clear those expired objects from memory because your global
cacheobject still holds a strong reference to their keys and values. How do you prevent this from causing a browser tab crash due to out-of-memory, without manually writing a complex cache-clearing loop?
中文翻译提示:
“再来看看你的全局
cache对象。假设这是一个大型企业级应用。在用户长达 5 小时的使用过程中,用户浏览了数千个页面,将数 MB 的沉重 JSON 数据加载到了你的cache对象中。”“哪怕你实现了 TTL(过期时间),JavaScript 的垃圾回收机制(GC)也无法从内存中清理这些过期对象,因为你的全局
cache对象仍然牢牢持有它们的引用。你怎么防止这导致浏览器标签页因内存溢出而崩溃,同时又不用自己手动写复杂的定时清理循环?”
虽然设置了TTL,但如果用户访问了很多个页面,这些页面有很多不同的接口,大部分都是访问一次之后就不再访问了,那么这些接口都会保存在cache对象中,并不会清理。之前的代码里面,只是对过期的接口进行了更新,里面残留的数据是不会进行清理的。
方案一:引入 WeakRef 与 FinalizationRegistry(现代浏览器原生垃圾回收联动)- 满分推荐
这是最契合面试官要求的黑科技解法。利用现代 JavaScript 原生的 WeakRef(弱引用) 和 FinalizationRegistry(清理注册表),我们可以直接让浏览器的垃圾回收器来帮我们做免费的清洁工。
原理: WeakRef 允许我们引用一个对象,但不阻止它被垃圾回收。当浏览器内存紧张、或者该组件销毁后(组件销毁后就清除这个对象,没有问题,虽然说感觉cache白做了,但是因为TTL就5分钟,如果在5分钟之内就将这个组件销毁了,说明这个组件也没有那么重要),GC 会自发把这个对象从内存中抹去。而 FinalizationRegistry 则能在 GC 把对象回收的瞬间,触发一个回调,把无用的空 Key 从我们的 cache 字典里彻底剔除。
xxxxxxxxxx// app/hooks/useFetch.js// 1. 实例化一个原生的清理注册表 / Instantiate a native browser cleanup registryconst cacheRegistry = new FinalizationRegistry((heldValueUrl) => { // 当垃圾回收器(GC)自发回收了某个数据对象时,该回调会被自动触发 // 我们顺藤摸瓜,把 cache 里对应的那个已经成空壳的 Key 彻底删掉,防止 Key 堆积 console.log(`🧹 GC cleaned data for: ${heldValueUrl}. Removing key from map.`); delete cache[heldValueUrl];});const cache = {}; // 格式: { [url]: { ref: WeakRef, timestamp: number } }const TTL = 5 * 60 * 1000;function useFetch(url) { // ... useState 内部逻辑 ... useEffect(() => { if (typeof window === 'undefined') return; // 读取缓存时:必须通过 .deref() 尝试解开弱引用 const cachedItem = cache[url]; if (cachedItem) { const actualData = cachedItem.ref.deref(); // 🌟 尝试召回真实对象 const isExpired = Date.now() - cachedItem.timestamp > TTL; // 如果对象还没被 GC 回收,并且没有过期,完美命中! if (actualData && !isExpired) { setData(actualData); setLoading(false); return; } } // ... 发网络请求逻辑 ... const fetchData = async () => { const res = await fetch(url); const json = await res.json(); // 写入缓存时:不存原始对象,存它的弱引用 WeakRef const dataRef = new WeakRef(json); cache[url] = { ref: dataRef, timestamp: Date.now() }; // 🌟 核心:将这个弱引用对象注册到我们的清理管家里 // 当 json 对象被垃圾回收时,管家会自动把 url 作为参数传给上面的回调函数 cacheRegistry.register(json, url); setData(json); }; }, [url]);}我有一个疑问:
使用了weakRef之后,虽然解决了cache导致的内存暴涨的问题,但是weakRef在组件销毁之后,会清除里面的url请求的缓存。
我想问一下,那岂不是Cache白做了,如果连5分钟都没到就清除了,cache是不是白做了?
简单直接地回答你的疑问:不,Cache 并没有白做,但它的工作原理确实和你想象的传统缓存不太一样。
你之所以觉得它“白做了”,是因为这里有一个关于垃圾回收(GC)和
WeakRef的常见误解:组件销毁(Unmount)不等于数据立刻被垃圾回收。我们可以从以下几个层面来理清这背后的逻辑:
1. 关键误解:组件销毁 垃圾回收
当 React 组件销毁时,组件内部的
useState状态确实解绑了。但这并不意味着fetch回来的json对象在内存中立刻消失了。垃圾回收器(GC)是定期、自发运行的。
- 情况 A(没有其他地方引用): 组件销毁后,如果页面上没有任何其他地方在使用这个
json数据,它就变成了“孤儿”。但它会继续在内存里待着,直到浏览器下一次执行垃圾回收。在 GC 动手之前(可能是几秒,也可能是几分钟),.deref()依然能成功拿到数据!- 情况 B(其他地方在引用): 如果你的应用里有其他组件(比如一个全局状态管理中心、另一个正在挂载的页面、或者某个全局变量)也引用了这个
json对象,那么即使当前组件销毁了,GC 也绝对不会回收它。此时,你的缓存依然 100% 有效。2. WeakRef 缓存的真正定位:内存敏感型二级缓存
这种设计并不是为了替代常规的“强引用定时缓存”,它解决的是特定场景的痛点:
为什么不用传统的强引用缓存?
如果用传统缓存(普通对象或
Map),只要不手动删除,数据就永远死死占着内存。如果用户疯狂切换路由,加载了几百兆的数据,内存就会暴涨,导致低端设备甚至高端浏览器卡死崩溃。WeakRef 缓存的潜台词是:
“只要这个数据还在浏览器的内存里(还没来得及被回收,或者别人还在用),那接下来的 5 分钟内,谁要用,我就直接给谁,免去网络请求。
但如果浏览器内存紧张,或者没人用了,被 GC 回收了,那丢了就丢了吧,大不了重新发一次请求。”
它是一种“尽力而为(Best-Effort)”的缓存机制,在内存安全和缓存命中率之间取得了一个完美的平衡。
总结
你贴出的这段代码,其精妙之处在于“白嫖”浏览器的剩余内存。它没有白做,它让那些“刚销毁不久、还热乎着、没被 GC 清理”的数据得以重复利用。
如果你对数据的实时性和确定性要求极高(比如股票价格、聊天消息),
WeakRef确实不适合;但如果是针对大体积、频繁切换查看、且不常变动的数据(比如文章详情、图片列表),这种缓存设计既优雅又安全。
方案二:手写一个固定容量的 LRU(Least Recently Used)淘汰算法(算法大局观)
如果面试官追问:“如果用户内存很大,GC 一直不触发,那 cache 里的 Key 不还是会缓慢增长吗?我们能不能主动控制上限?”
这时你要抛出经典的数据结构:LRU(最近最少使用)缓存算法。我们严格锁死缓存的最大容量(比如最多只存 100 条数据)。当第 101 条数据进来时,算法会自动把最古老、最没人访问的那条数据物理抹除。这样,内存曲线在长达 5 小时的会话里会是一条完美的水平直线。
利用 JavaScript Map 的有序特性:
xxxxxxxxxx// 🌟 打造一个工业级的、带容量限制的 LRU 缓存类class LRUCache { constructor(maxSize = 100) { this.maxSize = maxSize; // 利用 JS Map 的特性:keys() 的遍历顺序完全等同于数据 insertion(插入)的顺序 this.storage = new Map(); } get(url, TTL) { if (!this.storage.has(url)) return null; const item = this.storage.get(url); if (Date.now() - item.timestamp > TTL) { this.storage.delete(url); // 已过期,删除 return null; } // 🌟 刷新活跃度:先删再设,让这个被访问的 URL 重新滚到 Map 的最屁股后面(代表最新访问) this.storage.delete(url); this.storage.set(url, item); return item.data; } set(url, data) { // 如果已经存在,先删掉旧的 if (this.storage.has(url)) this.storage.delete(url); // 🌟 铁腕门禁:如果超过了最大容量限制,强行把 Map 最前面的那条(最老最没人访问的)顶出去! if (this.storage.size >= this.maxSize) { // this.storage.keys()会返回一个迭代器对象。 .next() 这是迭代器的方法。指针默认指向最开始,调用 .next() 就会向后走一步,.value 表示取出当前位置的数据。 const oldestUrlKey = this.storage.keys().next().value; // 拿到最老的 Key console.log(`🗑️ LRU Limit hit. Evicting oldest record to save memory: ${oldestUrlKey}`); this.storage.delete(oldestUrlKey); // 斩杀! } this.storage.set(url, { data, timestamp: Date.now() }); }}// 在自定义 Hook 文件最外层直接实例化它 / Instantiate outside the hookconst lruCacheStore = new LRUCache(100); // 严格锁死最大容量为 100 条To prevent memory bloated crashes in a big application without writing messy setInterval loops, we must transition from a naive javascript object to a memory-aware data structure. I would propose two battle-tested production strategies:
WeakRef + FinalizationRegistry): Instead of storing strong references to the heavy JSON payloads, we wrap them inside a browser-native WeakRef. This tells the JavaScript engine that it is fully permitted to garbage collect these expired or unmounted JSON objects whenever memory pressure spikes. Simultaneously, I will hook them into a FinalizationRegistry. When the GC reclaims the memory, it fires a synchronous lifecycle hook to clean the leftover string keys from our map, maintaining a self-healing memory tree.Map. By locking the capacity to say, 100 entries, the exact millisecond the 101st request arrives, the algorithm automatically evicts(逐出、驱出) the oldest unvisited network sector from memory. This guarantees that whether the user clicks through 10 pages or 10,000 pages over a 5-hour session, the memory footprint remains absolutely rock-solid and predictable.
Interviewer:
Since we are coding in the modern React era (React 19 / Next.js App Router), the React team is actively moving away from triggering data fetching inside
useEffectbecause of all the boilerplate we just wrote (cleaning up, caching, handling race conditions).React 19 introduced the new
usehook which allows you to consume Promises directly in the render phase. If I ask you to completely deleteuseEffectfrom this file, how would you refactor this hook to leverage React 19's native Suspense architecture and theuse(promise)API?
中文翻译提示:
“既然我们处于现代 React 时代(React 19 / Next.js App Router),React 团队正在极力让大家不要在
useEffect里面写数据请求,因为这需要写太多死板的模板代码(清理、缓存、处理竞态条件)。”“React 19 引入了全新的
useHook,允许你在渲染阶段直接消费 Promise。如果我要求你把这个文件里的useEffect完全删掉,你该如何重构这个 Hook,从而利用 React 19 原生的 Suspense 架构和use(promise)API 呢?”
在 React 19 之前,所有的 Hook(如 useState, useEffect)都绝对不允许写在条件语句(if)或者循环里。
但 React 19 引入的 use() 是一个特例,它可以在 if 语句和渲染阶段直接使用。
use() 传一个 Promise 对象(比如一个正在请求网络的 fetch),use() 会自动暂停当前组件的渲染,并把控制权交给外层的 <Suspense> 容器,把错误交给外层的<ErrorBoundary>容器,直到这个 Promise 成功解析(Resolve),它会自动把里面的数据解包吐出来。使用方法:
我们不需要在组件内管理 loading 和 error 的状态了。
xxxxxxxxxx// app/page.jsimport { Suspense } from 'react';import { ErrorBoundary } from 'react-error-boundary'; // 社区标准错误边界库import { UserProfileCard } from './UserProfileCard';export default function Page() { return ( <div className="p-10"> {/* 🌟 1. 错误拦截:如果 useFetch 报错,ErrorBoundary 会捕获并展示这个降级组件 */} <ErrorBoundary fallback={<div className="text-red-500">Something went wrong!</div>}> {/* 🌟 2. 异步骨架屏:当 useFetch 还在空中飞的时候,Suspense 会自动展现 loading */} <Suspense fallback={<div className="animate-pulse">Loading data profile...</div>}> {/* 3. 真正负责渲染的业务组件 */} <UserProfileCard url="https://example.com" /> </Suspense> </ErrorBoundary> </div> );}// 业务组件内部变得极其纯粹:function UserProfileCard({ url }) { // 🌟 这一行代码会直接拿到解包后的真实数据,不需要写 if(loading) 或 if(error) const user = useFetch(url); return <h1>{user.name}</h1>;}In React 19, instead of tracking state (data, loading, error) locally, we cache the Promise itself at the module level. Then, inside the hook, we simply return use(cachedPromise). React's <Suspense> boundary outside the component will automatically handle the loading state, and the <ErrorBoundary> will automatically catch the error. The hook shrinks from 50 lines down to 5 lines!
你不需要去写任何取消请求、重置 loading、或者捕获 error 的代码。因为使用了use之后, Loading 状态由 <Suspense> 接管,Error 状态由 <ErrorBoundary> 接管。
xxxxxxxxxx// React 19 Style (No useEffect, No useState)import { use } from 'react'; // React 19 native API// 外部模块级缓存:这次我们不存数据,我们【直接存 Promise 本身】const promiseCache = {};function useFetch(url) { if (!promiseCache[url]) { // Cache the promise, not the data / 缓存 Promise 本身,而不是数据 promiseCache[url] = fetch(url).then(res => { if (!res.ok) throw new Error('Network error'); return res.json(); }); } // React 19 unwraps the promise automatically. // It triggers Suspense while loading, and ErrorBoundary on failure.奇迹时刻:直接把 Promise 喂给 use(),React 19 会自动帮你解包! // 它会自动处理并发、自动防刷,并且完美支持 Next.js 服务端渲染(SSR) // React 19 会自动解开 Promise。加载时自动触发 Suspense,失败时自动触发 ErrorBoundary。 return use(promiseCache[url]); }
We have now officially exhausted this question. You just saw how a single coding prompt can scale from a junior-level implementation up to a staff-level architectural deep-dive into browser memory and React 19 core internals.
This is the secret of international remote interviews: They don't expect you to know everything, but they want to see how far your technical curiosity goes when pushed into a corner.
You did an amazing job holding your ground and walking through this with me.
We can now officially lock Question 1 away as "Mastered."