组件懒加载与 Suspense
不是所有代码都需要在首屏加载。代码编辑器、图表库、Markdown 预览器——这些大型组件可以延迟加载,等用户需要时再下载。本章讲清
React.lazy、next/dynamic、Suspense三个工具的用法、区别和最佳实践。
1. 为什么需要懒加载
1.1 问题:首屏 JS 过大
典型 AI SaaS 首屏加载的 JS 大小:
不做懒加载:
├── React + Next.js runtime ~80KB
├── shadcn/ui 组件 ~60KB
├── recharts 图表库 ~200KB ← 仪表盘才用
├── Monaco Editor ~800KB ← Prompt 编辑器才用
├── Markdown 渲染器 ~150KB ← 对话页面才用
└── 总计:~1.3MB(gzip 后 ~400KB)
做了懒加载:
├── React + Next.js runtime ~80KB
├── shadcn/ui 组件 ~60KB
└── 总计:~140KB(gzip 后 ~45KB)
└── 其他模块在需要时才下载
1.2 三种工具对比
| 工具 | 运行环境 | SSR 支持 | 推荐场景 |
|---|---|---|---|
React.lazy | Client only | ❌ 需配合 Suspense | 简单的客户端组件 |
next/dynamic | Client + Server | ✅ 可选 SSR | Next.js 项目首选 |
<Suspense> | Server + Client | ✅ | 异步 Server Component |
2. next/dynamic
2.1 基础用法
import dynamic from 'next/dynamic'
// 懒加载 Markdown 编辑器
const MarkdownEditor = dynamic(() => import('@/components/markdown-editor'), {
loading: () => <div className="h-64 animate-pulse bg-muted rounded-lg" />,
})
export default function PromptEditorPage() {
return (
<div>
<h1>Prompt 编辑器</h1>
<MarkdownEditor /> {/* 首次渲染时才下载 JS */}
</div>
)
}2.2 禁用 SSR
某些组件完全依赖浏览器 API,不能在服务端渲染:
// Monaco Editor 需要 window/document,必须禁用 SSR
const CodeEditor = dynamic(
() => import('@/components/code-editor'),
{
ssr: false,
loading: () => (
<div className="h-96 flex items-center justify-center bg-muted rounded-lg">
<span className="text-muted-foreground">编辑器加载中...</span>
</div>
),
},
)
// react-pdf 需要 canvas API
const PDFViewer = dynamic(() => import('@/components/pdf-viewer'), { ssr: false })
// 地图组件
const MapView = dynamic(() => import('@/components/map-view'), { ssr: false })2.3 命名导出
// 组件使用命名导出时
const ChatChart = dynamic(
() => import('@/components/charts').then((mod) => mod.TokenUsageChart),
{ loading: () => <ChartSkeleton /> },
)2.4 条件加载
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const HeavyModal = dynamic(() => import('@/components/heavy-modal'))
export function SettingsButton() {
const [showModal, setShowModal] = useState(false)
return (
<>
<button onClick={() => setShowModal(true)}>高级设置</button>
{showModal && <HeavyModal onClose={() => setShowModal(false)} />}
{/* JS 只在用户点击按钮后才下载 */}
</>
)
}3. React.lazy
3.1 基础用法
'use client'
import { lazy, Suspense } from 'react'
const ChatChart = lazy(() => import('@/components/chat-chart'))
export function DashboardPanel() {
return (
<Suspense fallback={<ChartSkeleton />}>
<ChatChart />
</Suspense>
)
}3.2 React.lazy vs next/dynamic
| 维度 | React.lazy | next/dynamic |
|---|---|---|
| SSR | ❌ 不支持 | ✅ 默认支持 |
| 禁用 SSR | N/A | { ssr: false } |
| loading 状态 | 需要 <Suspense> 包裹 | 内置 loading 选项 |
| 命名导出 | 需要 .then() | 需要 .then() |
| 推荐度 | 通用 React | Next.js 项目首选 |
在 Next.js 项目中,推荐统一使用 next/dynamic。
4. Suspense 深入
4.1 Server Component 的 Suspense
Suspense 在 Server Component 中实现流式渲染——数据准备好的部分先发送:
// app/(dashboard)/page.tsx (Server Component)
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div className="grid grid-cols-3 gap-6">
<Suspense fallback={<StatsSkeleton />}>
<UsageStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<TokenChart />
</Suspense>
<Suspense fallback={<ListSkeleton />}>
<RecentChats />
</Suspense>
</div>
)
}
// 每个组件独立异步
async function UsageStats() {
const stats = await getUsageStats() // 200ms
return <StatsCard stats={stats} />
}
async function TokenChart() {
const data = await getTokenUsage() // 500ms
return <Chart data={data} />
}
async function RecentChats() {
const chats = await getRecentChats() // 100ms
return <ChatList chats={chats} />
}4.2 嵌套 Suspense
export default function ChatPage() {
return (
<Suspense fallback={<PageSkeleton />}>
{/* 外层:页面级骨架 */}
<ChatContent />
</Suspense>
)
}
async function ChatContent() {
const chat = await getChat() // 先加载对话信息
return (
<div>
<ChatHeader chat={chat} />
<Suspense fallback={<MessagesSkeleton />}>
{/* 内层:消息列表单独 loading */}
<MessageList chatId={chat.id} />
</Suspense>
<ChatInput />
</div>
)
}用户体验:
T=0ms 页面级骨架屏
T=150ms 对话头部 + 输入框出现 + 消息列表骨架
T=400ms 消息列表加载完成
4.3 loading.tsx vs Suspense
loading.tsx = 页面级自动 Suspense
├── 适合:整个页面的 loading 状态
└── 位置:app/chat/loading.tsx
<Suspense> = 组件级手动 Suspense
├── 适合:页面内的独立区域
└── 位置:在 page.tsx 中包裹特定组件
两者可以组合使用:
app/(dashboard)/
├── loading.tsx → 仪表盘整体骨架
└── page.tsx
├── <Suspense> → 统计卡片区域
├── <Suspense> → 图表区域
└── <Suspense> → 对话列表区域
5. 预加载策略
5.1 鼠标 Hover 预加载
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
// 定义懒加载组件
const SettingsDialog = dynamic(() => import('@/components/settings-dialog'))
// 获取预加载函数
const preloadSettings = () => import('@/components/settings-dialog')
export function SettingsButton() {
const [open, setOpen] = useState(false)
return (
<>
<button
onMouseEnter={preloadSettings} // Hover 时开始下载
onClick={() => setOpen(true)} // 点击时立即显示
>
设置
</button>
{open && <SettingsDialog onClose={() => setOpen(false)} />}
</>
)
}5.2 路由预加载
Next.js <Link> 组件在视口可见时自动预加载目标页面的 JS——这对懒加载的组件同样有效。
5.3 Intersection Observer 预加载
'use client'
import dynamic from 'next/dynamic'
import { useEffect, useRef, useState } from 'react'
const HeavyChart = dynamic(() => import('@/components/heavy-chart'), {
loading: () => <ChartSkeleton />,
})
export function LazyChartSection() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true) },
{ rootMargin: '200px' }, // 提前 200px 开始加载
)
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<div ref={ref}>
{visible ? <HeavyChart /> : <ChartSkeleton />}
</div>
)
}6. 实战场景速查
| 场景 | 方案 | 代码 |
|---|---|---|
| 代码编辑器 | dynamic + ssr: false | dynamic(() => import('monaco'), { ssr: false }) |
| 图表组件 | dynamic + loading | dynamic(() => import('chart'), { loading }) |
| 模态框 | 条件渲染 + dynamic | {open && <DynamicModal />} |
| 仪表盘多面板 | <Suspense> 包裹 async 组件 | <Suspense><AsyncPanel /></Suspense> |
| 长页面底部内容 | Intersection Observer | 进入视口再加载 |
| 对话页消息列表 | 嵌套 <Suspense> | 头部先出,消息后到 |
| 富文本编辑器 | Hover 预加载 + dynamic | onMouseEnter 触发 import |
本章小结
next/dynamic:Next.js 项目的懒加载首选,支持 SSR、loading、ssr: falseReact.lazy:通用方案,需要手动<Suspense>包裹<Suspense>:Server Component 的流式渲染核心,实现渐进显示ssr: false:依赖浏览器 API 的组件(Monaco、地图、PDF)必须禁用 SSR- 预加载:Hover 预加载、Intersection Observer 预加载提升感知速度
- 嵌套 Suspense:外层页面骨架 + 内层区域骨架 = 最优体验
下一章讲 shadcn/ui 组件体系。