Next.js
- AI, 提供 AI SDK
- SSR, Server-Side Rendering 服务器端渲染
- SSG, Static Site Generation 静态站点生成
- SEO, Search Engine Optimization 搜索引擎优化
Turbopack
- 统一的依赖图: Next 支持多个输出环境 (例如客户端和服务器), turbopack 使用统一的依赖图覆盖所有的环境
- 开发时打包 vs 原生 esm: vite 开发时跳过打包, 只适用于小型应用, 对于大型应用, 网络请求过多, 可能降低大型应用的速度; Next 开发时使用 turbopack 打包, 可以提高大型应用的速度
- 增量计算: turbopack 使用多核 CPU 并行化计算, 缓存计算结果到函数级
- 懒打包: turbopack 开发时只打包实际请求的模块, 懒打包可以减少编译时间和内存占用
pnpm create next-app@latest
pnpm dlx create-next-app@latestReact Compiler
React Compiler 自动优化性能
// pnpm add babel-plugin-react-compiler -D
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;memo, useMemo, useCallback
memo, useMemo, useCallback
// 如果没有 React Compiler, 则需要手动 memo 缓存组件, useMemo 缓存值, useCallback 缓存函数以优化重新渲染
import { useMemo, useCallback, memo } from "react";
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onClick }) {
const processedData = useMemo(() => {
return expensiveProcessing(data);
}, [data]);
const handleClick = useCallback(
(item) => {
onClick(item.id);
},
[onClick],
);
return (
<div>
{processedData.map((item) => (
{/* 每次组件更新时, 都会创建新的 () => handleClick(item) 箭头函数, 破坏记忆化! */}
<Item key={item.id} onClick={() => handleClick(item)} />
))}
</div>
);
});App Router
Pages Router
app/pages
├── index.tsx # -> /
├── about
│ ├── index.tsx # -> /about
│ └── him.tsx # -> /about/him
└── posts
└── [id].tsx # -> /posts/[id]App Router
文件系统路由: app 目录下的每个子目录, 都代表一个路由段
- page.tsx 页面组件
- layout.tsx 布局组件
- app/layout.tsx 根布局组件 (必须)
- template.tsx 模板组件
- loading.tsx 加载组件: 基于异步组件
<Suspense /> - error.tsx 错误组件: 基于错误边界 Error Boundary
- app/not-found.tsx 全局 404 组件
<Layout>
<Template>
<Page />
</Template>
</Layout>- Next.js 会缓存
<Layout />布局组件, 只会挂载 1 次 (类似 vue<KeepAlive />) - Next.js 不会缓存
<Template />模板组件
app/about
├── error.tsx
├── him
│ └── page.tsx
├── layout.tsx
├── loading.tsx
├── page.tsx
├── her
│ └── page.tsx
└── template.tsxlayout.tsx
layout.tsx
"use client";
import Link from "next/link";
import { Component, Suspense } from "react";
interface IState {
cnt: number;
}
class AboutLayout extends Component<LayoutProps<"/about">, IState> {
state = { cnt: 0 };
handleClick = () => {
const { cnt } = this.state;
this.setState({ cnt: cnt + 1 }, () => console.log(this.state));
};
render() {
const { cnt } = this.state;
// children: template.tsx
const { children } = this.props;
return (
<>
<div>AboutLayer cnt {cnt}</div>
<button onClick={this.handleClick}>addCnt</button>
<header>AboutLayout header</header>
<Suspense fallback={<>loading...</>}>{children}</Suspense>
<Link href="/about/him">/about/him</Link>
<Link href="/about/her">/about/her</Link>
<footer>AboutLayout footer</footer>
</>
);
}
}
export default AboutLayout;路由导航
路由导航的 4 种方式
<Link />组件- useRouter: hook, 仅在客户端组件中可以使用
- redirect, permanentRedirect: 客户端组件, 服务器组件中都可以使用
- history API
<Link /> 组件
增强的 <a /> 标签
<Link href={{ pathname: "/about/him", query: { name: "lark", age: 24 } }}>
/about/him?name=lark&age=24
</Link>
<Link
href="/about/him"
// prefetch 预获取目的页面, 默认 true
prefetch
// 禁止默认滚动行为: 滚动到顶部, 即保留滚动位置
scroll={false}
// history.replaceState()
replace
>/about/him</Link>useRouter hook
"use client";
import { useRouter } from "next/navigation";
export default function HimPage() {
const router = useRouter();
return (
<>
<button onClick={() => router.push("/about")}>history.pushState()</button>
<button onClick={() => router.replace("/about")}>
history.replaceState()
</button>
<button onClick={() => router.back()}>history.back()</button>
<button onClick={() => router.forward()}>history.forward()</button>
<button onClick={() => router.refresh()}>refresh /about/him</button>
<button onClick={() => router.prefetch("/about/her")}>
prefetch /about/her
</button>
</>
);
}redirect, permanentRedirect
对比 redirect, permanentRedirect: 状态码不同
- redirect: 307 Temporary Redirect
- permanentRedirect: 308 Permanent Redirect
import { redirect, RedirectType } from "next/navigation";
redirect("/login");
redirect("/login", RedirectType.push);
redirect("/login", RedirectType.replace);动态路由, 平行路由, 路由组
动态路由 (url 路径参数)
about/[id]捕获一个参数about/[...idList]捕获多个参数about-id/[[...optionalList]]捕获多个参数 (可选)
about/[id]
about/[id]
"use client";
import { useParams } from "next/navigation";
export default function AboutIdPage() {
const params = useParams();
const { id } = params;
return <>AboutIdPage {id}</>;
}平行路由
app
├── @footer
│ ├── default.tsx # FooterDefault
│ └── page.tsx # FooterPage
├── @header
│ ├── default.tsx # HeaderDefault
│ ├── page.tsx # HeaderPage
│ ├── error.tsx # HeaderErrorComponent
│ ├── loading.tsx # HeaderLoadingComponent
│ └── parallel
│ └── page.tsx # HeaderParallel
├── default.tsx # PageDefault
├── layout.tsx # RootLayout
└── page.tsx # RootPageimport type { Metadata } from "next";
import { ReactNode } from "react";
import Link from "next/link";
export const metadata: Metadata = {
title: "Next.js Demo",
description: "Next.js Demo",
};
export default function RootLayout({
children,
header,
footer,
}: Readonly<{
children: ReactNode;
header: ReactNode; // @header
footer: ReactNode; // @footer
}>) {
return (
<html lang="en">
<body>
{header}
{children}
{footer}
<Link href="/">Header Page</Link>
<Link href="/parallel">Header Parallel</Link>
</body>
</html>
);
}路由组
app
├── (ieg)
│ └── honor-of-kings
│ └── page.tsx # http://localhost:3000/honor-of-kings
├── (wxg)
│ └── wechat
│ └── page.tsx # http://localhost:3000/wechat
├── layout.tsx
└── page.tsxapp
├── (ieg)
│ ├── honor-of-kings
│ │ └── page.tsx # HonorOfKingsPage
│ └── layout.tsx # HonorOfKingsRootLayout
├── (wxg)
│ ├── wechat
│ │ └── page.tsx # WeChatPage
│ └── layout.tsx # WeChatRootLayout
└── page.tsx后端路由
- 使用 route.ts 定义后端路由
- page.tsx 和 route.ts 不能放在同一个目录下
- api 函数名限制为 HEAD, GET, POST, PUT, DELETE, PATCH, OPTIONS, …
查询参数, 请求体参数
api/user/route.ts
// route.ts
import { NextRequest, NextResponse } from "next/server";
// Export the uppercase 'GET' method name
export async function GET(request: NextRequest) {
const queryParams = request.nextUrl.searchParams;
const name = queryParams.get("name");
const age = queryParams.get("age");
return NextResponse.json({
message: `GET user OK: name=${name}, age=${age}`,
});
}
interface IBody {
name: string;
age: number;
}
export async function POST(request: NextRequest) {
// request.json()
// request.formData()
// request.text()
// request.blob()
// request.arrayBuffer()
const body = (await request.json()) as IBody;
const { name, age } = body;
return NextResponse.json(
{
message: `user: name=${name}, age=${age}`,
},
{ status: 201 }, // HTTP response status code: 201 Created
);
}url 路径参数
import { NextRequest, NextResponse } from "next/server";
interface IParams {
id: string;
}
// Export the uppercase 'GET' method name
export async function GET(
request: NextRequest,
{ params }: { params: Promise<IParams> },
) {
const { id } = await params;
return NextResponse.json({
message: `user: id=${id}`,
});
}Cookie
api/login/route.ts
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export async function GET() {
const tokenStore = await cookies();
const token = tokenStore.get("token")?.value ?? null;
if (token && token === "161043261") {
return NextResponse.json({ code: 1, message: "Logged in" });
}
return NextResponse.json({ code: 0, message: "Not logged in" });
}
interface IBody {
username: string;
password: string;
}
export async function POST(request: NextRequest) {
const { username, password } = (await request.json()) as IBody;
if (username === "admin" && password === "pass") {
const cookieStore = await cookies();
cookieStore.set("token", "161043261", {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true, // 只允许服务器访问
});
return NextResponse.json({ message: "Login successful", code: 1 });
}
return NextResponse.json({ message: "Login failed", code: 0 });
}AI SDK
pnpm add ai @ai-sdk/deepseek @ai-sdk/reactapi/chat/route.ts
api/chat/route.ts
import { createDeepSeek } from "@ai-sdk/deepseek";
import { NextRequest } from "next/server";
import { streamText, convertToModelMessages } from "ai";
const deepSeek = createDeepSeek({ apiKey: "" });
export async function POST(request: NextRequest) {
const { messages } = await request.json();
const result = streamText({
model: deepSeek("deepseek-chat"),
messages: convertToModelMessages(messages),
// messages: [
// {
// role: "user",
// content: "Hello!",
// },
// {
// role: "assistant",
// content: "Hello! I'm a frontend engineer",
// },
// ],
system: "Hello! You're a frontend engineer",
});
return result.toUIMessageStreamResponse();
}Proxy (Middleware)
- 处理跨域请求
- 转发请求
- 限流
- 鉴权, 判断是否登录
proxy.ts
import { NextRequest, NextResponse, ProxyConfig } from "next/server";
export async function proxy(request: NextRequest) {
console.log("[proxy] url:", request.url);
const { pathname } = request.nextUrl;
console.log("[proxy] pathname:", pathname);
if (pathname.startsWith("/home")) {
return NextResponse.next();
}
if (pathname.startsWith("/api")) {
const cookie = request.cookies.get("token");
if (pathname === "/api/login" || cookie) {
return NextResponse.next();
}
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config: ProxyConfig = {
// matcher: '/home/:path*'
// matcher: ["/home/:path*", "/api/:path*"],
matcher: [
"/home/:path*",
{
source: "/api/login",
// 必须携带 header 请求头, 键为 Content-Type, 值为 application/json
has: [{ type: "header", key: "Content-Type", value: "application/json" }],
},
{
source: "/api/user",
has: [
// 必须携带 cookie, 键为 token, 值为 161043261
{ type: "cookie", key: "token", value: "161043261" },
// 必须携带 query 查询参数, 键为 username, 值为 lark
{ type: "query", key: "username", value: "lark" },
],
// 必须不携带查询参数, 键为 username, 值为 root
missing: [{ type: "query", key: "username", value: "root" }],
},
],
};允许跨域
proxy.ts
import { NextRequest, NextResponse, ProxyConfig } from "next/server";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
};
export async function proxy(request: NextRequest) {
const response = NextResponse.next();
Object.entries(corsHeaders).forEach(([k, v]) => {
response.headers.set(k, v);
});
return response;
}
export const config: ProxyConfig = {
matcher: "/api/:path*",
};CSR, SSR, SSG, Hydration, RSC, RCC
- SEO, Search Engine Optimization 搜索引擎优化
- CSR, Client Side Rendering 客户端渲染: 例如 Vue, React, 首屏加载慢, SEO 差
- SSR, Server Side Rendering 服务端渲染: 例如 Nuxt.js, Next.js, 首屏加载快, SEO 优
- SSG, Static Site Generation 静态站点生成: 例如 Vitepress, Astro, 首屏加载最快, SEO 最优
- Hydration 客户端水合
- RSC, React Server Components 服务器组件
- 使用 “use server” 指令
- 服务器组件必须是 async 异步函数
- 服务器组件在服务器渲染, 客户端局部水合, 避免全量水合导致的性能损耗
- 服务器组件不会被打包, 减小打包产物体积
- 服务器组件可以访问 Node.js API, 数据库
- 服务器组件支持流式传输
Transfer-Encoding: chunked, 减少 FCP 首次内容绘制时间 import "server-only"; // pnpm add server-only
- RCC, React Client Components 客户端组件
- 使用 “use client” 指令
- 客户端组件在服务器预渲染, 客户端局部水合, 避免全量水合导致的性能损耗
- 服务器组件中可以嵌入客户端组件, 客户端组件中不能嵌入服务器组件
未开启缓存组件
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactCompiler: true,
cacheComponents: false, // 默认
};
export default nextConfig;- ○ (Static) prerendered as static content 预渲染的静态内容
- ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content 部分预渲染: 静态的 HTML 和服务器流式传输的动态内容
- ƒ (Dynamic) server-rendered on demand 服务器按需渲染的动态内容
缓存策略
- 方式 1: 导出 revalidate
- 方式 2: 导出 dynamic
- 方式 3: 使用动态内容 API
Math.random(),fetch(url, { cache: "no-store" }),cookies(),headers(),connection(),searchParams
// 方式 1: 导出 revalidate
export const revalidate = 0; // 不使用缓存
// 5s 后, 客户端缓存失效, 服务器收到请求, 返回服务器缓存给客户端, 异步生成新内容, 更新服务器缓存
// 默认 1y 后, 服务器缓存失效, 服务器收到请求, 同步生成新内容, 更新服务器缓存, 返回新内容给客户端
export const revalidate = 5;
// 方式 2: 导出 dynamic
export const dynamic = "force-dynamic"; // 不使用缓存
export default async function Home() {
const res = await fetch("http://127.0.0.1:3001/api/image", {
cache: "no-store", // 方式 3: 使用动态内容 API
});
const arr = await res.arrayBuffer();
const base64str = Buffer.from(arr).toString("base64");
return (
<img
src={`data:image/png;base64,${base64str}`}
alt="base64str"
width={256}
/>
);
}开启缓存组件
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactCompiler: true,
cacheComponents: true, // 开启缓存组件
};
export default nextConfig;动态内容需要配合 <Suspense /> 使用
import { Suspense } from "react";
interface IData {
list: { name: string; age: number }[];
}
const DynamicComponent = async () => {
"use cache"; // 缓存组件
cacheLife();
const res = await fetch("http://127.0.0.1:3001/api/data");
const data: IData = await res.json();
return (
<>
<div>Dynamic Content</div>
{data.list.map((item, idx) => (
<div key={idx}>
{item.name}, {item.age}
</div>
))}
</>
);
};
export default async function Home() {
return (
<Suspense fallback={<div>Loading Dynamic Content...</div>}>
<DynamicComponent />
</Suspense>
);
}”use cache” 指令
- 在模块的顶部使用, 缓存模块的所有导出
- 在函数或组件的顶部使用, 缓存返回值
import { cacheLife } from "next/cache";
import { Suspense } from "react";
interface IData {
list: { name: string; age: number }[];
}
const DynamicComponent = async () => {
"use cache"; // 缓存组件
// cacheLife("days") // 预设
cacheLife({
stale: 5, // 5s 内, 客户端直接使用客户端缓存, 不请求服务器
revalidate: 5, // 5s 后, 客户端缓存失效, 服务器收到请求, 返回服务器缓存给客户端, 异步生成新内容, 更新服务器缓存
expire: 30, // 30s 后, 服务器收到请求, 服务器缓存失效, 同步生成新内容, 更新服务器缓存, 返回新内容给客户端
});
const res = await fetch("http://127.0.0.1:3001/api/data");
const data: IData = await res.json();
return (
<>
<div>Dynamic Content</div>
{data.list.map((item, idx) => (
<div key={idx}>
{item.name}, {item.age}
</div>
))}
</>
);
};
export default async function Home() {
return (
<Suspense fallback={<div>Loading Dynamic Content...</div>}>
<DynamicComponent />
</Suspense>
);
}font 字体
import { Geist_Mono } from "next/font/google";
// import FontLocal from "next/font/local";
const geistMono = Geist_Mono({
weight: "400",
style: ["normal"],
// swap 不会阻塞页面渲染, 先使用默认字体, 自定义字体加载完成后, 再替换为自定义字体
display: "swap",
});
// const ubuntuMono = FontLocal({
// src: '/path/to/ubuntu-mono.woff2',
// display: 'swap'
// })
export default async function Home() {
return <h1 className={geistMono.className}>Geist Mono</h1>;
}<Image /> 组件
- 尺寸优化
- CLS 累积布局偏移优化
- 懒加载
最佳实践
- 图片放在 /public 目录
- 使用图片路径 (以 / 开头) 时, 必须指定宽高或使用 fill 撑满父元素
- 使用 import 导入图片时, 不需要指定宽高
- 立即加载
loading="eager", 预加载preload, 更推荐使用立即加载
tsconfig.json
tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/public/*": ["./public/*"]
}
}
}<Script /> 组件
- 使用
<Script />组件加载本地或远程脚本, 将<Script />组件转换为<script>标签, 并插入到<head>标签中 - 全局引入: 在根布局组件 layout.tsx 中引入, 加载脚本 (只加载一次) 并缓存
- 局部引入: 例如在 Home 组件 home/page.tsx 中引入, 跳转到 /home 路由时, 加载脚本 (只加载一次) 并缓存
加载策略
- beforeInteractive: 代码和页面水合前加载脚本, 会阻塞页面渲染
- afterInteractive: 默认, 页面渲染, 并部分水合后加载脚本
- lazyOnload: 浏览器空闲时加载脚本
- worker: 使用 web worker 加载脚本
import Script from "next/script";
export default async function Home() {
// Next.js 使用 preload 预加载远程脚本 (200 from memory cache)
return (
<Script
id="scoped-script"
strategy="afterInteractive"
src="https://unpkg.com/vue@3/dist/vue.global.js"
/>
);
}内联脚本
方式 1
方式 1
import Script from "next/script";
export default async function Home() {
return (
<>
<div id="scoped-app"></div>
<Script id="inline_script">
{`
const { createApp, ref } = Vue;
createApp({
template: \`
<div>
<div>{{ count }}</div>
<button @click="addCount">Add count</button>
</div>
\`,
setup() {
const count = ref(0);
const addCount = () => count.value++;
return { count, addCount };
}
}).mount('#scoped-app')
`}
</Script>
</>
);
}事件
- onLoad 脚本加载完成时触发
- onReady 脚本加载加载完成时, 每次组件挂载时触发
- onError 脚本加载失败时触发
导出静态站点
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export", // 导出静态站点
distDir: "out", // 导出目录
trailingSlash: true, // 添加 url 尾部斜杠, 打包 /about.html => /about/index.html
};
export default nextConfig;图片优化
导出静态站点时, 如果使用 <Image /> 组件的默认 loader 优化图片, 则会报错
方法 1: 禁用图片优化
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export", // 导出静态站点
distDir: "out", // 导出目录
trailingSlash: true, // 添加 url 尾部斜杠, 打包 /about.html => /about/index.html
images: {
unoptimized: true, // 禁用图片优化
},
};
export default nextConfig;方法 2: 自定义 loader + CDN
next.config.ts
next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export", // 导出静态站点
distDir: "out", // 导出目录
trailingSlash: true, // 添加 url 尾部斜杠, 打包 /about.html => /about/index.html
images: {
// 自定义 loader
loader: "custom",
loaderFile: "./image-loader.ts",
},
};
export default nextConfig;动态路由
导出静态站点时, 如果使用动态路由, 例如 about/[id]/page.tsx, 则需要使用 generateStaticParams 构建时生成路由, 而不是请求时按需生成路由
export async function generateStaticParams() {
return [{ id: "1" }, { id: "2" }]; // All possible ids
}
export default async function AboutIdPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <>AboutIdPage {id}</>;
}MDX
next.config.js
next.config.js
import createMDX from "@next/mdx";
const withMDX = createMDX({});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactCompiler: true,
// 页面的文件扩展名 page.{js,jsx,ts,tsx,md,mdx}
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
};
export default withMDX(nextConfig);服务器函数 Server Actions
export default function Login() {
// 服务器函数必须是 async 异步的
const action = async (id: number, formData: FormData) => {
"use server";
const username = formData.get("username");
const password = formData.get("password");
console.log(id, username, password);
console.log(id, Object.fromEntries(formData));
};
const actionWithId = action.bind(null, 3);
return (
<>
<h1>Login</h1>
<div className="mx-auto mt-20 w-20">
<form action={actionWithId} className="flex flex-col gap-2">
{/* input 必须有 name 属性, 作为 formData 对象的 key */}
<input type="text" name="username" placeholder="username" />
<input type="password" name="password" placeholder="password" />
{/* button type 属性值必须是 submit, 触发表单提交 */}
<button type="submit">submit</button>
</form>
</div>
</>
);
}hook: useActionState
参数
- action 表单提交或按下表单中的按钮时, 触发的回调函数, 接收上一个状态 (initialState 或上一个返回值) 和表单数据, 返回当前状态
- initialState 初始状态
- permalink 表单提交后跳转的 url, 可选
返回值
- state 当前状态
- formAction 可以作为 form 属性传递给表单组件, 或作为 formAction 属性传递给表单中的按钮组件
const [state, formAction, isPending] = useActionsState<IState, FormData>(
action, // (oldState: IState, formData: FormData) => Promise<IState>
initialState, // IState
);lib/login/actions.ts
lib/login/actions.ts
import { z } from "zod";
const loginSchema = z.object({
username: z.string().min(4).max(16),
password: z.string().min(4).max(16),
});
export interface IState {
message: string;
}
export async function action(
oldState: IState,
formData: FormData,
): Promise<IState> {
return new Promise((resolve) => {
console.log(oldState, Object.fromEntries(formData));
setTimeout(() => {
const res = loginSchema.safeParse(Object.fromEntries(formData));
if (!res.success) {
resolve({ message: res.error.message });
} else {
resolve({ message: new Date().toISOString() });
}
}, 5000);
});
}环境变量
package.json
package.json
{
"scripts": {
"dev": "BASE_URL=/homepage/ NEXT_PUBLIC_BASE_URL=/homepage/ next dev"
}
}优先级
process.env, 例如BASE_URL=/homepage/ NEXT_PUBLIC_BASE_URL=/homepage/ next dev.env.$NODE_ENV.local, $NODE_ENV 是 Next.js 自动注入的环境变量, 开发模式 $NODE_ENV == development, 生产模式 $NODE_ENV == production.env.local.env.$NODE_ENV.env
i18n
- language 语言: en, zh, ja, …
- territory 地区: US, CN, JP, …
pnpm add negotiator @formatjs/intl-localematcher
pnpm add @types/negotiator -DHTTP 请求头字段 Accept-Language 客户端的偏好语言, 例如 Accept-Language: zh-CN
i18n/index.ts
i18n/index.ts
// 支持的语言
export const locales = ["en", "zh", "ja"] as const;
// 默认语言
export const defaultLocale = "en";
export interface IResource {
title: string;
description: string;
keywords: string;
}
export async function getResource(locale: string): Promise<IResource> {
return import(`./${locale}.json`).then((module) => module.default);
}Demo
proxy.ts
proxy.ts
// 获取客户端的偏好语言
import { NextRequest, NextResponse, ProxyConfig } from "next/server";
import { defaultLocale, locales } from "./i18n";
import Negotiator from "negotiator";
import { match } from "@formatjs/intl-localematcher";
export async function proxy(req: NextRequest, res: NextResponse) {
if (req.nextUrl.pathname === "/") {
return NextResponse.next();
}
if (locales.some((locale) => req.nextUrl.pathname.startsWith(`/${locale}`))) {
return NextResponse.next();
}
const headers = {
"accept-language": req.headers.get("accept-language") ?? "",
};
const negotiator = new Negotiator({ headers });
const langs = negotiator.languages();
const lang = match(langs, locales, defaultLocale);
const { pathname } = req.nextUrl;
req.nextUrl.pathname = `/${lang}${pathname}`;
return NextResponse.redirect(req.nextUrl);
}
export const config: ProxyConfig = {
// (?!) 匹配的路径不包含 api, _next/static, _next/image, favicon.ico
// .* 匹配任意字符 0 次, 1 次或多次
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};next.config
next.config.ts
next.config.ts
import { PHASE_DEVELOPMENT_SERVER, PHASE_TYPE } from "next/constants";
import type { NextConfig } from "next";
export default (phase: PHASE_TYPE): NextConfig => {
const nextConfig: NextConfig = {
reactCompiler: false,
};
if (phase === PHASE_DEVELOPMENT_SERVER) {
nextConfig.reactCompiler = true; // 开发环境使用 react compiler
}
return nextConfig;
};端口号
{
"scripts": {
"dev": "next dev -p 8080", // 开发环境端口号
"build": "next build",
"start": "next start -p 3000" // 生产环境端口号
}
}其他配置
const nextConfig: NextConfig = {
basePath: "/docs", // 基础路径
redirects() {
return [
{
source: "/", // 源路径
destination: "/docs", // 目标路径
// 默认 true, source 和 destination 都会自动添加 basePath 前缀
basePath: false, // 是否自动添加 basePath 前缀
permanent: false, // 是否永久重定向 (301, 308)
},
];
},
assetsPrefix: "https://github.com/161043261", // 静态资源前缀
compress: true, // 是否开启压缩
// devIndicators: true, // 是否开启开发调试器
devIndicators: {
// top-left top-right bottom-left bottom-right
position: "bottom-right", // 开发调试器 logo 的位置
},
generateEtags: true, // 是否生成.用于协商缓存的 etag
// 自定义响应头
headers: () => {
return [
{
source: "/:path*", // 匹配所有路径
headers: [
// 指定允许 (跨域) 资源共享的源站
{ key: "Access-Control-Allow-Origin", value: "*" },
// 用于响应预检请求, 指定实际请求允许使用的请求方法
{
key: "Access-Control-Allow-Methods",
value: "GET,POST,PUT,DELETE,OPTIONS",
},
// 用于响应预检请求, 指定实际请求允许使用的请求头字段
{
key: "Access-Control-Allow-Headers",
value: "Authorization,Content-Type",
},
],
},
];
},
logging: {
fetches: {
fullUrl: true, // 日志记录 fetch 的完整 url
},
},
// 页面的文件扩展名 page.{js,jsx,ts,tsx,md,mdx}
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
// 透传给 turbopack
turbopack: {
root: resolve(__dirname, "./"),
// root: resolve(__dirname, "../../"),
},
// 全局 scss 变量
sassOptions: {
// additionalData: `@use "@/styles/variables" as *;`,
additionalData: `$color-primary: lightgreen;`,
},
compiler: {
removeConsole: true, // 移除 console.log
styledComponents: true, // styled-components 支持
},
};Last updated on