Next.js GET 请求为何会执行多次而不是一次?
结论
这 6 行输出反映的是组件渲染次数,无法据此判断 GET 请求是否执行了 6 次。console.log(post) 写在 PromptCard 的组件函数中,每次渲染都会运行。
结合输出顺序,可能经历了以下过程:
posts的初始值是[],首次渲染时data[0]为undefined。- 开发环境启用了 React Strict Mode,React 会额外执行组件渲染和 Effect,以检查副作用。
useEffect可能发起两次请求。两次响应分别调用setPosts(),触发后续渲染。- 控制台最终出现两次
undefined和四次对象输出。
这符合 Strict Mode 下的常见表现,但 React 并不保证固定输出 6 次。热更新、父组件重新挂载和路由切换等操作也会影响实际次数。
为什么前两次是 undefined
useEffect 会在首次渲染完成后执行。此时请求尚未返回,posts 仍是空数组:
const [posts, setPosts] = useState([]);
因此:
posts[0] === undefined
组件仍会立即执行:
<PromptCard post={data[0]} />
PromptCard 收到的 post 也就成了 undefined。如果组件接着读取 post.prompt,会抛出类似下面的错误:
Cannot read properties of undefined
MongoDB 中有多少条数据不会改变这个过程。查询是异步的,客户端要等请求完成后才能取得结果。
区分请求次数和渲染次数
PromptCard 中的日志只能用来观察渲染次数:
const PromptCard = ({ post }) => {
console.log(post);
// ...
};
若要核对请求次数,可以分别记录 Effect、请求和路由处理函数:
useEffect(() => {
console.count("Feed effect");
const fetchPosts = async () => {
console.count("GET /api/prompt started");
const response = await fetch("/api/prompt");
const data = await response.json();
console.count("GET /api/prompt completed");
setPosts(data);
};
fetchPosts();
}, []);
也可以临时在 API 路由中加入服务端日志:
export const GET = async () => {
console.count("API route GET");
try {
await connectToDb();
const prompts = await Prompt.find({}).populate("creator");
return Response.json(prompts);
} catch (error) {
console.error(error);
return new Response("Failed to fetch prompts", { status: 500 });
}
};
浏览器开发者工具中的 Network 面板会显示实际发出的 Fetch/XHR 请求。服务端的 console.count("API route GET") 通常会打印在运行 Next.js 的终端中。
处理初始空数据
数据返回前,先不要渲染依赖该数据的 PromptCard。可以为页面加入 loading、error 和空结果状态:
"use client";
import { useEffect, useState } from "react";
const PromptCard = ({ post }) => {
return (
<div className="prompt_card">
<p>{post.prompt}</p>
<span>{post.tag}</span>
</div>
);
};
const PromptCardList = ({ data }) => {
if (data.length === 0) {
return <p>No prompts found.</p>;
}
return (
<div className="mt-16 prompt_layout">
{data.map((post) => (
<PromptCard key={post._id} post={post} />
))}
</div>
);
};
const Feed = () => {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchPosts = async () => {
try {
const response = await fetch("/api/prompt", {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error("API response is not an array");
}
setPosts(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
fetchPosts();
return () => {
controller.abort();
};
}, []);
if (isLoading) {
return <p>Loading prompts...</p>;
}
if (error) {
return <p>Failed to load prompts.</p>;
}
return (
<section className="feed">
<PromptCardList data={posts} />
</section>
);
};
export default Feed;
API 返回的是数组,所以即使数据库目前只有一条记录,也适合用 .map() 渲染。这样可以统一处理零条、一条和多条数据,同时避免直接访问不存在的 data[0]。
如果业务只需要显示第一条,应先检查这条数据是否存在:
const PromptCardList = ({ data }) => {
const post = data[0];
if (!post) {
return <p>No prompt found.</p>;
}
return (
<div className="mt-16 prompt_layout">
<PromptCard post={post} />
</div>
);
};
也可以在父组件中进行条件渲染:
{posts.length > 0 && <PromptCard post={posts[0]} />}
Strict Mode 为什么会重复执行 Effect
在开发环境中,React Strict Mode 可能额外执行一次“挂载、清理、再次挂载”流程,用来发现未正确清理的副作用,例如没有取消的请求、重复订阅和遗留的定时器。
空依赖数组表示 Effect 在每次挂载时执行一次:
useEffect(() => {
fetchPosts();
}, []);
如果 Strict Mode 触发了额外的挂载流程,开发环境中的页面生命周期内仍可能发出多次请求。生产构建通常不会以同样的方式执行这项检查。
不必为了消除重复日志而关闭 Strict Mode。可以让 Effect 支持安全清理,例如使用 AbortController。同时要避免把写入、扣款或发送消息等不可重复的操作直接放进可能重复执行的 Effect,GET 接口也应保持幂等。
要确认现象是否由开发模式引起,可以运行生产构建进行对比:
npm run build
npm run start
项目使用的包管理器可能不同,具体命令以 package.json 中的 scripts 为准。
Network 面板中出现多个 GET 请求时怎么排查
如果 Network 面板确认出现了多个真实请求,可以按以下顺序检查:
- 筛选 Fetch/XHR,核对请求数量、状态以及是否标记为 canceled。
- 在
useEffect内使用console.count(),确认 Effect 的执行次数。 - 在 API 路由中记录服务端收到请求的次数。
- 检查多个页面或布局是否同时渲染了
Feed。 - 检查父组件是否传入了不断变化的
key,导致Feed反复卸载和重新挂载。 - 排查 Fast Refresh、保存文件、路由跳转和浏览器扩展产生的额外请求。
- 搜索代码库中是否还有其他
fetch("/api/prompt")调用。 - 对比开发模式与生产构建的结果。
AbortController 能阻止已经卸载的组件继续处理响应,但请求发出后,服务端可能已经收到了它。Network 面板也可能保留被取消的请求记录。
为 API 路由补充错误检查
App Router 的 Route Handler 可以直接返回 Response.json()。建议同时记录异常,否则客户端只能收到笼统的 500 响应:
import { connectToDb } from "@utils/database";
import Prompt from "@models/prompt";
export const GET = async () => {
try {
await connectToDb();
const prompts = await Prompt.find({}).populate("creator");
return Response.json(prompts, { status: 200 });
} catch (error) {
console.error("Failed to fetch prompts:", error);
return Response.json(
{ message: "Failed to fetch prompts" },
{ status: 500 }
);
}
};
示例文档中没有 creator 字段。如果 Prompt schema 也没有定义该路径,.populate("creator") 可能报错,具体行为取决于 Mongoose 的版本和配置。页面不需要创建者信息时,可以暂时移除 .populate("creator");需要这项信息时,则要确认 schema 中存在对应的引用字段。
当前渲染失败是因为组件没有处理异步请求完成前的空数组。开发环境中的重复渲染通常会产生重复日志,是否真的发出了多个 GET 请求,还需查看 Network 面板和服务端日志。
备注:内容仅供参考。
