它拥有一系列特性和优势,包括但不限于:
Next.js 首次发布于 2016 年,并在 2017 年 4 月 20 日发布 7.0 版本。它的诞生基于开发者和企业对构建更快、更优化的web应用程序的需求。
React 生态的扩展需求:随着 React 在前端开发中的广泛应用,开发者对基于 React 的应用提出了更多需求,如更好的 SEO 支持、更快的页面加载速度等。传统的客户端渲染(CSR)虽然适合交互丰富的单页应用,但在首次加载性能和 SEO 方面存在不足。
服务端渲染的需求:为了解决上述问题,服务端渲染(SSR)成为一种流行的解决方案。然而,实现 SSR 需要处理许多复杂的问题,如路由管理、数据获取、状态同步等。Next.js 应运而生,它简化了这些复杂性,使得开发者可以更轻松地构建 SSR 应用。
静态站点生成的趋势:随着 JAMstack 架构的兴起,静态站点生成(SSG)成为构建高性能网站的一种流行方式。Next.js 不仅支持 SSR,还引入了 SSG 和增量静态再生(ISR),使得开发者可以根据需求灵活选择最适合的渲染方式。
全栈开发的需求:现代 Web 开发越来越倾向于全栈开发,即前后端一体化。Next.js 内置了 API 路由功能,允许开发者在同一项目中同时编写前后端逻辑,进一步简化了全栈开发的流程。
node环境(环境参考官网最低版本要求)
npm、yarn或pnpm等工具(依个人习惯选择)
初始化项目
使用 npx create-next-app@latest
命令快速创建新项目。安装时,将有如下内容:
What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like your code inside a `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to use Turbopack for `next dev`? No / Yes
Would you like to customize the import alias (`@/*` by default)? No / Yes
What import alias would you like configured? @/*
create-next-app
会创建一个以你的项目名称命名的文件夹,并安装所需的依赖项。
进入项目目录后,可以使用 npm run dev
启动开发服务器。
项目创建完成后,我们的项目结构如下:
├── README.md
├── src
│ └──app 相当于src
│ ├── favicon.ico
│ ├── globals.css 全局CSS
│ ├── layout.tsx 项目入口文件,相当于main.js
│ └── page.tsx 首页地址文件,访问 /
├── next-env.d.ts
├── next.config.mjs next 配置文件
├── package-lock.json
├── package.json
├── postcss.config.mjs postcss配置文件
├── public 静态文件目录,存放图片
│ ├── next.svg
│ └── vercel.svg
├── tailwind.config.ts tailwindcss 配置文件
└── tsconfig.json
顶层文件有两种可能,根据上一步的Would you like your code inside a 'src/' directory? No / Yes
选择结果,yes
为右边结构,app和pages直接置于顶层,反之则为左边结构
名称 | 用途 |
---|---|
app | App Router |
pages | Pages Router |
public | 静态文件目录 |
src | 可选的应用程序源文件夹 |
app/layout.tsx
是整个应用的主布局文件,相当于React的main.ts或App.tsx,它主要做以下几个事情:
同时在每个页面目录下,也可以定义自己的布局文件layout.tsx
总结:顶层 RootLayout 作用于所有页面,各个子 Layout 只作用于自己所属的目录下的所有页面
例:
// app/layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
{/* header */}
<Header />
{/* 页面注入区 */}
{children}
</body>
</html>
);
}
// app/overview/layout.tsx
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<section className="flex mt-4 space-x-6">
{/* Nav */}
<Nav />
{/* main */}
<main className="w-full border border-green-600 p-4">{children}</main>
</section>
);
}
Nextjs 使用 next/font
模块加载谷歌字体,而不像以前通过css去加载字体,这是next帮我们优化字体的加载
进行替换时,会产生字体大小、空隙、布局的偏移,即CLS (CLS:谷歌用于评估网站性能和用户体验的指标之一,用于衡量网页在加载过程中内容布局的稳定性)
使用 next/font
模块后,NextJs会自动优化字体,项目构建时,会自动下载字体文件和其他资源文件放在一起,提升页面访问性能
例如我们使用Google字体:
// app/layout.tsx
import { Geist } from 'next/font/google'
const geist = Geist({
subsets: ['latin'],
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={geist.className}>
<body>{children}</body>
</html>
)
}
这样会将我们字体包含在我们的部署中,当我们访问网站时,字体将由我们的部署提供,而不是向Google发起请求。
也可以使用next/font/local
从本地加载我们自己的字体文件
import localFont from 'next/font/local'
const myFont = localFont({
src: './my-font.woff2',
})
...
<html lang="en" className={myFont.className}>
<body>{children}</body>
</html>
Nextjs 内置文件路由系统,分App Router和Page Router两种模式,从Nextjs 14开始默认使用App Router模式,所以我们在此只介绍App Router。
在App Router模式下,会根据app文件夹下的目录自动生成路由,目录内的可访问页面,必须固定为page.tsx(或.jsx.js)
例如:
访问路径 | App Router | 简介 |
---|---|---|
/ | app/page.tsx | 根路由 |
/about | app/about/page.tsx | 嵌套路由:xxx/xxx |
/blog/1 | app/blog/[id]/page.tsx | 动态路由: [xxx] |
/test/1/a | app/test/[...slug]/page.tsx | 捕获所有路线: [...xxx] |
/a, /b | app/(test)/a/page.tsx, app/(test)/b/page.tsx | 路由组,只用来组织文件,不生成实际路线段: (xxx) |
app/_folder/page.tsx | 私人文件夹,该带有_ 的文件夹及其子项不生成路由 |
除此之外还有平行和拦截路线等内容,具体可以参考官网示例
App Router模式下,api要放置在app目录下,而且,必须命名为 route.ts
访问路径 | App Router |
---|---|
/api/product | app/api/product/route.ts |
/api/product/1 | app/api/product/[id]/route.ts |
export async function GET(request: NextRequest) {
// 获取数据逻辑
const data = await ...
return NextResponse.json({ data });
}
export async function POST(request: NextRequest) {
const body = await request.json();
// 插入数据逻辑
return NextResponse.json({ msg: 'Created success' });
}
export async function DELETE(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id')!;
if (!id) {
return NextResponse.json({ msg: 'Delete Failed' });
}
// 删除数据逻辑
return NextResponse.json({ msg: 'Deleted Success ' });
}
export async function PUT(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ msg: 'Update Failed' });
}
// 更新数据逻辑
return NextResponse.json({ msg: 'Updated Success' });
}
NextJs 会自动生成 404 页面,如果我们要使用自己的 404 页面,要遵循以下几个原则:
在 app 目录下,创建 not-found.tsx
文件,当访问路由不存在时,自动跳转到全局 not-found 页面
注意:全局 not-found.tsx 只能在app目录下,否则不生效, /app/not-found.tsx
not-found.tsx
文件,next/navigation
的 notFound
方法
比如我们有一个 /app/overview/not-found.tsx
,当访问 /app/overview/page2
时,page2页面获取不到接口数据时,出现局部404// /app/overview/page2/page.tsx
import { notFound } from 'next/navigation';
export default function Page1() {
// 模拟获取数据
const data = null;
if (!data) {
return notFound();
}
return <h1>I am page2...</h1>;
}
global-error.tsx
,否则不生效'use client';
export default function GlobalError({
error, reset
}: {
error: Error & { digest?: string };
reset: () => void })
{
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}
>Try again</button>
</body>
</html>
);
}
在某个页面或多个共享layout页面目录下,创建 error.tsx
//当发生错误时,error.tsx替换当前page.tsx内容展示,效果参考局部404效果
export default async function Page1() {
throw new Error('xxx');
return <h1>I am page2...</h1>;
}
当访问 loading.tsx 所在目录下的任何页面时,浏览器会先自动加载并渲染 loading,当页面准备好后,会隐藏loading,再显示页面。
/app/overview
| page1/page.tsx
| page2/page.tsx
| page.tsx
| loading.tsx
局部loading 将作用于 overview 下的所有页面
/app/overview
| page1
| page.tsx
| loading.tsx
| page2/page.tsx
| page.tsx
局部loading 只有作用于 overview/page1
如果让局部 loading 只作用于某一个页面,而不作用于该目录下的其他页面的情况,该如何实现呢?
使用路由组包裹单独作用域的loading和page
/app/overview
|(overview)
| loading.tsx
| page.tsx
| page1/page.tsx
| page2/page.tsx
loading 将作用于 overview/(overview) 下的所有页面,但是实际上并没有(overview)这个路由,所以访问 /overview 时,出现局部loading。但访问 /overview/page1 或 /overview/page2,因为不会进入到(overview)文件夹, 会回退到全局 loading。
NextJS 14 默认SSG。看一个例子
// http://localhost:3000
import { ArticleApi } from '@/app/api/article-api';
export default async function Home() {
const articles = await ArticleApi.fetchToday();
return (
<>
<section>{new Date().toLocaleTimeString()}</section>
<section>{JSON.stringify(articles)}</section>;
</>
);
}
默认情况下,当你 npm run build 后,再 npm run start,无论你刷新多少次浏览器,内容都是不变的,即使有 fetch 也不行, 显示的时间仍旧是不变的
SSG:静态渲染,在服务端构建部署时,数据重新生效,产生的静态页面可以被分发、缓存到全世界各地
SSR:动态渲染,在服务端接收到每个用户请求时,重新请求数据,重新渲染页面
收益:显示实时数据、特定用户的特定数据(用于区别对待)、可以获取到客户端请求的cookie和URL参数
当你需要给SSG固定一个缓存生效时间,可以在页面文件顶部增加一行
export const revalidate = 10; // 10秒
export default Page() {}
加入这行代码后,重新执行 npm run build -> npm start
可以在浏览器观察到, 每10s 刷新后,时间才会发生变化,这是因为给整个页面设置了 revalidate 为 10s
如果你不想将页面预渲染缓存,可以在页面文件顶部加入一行,使每次访问时重新渲染
export const dynamic = 'force-dynamic';
export default Page() {}
使用generateStaticParams
来获取生成页面的数据
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://...').then((res) => res.json())
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// ...
}
NextJs 默认将所有组件都视为服务端组件
'use client'
标识,用户可以在浏览器进行页面交互,比如使用 useState、useEffuct、next/navigation等,「它既在服务器又在浏览器运行,先服务器,然后浏览器」如果组件使用了 useState
、useEffect
或 第三方库(用于交互的)等,而没有将文件标识为 'use client'
,系统将会报错
如果没有将一个文件手动标记为 'use server'(虽然NextJs默认将所有文件都视为在服务端运行),但是如果你在客户端组件'use client' 里面想直接调用这个服务端的函数,比如执行 form action、点击请求service, 一定要提前告诉浏览器,这个函数是运行在服务端的。如果不做标记,默认会视为服务端组件,但是你使用'use client'的组件,import 要调用的函数,浏览器会把它视为改组件的下级,当作在浏览器执行的。
这个时候我们有两种标记方式:
// server, /app/actionss/user.ts
export async function createUser(body) {}
export async function test() {}
// client, /app/user/create/page.tsx
'use client';
import { createUser, test } from '@/app/actions/user';
export default function Page() {
async function create(formData: FormData) {
'use server'; // 标记
// 调用服务端 service
await createUer(formData);
}
async function clickHandler() {
'use server'; // 标记
await test()
}
return (<>
<form action={create}>...</form>
<section onClick={clickHandler}>test</section>
</>);
}
// server, /app/actionss/user.ts
'use server';
export async function createUser(body) {}
export async function test() {}
// client, /app/user/create/page.tsx
'use client';
import { createUser, test } from '@/app/actions/user';
export default function Page() {
async function create(formData: FormData) {
// 'use server'; // 不标记
// 调用服务端 service
await createUer(formData);
}
async function clickHandler() {
// 'use server'; // 不标记
await test()
}
return (<>
<form action={create}>...</form>
<section onClick={clickHandler}>test</section>
</>);
}
当一个文件标识为 'use server',那么它 export 的函数必须为 async,export 对象不行,普通函数也不行,否则客户端组件 import 时,报错
"use server" file can only export async functions, found object.
These functions are required to be defined as async, because "use server" marks them as Server Actions and they can be invoked directly from the client through a network request.
服务器组件中只能导出类型和异步函数
//❌ 以下写法会报错
// api/service.ts
'use server';
export const QueryUserSchema = z
.object({
name: z.string(),
age: z.number().optional()
});
// api/route.ts
import { QueryUserSchema } from './service.ts';
export async function GET(req: NextRequest) {
const filters =
getQueryParams<typeof QueryUserSchema._type>(req);
return NextResponse.json({});
}
//✅ 这样不会
// api/service.ts
'use server';
const QueryUserSchema = z
.object({
name: z.string(),
age: z.number().optional()
});
export type QueryUserParams = typeof
QueryUserSchema._type;
// api/route.ts
import { QueryUserParams } from './service.ts';
export async function GET(req: NextRequest) {
const filters =
getQueryParams<QueryUserParams>(req);
return NextResponse.json({});
}
在 /app/layout.tsx中,设置顶级 metadata
在页面中也可以设置专属页面 metadata,并与顶级 metadata 进行merge
设置meta内容的方式有两种
1. 在组件中导出metadata:
//layout.tsx|page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '...',
description: '...',
}
export default function Page() {}
2. 在组件中导出generateMetadata函数(不能再layout中使用):
//动态获取参数设置meta
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: Promise<{ id: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
// read route params
const id = (await 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) {}
middleware.ts
(或.js),要与app
目录在同一级,app在src内部时,中间件也要在src内部// /middleware.ts或/src/middleware.ts
import { NextResponse, NextRequest } from 'next/server'
// 中间件处理逻辑,也可以是 async 函数
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
// 配置项
export const config = {
matcher: '/about/:path*',
}
该文件必须导出单个函数,可以是默认导出或命名的middleware
,但是不支持导出多个中间件
export default function middleware(request) {
// Middleware logic
}
在项目中,每个请求都会经过中间件,比如图片,api或者ico文件,实际上我们不需要每个请求都处理,我们可以在middleware.ts
导出名为config
的配置对象,这个对象包含matcher
,可以指定适用于中间件的路径matcher
可以指定固定的路径或者使用正则表达式,例如:
export const config = {
matcher: '/about',
}
// or
export const config = {
matcher: ['/about', '/contact'],
}
// or
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
matcher
还可以是一个对象,包含以下属性
source
:用于匹配请求路径的路径或模式。它可以是用于直接路径匹配的字符串,也可以是用于更复杂匹配的模式。regexp
(可选):根据源微调匹配的正则表达式字符串。它提供了对包含或排除哪些路径的额外控制。locale
(可选):布尔值,当设置为时false,将在路径匹配中忽略基于语言环境的路由。has
(可选):根据特定请求元素(例如标头、查询参数或 cookie)的存在指定条件。missing
(可选):关注某些请求元素缺失的情况,例如缺少标头或 cookie。export const config = {
matcher: [
{
source: '/api/*',
regexp: '^/api/(.*)',
locale: false,
has: [
{ type: 'header', key: 'Authorization', value: 'Bearer Token' },
{ type: 'query', key: 'userId', value: '123' },
],
missing: [{ type: 'cookie', key: 'session', value: 'active' }],
},
],
}
export default function middleware(req: NextRequest) {
// 在request.cookies可以查看请求的cookie
let cookie = req.cookies.get('xxx');
console.log(cookie);
const allCookies = req.cookies.getAll();
console.log(allCookies);
req.cookies.has('xxx'); // => boolean
req.cookies.delete('xxx');
// `ResponseCookies` API来设置响应cookie
const response = NextResponse.next();
response.cookies.set('vercel', 'fast');
response.cookies.set({
name: 'vercel',
value: 'fast',
path: '/',
});
cookie = response.cookies.get('vercel');
console.log(cookie);
return response; // 会在浏览器中设置 cookie
}
export default function middleware(req: NextRequest) {
// Clone the request headers and set a new header `x-hello-from-middleware1`
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-hello-from-middleware1', 'hello');
// 可以在 api 请求中,req.headers 看到 'x-hello-from-middleware1' => { name: 'x-hello-from-middleware1', value: 'hello' },
// NextResponse.rewrite设置handers
const response = NextResponse.next({
request: {
// New request headers
headers: requestHeaders,
},
});
response.headers.set('x-hello-from-middleware2', 'hello');
return response;
}
import { NextRequest, NextResponse } from 'next/server';
const allowedOrigins = ['https://acme.com', 'https://my-app.org'];
const corsOptions = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin') ?? '';
const isAllowedOrigin = allowedOrigins.includes(origin);
const isPreflight = request.method === 'OPTIONS';
if (isPreflight) {
const preflightHeaders = {
...(isAllowedOrigin && { 'Access-Control-Allow-Origin': origin }),
...corsOptions,
};
return NextResponse.json({}, { headers: preflightHeaders });
}
const response = NextResponse.next();
if (isAllowedOrigin) {
response.headers.set('Access-Control-Allow-Origin', origin);
}
Object.entries(corsOptions).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
export const config = {
matcher: '/api/:path*',
};
往期精彩内容指路