Skip to content

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 开发时只打包实际请求的模块, 懒打包可以减少编译时间和内存占用
bash
pnpm create next-app@latest

pnpm dlx create-next-app@latest

React Compiler

React Compiler 自动优化性能

ts
// pnpm add babel-plugin-react-compiler -D
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;
tsx
// 如果没有 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>
  );
});
tsx
function ExpensiveComponent({ data, onClick }) {
  const processedData = expensiveProcessing(data);

  const handleClick = (item) => {
    onClick(item.id);
  };

  return (
    <div>
      {processedData.map((item) => (
        {/* React Compiler 可以在使用或不使用箭头函数的情况下, 正确优化性能
        确保 Item 仅在 props.onClick 更新时重新渲染 */}
        <Item key={item.id} onClick={() => handleClick(item)} />
      ))}
    </div>
  );
}

App Router

Pages Router

bash
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 组件
tsx
<Layout>
  <Template>
    <Page />
  </Template>
</Layout>
  • Next.js 会缓存 <Layout /> 布局组件, 只会挂载 1 次 (类似 vue <KeepAlive />)
  • Next.js 不会缓存 <Template /> 模板组件
bash
app/about
├── error.tsx
├── him
   └── page.tsx
├── layout.tsx
├── loading.tsx
├── page.tsx
├── her
   └── page.tsx
└── template.tsx
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;
tsx
"use client";

import { ReactNode, useState } from "react";

interface IProps {
  children: ReactNode;
}

const AboutTemplate = function (props: IProps) {
  const [cnt, setCnt] = useState(0);
  const handleClick = () => setCnt(cnt + 1);
  // children: page.tsx
  const { children } = props;

  return (
    <>
      <div>cnt: {cnt}</div>
      <button onClick={handleClick}>addCnt</button>
      <header>AboutTemplate header</header>
      {children}
      <footer>AboutTemplate footer</footer>
    </>
  );
};

export default AboutTemplate;
tsx
// about/page.tsx
"use server";

const getData = async (): Promise<{ timestamp: string }> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ timestamp: new Date().toISOString() });
    }, 5000);
  });
};

async function AboutPage() {
  const data = await getData();
  return <>About me {data.timestamp}</>;
}

export default AboutPage;

// about/him/page.tsx
export default function AboutHimPage() {
  return <>About Him</>;
}

// about/her/page.tsx
export default function AboutHerPage() {
  return <>About Her</>;
}
tsx
function LoadingComponent() {
  return <>loading...</>;
}

export default LoadingComponent;
tsx
"use client"; // Error 组件必须是客户端组件

function ErrorComponent(props: unknown) {
  return <>error: {JSON.stringify(props)}</>;
}

export default ErrorComponent;

路由导航

路由导航的 4 种方式

  1. <Link /> 组件
  2. useRouter: hook, 客户端组件中可以使用
  3. redirect, permanentRedirect: 客户端组件, 服务器组件中都可以使用
  4. history API

增强的 <a /> 标签

tsx
<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

tsx
"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
ts
import { redirect, RedirectType } from "next/navigation";

redirect("/login");
redirect("/login", RedirectType.push);
redirect("/login", RedirectType.replace);

动态路由, 平行路由, 路由组

动态路由 (url 路径参数)

  • about/[id] 捕获一个参数
  • about/[...idList] 捕获多个参数
  • about-id/[[...optionalList]] 捕获多个参数 (可选)
tsx
"use client";

import { useParams } from "next/navigation";

export default function AboutIdPage() {
  const params = useParams();
  const { id } = params;
  return <>AboutIdPage {id}</>;
}
tsx
"use client";

import { useParams } from "next/navigation";

export default function AboutIdListPage() {
  const params = useParams();
  const { idList } = params;
  return (
    <>
      {Array.isArray(idList) &&
        idList.map((item) => <div key={item}>{item}</div>)}
    </>
  );
}
tsx
"use client";

import { useParams } from "next/navigation";

export default function AboutIdOptionalListPage() {
  const params = useParams();
  const { optionalList } = params;
  return (
    <>
      {Array.isArray(optionalList) &&
        optionalList.map((item) => <div key={item}>{item}</div>)}
    </>
  );
}

平行路由

bash
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         # RootPage
tsx
import 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>
  );
}

路由组

bash
app
├── (ieg)
   └── honor-of-kings
       └── page.tsx # http://localhost:3000/honor-of-kings
├── (wxg)
   └── wechat
       └── page.tsx # http://localhost:3000/wechat
├── layout.tsx
└── page.tsx
bash
app
├── (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

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 路径参数

ts
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}`,
  });
}

api/login/route.ts

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

bash
pnpm add ai @ai-sdk/deepseek @ai-sdk/react
tsx
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();
}
tsx
"use client";

import { useChat } from "@ai-sdk/react";
import { Ref, useEffect, useImperativeHandle, useRef } from "react";

export interface IExpose {
  sendMessage: (message: { text: string }) => void;
}

interface IProps {
  ref: Ref<IExpose>;
}

export default function Chat({ ref }: IProps) {
  const { messages, sendMessage } = useChat();

  // sendMessage({ text });
  useImperativeHandle(ref, () => ({ sendMessage }), [sendMessage]);
  const containerRef = useRef<HTMLDivElement | null>(null);

  // 自动滚动到底部
  useEffect(() => {
    containerRef.current?.scrollIntoView({
      behavior: "smooth",
    });
  }, [messages]);

  return (
    <>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, partIdx) => (
            <div key={partIdx}>{part.type === "text" ? part.text : ""}</div>
          ))}
        </div>
      ))}
      <div ref={containerRef} />
    </>
  );
}
tsx
"use client";

import { ChangeEvent, Component, createRef, ReactNode } from "react";
import Chat, { IExpose as IChatExpose } from "./components/chat";

interface IState {
  inputVal: string;
}

class ChatPage extends Component<{}, IState> {
  state = {
    inputVal: "",
  };
  handleInput = (e: ChangeEvent<HTMLInputElement>) => {
    this.setState({
      inputVal: e.target.value,
    });
  };

  chatRef = createRef<IChatExpose>();

  handleSendMessage = () => {
    if (!this.chatRef.current) {
      return;
    }
    const { inputVal } = this.state;
    const { sendMessage } = this.chatRef.current;
    sendMessage({ text: inputVal });
    this.setState({ inputVal: "" });
  };

  render(): ReactNode {
    const { inputVal } = this.state;
    return (
      <>
        <Chat ref={this.chatRef} />
        <input value={inputVal} onChange={this.handleInput} />
        <button onClick={this.handleSendMessage}>Send message</button>
      </>
    );
  }
}

export default ChatPage;

Proxy (Middleware)

  • 处理跨域请求
  • 转发请求
  • 限流
  • 鉴权, 判断是否登录

proxy.ts

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

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" 指令
    • 客户端组件在服务器预渲染, 客户端局部水合, 避免全量水合导致的性能损耗
    • 服务器组件中可以嵌入客户端组件, 客户端组件中不能嵌入服务器组件

未开启缓存组件

js
// 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
tsx
// 方式 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}
    />
  );
}

开启缓存组件

js
// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactCompiler: true,
  cacheComponents: true, // 开启缓存组件
};

export default nextConfig;

动态内容需要配合 <Suspense /> 使用

tsx
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" 指令

  • 在模块的顶部使用, 缓存模块的所有导出
  • 在函数或组件的顶部使用, 缓存返回值
tsx
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 字体

tsx
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, 更推荐使用立即加载
json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@/public/*": ["./public/*"]
    }
  }
}
tsx
import Image from "next/image";
import nextImg from "@/public/next.svg";

export default async function Home() {
  const nextImg2 = await import("@/public/next.svg");

  return (
    <>
      {/* 默认 loading="lazy" 懒加载
      使用图片路径 (以 / 开头) 时, 必须指定宽高或使用 fill 撑满父元素 */}
      <Image
        preload
        src="/next.svg"
        loading="eager"
        alt="next"
        width={394}
        height={80}
      />
      {/* 使用 import 导入图片时, 不需要指定宽高 */}
      <Image preload src={nextImg} loading="lazy" alt="next" />
      <Image preload src={nextImg2.default} loading="lazy" alt="next" />
    </>
  );
}

<Script /> 组件

  • 使用 <Script /> 组件加载本地或远程脚本, 将 <Script /> 组件转换为 <script> 标签, 并插入到 <head> 标签中
  • 全局引入: 在根布局组件 layout.tsx 中引入, 加载脚本 (只加载一次) 并缓存
  • 局部引入: 例如在 Home 组件 home/page.tsx 中引入, 跳转到 /home 路由时, 加载脚本 (只加载一次) 并缓存

加载策略

  • beforeInteractive: 代码和页面水合前加载脚本, 会阻塞页面渲染
  • afterInteractive: 默认, 页面渲染, 并部分水合后加载脚本
  • lazyOnload: 浏览器空闲时加载脚本
  • worker: 使用 web worker 加载脚本
tsx
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"
    />
  );
}

内联脚本

tsx
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>
    </>
  );
}
tsx
import Script from "next/script";

export default async function HomeV3() {
  return (
    <>
      <div id="scoped-app"></div>
      <Script
        id="inline_script"
        dangerouslySetInnerHTML={{
          __html: `
            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')
          `,
        }}
      />
    </>
  );
}

事件

  • onLoad 脚本加载完成时触发
  • onReady 脚本加载加载完成时, 每次组件挂载时触发
  • onError 脚本加载失败时触发

导出静态站点

js
// 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: 禁用图片优化

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: {
    unoptimized: true, // 禁用图片优化
  },
};

export default nextConfig;

方法 2: 自定义 loader + CDN

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;
ts
export default function imageLoader({
  src,
}: {
  src: string;
  width: number;
  quality: number;
}) {
  return `https://github.com/161043261/161043261/tree/main/assets/${src}`; // CDN
}

动态路由

导出静态站点时, 如果使用动态路由, 例如 about/[id]/page.tsx, 则需要使用 generateStaticParams 构建时生成路由, 而不是请求时按需生成路由

tsx
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

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);
tsx
import type { MDXComponents } from "mdx/types";

const components: MDXComponents = {
  // mdx 全局样式
  h1: (props) => <h1 {...props} className="text-red-300" />,
};

export function useMDXComponents(): MDXComponents {
  return components;
}
tsx
"use client";

import { useState } from "react";

export default function MyComponent() {
  const [cnt, setCnt] = useState(0);
  return (
    <>
      <h1>{cnt}</h1>
      <button onClick={() => setCnt(cnt + 1)}>Add</button>
    </>
  );
}
mdx
import MyComponent from "./my-component";

# Hello MDX

<MyComponent />

服务器函数 Server Actions

tsx
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="w-20 mx-auto mt-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 属性传递给表单中的按钮组件
ts
const [state, formAction, isPending] = useActionsState<IState, FormData>(
  action, // (oldState: IState, formData: FormData) => Promise<IState>
  initialState, // IState
);
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);
  });
}
tsx
"use client";

import { action, IState } from "@/app/lib/login/actions";
import { useActionState } from "react";

const initialState: IState = { message: "" };

export default function Login() {
  const [state, formAction, isPending] = useActionState<IState, FormData>(
    action,
    initialState,
  );

  return (
    <>
      <h1>{isPending ? "Loading..." : state.message}</h1>
      <div className="w-20 mx-auto mt-20">
        <form action={formAction} className="flex flex-col gap-2">
          <input type="text" name="username" placeholder="username" />
          <input type="password" name="password" placeholder="password" />
          <button type="submit">submit</button>
        </form>
      </div>
    </>
  );
}

环境变量

json
{
  "scripts": {
    "dev": "BASE_URL=/homepage/ NEXT_PUBLIC_BASE_URL=/homepage/ next dev"
  }
}
ts
"use client";

export default function Login() {
  return (
    <>
      <div>process.env.NODE_ENV {process.env.NODE_ENV}</div>
      {/* process.env.BASE_URL 只能在服务器组件中访问 */}
      <div>process.env.BASE_URL {process.env.BASE_URL}</div>
      {/* process.env.NEXT_PUBLIC_BASE_URL
      以 NEXT_PUBLIC_ 开头的环境变量, 服务器组件, 客户端组件中都可以访问 */}
      <div>process.env.NEXT_PUBLIC_BASE_URL {process.env.NEXT_PUBLIC_BASE_URL}</div>
    </>
  );
}

优先级

  • 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, ...
bash
pnpm add negotiator @formatjs/intl-localematcher
pnpm add @types/negotiator -D

HTTP 请求头字段 Accept-Language 客户端的偏好语言, 例如 Accept-Language: zh-CN

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);
}
json
{
  "title": "title",
  "description": "description",
  "keywords": "keywords"
}
json
{
  "title": "标题",
  "description": "描述",
  "keywords": "关键词"
}
json
{
  "title": "タイトル",
  "description": "説明",
  "keywords": "キーワード"
}

Demo

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).*)"],
};
tsx
"use client";

import { locales } from "@/i18n";
import { usePathname, useRouter } from "next/navigation";
import { ChangeEvent } from "react";

export default function SwitchI18n({ lang }: { lang: string }) {
  const pathname = usePathname();
  const router = useRouter();
  const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
    const newLang = e.target.value;
    const newPath = pathname.replace(`/${lang}`, `/${newLang}`);
    router.replace(newPath);
  };
  return (
    <select value={lang} onChange={handleChange}>
      {locales.map((locale) => (
        <option key={locale} value={locale}>
          {locale}
        </option>
      ))}
    </select>
  );
}
tsx
import { getResource } from "@/i18n";
import SwitchI18n from "./switch-i18n";
export default async function Home({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  const { title, description, keywords } = await getResource(lang);
  return (
    <>
      <SwitchI18n lang={lang} />
      <h1>{title}</h1>
      <p>{description}</p>
      <p>{keywords}</p>
    </>
  );
}

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;
};
ts
export declare const PHASE_EXPORT = "phase-export"; // 静态站点导出阶段
export declare const PHASE_PRODUCTION_BUILD = "phase-production-build"; // 生产构建阶段
export declare const PHASE_PRODUCTION_SERVER = "phase-production-server"; // 生产服务器阶段
export declare const PHASE_DEVELOPMENT_SERVER = "phase-development-server"; // 开发服务器阶段
export declare const PHASE_TEST = "phase-test"; // 测试阶段

端口号

json
{
  "scripts": {
    "dev": "next dev -p 8080", // 开发环境端口号
    "build": "next build",
    "start": "next start -p 3000" // 生产环境端口号
  }
}

其他配置

ts
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 支持
  },
};