异步组件会让整页加载变慢,即使已经使用Suspense和回退内容
当单击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
根节点 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中调整了 dependencies 在 package.json 的设置,以确保顺利运行且不产生冲突。我在本地机器上也匹配了这些依赖项,希望能解决问题,但似乎都没有效果。
随后,我使用另一种IDE,VScode 运行应用,出乎意料地,在VS Code的集成浏览器中启动后,表现如预期。然而,在 Chrome、Firefox 或 Edge 中仍然无法正确工作。
我真的很困惑。为什么会这样,明明同一份代码库却有不同的表现?我不确定自己是否遗漏了什么。如果有人有建议,请告诉我。
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 秒后到达,那么应用的流传输正常,问题出在服务器和浏览器之间。
修复/变通方法
- 使用HTTPS运行开发服务器:
next dev --experimental-https。AV网保护通常无法缓冲TLS流量,因此流能原封不动地通过(这也是本案中得到的确认)。 - 或将localhost添加到AV的网页保护排除列表。
同样的情况在Next.js的仓库中也有记录:https://github.com/vercel/next.js/discussions/70763(在一台机器上可用,在另一台机器上被缓冲;那里的元凶是Sophos和 Kaspersky)。