最近学完了官网 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="Picture of the author"
// 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="Picture of the author"
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}>My page</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>Loading...</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)}>Toggle</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="Search"
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>Results: {JSON.stringify(results, null, 2)}</pre>
</div>
)
}
- 导入命名导出
// components/hello.js
'use client'
export function Hello() {
return <p>Hello!</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>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</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,有点全栈的味儿了
完结