目錄
- 為什麼資產優化重要?
- next/image 圖片優化
- next/font 字型優化
- next/script 腳本優化
- public/ vs import 資源
- Bundle 分析
- next/dynamic 與 code split
- Suspense 與 lazy
- 實戰範例
- 常見問題
- 總結
- 參考資源
為什麼資產優化重要?
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:
- HTML 載入 → 解析 → 下載 fonts.googleapis.com 的 CSS
- CSS 內又指向 fonts.gstatic.com 的字型檔
- 字型載入完成前,文字會 invisible (FOIT) 或閃變 (FOUT)
- 多一個 DNS / 連線 → 拖慢 LCP
- 第三方追蹤隱憂
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:該路由獨有的 JSFirst 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.ts 加 remotePatterns:
images: {
remotePatterns: [{ protocol: "https", hostname: "cdn.example.com" }],
}
問題 2:圖片 LCP 還是很慢
檢查清單:
- 首屏關鍵圖加
priority✓ - 提供正確
sizes✓ - 確認沒被包在
dynamic({ ssr: false })內 - 檢查圖片本身體積是否過大(即使優化,4MB 的原始檔還是慢)
問題 3:next/font 字型在 dev 模式很慢
原因:dev 模式每次 reload 都重新下載
解決方案:用 production build 測量真實效能:
npm run build && npm start
問題 4:第三方套件硬要打進 bundle
症狀:bundle analyzer 看到某 lib 超大
解決方案:
- 個別 import:
import { debounce } from "lodash-es" - 找替代品:
moment→date-fns/dayjs - 用 dynamic import:只在需要時載入
- 移到 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+afterInteractive或lazyOnload - 大型互動元件用
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/google 或 next/font/local |
| 第三方 script | next/script + strategy |
| 首頁 bundle 太大 | dynamic() 把不必要元件 lazy load |
| 慢 API | <Suspense> + Server Component |
| 同一 lib 重複打包 | Bundle analyzer 找出來 |
| 客戶端專屬 lib(用 window) | dynamic(import, { ssr: false }) |
相關閱讀
- Next.js 入門指南 — Server/Client Components 基礎
- 渲染與快取 — Streaming 機制完整解析
- Metadata 與 SEO — Core Web Vitals 影響 SEO 排名
- 部署與 Runtime — Edge runtime 對圖片優化的影響
參考資源
- next/image:https://nextjs.org/docs/app/api-reference/components/image
- next/font:https://nextjs.org/docs/app/api-reference/components/font
- next/script:https://nextjs.org/docs/app/api-reference/components/script
- next/dynamic:https://nextjs.org/docs/app/api-reference/functions/dynamic
- Bundle Analyzer:https://www.npmjs.com/package/@next/bundle-analyzer
- Core Web Vitals:https://web.dev/vitals/
建立日期:2026-05-16 最後更新:2026-05-16