PAYATHON 2026

Next.js GET 请求为何会执行多次而不是一次?

排障赵师傅

结论

这 6 行输出反映的是组件渲染次数,无法据此判断 GET 请求是否执行了 6 次。console.log(post) 写在 PromptCard 的组件函数中,每次渲染都会运行。

结合输出顺序,可能经历了以下过程:

  1. posts 的初始值是 [],首次渲染时 data[0] 为 undefined。
  2. 开发环境启用了 React Strict Mode,React 会额外执行组件渲染和 Effect,以检查副作用。
  3. useEffect 可能发起两次请求。两次响应分别调用 setPosts(),触发后续渲染。
  4. 控制台最终出现两次 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 面板确认出现了多个真实请求,可以按以下顺序检查:

  1. 筛选 Fetch/XHR,核对请求数量、状态以及是否标记为 canceled。
  2. 在 useEffect 内使用 console.count(),确认 Effect 的执行次数。
  3. 在 API 路由中记录服务端收到请求的次数。
  4. 检查多个页面或布局是否同时渲染了 Feed。
  5. 检查父组件是否传入了不断变化的 key,导致 Feed 反复卸载和重新挂载。
  6. 排查 Fast Refresh、保存文件、路由跳转和浏览器扩展产生的额外请求。
  7. 搜索代码库中是否还有其他 fetch("/api/prompt") 调用。
  8. 对比开发模式与生产构建的结果。

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 面板和服务端日志。

备注:内容仅供参考。