Next.js 資產與效能

Next.js 資產優化與效能完整指南 — next/image、next/font、next/script、Bundle 分析、dynamic import、Code Split


目錄


為什麼資產優化重要?

Core Web Vitals

Google 用這三項指標衡量網頁體驗(直接影響 SEO 排名):

指標 目標 影響因素
LCP(Largest Contentful Paint) < 2.5s 首屏最大元素載入速度
CLS(Cumulative Layout Shift) < 0.1 元素載入造成版面跳動
INP(Interaction to Next Paint) < 200ms 互動回應速度

Next.js 內建解法

  • next/image → LCP + CLS(圖片優化 + 預留空間)
  • next/font → LCP + CLS(避免 FOIT/FOUT)
  • next/script → INP(延後載入第三方)
  • 自動 code splitting → LCP(首屏 bundle 變小)

next/image 圖片優化

基本用法

import Image from "next/image";

// 1. 本地圖片(自動偵測尺寸)
import logo from "@/public/logo.png";

<Image src={logo} alt="Logo" priority />;

// 2. 遠端圖片(必須指定 width / height)
<Image
  src="https://cdn.example.com/photo.jpg"
  alt="風景"
  width={1200}
  height={800}
/>;

// 3. Fill 模式(撐滿父容器)
<div style={{ position: "relative", width: 400, height: 300 }}>
  <Image src="..." alt="..." fill style={{ objectFit: "cover" }} />
</div>;

Next.js 自動做的優化

優化 說明
格式轉換 自動轉成 WebP / AVIF(瀏覽器支援時)
尺寸最佳化 依據 sizes 與螢幕寬度,提供最合適大小
Lazy loading viewport 外的圖片延遲載入
Placeholder 載入中顯示模糊縮圖避免 CLS
快取 處理過的圖片快取在 CDN

remotePatterns

要允許遠端圖片優化,必須在 next.config.ts 設定白名單:

// next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "cdn.example.com",
        pathname: "/**",
      },
      {
        protocol: "https",
        hostname: "**.amazonaws.com",
      },
    ],
  },
};

export default config;

sizes 屬性

讓 Next.js 知道在不同螢幕寬度下圖片實際多大,避免下載過大尺寸:

<Image
  src="..."
  alt="..."
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

意思:

  • 螢幕 ≤ 768px 時,圖片佔 100vw
  • 螢幕 ≤ 1200px 時,圖片佔 50vw
  • 其他情況佔 33vw

priority 與 LCP

首屏的關鍵圖(hero、文章封面)一定要加 priority

<Image src={hero} alt="主視覺" priority />

這會:

  • 預先發起載入請求
  • 移除 lazy load
  • fetchpriority="high" 提示瀏覽器

Placeholder 模糊圖

// 本地圖片:自動產生模糊
import photo from "@/public/photo.jpg";
<Image src={photo} alt="..." placeholder="blur" />;

// 遠端圖片:自己提供 blurDataURL
<Image
  src="https://..."
  alt="..."
  width={1200}
  height={800}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..." // 通常用 plaiceholder 套件產
/>;

next/font 字型優化

為什麼要用 next/font?

傳統載入 Google Fonts:

  1. HTML 載入 → 解析 → 下載 fonts.googleapis.com 的 CSS
  2. CSS 內又指向 fonts.gstatic.com 的字型檔
  3. 字型載入完成前,文字會 invisible (FOIT) 或閃變 (FOUT)
  4. 多一個 DNS / 連線 → 拖慢 LCP
  5. 第三方追蹤隱憂

next/font 的解法

  • ✅ build 時下載字型檔,self-host
  • ✅ 自動 subset(只保留用到的字元)
  • ✅ 自動產生 CSS variable
  • ✅ 跨頁面快取,不會閃

Google Fonts

// app/layout.tsx
import { Inter, Noto_Sans_TC } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});

const notoSansTC = Noto_Sans_TC({
  weight: ["400", "500", "700"],
  subsets: ["latin"],
  variable: "--font-noto",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html
      lang="zh-TW"
      className={`${inter.variable} ${notoSansTC.variable}`}
    >
      <body className={inter.className}>{children}</body>
    </html>
  );
}
/* globals.css */
body {
  font-family: var(--font-inter), var(--font-noto), sans-serif;
}

本地字型

// app/layout.tsx
import localFont from "next/font/local";

const customFont = localFont({
  src: [
    {
      path: "../fonts/MyFont-Regular.woff2",
      weight: "400",
      style: "normal",
    },
    {
      path: "../fonts/MyFont-Bold.woff2",
      weight: "700",
      style: "normal",
    },
  ],
  variable: "--font-custom",
  display: "swap",
});

display 屬性

行為
swap 立即用 fallback 字型,下載完換 — 推薦
block 等字型載入完才顯示文字(會閃白)
fallback 100ms 後用 fallback,等待 3 秒
optional 100ms 內沒下載完就放棄這次

中文網站通常選 swap(中文字檔大,等不起)。


next/script 腳本優化

三種載入策略

import Script from "next/script";

// 1. beforeInteractive:在頁面互動前載入(極少用)
<Script src="..." strategy="beforeInteractive" />

// 2. afterInteractive(預設):在 hydration 之後載入
<Script src="..." strategy="afterInteractive" />

// 3. lazyOnload:閒置時才載入(最省效能)
<Script src="..." strategy="lazyOnload" />

// 4. worker:放到 Web Worker(實驗性)
<Script src="..." strategy="worker" />

常見場景

// Google Analytics
<Script
  src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
  strategy="afterInteractive"
/>
<Script id="ga" strategy="afterInteractive">
  {`
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'GA_ID');
  `}
</Script>

// 客服 widget(lazy 載入)
<Script src="https://livechat.example.com/widget.js" strategy="lazyOnload" />

// 必須在 React hydrate 前載入(如 polyfill)
<Script src="/polyfills.js" strategy="beforeInteractive" />

onLoad / onError

<Script
  src="https://example.com/sdk.js"
  onLoad={() => console.log("loaded")}
  onError={(e) => console.error("failed", e)}
/>

public/ vs import 資源

public/ 資料夾

放在 public/ 的檔案會原樣 serve,URL = 檔名:

public/
├── favicon.ico       → /favicon.ico
├── robots.txt        → /robots.txt
└── images/
    └── logo.png      → /images/logo.png
<img src="/images/logo.png" alt="Logo" />
// 或
<Image src="/images/logo.png" alt="Logo" width={200} height={50} />

特性

  • 不會被 webpack 處理 → 沒 hash、不會 inline
  • 適合:favicon、robots.txt、ads.txt、大檔案
  • ❌ 不會自動 versioning(更新後 client 可能還抓舊版)

import 資源

直接 import 進 JS:

import logo from "@/assets/logo.png";

<Image src={logo} alt="Logo" />;

特性

  • 自動 hash filename(cache busting)
  • 自動 inline 小圖(base64)
  • 編譯時靜態檢查路徑
  • TypeScript 自動推斷尺寸
用途 建議
元件相關圖(icon、illustration) import
動態 / SEO 用圖(OG image fallback) public
大尺寸不變動的圖 public
需要 next/image 自動偵測尺寸 import

Bundle 分析

安裝 bundle analyzer

npm install -D @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";

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

const config = {
  // ...
};

export default bundleAnalyzer(config);

執行:

ANALYZE=true npm run build

會自動開啟瀏覽器顯示視覺化圖表。

找出問題的 chunk

看到任何 chunk 大於 250KB 都該檢查:

  • 第三方套件是否可改用更小的(lodash → lodash-es 個別 import;moment → date-fns)
  • 是否該 dynamic import 改 lazy 載入
  • 是否有重複打包(同一 lib 被打進多個 chunk)

路由級別的 bundle size 報告

build 完輸出已經有概要:

Route (app)                              Size     First Load JS
┌ ○ /                                    1.2 kB        85 kB
├ ○ /about                               1.5 kB        85 kB
└ λ /dashboard                           120 kB       205 kB
  • Size:該路由獨有的 JS
  • First Load JS:含 shared chunk 的總載入量
  • ○ = Static、λ = Dynamic

next/dynamic 與 code split

為什麼需要 dynamic import?

  • 大型 lib 只在某些頁面用 → 不要進 main bundle
  • 互動才會用到的元件 → lazy load
  • 只在 client 跑的 lib(如 chart、map) → SSR 避開

基本用法

import dynamic from "next/dynamic";

const Chart = dynamic(() => import("./Chart"), {
  loading: () => <p>載入圖表中...</p>,
  ssr: false, // 完全在 client 跑(適合用 window 的元件)
});

export default function Page() {
  return <Chart data={...} />;
}

與 React.lazy 的差別

next/dynamic React.lazy
SSR 支援 ✅(可選) ❌(只 client)
載入中 fallback 內建 loading prop 要包 <Suspense>
推薦在 Next.js 內用 可以用但 next/dynamic 更方便

Named export

const Modal = dynamic(() =>
  import("./components").then((mod) => mod.Modal)
);

Server-side import(純 server 用)

如果只是想避免某 lib 進入 client bundle(用在 Server Component 內),直接 import 即可 — Server Component 預設不會打包到 client bundle。


Suspense 與 lazy

Server Component 中的 Streaming

Server Component 內直接 await 慢 API,搭配 <Suspense> 包裹:

import { Suspense } from "react";

export default function Page() {
  return (
    <div>
      <FastContent />

      <Suspense fallback={<Skeleton />}>
        <SlowContent /> {/* async Server Component */}
      </Suspense>
    </div>
  );
}

async function SlowContent() {
  const data = await fetch("https://slow-api.example.com").then((r) => r.json());
  return <div>{data}</div>;
}

Streaming 完整介紹見 rendering-and-cache.md

Client Component 配合 dynamic

"use client";
import dynamic from "next/dynamic";
import { Suspense, useState } from "react";

const HeavyEditor = dynamic(() => import("./HeavyEditor"), { ssr: false });

export default function EditorWrapper() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>開啟編輯器</button>
      {open && (
        <Suspense fallback={<p>載入編輯器...</p>}>
          <HeavyEditor />
        </Suspense>
      )}
    </>
  );
}

實戰範例

範例 1:完整首頁優化套餐

// app/layout.tsx
import { Inter, Noto_Sans_TC } from "next/font/google";
import Script from "next/script";

const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
const noto = Noto_Sans_TC({ subsets: ["latin"], variable: "--font-noto", display: "swap" });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-TW" className={`${inter.variable} ${noto.variable}`}>
      <body>
        {children}
        {/* GA 在 hydration 後載入,不影響 LCP */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}
// app/page.tsx
import Image from "next/image";
import dynamic from "next/dynamic";
import hero from "@/assets/hero.jpg";

const Carousel = dynamic(() => import("./Carousel"), {
  loading: () => <div className="skeleton h-96" />,
});

export default function HomePage() {
  return (
    <>
      <Image
        src={hero}
        alt="主視覺"
        priority
        sizes="100vw"
        placeholder="blur"
      />
      <Carousel />
    </>
  );
}

範例 2:互動才載入的編輯器

// app/posts/edit/page.tsx
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";

const MarkdownEditor = dynamic(() => import("@/components/MarkdownEditor"), {
  ssr: false,
  loading: () => <textarea disabled placeholder="編輯器載入中..." />,
});

export default function EditPage() {
  const [content, setContent] = useState("");
  return <MarkdownEditor value={content} onChange={setContent} />;
}

MarkdownEditor 內部用了 monaco-editor(~500KB),這樣首屏完全不會載入這個大套件。

範例 3:響應式圖片

import Image from "next/image";

export default function ProductCard({ image, name }: {
  image: { src: string; width: number; height: number };
  name: string;
}) {
  return (
    <div className="aspect-square relative">
      <Image
        src={image.src}
        alt={name}
        fill
        sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
        style={{ objectFit: "cover" }}
      />
    </div>
  );
}

常見問題

問題 1:<Image> 報錯「remotePatterns is required」

症狀:用遠端圖片 URL 但 build 失敗

解決方案next.config.tsremotePatterns

images: {
  remotePatterns: [{ protocol: "https", hostname: "cdn.example.com" }],
}

問題 2:圖片 LCP 還是很慢

檢查清單

  1. 首屏關鍵圖加 priority
  2. 提供正確 sizes
  3. 確認沒被包在 dynamic({ ssr: false })
  4. 檢查圖片本身體積是否過大(即使優化,4MB 的原始檔還是慢)

問題 3:next/font 字型在 dev 模式很慢

原因:dev 模式每次 reload 都重新下載

解決方案:用 production build 測量真實效能:

npm run build && npm start

問題 4:第三方套件硬要打進 bundle

症狀:bundle analyzer 看到某 lib 超大

解決方案

  1. 個別 import:import { debounce } from "lodash-es"
  2. 找替代品:momentdate-fns / dayjs
  3. 用 dynamic import:只在需要時載入
  4. 移到 Server Component(Server Component 的依賴不會進 client bundle)

問題 5:next/script 的 onLoad 沒觸發

原因:可能寫成 Server Component

解決方案:用 onLoad 的話 script 元件必須在 Client Component 內:

"use client";
import Script from "next/script";

export default function Wrapper() {
  return <Script src="..." onLoad={() => {}} />;
}

總結

核心要點

資產優化 = next/image + next/font + next/script
效能優化 = Bundle 分析 + dynamic import + Code Split + Streaming

SEO 友善的優化清單

  • 所有圖片用 next/image
  • 首屏關鍵圖加 priority
  • 字型用 next/font(self-host + subset)
  • 第三方 script 用 next/script + afterInteractivelazyOnload
  • 大型互動元件用 dynamic({ ssr: false })
  • 慢 API 用 <Suspense> + Server Component streaming
  • 設定 remotePatterns 允許 CDN
  • npm run build 後檢查 First Load JS < 200KB

速查表

想優化
圖片載入 next/image + priority + sizes
字型 FOIT/FOUT next/font/googlenext/font/local
第三方 script next/script + strategy
首頁 bundle 太大 dynamic() 把不必要元件 lazy load
慢 API <Suspense> + Server Component
同一 lib 重複打包 Bundle analyzer 找出來
客戶端專屬 lib(用 window) dynamic(import, { ssr: false })

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章