组件懒加载与 Suspense

不是所有代码都需要在首屏加载。代码编辑器、图表库、Markdown 预览器——这些大型组件可以延迟加载,等用户需要时再下载。本章讲清 React.lazynext/dynamicSuspense 三个工具的用法、区别和最佳实践。

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.lazyClient only❌ 需配合 Suspense简单的客户端组件
next/dynamicClient + Server✅ 可选 SSRNext.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.lazynext/dynamic
SSR❌ 不支持✅ 默认支持
禁用 SSRN/A&#123; ssr: false &#125;
loading 状态需要 &lt;Suspense&gt; 包裹内置 loading 选项
命名导出需要 .then()需要 .then()
推荐度通用 ReactNext.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 &lt;Link&gt; 组件在视口可见时自动预加载目标页面的 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: falsedynamic(() => import('monaco'), &#123; ssr: false &#125;)
图表组件dynamic + loadingdynamic(() => import('chart'), &#123; loading &#125;)
模态框条件渲染 + dynamic&#123;open && &lt;DynamicModal /&gt;&#125;
仪表盘多面板&lt;Suspense&gt; 包裹 async 组件&lt;Suspense&gt;&lt;AsyncPanel /&gt;&lt;/Suspense&gt;
长页面底部内容Intersection Observer进入视口再加载
对话页消息列表嵌套 &lt;Suspense&gt;头部先出,消息后到
富文本编辑器Hover 预加载 + dynamiconMouseEnter 触发 import

本章小结

  • next/dynamic:Next.js 项目的懒加载首选,支持 SSR、loading、ssr: false
  • React.lazy:通用方案,需要手动 &lt;Suspense&gt; 包裹
  • &lt;Suspense&gt;:Server Component 的流式渲染核心,实现渐进显示
  • ssr: false:依赖浏览器 API 的组件(Monaco、地图、PDF)必须禁用 SSR
  • 预加载:Hover 预加载、Intersection Observer 预加载提升感知速度
  • 嵌套 Suspense:外层页面骨架 + 内层区域骨架 = 最优体验

下一章讲 shadcn/ui 组件体系。