Skip to content

nextjs@15

shell
# install
npx create-next-app@latest

pages 架构

typescript
// https://nextjs.org/docs/app/api-reference/file-conventions/route
import { cookies } from 'next/headers'

export async function GET(request: Request,  { params }: { params: Promise<{ team: string }> }) {
    const { team } = await params
    const data = {
      hello: "world",
    };
    const res = Response.json(data);
    res.headers.set('x-token', 'abc')
    return res
}

export async function POST(params:type) {
    const cookieStore = await cookies()
    const a = cookieStore.get('a')
    const b = cookieStore.set('b', '1')
    const c = cookieStore.delete('c')
    // ...
}

Example                          URL         params
app/dashboard/[team]/route.js  /dashboard/1   Promise<{ team: '1' }>
app/shop/[tag]/[item]/route.js  /shop/1/2      Promise<{ tag: '1', item: '2' }>
app/blog/[...slug]/route.js      /blog/1/2      Promise<{ slug: ['1', '2'] }>
typescript
// 可以在 dashboard 前面再加个 (admin) 目录,此时访问 url 不变,但是从文件夹结构上会更好区分路由模块
import { Metadata } from 'next/types';

export default function page(params:type) {
    return (
            <div>my page</div>
    )
}

export const metadata: Metadata = {
  title: "首页",
  description: "描述信息",
}
typescript
// 就相当于 route-view
import "./globals.css";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <h1 className="text-indigo-200 text-center py-3 px-2 leading-8 bg-indigo-700 font-bold">这是 app 的layout</h1>
        {children}
      </body>
    </html>
  );
}
typescript
'use client'

export default function MyError() {
    return <div>My Error Page</div>
}

页面缓存

typescript
export default async function Page() {
    const data = await fetch('https://...', { next: { revalidate: 3600 } })
}
typescript
import { unstable_cache } from 'next/cache'
import { getUserById } from '@/app/lib/data'

export default async function Page({ params }: { params: Promise<{ userId: string }> }) {
    const { userId } = await params

    const getCachedUser = unstable_cache(
        async () => {
            return getUserById(userId)
        },
        [userId] // add the user ID to the cache key
    )
}
typescript
export async function getUserById(id: string) {
    const data = await fetch(`https://...`, {
        next: {
            tags: ['user'],
        },
    })
}
// app/lib/actions.ts  调用 updateUser,将刷新所有标记为 'user' 的 tag 请求
import { revalidateTag } from 'next/cache'

export async function updateUser(id: string) {
    // Mutate data
    revalidateTag('user')
}
typescript
import { revalidatePath } from 'next/cache'

export async function updateUser(id: string) {
  // Mutate data
  revalidatePath('/profile')

linking-and-navigating

nextjs 中,默认是 React Server Components,在后续渲染中,包括有两种渲染方式:静态渲染(结果已经被缓存或者 revalidation 结果一致)、动态渲染(客户端请求)

  1. 预取

    page 中使用到了 Link 组件(a 标签不行),那么会自动的在后台加载对应的资源,当用户点击的时候能更快的拿到响应结果

    如果是静态路由,那么就是完整预取;动态路由则取决于有没有 loading.js(存在则部份预取)

  2. Streaming

    允许先返回部份已经准备好的动态路由,而不是一直等待完整的页面;开启该功能应该创建 loading.tsx 文件

    优点:减少可视等待时间;页面其余部份是可操作的;提升页面性能指标:TTFB、FCP、TTI.

  3. Client-side transitions

    传统页面通过 a 链接跳转会触发整个页面的重新加载,nextjs 使用 Link 组件避免这种情况

    优点:客户端过渡使得服务端渲染带来类似客户端(SPA)渲染的效果 其他:

    • <Link prefetch={false} href="/blog">blog</Link> 禁用 prefetch

    • Link 可以监听 hover,动态修改 prefetch 属性,来达到预加载

Image

  • 默认懒加载(可视区)
  • 防止偏移(设置宽高(在线 url),本地图片(import jpg from './xx.jpg')无需设置,会自动读取并设置)

loading.js

如果 server component 引入 client component(使用到了某些 client 才允许的 api),且没有将该组件置于 <Suspense> 中,此时 server component 没有同级的 loading.js,那么就会构建错误,如果有,就会先显示 loading.js,然后再客户端再执行完整的渲染

Fonts

构建之后,fonts 将作为静态资源存在,不会再请求 google

typescript
import { Geist } from 'next/font/google'

const geist = Geist({
  subsets: ['latin'],
})

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={geist.className}>
      <body>{children}</body>
    </html>
  )
}
typescript
import localFont from 'next/font/local'

const myFont = localFont({
  src: './my-font.woff2',
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={myFont.className}>
      <body>{children}</body>
    </html>
  )
}

css

  • locally css 文件以 module.css 结尾; 如 button.module.css
  • global app/global.css

When to use Server and Client Components?

  • client components

    1. 需要状态维护(useState)、事件处理(onClick, onChange)
    2. 生命周期(useEffect)
    3. 浏览器专属 api(localStorage, window, Navigator.geolocation)
    4. 自定义 hook
  • Server Components

    1. 从数据库或者 api 获取数据
    2. tokens 等不想暴露在客户端的数据
    3. 减少发送到浏览器的 JavaScript 数量
    4. 提升客户端体验、浏览器页面评分

渲染流程

  • 服务端

Server Components 在服务器被渲染成特殊的数据结构:React Server Component Payload (RSC Payload),包含渲染结果、渲染位置、props

  • 客户端

    1. 快速渲染 无交互 HTML
    2. 使用 RSC Payload 调和 components tree
    3. 水合(激活交互事件)

"use client"

服务组件跟客户端组件的边界标识,如果一个文件使用了该标识,那么它本身以及它 import 的全部组件都会被视为 client component,子组件也不再需要都加上该标识

TIP

需要交互的就标识为 client component,其余使用 server component

数据传递

typescript
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'

export default async function Page({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)

  return <LikeButton likes={post.likes} />
}
typescript
'use client'

export default function LikeButton({ likes }: { likes: number }) {
    // ...
}

使用 use, 服务端通过 props 传递 promise,交由客户端获取

typescript
import Posts from '@/app/ui/posts
import { Suspense } from 'react'

export default function Page() {
  // Don't await the data fetching function
  const posts = getPosts()

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Posts posts={posts} />
    </Suspense>
  )
}
typescript
'use client'
import { use } from 'react'

export default function Posts({
  posts,
}: {
  posts: Promise<{ id: string; title: string }[]>
}) {
  const allPosts = use(posts)

  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

react context

server component 不支持直接使用 react context,需要按下面的方式进行使用

typescript
'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
typescript
import ThemeProvider from './theme-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

如何使用第三方组件

如果第三方组件中使用 client-only 功能,将不能直接在 server component import 使用

  • 在一个 'use client' 的组件中导入使用(其子组件被默认为都是 client)
  • 文件中转,在 'use client' 文件下中 import 再 export 下

隐私变量

process.env.xxx, 其中 xxx 必须以 NEXT_PUBLIC_ 开头才能够保留,否则在客户端中会被替换成空字符串,也不会正常运行

fetching Data

typescript
export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog')
  const posts = await data.json()
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
typescript
import { db, posts } from '@/lib/db'

export default async function Page() {
  const allPosts = await db.select().from(posts)
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Client Components 获取数据方式

  • use (上面已经有示例 -> 300 行)

  • A community library like SWR or React Query

typescript
// swr 示例
'use client'
import useSWR from 'swr'

const fetcher = (url) => fetch(url).then((r) => r.json())

export default function BlogPage() {
  const { data, error, isLoading } = useSWR(
    'https://api.vercel.app/blog',
    fetcher
  )

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <ul>
      {data.map((post: { id: string; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Streaming

Server Components 使用 async/await 的时候,会触发动态渲染,即等待请求结束结果才返回,耗时较长;

如何开启 Streaming

  • 在需要开启的 page.tsx 同级目录下增加 loading.tsx file (一旦 page 渲染完成,新的内容会立即替换 loading )

  • 使用 Suspense (Any content wrapped in a <Suspense> boundary will be streamed)

渲染顺序

  • 按序:使用 await 阻塞了接下来的渲染

  • 并发:promise.all (请求多个无依赖关系接口的场景)

  • preload:可以在 await api() 前面运行 preloadApi(),该函数执行的 fetch 会被缓存,所以等实际组件发起 fetch 的时候能更快的拿到接口返回

缓存跟重校验数据

开发阶段不会启用,生产环境生效

命中缓存的接口将不会再次发起网络请求,revalidation 能够允许更新缓存而不用重新构建

typescript
// fetch 默认不会缓存,可以修改 cache 参数(但在 nextjs 中会预先渲染并缓存 html,可以使用 connection api 使得每次渲染都是动态的)
export default async function Page() {
  const data = await fetch('https://...', { cache: 'force-cache',next: { revalidate: 3600 } })
}
typescript
// unstable_cache 允许你缓存 promise 函数结果
import { unstable_cache } from 'next/cache'
import { getUserById } from '@/app/lib/data'

export default async function Page({ params }: { params: Promise<{ userId: string }> }) {
    const { userId } = await params

    const getCachedUser = unstable_cache(
        async () => {
            return getUserById(userId)
        },
        [userId], // 根据不同的 userId 来判断是否读取缓存
        {
            tags: ['user'],
            revalidate: 3600,
        }
    )
}
typescript
// 给缓存添加 tag 标识,比如说登录用户切换了,可以一键刷新全部依赖此 tag 的缓存
export async function getUserById(id: string) {
    const data = await fetch(`https://...`, {
        next: {
            tags: ['user'],
        },
    })
}
// 刷新
import { revalidateTag } from 'next/cache'

export async function updateUser(id: string) {
    // Mutate data
    revalidateTag('user')
}
typescript
import { revalidatePath } from 'next/cache'

export async function updateUser(id: string) {
  // Mutate data
  revalidatePath('/profile')

Server Functions

通过在异步函数上或者文件(该文件所以的导出函数都被认为是)顶部标记 use server,声明这是服务器函数

client component 文件内不能使用 use server,但是可以引入并使用 Server Function

typescript
export async function createPost(formData: FormData) {
    'use server'
    const title = formData.get('title')
    const content = formData.get('content')

    // Update data
    // Revalidate cache
}

export async function deletePost(formData: FormData) {
    'use server'
    const id = formData.get('id')

    // Update data
    // Revalidate cache
}

如何使用

  • <form action={createPost}>
  • Event Handlers: onClick

搭配

typescript
// loading 效果
'use client'

import { useActionState, startTransition } from 'react'
import { createPost } from '@/app/actions'
import { LoadingSpinner } from '@/app/ui/loading-spinner'

export function Button() {
  const [state, action, pending] = useActionState(createPost, false)

  return (
    <button onClick={() => startTransition(action)}>
      {pending ? <LoadingSpinner /> : 'Create Post'}
    </button>
  )
}
typescript
// 更新缓存
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
    'use server'
    // Update data
    // ...
    revalidatePath('/posts')
}
typescript
// 数据更新后,跳转到新的页面
'use server'

import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
    // Update data
    // ...
    redirect('/posts')
}

provider

共享全局数据

tsx
'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({ children }: { children: React.ReactNode }) {
    return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
ts
import ThemeProvider from './theme-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

服务端 api(Route Handlers)

创建 route.ts,位置: src/api/{server}/route.ts

server action 比较,前者只适用于简单的表单交互:数据提交、获取,无法修改响应头等完整的 http 配置

ts
import { NextResponse } from 'next/server'

export async function GET() {
    const encoder = new TextEncoder()
    const stream = new ReadableStream({
        async start(controller) {
            const messages = ['Hello', 'World', 'Streaming', 'Example']
            for (const message of messages) {
                controller.enqueue(encoder.encode(`${message}\n`))
                await new Promise((resolve) => setTimeout(resolve, 1000)) // 模拟延迟
            }
            controller.close()
        },
    })

    return new NextResponse(stream, {
        headers: {
            'Content-Type': 'text/plain; charset=utf-8',
            'Transfer-Encoding': 'chunked',
        },
    })
}

export async function POST() {
    return NextResponse.json({ name: 'rick' })
}