0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 课时

动态导入、代码拆分与延迟水合

使用 next/dynamic 延后加载非关键组件,并调整加载策略以缩短可交互时间。

动态导入、代码拆分与延迟水合 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Why Code Splitting Matters in Next.js 15

Modern Next.js applications can grow large fast. Without code splitting, the browser downloads every component and dependency on the first page load — even code the user may never need.

Next.js 15 splits your bundle automatically at the route level using the App Router. But route-level splitting alone is not enough. Consider these common performance killers:

  • A rich text editor loaded on every page but only used in the admin dashboard
  • A heavy chart library rendered below the fold
  • A modal or drawer that is never opened by most visitors

For these cases you need component-level code splitting via next/dynamic and lazy hydration strategies. Together they reduce your initial JavaScript payload, improve Time-to-Interactive (TTI), and raise your Core Web Vitals scores.

Introducing next/dynamic

next/dynamic is Next.js's wrapper around React.lazy with additional features tailored for SSR. It returns a component that is loaded on demand — the JavaScript for that component is placed in a separate chunk and only fetched when the component is about to render.

Basic usage is straightforward:

  • Import dynamic from 'next/dynamic'
  • Pass a factory function that returns a dynamic import()
  • Optionally pass a loading fallback component shown while the chunk downloads

The resulting component can be used exactly like any normal React component in your JSX.

'use client';

import dynamic from 'next/dynamic';

// The HeavyEditor chunk is NOT included in the initial bundle.
// It is fetched only when <HeavyEditor /> is first rendered.
const HeavyEditor = dynamic(
  () => import('@/components/HeavyEditor'),
  {
    loading: () => <p>Loading editor…</p>,
  }
);

export default function AdminPage() {
  return (
    <main>
      <h1>Admin Dashboard</h1>
      <HeavyEditor />
    </main>
  );
}

Disabling SSR for Client-Only Components

Some components depend on browser APIs (window, document, localStorage) and cannot run on the server at all. Attempting SSR on these causes hydration mismatches or runtime errors.

next/dynamic supports an ssr: false option that tells Next.js to skip server rendering entirely for that component. The placeholder (or nothing) is sent in the HTML, and the real component is mounted only in the browser.

Common use cases for ssr: false:

  • Canvas / WebGL renderers
  • Browser-only animation libraries (e.g. GSAP ScrollTrigger)
  • Components that read window.matchMedia on mount
  • Third-party widgets that inject into document.body
'use client';

import dynamic from 'next/dynamic';

// This component uses `window` and `document` internally.
// ssr: false prevents Next.js from attempting to render it on the server.
const ConfettiBlast = dynamic(
  () => import('@/components/ConfettiBlast'),
  {
    ssr: false,
    loading: () => null, // render nothing until JS loads
  }
);

export default function CelebrationBanner() {
  return (
    <section>
      <h2>You did it! 🎉</h2>
      <ConfettiBlast particleCount={200} />
    </section>
  );
}

Named Exports and next/dynamic

By default next/dynamic expects the imported module to have a default export. When you need to dynamically import a named export, you must extract it inside the factory function.

This is done by returning the named export from the async factory, effectively making it the default for the dynamic wrapper:

'use client';

import dynamic from 'next/dynamic';

// Module exports: { LineChart, BarChart, PieChart }
// We only need LineChart — extract it inside the factory.
const LineChart = dynamic(
  () =>
    import('@/components/charts/ChartLibrary').then(
      (mod) => mod.LineChart
    ),
  { loading: () => <div className="h-64 animate-pulse bg-gray-100" /> }
);

interface SalesChartProps {
  data: { month: string; revenue: number }[];
}

export default function SalesChart({ data }: SalesChartProps) {
  return <LineChart data={data} width={600} height={300} />;
}

Conditional Dynamic Imports — Load on Interaction

The most powerful pattern is to defer a component until the user actually needs it — triggered by a click, hover, or scroll event. This avoids loading the chunk even during idle time.

The approach uses React state to conditionally render the dynamically imported component. Until the user interacts, the chunk is never requested. Once they click (or trigger the condition), React renders the dynamic component and the browser fetches the chunk on demand.

This pattern is ideal for:

  • Modals and drawers opened by a button
  • Settings panels
  • Video players that start on play
  • Comment sections below a long article
'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const FeedbackModal = dynamic(
  () => import('@/components/FeedbackModal'),
  { loading: () => <p>Opening…</p> }
);

export default function FeedbackButton() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="btn-primary"
      >
        Leave Feedback
      </button>

      {/* FeedbackModal chunk is only fetched after the first click */}
      {open && (
        <FeedbackModal onClose={() => setOpen(false)} />
      )}
    </>
  );
}

Lazy Hydration with 'use client' Boundaries

In the App Router, components are React Server Components (RSC) by default. Only components marked 'use client' ship JavaScript to the browser and hydrate.

This means you get free lazy hydration by keeping components as Server Components whenever possible. The server renders the HTML; no hydration cost is paid at all.

When you do need interactivity, push the 'use client' boundary as far down the tree as possible — to the exact leaf component that needs event handlers or state. Parent layout and wrapper components stay as RSC and add zero client JS.

  • Bad: Mark the entire page layout as 'use client' because one button needs onClick
  • Good: Extract only the button into its own 'use client' component; the rest stays RSC
// app/products/[id]/page.tsx — Server Component (no 'use client')
import { getProduct } from '@/lib/db';
import AddToCartButton from '@/components/AddToCartButton'; // 'use client'

interface PageProps {
  params: { id: string };
}

export default async function ProductPage({ params }: PageProps) {
  // Runs on the server — zero client JS for this component
  const product = await getProduct(params.id);

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p className="price">${product.price}</p>

      {/* Only this leaf component ships client JS */}
      <AddToCartButton productId={product.id} />
    </article>
  );
}

Intersection Observer — Hydrate on Scroll

Components below the fold do not need to hydrate immediately. A popular pattern is to use the Intersection Observer API to defer mounting (and therefore hydrating) a component until it scrolls into the viewport.

You can combine this with next/dynamic to achieve true lazy hydration: the JS chunk is requested only when the component enters the viewport, and mounting happens right after the chunk arrives.

Libraries like react-intersection-observer make this ergonomic. The pattern below mounts CommentsSection only once the user scrolls near it:

'use client';

import { useRef, useState, useEffect } from 'react';
import dynamic from 'next/dynamic';

const CommentsSection = dynamic(
  () => import('@/components/CommentsSection'),
  { loading: () => <div className="h-32 animate-pulse bg-gray-100" /> }
);

export default function ArticlePage() {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [showComments, setShowComments] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setShowComments(true);
          observer.disconnect(); // hydrate once, then stop observing
        }
      },
      { rootMargin: '200px' } // start loading 200px before viewport
    );

    if (sentinelRef.current) observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, []);

  return (
    <article>
      <h1>A Very Long Article</h1>
      <p>…article content…</p>

      {/* Sentinel sits at the bottom; triggers chunk fetch when visible */}
      <div ref={sentinelRef} />
      {showComments && <CommentsSection />}
    </article>
  );
}

Preloading Chunks on Hover

Waiting until a click to start downloading a chunk adds latency: the user sees a spinner while the network request completes. A smarter UX is to preload the chunk on hover — typically 100–300 ms before the user clicks, which is often enough time for the chunk to arrive.

next/dynamic exposes a static .preload() method on the returned component. Calling it triggers the dynamic import without rendering anything, priming the browser cache so that when the component mounts it is nearly instant.

'use client';

import dynamic from 'next/dynamic';

const ShareDialog = dynamic(
  () => import('@/components/ShareDialog')
);

import { useState } from 'react';

export default function ShareButton() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button
        // Preload the chunk as soon as the user hovers
        onMouseEnter={() => ShareDialog.preload()}
        // Open (render) on click — chunk is likely already cached
        onClick={() => setOpen(true)}
        className="btn-secondary"
      >
        Share
      </button>

      {open && <ShareDialog onClose={() => setOpen(false)} />}
    </>
  );
}

Analyzing Bundle Size with @next/bundle-analyzer

Before you can optimise bundle size you need to see it. The @next/bundle-analyzer package wraps Webpack Bundle Analyzer and generates a visual treemap of every module in your build.

Setup is two steps:

  • Install: npm install --save-dev @next/bundle-analyzer
  • Wrap your Next.js config with the analyzer

Run ANALYZE=true next build and two browser tabs open — one for the client bundle, one for the server bundle. Look for:

  • Unexpectedly large modules in the initial chunk
  • Duplicate libraries (e.g. two versions of date-fns)
  • Libraries that should be dynamically imported but appear in the main chunk
// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,
});

const nextConfig: NextConfig = {
  // your existing config…
  experimental: {
    optimizePackageImports: [
      // Tell Next.js to tree-shake these icon/component libraries
      // so only the icons you actually import are bundled
      '@heroicons/react',
      'lucide-react',
      '@radix-ui/react-icons',
    ],
  },
};

export default withBundleAnalyzer(nextConfig);

Dynamic Imports in Server Components

You can also use dynamic import() inside Server Components — not via next/dynamic, but via plain ES dynamic import. The result is still server-rendered; the benefit is conditional loading on the server: you avoid importing heavy modules when they are not needed for a given request.

A typical case is locale-specific data, feature-flag-gated renderers, or optional plugins loaded based on configuration:

// app/report/page.tsx — Server Component
import type { NextPage } from 'next';

interface ReportPageProps {
  searchParams: { format?: string };
}

const ReportPage: NextPage<ReportPageProps> = async ({ searchParams }) => {
  const format = searchParams.format ?? 'html';

  if (format === 'pdf') {
    // Heavy PDF renderer is only imported when the query param is 'pdf'.
    // It never reaches the browser — this is pure server-side splitting.
    const { renderPDF } = await import('@/lib/pdf-renderer');
    const pdfBuffer = await renderPDF({ title: 'Q2 Report' });

    return new Response(pdfBuffer, {
      headers: { 'Content-Type': 'application/pdf' },
    }) as unknown as JSX.Element;
  }

  // Default: lightweight HTML version
  const { ReportView } = await import('@/components/ReportView');
  return <ReportView />;
};

export default ReportPage;

Measuring Impact — Core Web Vitals

Code splitting and lazy hydration directly improve two Core Web Vitals:

  • LCP (Largest Contentful Paint) — less blocking JS means the browser paints the largest element sooner
  • INP (Interaction to Next Paint) — smaller main-thread work during load means the page responds faster to the first user tap

Measure before and after your changes using:

  • next build && next start + Chrome DevTools Lighthouse (local, reproducible)
  • web-vitals npm package + the useReportWebVitals hook from next/navigation to log metrics to your analytics backend in production
  • Vercel Speed Insights or Google Search Console for real-user data

A common result after switching a heavy component to next/dynamic: the initial JS payload drops by 30–60 KB (gzipped), translating to 200–500 ms faster TTI on a median mobile connection.

Knowledge Check: When to Use ssr: false

You are integrating a third-party mapping library that accesses window.navigator.geolocation synchronously during module initialisation. Which next/dynamic configuration is correct and why?

Recap — Dynamic Imports and Lazy Hydration

In this lesson you learned how to cut time-to-interactive in Next.js 15 App Router applications by deferring JavaScript that is not needed upfront.

Key takeaways:

  • next/dynamic splits a component into a separate chunk fetched on demand — reducing the initial JS payload
  • ssr: false skips server rendering for components that rely on browser-only APIs, preventing runtime errors
  • Named exports are handled by extracting them inside the factory: import(…).then(mod => mod.Named)
  • Conditional rendering ({open && <Modal />}) defers the chunk fetch until the first render of the component
  • Component.preload() on hover primes the cache before the user clicks, hiding network latency
  • Keeping 'use client' boundaries as narrow as possible gives you free lazy hydration for the RSC parts of your tree
  • Intersection Observer enables viewport-triggered hydration for below-the-fold content
  • @next/bundle-analyzer and useReportWebVitals let you measure the real impact of these optimisations

Apply these techniques to the heaviest components in your bundle first — editors, charts, maps, and media players — for the greatest TTI gains.

常见问题解答

「动态导入、代码拆分与延迟水合」课时是免费的吗?

是的 — 「动态导入、代码拆分与延迟水合」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

「动态导入、代码拆分与延迟水合」这节课中我会学到什么?

使用 next/dynamic 延后加载非关键组件,并调整加载策略以缩短可交互时间。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「动态导入、代码拆分与延迟水合」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?

能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 分析并缩小客户端包
  2. Turbopack 与编译器配置深入解析
  3. 使用 server-only 与 client-only 划分模块边界
  4. 动态导入、代码拆分与延迟水合
← 返回 Next.js 15 Fullstack (App Router + Server Actions)