最近學完了官網 tutorial(中文官網),作為入門嘗鮮教程質量還是不錯的,而且教程初始 code template 也很容易,按部就班就能熟悉 Next.js 裡面一些設計理念與開發模式,最終實現並在 Vercel 部署一個 dashboard 項目,看看我的 Demo 👉 入口
Next.js 的概念特別多,官網有很詳盡的說明,學習新技術的第一手資料永遠來自官方文檔。本文不做翻譯工作,而是把涉及性能 / 體驗優化相關的 feature 單拎出來彙總,好了,下面進入正文
什麼是 Next.js#
Next.js 是一款基於 React 的前端應用開發框架,旨在開發出兼具 high-quality & high-performance 前端應用,功能非常強大也如其官網一樣非常 cool:
- 基於文件系統的路由
- 內置支持提升 UX&CWV 性能優化相關組件
- 動態 HTML 流式渲染
- React 最新特性
- 支持多種 CSS 模式,CSS 模塊、TailwindCSS、各種 CSS 變體如 Sass、Less
- C/S/ISR,服務端組件、服務端數據校驗、獲取
恰逢 Next.js 2024 開發者峰會,我去搞了一張虛擬入場證件 😬:
上面提到的 “內置性能優化相關組件” 主要包含下面幾種:
Image - LCP & CLS#
Web Almanac 網站統計到,互聯網上的靜態資源中,圖片資源占比遠比 HTML、CSS、Javascript、Font 資源高,而且圖片又往往決定著一個網站的 LCP 性能指標,因此,Next.js 擴展了<img>
標籤,提供 next/image 組件內置實現了以下優化:
- 體積優化:為不同設備提供現代瀏覽器圖片格式支持:WebP/AVIF
- CLS 優化:圖片加載時事先寬高占位,從而保證視覺穩定性
- 加快頁面初始加載速度:圖片懶加載、模糊圖
- 尺寸自適應靈活調整:展示設備響應性圖片
使用:
// 本地圖片
import Image from 'next/image'
import profilePic from './me.png'
export default function Page() {
return (
<Image
src={profilePic}
alt="作者的圖片"
// width={500} automatically provided
// height={500} automatically provided
// blurDataURL="data:..." automatically provided
// placeholder="blur" // Optional blur-up while loading
priority // fetchPriority="high" 提高加載優先級
/>
)
}
注:】不支持await import
或require
,靜態import
是為了構建時分析圖片信息
// 網絡圖片
import Image from 'next/image'
export default function Page() {
return (
<Image
src="https://s3.amazonaws.com/my-bucket/profile.png"
alt="作者的圖片"
width={500} // manual set
height={500} // manual set
/>
)
}
注:】需手動設置寬高
Video#
最佳實踐:
- Fallback Content:不支持視頻標籤時展示後備內容
- 提供子標題和字幕:無障礙支持
- 兼容性視頻控件:支持鍵盤操作視頻控件(controls)相關功能
video 資源 self-hosted 好處:
- 完全可控,不受第三方限制
- 存儲方案選擇自由:選擇高性能、彈性收縮的 CDN 做存儲
- 均衡存儲容量與帶寬
Next.js 提供的方案:@vercel/blob
Font - CLS#
在 Next.js 中引用字體使用,構建階段將下載好字體資源與其他靜態資源一齊托管在自己的伺服器上,不需要再向 Google 發出額外網絡請求下載字體,這有利於隱私保護和性能提升。組件提供的優化能力:
- 支持使用所有 Google Fonts,auto subset 減少字體體積、只加載部分字符集
- 字體資源也部署在相同主域名下
- 字體資源不占用額外請求
- 使用多種字體時能按需加載,如:只在特定 page 下加載、layout 範圍內加載和全局加載
如何使用#
- 工具函數式
// app/fonts.ts
import { Inter, Roboto_Mono } from 'next/font/google'
export const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
export const roboto_mono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
})
// app/layout.tsx
import { inter } from './fonts'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>
<div>{children}</div>
</body>
</html>
)
}
// app/page.tsx
import { roboto_mono } from './fonts'
export default function Page() {
return (
<>
<h1 className={roboto_mono.className}>我的頁面</h1>
</>
)
}
- CSS Variables 式:
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const roboto_mono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${roboto_mono.variable}`}>
<body>{children}</body>
</html>
)
}
// app/global.css
html {
font-family: var(--font-inter);
}
h1 {
font-family: var(--font-roboto-mono);
}
- with Tailwind CSS:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./app/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)'],
mono: ['var(--font-roboto-mono)'],
},
},
},
plugins: [],
}
Metadata - SEO#
Metadata 提供以下功能:
- 輕鬆設置網站的 TDK(title、description、keywords)
- Open Graph 設置社交平台(Facebook、Twitter)分享信息如 title、描述、圖片、作者、時間等
- robots 控制搜索引擎爬蟲如何處理頁面,是否索引、跟蹤、緩存
- 支持異步生成 MetaData
如何使用#
- 靜態配置
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '...',
description: '...',
}
export default function Page() {}
- 動態配置
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
// read route params
const id = params.id
// fetch data
const product = await fetch(`https://.../${id}`).then((res) => res.json())
// optionally access and extend (rather than replace) parent metadata
const previousImages = (await parent).openGraph?.images || []
return {
title: product.title,
openGraph: {
images: ['/some-specific-page-image.jpg', ...previousImages],
},
}
}
export default function Page({ params, searchParams }: Props) {}
注:】通過異步 generateMetadata
生成的 Metadata
僅支持在 Server Components 中配置,Next.js 會等異步執行完後再將 UI 流式返回,這確保流式第一段響應能包含正確的 <head>
標籤
Scripts - LCP & INP#
組件提供的優化能力:
- 第三方依賴可在多個路由頁面之間共享,只加載一次
- 依賴支持按 Layout/Page 粒度分,按需加載
- 支持配置多種加載策略:
beforeInteractive
、afterInteractive
、lazyLoad
、worker
(利用 partytown 在 Web worker 中加載第三方依賴)
如何使用#
- layout/page scripts,只在特定 Layout/Page 下加載第三方依賴,能減小其對性能的影響
// app/dashboard/layout.tsx
import Script from 'next/script'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<section>{children}</section>
<Script src="https://example.com/script.js" />
</>
)
}
- 調整加載策略
beforeInteractive
:頁面 hydration 之前afterInteractive
:頁面 hydration 之後lazyLoad
:瀏覽器 Idle 時間加載worker
(實驗中):開啟 Web worker 加載,非 CRP 的 JS 應當放在 Worker 中執行,讓步主線程:
- 支持行內腳本
id
prop required,便於 Next.js 跟蹤優化 Scripts
<Script id="show-banner">
{`document.getElementById('banner').classList.remove('hidden')`}
</Script>
// OR
<Script
id="show-banner"
dangerouslySetInnerHTML={{
__html: `document.getElementById('banner').classList.remove('hidden')`,
}}
/>
- 支持三個回調
onLoad
:腳本加載完成onReady
:腳本加載完成後,每次組件掛載完成onError
:腳本加載失敗
Package Bundling - LCP & INP#
產物分析#
類似於 webpack-bundle-analyzer,Next.js 也提供了@next/bundle-analyzer
供產物分析,使用如下:
// pnpm add @next/bundle-analyzer -D
/** @type {import('next').NextConfig} */
const nextConfig = {}
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)
// ANALYZE=true pnpm build
優化引入#
Next.js 已優化的三方依賴列表,按需引入,非全量引入
external#
Next.js 已 external 掉的三方依賴列表,類似於 webpack externals ,指定依賴以 CDN 形式引入,從而減少構建產物體積、控制加載時機等
Lazy Loading - LCP/INP#
按需加載組件或第三方依賴,能加快頁面加載速度
如何使用#
next/dynamic
// app/page.ts
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
// Client Components:
const ComponentA = dynamic(() => import('../components/A'))
const ComponentB = dynamic(() => import('../components/B'))
// 主動關閉SSR
const ComponentC = dynamic(() => import('../components/C'), { ssr: false })
// custom loading component
const WithCustomLoading = dynamic(
() => import('../components/WithCustomLoading'),
{
loading: () => <p>加載中...</p>,
}
)
export default function ClientComponentExample() {
const [showMore, setShowMore] = useState(false)
return (
<div>
{/* Load immediately, but in a separate client bundle */}
<ComponentA />
{/* Load on demand, only when/if the condition is met */}
{showMore && <ComponentB />}
<button onClick={() => setShowMore(!showMore)}>切換</button>
{/* Load only on the client side */}
<ComponentC />
{/* custom loading */}
<WithCustomLoading />
</div>
)
}
React.lazy
&Suspense
import()
動態引入
'use client'
import { useState } from 'react'
const names = ['Tim', 'Joe', 'Bel', 'Lee']
export default function Page() {
const [results, setResults] = useState()
return (
<div>
<input
type="text"
placeholder="搜索"
onChange={async (e) => {
const { value } = e.currentTarget
// Dynamically load fuse.js
const Fuse = (await import('fuse.js')).default
const fuse = new Fuse(names)
setResults(fuse.search(value))
}}
/>
<pre>結果: {JSON.stringify(results, null, 2)}</pre>
</div>
)
}
- 導入命名導出
// components/hello.js
'use client'
export function Hello() {
return <p>你好!</p>
}
// app/page.ts
import dynamic from 'next/dynamic'
const HelloComponent = dynamic(() =>
import('../components/hello').then((mod) => mod.Hello)
)
Analytics#
內置支持性能指標衡量與上報,太細了我的哥!!
// app/_components/web-vitals.js
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric)
})
}
// app/layout.js
import { WebVitals } from './_components/web-vitals'
export default function Layout({ children }) {
return (
<html>
<body>
<WebVitals />
{children}
</body>
</html>
)
}
Web Vitals 已無需多言
內存優化#
隨著應用迭代,功能越來越豐富,我們在開發和構建的過程中,項目運行會消耗越來越多的系統資源,Next.js 提供了一些策略方法去優化:
experimental.webpackMemoryOptimizations: true
減少 webpack 構建時的最大內存使用量,實驗階段next build --experimental-debug-memory-usage
構建時打印內存使用情況node --heap-prof node_modules/next/dist/bin/next build
記錄堆棧信息方便排查內存問題
監控#
- Instrumentation,整合監控與日誌工具到項目中
// instrumentation.ts
import { registerOTel } from '@vercel/otel'
export function register() {
registerOTel('next-app')
}
- 第三方庫
- Google Tag Manager
// app/layout.tsx
import { GoogleTagManager } from '@next/third-parties/google'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">Google Analytics - gtag.js
<GoogleTagManager gtmId="GTM-XYZ" />
<body>{children}</body>
</html>
)
}
- Google Analytics - gtag.js
// app/layout.tsx
import { GoogleAnalytics } from '@next/third-parties/google'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
<GoogleAnalytics gaId="G-XYZ" />
</html>
)
}
- Google Maps Embed
- Youtube Embed
Render#
Server Components#
SSR 能帶來諸多收益:
- 離數據源更近(同機房),數據獲取更快,並能減少客戶端請求數;
- 更安全,較為敏感的 token、api keys 不必在網絡上傳輸;
- 利用緩存,下次請求直接返回緩存數據,無需再次走數據獲取、渲染的邏輯,優化 RT;
- 提高弱網、設備性能較差那部分用戶的性能體驗,純展示型 UI 交由服務端渲染,有效節省瀏覽器下載解析執行 JS 代碼的時間;
- 更快的初始加載與綠色的 FCP,瀏覽器直接下載準備好的 HTML,第一時間呈現頁面內容,減少白屏時間;
- SEO;
- 進一步應用流式渲染策略;
默認情況下,Next.js 中的組件都是服務端組件。需要注意的是 SSR 不能用以下 API:
- Event Listener
- DOM/BOM API
- useContext、useEffect、useState 等
how to render#
首先是服務端:
- React 將 React Component 渲染為
React Server Component Payload
(RSC Payload
) - Next.js 利用
RSC Payload
和 Client Component Javascript 在服務端渲染 HTML(類似renderToString
?renderToPipeableStream
?)
到了客戶端:
- 拿到 HTML 立即渲染頁面
RSC Payload
拿來調和客戶端、服務端組件樹,更新 DOM- hydrate 客戶端組件,綁定事件,使其具備交互性
Server Components 的渲染方式分為 Static Render、Dynamic Render、Stream Render
Static Render#
構建時在服務端被渲染為靜態內容,緩存下來,應對接下來的請求時直接返回,適用於純靜態的展示型 UI 或數據不變、用戶無差別的 UI
Dynamic Render#
與靜態渲染相反,如果數據有千人千面特徵,依賴每次請求要拿到如 Cookie、searchParams 等數據,就需要每次請求時實時渲染結果
動態渲染還是靜態渲染的切換是 Next.js 自動完成的,會根據開發者所用 API 自行選擇相應的渲染策略,比如 Dynamic Functions:
cookies()
headers()
unstable_noStore()
unstable_after()
searchParams prop
使用這些動態渲染 API 的路由,Next.js 將採用動態渲染策略
Stream Render - TTFB & FCP & FID#
非流式渲染#
SSR 必須經歷 A、B、C、D 序列化的、先後阻塞的步驟。伺服器只有拿到所有數據才能開始渲染 HTML,客戶端只有拿到完整 JS 才能開始 hydration
流式渲染#
將頁面不同部分劃分為不同 chunks,伺服器漸進式返回,先返回先渲染先顯示,不用等待所有的數據準備完畢。React Component 天然就是獨立的 chunk,不依賴異步數據獲取的 chunk 可直接返回,剩下的依次逐個返回
Client Components#
客戶端渲染的好處:
- 提供交互性,能直接編寫 State、Effect、EventListener
- 能直接調用 BOM、DOM API
想要聲明客戶端組件特簡單,文件行首編寫'use client'
指令告知 Next.js 該組件為客戶端組件即可:
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>你點擊了 {count} 次</p>
<button onClick={() => setCount(count + 1)}>點擊我</button>
</div>
)
}
不聲明客戶端組件默認服務端組件,使用了客戶端 API 將報錯:
how to render#
對於整頁初始加載:與 Server Component 一致
對於後續導航:類似於客戶端 SPA 導航,不向伺服器發起新的生成 HTML 的請求,而是利用 RSC Payload 去完成導航
何時 SC 何時 CC 請看#
PPR#
Partial Prerender,部分預渲染,Next.js 在構建時會儘可能多得去預渲染組件,碰到被 Suspense 包裹的異步組件,fallback 中的 UI 也將先被預渲染。這樣做的好處是合併多個請求,減少瀏覽器 Network 請求瀑布流
loading#
loading.tsx
是 Next.js 中的一種特殊文件,基於 Suspense
實現路由、組件級別加載態,可以自實現SkeletonComponent
或 SpinnerComponent
作為異步組件加載時的 fallback UI,能夠有效提高 UX
最後#
Next.js 官網還有非常多的設計哲學與理念,值得反覆推敲與思考。最近在推上也看到不少獨立開發者基於 Next.js 快速開發一個產品出來,將自己的思路落地,它是一個從開發、部署、性能到後端提供一條龍服務的 Next Gen Web Framework,有點全棧的味兒了
完結