异步组件会让整页加载变慢,即使已经使用Suspense和回退内容

前端开发 2026-07-08

当单击Meals Link 时,整个 MealsPage 会在经过一个故意的3 秒延迟后显示。页面分为两部分,header 和实际的餐点内容。

meals页面中的静态部分应立即呈现。一旦数据被获取,加载指示器应被移除,内容应渐进显示,加载指示器在数据准备就绪前保持可见

然而,尽管 Meals 元素被包装在 Suspense 组件中,且 fallback 的设定为 LoadingMeals,完整页面仍然要等到延迟之后才显示。

这两个 GIFs 同时展示了在 header 中点击meals链接,以及对页面进行强制刷新。两者都体现了页面渲染前的一个 3秒延迟。在控制台中,NextJS 对这两种情况都输出 ≈3000ms

GET /meals 200 in 3018ms
GET /meals 200 in 3032ms

The link click

The page refresh

根节点 page.js

import Link from "next/link";

export default function Home() {
  return (
    <main style={{ maxWidth: '38rem', margin: '0 auto', padding: '3rem 1.5rem' }}>
      <h1 style={{ fontSize: '2rem', margin: '0 0 1rem' }}>Slow Kitchen</h1>
      <p style={{ color: 'var(--muted)', lineHeight: 1.6 }}>
        Recipes that reward patience — and, conveniently, a 3-second
        artificial delay that stands in for a slow database call.
      </p>

      <div
        style={{
          marginTop: '2rem',
          padding: '1.25rem',
          background: 'var(--bg-soft)',
          border: '1px solid var(--border)',
          borderRadius: '8px',
        }}
      >
        <p style={{ margin: '0 0 0.75rem' }}>
          <strong>Try it:</strong> open <Link href="/meals">Meals</Link>, then hard-refresh
          on that page once it loads.
        </p>
      </div>
    </main>
  );
}

根节点 layout.js

import './globals.css';
import Link from 'next/link';

export const metadata = {
    title: 'Slow Kitchen — streaming repro',
    description: 'Minimal Next.js repro for a Suspense streaming issue',
};

export default function RootLayout({children}) {
    return (
        <html lang="en">
        <body>
        <header
            style={{
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                padding: '1.25rem 2rem',
                borderBottom: '1px solid var(--border)',
            }}
        >
            <a href="/" style={{color: 'var(--text)', fontFamily: 'Georgia, serif', fontSize: '1.1rem'}}>
                🍲 Slow Kitchen
            </a>
            <nav style={{display: 'flex', gap: '1.5rem'}}>
                <Link href="/meals">Meals</Link>
            </nav>
        </header>
        {children}
        </body>
        </html>
    );
}

餐点 page.js

import { Suspense } from 'react';

import { getAllMeals } from '@/lib/meals';
import LoadingMeals from './loading-meals';

export const dynamic = 'force-dynamic';

async function Meals() {
  const meals = await getAllMeals();
  return (
    <ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '1rem' }}>
      {meals.map((meal) => (
        <li
          key={meal.id}
          style={{
            padding: '1rem 1.25rem',
            background: 'var(--bg-soft)',
            border: '1px solid var(--border)',
            borderRadius: '8px',
          }}
        >
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
            <strong>{meal.title}</strong>
            <span style={{ fontSize: '0.85rem', color: 'var(--accent)' }}>{meal.time}</span>
          </div>
          <p style={{ margin: '0.4rem 0 0', color: 'var(--muted)' }}>{meal.summary}</p>
        </li>
      ))}
    </ul>
  );
}

export default function MealsPage() {
  return (
      <>
          <main style={{maxWidth: '38rem', margin: '0 auto', padding: '3rem 1.5rem'}}>
              <h1 style={{fontSize: '1.75rem', margin: '0 0 0.5rem'}}>Tonight's meals</h1>
              <p style={{color: 'var(--muted)', marginBottom: '1.5rem'}}>
                  This header should appear instantly, before the meal list below.
              </p>
          </main>

          <Suspense fallback={<LoadingMeals/>}>
              <Meals/>
          </Suspense>
      </>
  );
}

loading-meals.js

export default function LoadingMeals() {
  return (
    <p style={{ color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
      <span
        aria-hidden="true"
        style={{
          width: '0.5rem',
          height: '0.5rem',
          borderRadius: '50%',
          background: 'var(--accent)',
          display: 'inline-block',
          animation: 'pulse 1.2s ease-in-out infinite',
        }}
      />
      Simmering the menu…
      <style>{`
        @keyframes pulse {
          0%, 100% { opacity: 0.3; transform: scale(0.85); }
          50% { opacity: 1; transform: scale(1); }
        }
      `}</style>
    </p>
  );
}

meals.js(模拟一个慢数据库):

const DUMMY_MEALS = [
    {id: 1, title: 'Braised Short Ribs', time: '6 hours', summary: 'Falls apart at the touch of a fork.'},
    {
        id: 2,
        title: 'Slow-Roasted Tomato Sauce',
        time: '4 hours',
        summary: 'Reduced low and slow until it tastes like summer.'
    },
    {id: 3, title: 'Overnight Sourdough', time: '14 hours', summary: 'Most of the work happens while you sleep.'},
];

export async function getAllMeals() {
    // Simulates a slow database/network call (the thing Suspense is meant to hide)
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return DUMMY_MEALS;
}

软件包版本:

"next": "^15.0.0",
"react": "^19.0.0",

更新06/30:

ullas kunder 的评论指出他这边一切功能正常后,我决定在不同环境中测试应用。我把它上传到这个 stackblitz 沙箱,以验证其是否按预期运行,答案是肯定的。我还在StackBlitz中调整了 dependenciespackage.json 的设置,以确保顺利运行且不产生冲突。我在本地机器上也匹配了这些依赖项,希望能解决问题,但似乎都没有效果。

随后,我使用另一种IDE,VScode 运行应用,出乎意料地,在VS Code的集成浏览器中启动后,表现如预期。然而,在 ChromeFirefoxEdge 中仍然无法正确工作。

我真的很困惑。为什么会这样,明明同一份代码库却有不同的表现?我不确定自己是否遗漏了什么。如果有人有建议,请告诉我。

PS:我在开发模式和构建模式下都对应用进行了测试。

解决方案

原来是杀毒软件的网页保护在缓冲响应流,而不是Next.js或 React的问题(来自我们调试时的Reddit讨论)。

发生了什么

Suspense流式传输通过一个HTTP响应分块发送页面:先发送shell,挂起的部分随后在同一连接上到达。带有“网页保护”的安全软件(如ESET、Kaspersky、Avast、Sophos等)会拦截本地主机的HTTP流量,并在向浏览器释放前缓冲整份响应。因此浏览器在3 秒后一次性收到shell与内容,这也解释了控制台为什么在没有渐进渲染时显示 GET /meals 200 in 3018ms

为什么看起来像环境相关

  • Chrome/Firefox/Edge:流量通过AV代理,流被缓冲
  • VS Code的嵌入式浏览器:不经过AV网保护层
  • StackBlitz:开发服务器在浏览器内部运行(WebContainers),中间没有缓冲

如何验证

curl -N http://localhost:3000/meals

-N 会禁用curl自身的缓冲。如果shell HTML立即出现,且meals的标记大约3 秒后到达,那么应用的流传输正常,问题出在服务器和浏览器之间。

修复/变通方法

  1. 使用HTTPS运行开发服务器:next dev --experimental-https。AV网保护通常无法缓冲TLS流量,因此流能原封不动地通过(这也是本案中得到的确认)。
  2. 或将localhost添加到AV的网页保护排除列表。

同样的情况在Next.js的仓库中也有记录:https://github.com/vercel/next.js/discussions/70763(在一台机器上可用,在另一台机器上被缓冲;那里的元凶是Sophos和 Kaspersky)。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章