普通视图

发现新文章,点击刷新页面。
昨天以前首页

nextjs学习6:服务端组件和客户端组件

2026年1月5日 11:14

服务端组件和客户端组件是 Next.js 中非常重要的概念。

如果没有细致的了解过,你可能会简单的以为所谓服务端组件就是 SSR,客户端组件就是 CSR,服务端组件在服务端进行渲染,客户端组件在客户端进行渲染等等,实际上并非如此。

本篇就深入学习和探究 Next.js 的双组件模型

服务端组件

介绍

在 Next.js 中,组件默认就是服务端组件服务端组件一般会在function 前面加上async(不加也行)。往往意味着你需要利用服务端能力(比如异步数据获取),而 Next.js 的默认规则会让这类组件天然运行在服务端。

举个例子,新建 app/todo/page.js,代码如下:

export default async function Page() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos')
  const data = (await res.json()).slice(0, 10)
  console.log(data)
  return <ul>
    {data.map(({ title, id }) => {
      return <li key={id}>{title}</li>
    })}
  </ul>
}

请求会在服务端执行,并将渲染后的 HTML 发送给客户端:

image.png

因为在服务端执行,console 打印的结果也只可能会出现在命令行中,而非客户端浏览器中

优势

  1. 数据获取:通常服务端环境(网络、性能等)更好,离数据源更近,在服务端获取数据会更快。通过减少数据加载时间以及客户端发出的请求数量来提高性能。

  2. 安全:在服务端保留敏感数据和逻辑,不用担心暴露给客户端。服务端组件不会生成客户端 Chunk.js仅在服务端渲染为 HTML,代码不暴露给浏览器)。

  3. bundle 大小:服务端组件的代码不会打包到 bundle 中,减少了 bundle 包的大小。

  4. 初始页面加载和 FCP:服务端渲染生成 HTML,快速展示 UI。

  5. Streaming:服务端组件可以将渲染工作拆分为 chunks,并在准备就绪时将它们流式传输到客户端。用户可以更早看到页面的部分内容,而不必等待整个页面渲染完毕。

因为服务端组件的诸多好处,在实际项目开发的时候,能使用服务端组件就尽可能使用服务端组件

限制

虽然使用服务端组件有很多好处,但使用服务端组件也有一些限制,比如不能使用 useState 管理状态,不能使用浏览器的 API 等等。

RSC 与 SSR

了解了这两个基本概念,现在让我们来回顾下 React Server Components 和 Server-side Rendering,表面上看,RSC 和 SSR 非常相似,都发生在服务端,都涉及到渲染,目的都是更快的呈现内容。但实际上,这两个技术概念是相互独立的。

正如它们的名字所表明的那样,Server-side Rendering 的重点在于 Rendering,React Server Components 的重点在于 Components

简单来说:

  • RSC 提供了更细粒度的组件渲染方式,可以在组件中直接获取数据,而不用像传统的 SSR 顶层获取数据
  • RSC 在服务端进行渲染,组件依赖的代码不会打包到 bundle 中,而 SSR 需要将组件的所有依赖都打包到 bundle 中

当然两者最大的区别是:

SSR 是在服务端将组件渲染成 HTML 发送给客户端,而 RSC 是将组件渲染成一种特殊的格式,我们称之为 RSC Payload

这个 RSC Payload 的渲染是在服务端,但不会一开始就返回给客户端,而是在客户端请求相关组件的时候才返回给客户端,RSC Payload 会包含组件渲染后的数据和样式,客户端收到 RSC Payload 后会重建 React 树,修改页面 DOM。

让我们本地开启一下当时 React 提供的 Server Components Demo:

image.png

你会发现 localhost 这个 HTML 页面的内容就跟 CSR 一样,都只有一个用于挂载的 DOM 节点。当点击左侧 Notes 列表的时候,会发送请求,这个请求的地址是http://localhost:4000/react?location={"selectedId":3,"isEditing":false,"searchText":""}

返回的结果是:

image.png

除此之外没有其他的请求了。其实这条请求返回的数据就是 RSC Payload。

让我们看下这条请求,我们请求的这条笔记的标题是 Make a thing,具体内容是 It's very easy to make some……,我们把返回的数据具体查看一下,你会发现,返回的请求里包含了这些数据:

image.png

不仅包含数据,完整渲染后的 DOM 结构也都包含了。

客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)

这也就带来了我们常说的 SSR 和 RSC 的最大区别,那就是状态的保持。SSR 每次都是一个新的 HTML 页面,所以状态不会保持(传统的做法是 SSR 初次渲染,然后 CSR 更新,这种情况,状态可以保持,不过现在讨论的是 SSR,对于两次 SSR,状态是无法维持的)。

但是 RSC 不同,RSC 会被渲染成一种特殊的格式(RSC Payload),可以多次重新获取,然后客户端根据这个特殊格式更新 UI,而不会丢失客户端状态。

客户端组件

使用客户端组件,你需要在文件顶部添加一个 "use client" 声明,修改 app/todo/page.js,代码如下:

'use client'

import { useEffect, useState } from 'react';

function getRandomInt(min, max) {
  const minCeiled = Math.ceil(min);
  const maxFloored = Math.floor(max);
  return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

export default function Page() {

  const [list, setList] = useState([]);

  const fetchData = async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos')
    const data = (await res.json()).slice(0, getRandomInt(1, 10))
    setList(data)
  }

  useEffect(() => {
    fetchData()
  }, [])

  return (
    <>
      <ul>
        {list.map(({ title, id }) => {
          return <li key={id}>{title}</li>
        })}
      </ul>
      <button onClick={() => {
        location.reload()
      }}>换一批</button>
    </>
  )
}

在这个例子中,我们使用了 useEffect、useState 等 React API,也给按钮添加了点击事件、使用了浏览器的 API。无论使用哪个都需要先声明为客户端组件。

注意:"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

它的优势是:

  1. 交互性:客户端组件可以使用 state、effects 和事件监听器,意味着用户可以与之交互;
  2. 浏览器 API:客户端组件可以使用浏览器 API 如地理位置、localStorage 等;

服务端组件 VS 客户端组件

1、如何选择使用?

image.png

组件类型 执行 / 渲染位置 核心特征
服务端组件(SC) 仅在服务端(Node.js 环境)执行,渲染为 HTML 片段 / React 服务端数据结构 可直接访问数据库、后端接口,无浏览器 API 限制,代码不会发送到客户端
客户端组件(CC) 先在服务端做 “首屏渲染”(生成 HTML),再在客户端(浏览器)hydrate(水合)并运行 可使用 useState/useEffect 等 Hooks、访问 window/document,代码会打包发送到客户端

2、渲染环境

服务端组件只会在服务端渲染,但客户端组件会在服务端渲染一次,然后在客户端渲染。

这是什么意思呢?让我们写个例子,新建 app/client/page.js,代码如下:

'use client'

import { useState } from 'react';

console.log('client')

export default function Page() {

  console.log('client Page')

  const [text, setText] = useState('init text');

  return (
    <button onClick={() => {
      setText('change text')
    }}>{text}</button>
  )
}

新建 app/server/page.js,代码如下:

console.log('server')

export default function Page() {

  console.log('server Page')

  return (
    <button>button</button>
  )
}

现在运行 npm run build,会打印哪些数据呢?

答案是无论客户端组件还是服务端组件,都会打印:

image.png

而且根据输出的结果,无论是 /client还是 /server走的都是静态渲染。

当运行 npm run start的时候,又会打印哪些数据呢?

答案是命令行中并不会有输出,访问 /client的时候,浏览器会有打印:

image.png

访问 /server的时候,浏览器不会有任何打印:

image.png

客户端组件在浏览器中打印,这可以理解,毕竟它是客户端组件,当然要在客户端运行。可是客户端组件为什么在编译的时候会运行一次呢?

让我们看下 /client 的返回:

image.png

你会发现 init text其实是来自于 useState 中的值,但是却依然输出在 HTML 中。

这就是编译客户端组件的作用,为了第一次加载的时候能更快的展示出内容。

所以,其实所谓服务端组件、客户端组件并不直接对应于物理上的服务器和客户端。服务端组件运行在构建时和服务端,客户端组件运行在构建时、服务端(生成初始 HTML)和客户端(管理 DOM)

3、交替使用服务端组件和客户端组件

实际开发的时候,不可能纯用服务端组件或者客户端组件,当交替使用的时候,一定要注意一点,那就是:

服务端组件可以直接导入客户端组件,但客户端组件并不能导入服务端组件

1. 服务端组件能导入客户端组件:符合 “渲染流向”

服务端组件的核心作用是在服务端组装页面骨架、获取数据,而客户端组件是为了处理交互(点击、输入、状态)。Next.js 设计时,把 SC 作为 “页面的根 / 容器”,CC 作为 “交互子节点”,这种 “父(SC)包含子(CC)” 的结构完全契合渲染逻辑

执行过程

  1. 服务端执行 SC 时,遇到导入的 CC,不会直接执行CC 的代码(CC 的代码是给浏览器用的),而是将 CC 标记为 需要客户端水合的组件;
  2. 服务端把 SC 渲染为 HTML 片段,同时把 CC 的占位标记和 CC 的打包代码路径一起发给客户端;
  3. 客户端接收到页面后,先渲染 SC 生成的静态内容,再加载 CC 的代码并完成水合,让 CC 具备交互能力。

整个React 树会变成这样:

image.png

其中黄色节点表示 React Server Component。在服务端,React 会将其渲染会一个包含基础 HTML 标签和客户端组件占位的树。

因为客户端组件的数据和结构在客户端渲染的时候才知道,所以客户端组件此时在树中使用特殊的占位进行替代。

当然这个树不可能直接就发给客户端,React 会做序列化处理,客户端收到后会在客户端根据这个数据重构 React 树,然后用真正的客户端组件填充占位,渲染最终的结果。

image.png

2. 客户端组件不能能导入客户端组件

'use client'
 
// 这是不可以的
import ServerComponent from './Server-Component'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  )
}

正如介绍客户端组件时所说:

"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

组件默认是服务端组件,但当组件导入到客户端组件中会被认为是客户端组件。客户端组件不能导入服务端组件,其实是在告诉你,如果你在服务端组件中使用了诸如 Node API 等,该组件可千万不要导入到客户端组件中

另外,渲染逻辑闭环被打破,Next.js 的渲染逻辑是 “服务端先处理静态 / 数据层(SC)→ 客户端再处理交互层(CC)”,是单向的 “服务端 → 客户端” 流向。如果允许 CC 导入 SC,相当于让 “客户端” 反向控制 “服务端”。

但你可以将服务端组件以 props 的形式传给客户端组件:

'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}


import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

使用这种方式,<ClientComponent> 和 <ServerComponent> 代码解耦且独立渲染。

4、组件渲染原理

1. 在服务端

Next.js 使用 React API 编排渲染,渲染工作会根据路由和 Suspense 拆分成多个块(chunks),每个块分两步进行渲染:

  1. React 将服务端组件渲染成一个特殊的数据格式称为 React Server Component Payload (RSC Payload)
  2. Next.js 使用 RSC Payload 和客户端组件代码在服务端渲染 HTML;

RSC payload 中包含如下这些信息:

  1. 服务端组件的渲染结果
  2. 客户端组件占位符和引用文件
  3. 从服务端组件传给客户端组件的数据

为什么会包含“客户端组件占位符和引用文件”呢?

1. 占位符:告诉客户端 “这里有个需要交互的组件,先留位置”

其实在上面我们已经说了,服务端组件(SC)执行在服务端,客户端组件(CC)执行在浏览器,两者的职责边界是:SC 负责搭骨架,CC 负责加交互。但 SC 在服务端渲染时,根本无法执行 CC 的代码(CC 依赖浏览器 API、React 状态等),只能做 “标记”,这就是 “占位符 + 引用文件” 的核心作用。

SC 渲染时,遇到导入的 CC,不会生成 CC 的真实 DOM(因为 CC 还没在客户端激活),而是生成一个特殊的占位标记(RSC 协议里的 JSON 标记) ,比如:

// 简化的 RSC payload 片段
{ 
    "type": "client.component",
    "id": "cc-123", // 唯一标识 
    "fallback": "<div>加载中...</div>" // 可选的占位内容 
}

这个占位符的作用:

  • 保证页面结构完整:客户端拿到 payload 后,先渲染 SC 生成的静态内容 + CC 的占位符,不会出现 “交互组件位置空白” 的情况,避免布局错乱;
  • 标记待激活区域:告诉 React 运行时这个位置的组件需要后续加载客户端代码并水合,是客户端激活 CC 的 锚点。

2. 引用文件:告诉客户端去哪找这个 CC 的交互代码

CC 的代码会被 Next.js 打包成独立的客户端 JS 包(比如 static/chunks/cc-123.js),RSC payload 中会附带这个包的引用路径和哈希值,比如:

{ 
   "type": "client.reference", 
   "id": "cc-123", 
   "filePath": "/_next/static/chunks/cc-123.js", 
   "name": "ClientButton" 
}

这个引用的核心价值:

  • 按需加载:客户端只会加载页面中实际用到的 CC 代码,而不是把所有 CC 代码都打包进首屏(比如页面有 10 个 CC,但首屏只显示 2 个,就只加载这 2 个的代码),减少客户端 JS 体积;
  • 精准激活:React 运行时根据引用路径下载对应的 CC 代码后,能精准替换掉之前的占位符,完成 CC 的水合(让 CC 具备 useState/useEffect 等交互能力);
  • 版本控制:通过哈希值(比如 cc-123.abc123.js)实现缓存复用,后续页面如果用到同一个 CC,客户端不用重复下载。

为什么包含从服务端组件传给客户端组件的数据?

如果 RSC payload 不附带这份数据,CC 激活后只能自己通过 fetch 去请求相同的数据,会导致:

  • 重复的网络请求: 服务端查一次数据库,客户端又查一次,浪费服务器资源;
  • 额外的网络延迟:CC 要等 fetch 返回才能渲染,出现 “占位符→加载中→真实内容” 的二次等待。而 SC 把数据直接塞进 payload,CC 激活后能直接用。

2. 在客户端

  1. 加载渲染的 HTML 快速展示一个非交互界面(Non-interactive UI)
  2. RSC Payload 会被用于协调(reconcile)客户端和服务端组件树,并更新 DOM
  3. JavaScript 代码被用于水合客户端组件,使应用程序具有交互性(Interactive UI)

image.png

注意:上图描述的是页面初始加载的过程。其中 SC 表示 Server Components 服务端组件,CC 表示 Client Components 客户端组件。

在前一篇文章中讲到 Suspense 和 Streaming 也有一些问题没有解决,比如该加载的 JavaScript 代码没有少、所有组件都必须水合,即使组件不需要水合。

使用服务端组件和客户端组件就可以解决这个问题,服务端组件的代码不会打包到客户端 bundle 中。渲染的时候,只有客户端组件需要进行水合,服务端组件无须水合

而在后续导航的时候:

image.png

后续导航(客户端路由导航)  则是 Next.js 基于 next/navigation(App Router)实现的客户端侧无刷新导航,核心是 “按需加载资源 + 局部更新页面 + 保留客户端状态”,全程不触发浏览器的整页刷新。

核心前提:后续导航的触发条件

用户点击 Next.js 提供的 <Link> 组件(而非原生 <a> 标签)、调用 useRouter().push()/replace() 等客户端路由方法时,会触发后续导航;

如果直接刷新页面 / 输入 URL,仍会走首次导航流程。

完整流程(App Router)

  • Next.js 的客户端路由运行时(next/navigation 底层)会拦截 <Link> 点击事件,阻止浏览器的默认页面跳转(event.preventDefault());

  • 客户端向服务端发起一个轻量的 RSC 请求(不是整页 HTML 请求),请求目标路由的 Server Components 渲染结果(即 RSC Payload,格式是特殊的 JSON 流);这个请求只会获取目标路由的 Server Components 渲染出的静态内容、客户端组件的占位符 + 代码引用、服务端传给客户端组件的数据

  • React 运行时接收 RSC Payload 后,RSC Payload 内容如下:

image.png

不仅包含数据,完整渲染后的 DOM 结构也都包含了。

客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)

也就是,先渲染 SC 生成的静态内容,替换当前页面的主内容区域,同时保留页面的公共布局(比如导航栏、页脚),这就是局部更新,公共部分不重新渲染。

如果目标路由包含新的客户端组件(未在当前页面加载过),Next.js 会根据 RSC Payload 中的 “客户端组件引用路径”,异步加载对应的客户端 JS 包(体积很小,按需加载);已加载过的客户端组件会复用缓存,不会重复下载。

  • 对客户端组件来说:先渲染占位符(比如加载中),等对应的 JS 包下载完成后,完成 “水合”(激活交互,比如 useState/useEffect 生效),替换占位符为真实交互组件;

  • 整个过程中,页面的 <head> 标签(标题、meta 等)会被 Next.js 自动更新(基于目标路由的 generateMetadata 或 metadata 配置),但不会刷新页面

  • Next.js 调用浏览器的 history.pushState()/replaceState() API,更新地址栏 URL,但不会触发浏览器的 popstate 整页刷新;

  • 客户端状态(比如全局 Redux 状态、组件内的 useState、表单输入值)会被保留(除非主动重置),比如从 /home 跳转到 /post/123,导航栏的登录状态、全局主题设置不会丢失。

Next.js第十九章(服务器函数)

作者 小满zs
2026年1月1日 11:10

服务器函数(Server Actions)

服务器函数指的是可以是服务器组件处理表单的提交,无需手动编写API接口,并且还支持数据的验证,以及状态管理等。

核心原理

是因为React扩展了原生HTMLform表单,允许通过action属性直接绑定server action函数,当表单提交后,函数会自动接受原生的FormData数据。

基本用法

我们先回顾一下传统的表单提交方式:

sequenceDiagram
    participant U as 用户
    participant F as 前端表单
    participant A as API路由(route.ts)
    participant D as 数据库

    U->>F: 1. 填写表单数据<br/>{name: '张三', age: 18}
    U->>F: 2. 点击提交按钮
    F->>A: 3. fetch POST请求<br/>/api/xxx
    A->>A: 4. 解析请求数据
    A->>D: 5. 插入数据<br/>db.insert('user')
    D-->>A: 6. 返回结果
    A-->>F: 7. 响应 {code: 1, message: '成功'}
    F-->>U: 8. 显示结果

那么来看一下服务器函数的用法:

sequenceDiagram
    participant User as 用户
    participant Form as 表单组件
    participant ServerAction as handleLogin<br/>(服务器函数)
    participant DB as 数据库

    User->>Form: 填写用户名和密码
    User->>Form: 点击登录按钮(type="submit")
    Form->>ServerAction: 提交 FormData(action属性)
    Note over ServerAction: 'use server'<br/>在服务器端执行
    ServerAction->>ServerAction: 获取 username
    ServerAction->>ServerAction: 获取 password
    ServerAction->>ServerAction: 转换所有数据为对象
    ServerAction->>DB: 直接操作数据库<br/>(无需API接口)
    DB-->>ServerAction: 登录成功

src/app/login/page.tsx

export default function Login() {
    async function handleLogin(formData: FormData) {
        'use server'
        const username = formData.get('username') //接受单个参数
        const password = formData.get('password') //接受单个数据
        const form = Object.fromEntries(formData) //接受所有数据 {username: '张三', password: '123456'}
        //可以直接操作数据库,这样就无需编写API接口了 哇哦太方便了
    }
    return (
        <div>
            <h1>登录页面</h1>
            <div className="flex flex-col gap-2 w-[300px] mx-auto mt-30">
                <form action={handleLogin} className="flex flex-col gap-2">
                    <input className="border border-gray-300 rounded-md p-2" type="text" name="username" placeholder="用户名" />
                    <input className="border border-gray-300 rounded-md p-2" type="password" name="password" placeholder="密码" />
                    <button type="submit" className="bg-blue-500 text-white p-2 rounded-md">登录</button>
                </form>
            </div>
        </div>
    )
}

额外参数

目前只能携带固定参数例如 username password,无法携带其他参数。

<form action={handleLogin}>
    <input type="text" name="username" placeholder="用户名" />
    <input type="password" name="password" placeholder="密码" />
    <button type="submit">登录</button>
</form>

那么我想携带ID或者其他自定义参数怎么做?

我们需要使用bind方法来进行参数扩展,这样在函数内部就可以接收到ID参数。

export default function Login() {
                              //接受id参数
    async function handleLogin(id: number,formData: FormData) {
        'use server'
        const username = formData.get('username')
        const password = formData.get('password')
        const form = Object.fromEntries(formData)
        console.log(username, password,form,id)
    }
    const userFunction = handleLogin.bind(null,1) //绑定id参数
    return (
        <div>
            <h1>登录页面</h1>
            <div className="flex flex-col gap-2 w-[300px] mx-auto mt-30">
                        {/*使用新的函数绑定id参数 userFunction*/}
                <form action={userFunction} className="flex flex-col gap-2">
                    <input className="border border-gray-300 rounded-md p-2" type="text" name="username" placeholder="用户名" />
                    <input className="border border-gray-300 rounded-md p-2" type="password" name="password" placeholder="密码" />
                    <button type="submit" className="bg-blue-500 text-white p-2 rounded-md">登录</button>
                </form>
            </div>
        </div>
    )
}

参数校验(zod) + 读取状态

zod是一个目前非常流行的数据验证库,可以让我们在服务器端进行数据验证,避免用户输入非法数据。

npm i zod

src/app/lib/login/actions.ts

'use server'
import { z } from "zod"
const loginSchema = z.object({
    username: z.string().min(6, '用户名不能少于6位'), //zod基本用法表示这是一个字符串,并且不能少于6位
    password: z.string().min(6, '密码不能少于6位') //zod基本用法表示这是一个字符串,并且不能少于6位
})

export async function handleLogin(_prevState: any, formData: FormData) {
    const result = loginSchema.safeParse(Object.fromEntries(formData)) //调用zod的safeParse方法进行校验
    
    if (!result.success) {
        const errorMessage = z.treeifyError(result.error).properties; //调用zod的treeifyError方法将错误信息转换为对象
        let str = ''
        Object.entries(errorMessage!).forEach(([_key, value]) => {
            value.errors.forEach((error: any) => {
                str += error + '\n' //将错误信息拼接成字符串
            })
        })
        return { message: str} //返回错误信息
    }
    //校验成功,进行数据库操作逻辑
    return { message: '登录成功' } //返回成功信息
}

src/app/login/page.tsx

如果要读取状态需要使用React19的useActionState hook,这个hook必须在客户端组件中使用。所以需要增加'use client'声明这是一个客户端组件。

参数

useActionState hook接受三个参数:

  • fn: 表单提交时触发的函数,接收上一次的 state(首次为 initialState)作为第一个参数,其余参数为表单参数
  • initialState: state 的初始值,可以是任何可序列化的值
  • permalink(可选): 表单提交后跳转的 URL,用于 JavaScript 加载前的渐进式增强

返回值:

  • state: 当前状态,初始值为 initialState,之后为 action 的返回值
  • formAction: 新的 action 函数,用于传递给 form 或 button 组件
  • isPending: 布尔值,表示是否有正在进行的 Transition
'use client'
import { useActionState } from "react"
import { handleLogin } from "../lib/login/actions"
const initialState = { message:'' }
export default function Login() {
    const [state, formAction,isPending] = useActionState(handleLogin, initialState)
    
    return (
        <div>
            <h1>登录页面</h1>
            {isPending && <div>Loading...</div>}
            {state.message}
            <div className="flex flex-col gap-2 w-[300px] mx-auto mt-30">
                <form action={formAction} className="flex flex-col gap-2">
                    <input className="border border-gray-300 rounded-md p-2" type="text" name="username" placeholder="用户名" />
                    <input className="border border-gray-300 rounded-md p-2" type="password" name="password" placeholder="密码" />
                    <button type="submit" className="bg-blue-500 text-white p-2 rounded-md">登录</button>
                </form>
            </div>
        </div>
    )
}

nextjs学习3:动态路由、路由组、平行路由

2025年12月31日 14:50

动态路由(Dynamic Routes)

有的时候,你并不能提前知道路由的地址,就比如根据 URL 中的 id 参数展示该 id 对应的文章内容,文章那么多,我们不可能一一定义路由,这个时候就需要用到动态路由。

[folderName]

使用动态路由,你需要将文件夹的名字用方括号括住,比如 [id][slug]。这个路由的名字会作为 params prop 传给布局、页面、路由处理程序等。

举个例子,我们在 app/blog 目录下新建一个名为 [slug] 的文件夹,在该文件夹新建一个 page.js 文件,代码如下:


// app/blog/[slug]/page.js
export default function Page({ params }) {
  return <div>My Post: {params.slug}</div>
}

当你访问 /blog/a的时候,params 的值为 { slug: 'a' }; 当你访问 /blog/yayu的时候,params 的值为 { slug: 'yayu' }

 [...folderName]

在命名文件夹的时候,如果你在方括号内添加省略号,比如 [...folderName],这表示捕获所有后面所有的路由片段。

也就是说,app/shop/[...slug]/page.js会匹配 /shop/clothes,也会匹配 /shop/clothes/tops/shop/clothes/tops/t-shirts等等。

// app/shop/[...slug]/page.js
export default function Page({ params }) {
  return <div>My Shop: {JSON.stringify(params)}</div>
}

当你访问 /shop/a的时候,params 的值为 { slug: ['a'] }

当你访问 /shop/a/b的时候,params 的值为 { slug: ['a', 'b'] }

当你访问 /shop/a/b/c的时候,params 的值为 { slug: ['a', 'b', 'c'] }

路由组(Route groups)

在 app目录下,文件夹名称通常会被映射到 URL 中,但你可以将文件夹标记为路由组,阻止文件夹名称被映射到 URL 中。

使用路由组,你可以将路由和项目文件按照逻辑进行分组,但不会影响 URL 路径结构。路由组可用于比如:

  1. 按站点、意图、团队等将路由分组
  2. 在同一层级中创建多个布局,甚至是创建多个根布局

那么该如何标记呢?

把文件夹用括号括住就可以了,就比如 (dashboard)

按逻辑分组

将路由按逻辑分组,但不影响 URL 路径:

image.png

你会发现,最终的 URL 中省略了带括号的文件夹(上图中的(marketing)(shop))。

创建不同布局

借助路由组,即便在同一层级,也可以创建不同的布局:

image.png

在这个例子中,/account 、/cart/checkout 都在同一层级。但是 /account和 /cart使用的是 /app/(shop)/layout.js布局和app/layout.js布局,/checkout使用的是 app/layout.js

平行路由(Parallel Routes):vue中的插槽

平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽功能)。

本质是一种控制页面渲染区域的机制,允许在同一个 URL 下同时渲染多个独立的路由组件

实现多区域独立渲染,比如侧边栏、主内容区、弹窗等区域各自对应不同的路由,且互不干扰。

用途 1:条件渲染

image.png

平行路由的使用方式是将文件夹以 @作为开头进行命名,比如在上图中就定义了两个插槽 @team 和 @analytics

插槽会作为 props 传给共享的父布局。在上图中,app/layout.js 从 props 中获取了 @team 和 @analytics 两个插槽的内容,并将其与 children 并行渲染:

// app/layout.js
// 这里我们用了 ES6 的解构,写法更简洁一点
export default function Layout({ children, team, analytics }) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  )
}

注:从这张图也可以看出,children prop 其实就是一个隐式的插槽,/app/page.js相当于 app/@children/page.js

除了让它们同时展示,你也可以根据条件判断展示:

image.png

在这个例子中,先在布局中获取用户的登录状态,如果登录,显示 dashboard 页面,没有登录,显示 login 页面。这样做的一大好处就在于代码完全分离。

用途 2:独立路由处理

平行路由可以让你为每个路由定义独立的错误处理和加载界面:

image.png

用途 3:子导航

注意我们描述 team 和 analytics 时依然用的是“页面”这个说法,因为它们就像书写正常的页面一样使用 page.js。

除此之外,它们也能像正常的页面一样,添加子页面,比如我们在 @analytics 下添加两个子页面:/page-views and /visitors

image.png

平行路由跟路由组一样,不会影响 URL,所以 /@analytics/page-views/page.js 对应的地址是 /page-views/@analytics/visitors/page.js 对应的地址是 /visitors,你可以导航至这些路由:

// app/layout.js
import Link from "next/link";

export default function RootLayout({ children, analytics }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/">Home</Link>
          <br />
          <Link href="/page-views">Page Views</Link>
          <br />
          <Link href="/visitors">Visitors</Link>
        </nav>
        <h1>root layout</h1>
        {analytics}
        {children}
      </body>
    </html>
  );
}

当导航至这些子页面的时候,子页面的内容会取代 /@analytics/page.js 以 props 的形式注入到布局中

这也就是说,每个插槽都可以有自己独立的导航和状态管理,就像一个小型应用一样。这种特性适合于构建复杂的应用如 dashboard。

路由组和平行路由的区别

  1. 路由组是文件层面的组织工具,核心解决结构混乱分组布局复用问题,不改变渲染逻辑;
  2. 平行路由渲染层面的控制机制,核心解决 多区域并行渲染 问题,允许同一个 URL 下渲染多个独立的路由组件。

nextjs学习2:app router

2025年12月31日 14:27

本文是基于 nextjs v14 版本进行展示。

最快捷的创建 Next.js 项目的方式是使用 create-next-app脚手架,你只需要运行:

npx create-next-app@14

推荐使用 tailwindcss ,这也是 Next.js 推荐的 CSS 方案,很多 example 都会用它。

运行项目

查看项目根目录 package.json 文件的代码:

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }

开发的时候使用 npm run dev。部署的时候先使用 npm run build 构建生产代码,再执行 npm run start 运行生产项目。运行 npm run lint 则会执行 ESLint 语法检查。

next build

执行 next build 将会创建项目的生产优化版本:

image.png

从上图可以看出,构建时会输出每条路由的信息,比如 Size 和 First Load JS。注意这些值指的都是 gzip 压缩后的大小。其中 First Load JS 会用绿色、黄色、红色表示,绿色表示高性能,黄色或红色表示需要优化。

这里要解释一下 Size 和 First Load JS 的含义。

正常我们开发的 Next.js 项目,其页面表现类似于单页应用,即路由跳转(我们称之为“导航”)的时候,页面不会刷新,而会加载目标路由所需的资源然后展示,所以:

加载目标路由一共所需的 JS 大小 = 每个路由都需要依赖的 JS 大小(shared by all) + 目标路由单独依赖的 JS 大小

其中:

  • 加载目标路由一共所需的 JS 大小就是 First Load JS
  • 目标路由单独依赖的 JS 大小就是 Size
  • 每个路由都需要依赖的 JS 大小就是图中单独列出来的 First load JS shared by all

以上图中的 / 路由地址为例,87.6 kB(First Load JS)= 533 B(Size) + 87.1 kB(First load JS shared by all)

next start

生产模式下,使用 next start运行程序。不过要先执行 next build构建出生产代码。运行的时候,跟开发模式相同,程序默认开启在 http://localhost:3000。如果你想更改端口号:

npx next start -p 4000

app Router

app router 的目录结构类似于:

src/
└── app
    ├── page.js 
    ├── layout.js
    ├── template.js
    ├── loading.js
    ├── error.js
    └── not-found.js
    ├── about
    │   └── page.js
    └── more
        └── page.js

定义路由(Routes)

首先是定义路由,文件夹被用来定义路由。

每个文件夹都代表一个对应到 URL 片段的路由片段。创建嵌套的路由,只需要创建嵌套的文件夹。举个例子,下图的 app/dashboard/settings目录对应的路由地址就是 /dashboard/settings

image.png

定义页面(Pages)

那如何保证这个路由可以被访问呢?你需要创建一个特殊的名为 page.js 的文件。至于为什么叫 page.js呢?除了 page 有“页面”这个含义之外,你可以理解为这是一种约定或者规范。

image.png

  • app/page.js 对应路由 /
  • app/dashboard/page.js 对应路由 /dashboard
  • app/dashboard/settings/page.js 对应路由/dashboard/settings
  • analytics 目录下因为没有 page.js 文件,所以没有对应的路由。这个文件可以被用于存放组件、样式表、图片或者其他文件。

那 page.js 的代码该如何写呢?最常见的是展示 UI,比如:

// app/page.js
export default function Page() {
  return <h1>Hello, Next.js!</h1>
}

 定义布局(Layouts)

布局是指多个页面共享的 UI。

在导航的时候,布局会保留状态、保持可交互性并且不会重新渲染,比如用来实现后台管理系统的侧边导航栏。

定义一个布局,你需要新建一个名为 layout.js的文件,该文件默认导出一个 React 组件,该组件应接收一个 children prop,chidren 表示子布局(如果有的话)或者子页面。

举个例子,我们新建目录和文件如下图所示:

image.png

相关代码如下:

// app/dashboard/layout.js
export default function DashboardLayout({
  children,
}) {
  return (
    <section>
      <nav>nav</nav>
      {children}
    </section>
  )
}

// app/dashboard/page.js
export default function Page() {
  return <h1>Hello, Dashboard!</h1>
}

同一文件夹下如果有 layout.js 和 page.js,page 会作为 children 参数传入 layout。换句话说,layout 会包裹同层级的 page。

定义加载界面(Loading UI)

dashboard 目录下我们新建一个 loading.js

image.png

loading.js的代码如下:

// app/dashboard/loading.js
export default function DashboardLoading() {
  return <>Loading dashboard...</>
}

同级的 page.js 代码如下:

// app/dashboard/page.js
async function getData() {
  await new Promise((resolve) => setTimeout(resolve, 3000))
  return {
    message: 'Hello, Dashboard!',
  }
}

# 一定要是async定义的组件,不然loading.js不会生效
export default async function DashboardPage(props) {
  const { message } = await getData()
  return <h1>{message}</h1>
}

不再需要其他的代码,loading 的效果就实现了。

就是这么简单。其关键在于 page.js导出了一个 async 函数

loading.js 的实现原理是将 page.js和下面的 children 用 <Suspense> 包裹。因为page.js导出一个 async 函数,Suspense 得以捕获数据加载的 promise,借此实现了 loading 组件的关闭。

image.png

定义 404 页面

顾名思义,当该路由不存在的时候展示的内容。

Next.js 项目默认的 not-found 效果如下:

image.png

如果你要替换这个效果,只需要在 app 目录下新建一个 not-found.js,代码示例如下:

Next.js第十八章(静态导出SSG)

作者 小满zs
2025年12月29日 16:58

静态导出SSG

Next.js 支持静态站点生成(SSG,Static Site Generation),可以在构建时预先生成所有页面的静态 HTML 文件。这种方式特别适合内容相对固定的站点,如官网博客文档等,能够提供最佳的性能和 SEO 表现。

配置静态导出

需要在next.config.js文件中配置outputexport,表示导出静态站点。distDir表示导出目录,默认为out

import type { NextConfig } from "next";
const nextConfig: NextConfig = {
  /* config options here */
  output: "export", // 导出静态站点
  distDir: "dist", // 导出目录
};

export default nextConfig;

接着我们执行npm run build命令,构建静态站点。

构建完成之后,我们安装http-server来启动静态站点。

npm install http-server -g #安装http-server
cd dist #进入导出目录
http-server -p 3000 #启动静态站点

11.gif

启动完成之后发现点击a标签无法进行跳转,是因为打完包之后的页面叫about.html,而我们的跳转链接是/about,所以需要修改配置项。

build.png

修改配置项

需要在next.config.js文件中配置trailingSlashtrue,表示添加尾部斜杠,生成/about/index.html而不是/about.html

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  output: "export", // 导出静态站点
  distDir: "dist", // 导出目录
  trailingSlash: true, // 添加尾部斜杠,生成 /about/index.html 而不是 /about.html
};

export default nextConfig;

trailingSlash.png

此时重新点击a标签就可以进行跳转了。

动态路由处理

新建目录: src/app/posts/[id]/page.tsx

如果要使用动态路由,则需要使用generateStaticParams函数来生成有多少个动态路由,这个函数需要返回一个数组,数组中包含所有动态路由的参数,例如{ id: '1' }表示对应id为1的详情页。

export async function generateStaticParams() {
    //支持调用接口请求详情id列表 const res = await fetch('https://api.example.com/posts')
    return [
        { id: '1' }, //返回对应的详情id
        { id: '2' },
    ]
}

export default async function Post({ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params
    return (
        <div>
            <h1>Post {id}</h1>
        </div>
    )
}

图片优化

如果使用Image组件优化图片,在开发模式会进行报错

⚠️ 警告

get-img-props.ts 442 Uncaught Error: Image Optimization using the default loader is not compatible with { output: 'export' }.

可能的解决方案:

  • 移除 { output: 'export' } 并运行 "next start" 以启用包含图片优化 API 的服务器模式。
  • next.config.js 中配置 { images: { unoptimized: true } } 来禁用图片优化 API。
  • 使用自定义loader实现

了解更多:nextjs.org/docs/messag…

import Image from "next/image"
import test from '@/public/1.png'
export default function About() {
    return (
        <div>
            <h1>About</h1>
            <Image  loading="eager" src={test} alt="logo" width={250 * 3} height={131 * 3} />
        </div>
    )
}

我们使用自定义loader来实现图片优化,要求我们通过一个图床托管图片。路过图床 是一个免费的图床,我们可以使用它来托管图片。

luguo.png

url.png

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  output: "export", // 导出静态站点
  distDir: "dist", // 导出目录
  trailingSlash: true, // 添加尾部斜杠,生成 /about/index.html 而不是 /about.html
  images: {
    loader: 'custom', // 自定义loader
    loaderFile: './image-loader.ts', // 自定义loader文件
  },
};

export default nextConfig;

根目录:/image-loader.ts

export default function imageLoader({ src, width, quality }: { src: string, width: number, quality: number }) {
    return `https://s41.ax1x.com${src}`
}

src/app/about/page.tsx

import Image from "next/image"

export default function About() {
    return (
        <div>
            <h1>About</h1>
            <Image loading="eager" src='/2025/12/29/pZYbW7t.jpg' alt="logo" width={250 * 3} height={131 * 3} />
        </div>
    )
}

img.png

注意事项

以下功能在SSG中不支持,请勿使用:

  • Dynamic Routes with dynamicParams: true
  • 动态路由没有使用generateStaticParams()
  • 路由处理器依赖于Request
  • Cookies
  • Rewrites重写
  • Redirects重定向
  • Headers头
  • Proxy代理
  • Incremental Static Regeneration增量静态再生
  • Image Optimization with the default loader默认加载器的图像优化
  • Draft Mode草稿模式
  • Server Actions服务器操作
  • Intercepting Routes拦截路由

动态配色方案:在 Next.js 中实现 Shadcn UI 主题色切换

2025年12月29日 09:28

前言

Hi,大家好,我是白雾茫茫丶!

你是否厌倦了千篇一律的网站配色?想让你的 Next.js 应用拥有像 Figma 那样灵活的主题切换能力?在当今追求个性化和用户体验的时代,单一的配色方案早已无法满足用户多样化的审美需求。无论是适配品牌形象、响应节日氛围,还是提供用户自定义选项,动态主题色切换已成为现代 Web 应用的重要特性。

本文将带你深入探索如何在 Next.js 应用中实现专业级的主题色切换系统。我们将利用 Shadcn UI 的设计系统架构,结合 CSS 自定义属性(CSS Variables)的强大能力,打造一个不仅支持多套预设配色方案,还能保持代码优雅和性能高效的主题切换方案。无论你是想为用户提供“蓝色商务”、“绿色生态”还是“紫色创意”等不同视觉主题,这篇文章都将为你提供完整的实现路径。

告别单调,迎接多彩——让我们一起构建让用户眼前一亮的动态主题系统!

开发思路

我的实现思路主要基于 CSS 自定义属性(CSS Variables)。每套主题配色对应一组预定义的变量值,以独立的类型(或类名)标识。在切换主题时,只需为 <html> 根元素动态添加对应的类型类名,即可通过 CSS 变量的作用域机制,全局应用相应的配色方案,从而高效、无缝地完成主题切换。

主题构建工具

当然,要高效地实现基于 CSS 变量的动态主题系统,离不开一个强大的主题构建工具来生成和管理不同配色方案。在这里,我强烈推荐一款专为 shadcn/ui 打造的主题编辑与生成工具:

tweakcn.com/

TweakCN 不仅界面简洁直观,更深度集成了 shadcn/ui 的设计规范,支持实时预览、一键导出 Tailwind CSS 配置及 CSS 变量定义。你可以自由调整主色、辅助色、语义色(如成功、警告、错误等),并自动生成适配深色/浅色模式的完整配色方案。更重要的是,它输出的代码可直接用于 Next.js 项目,配合 CSS 变量策略,轻松实现主题切换——无需手动计算颜色值或反复调试样式,极大提升了开发效率与设计一致性。对于希望快速定制品牌化 UI 风格的开发者来说,TweakCN 无疑是一个强大而贴心的助手。

定义多套配色方案

1、在主题编辑页面,TweakCN 默认提供了 43 套精心设计的配色方案。你可以逐一浏览并实时预览每种方案在实际 UI 组件中的呈现效果。从中挑选几套符合项目风格或个人审美的配色,也可以基于现有方案进一步微调主色、辅助色或语义色,打造完全属于你自己的定制化主题。

202512/w0sy4ok3lelxx4fjx2tafw52lk1kunjr.gif

2、在确认主题配色后,点击右上角的 {} Code 按钮,点击 Copy 复制样式:

202512/ucjfomwfkpjmbuz9n82kgyx1u9ia8eym.png

3、新建一个 theme.css 文件,用来保存不同的主题配色:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.1450 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.1450 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.1450 0 0);
  --primary: oklch(0.2050 0 0);
  --primary-foreground: oklch(0.9850 0 0);
  --secondary: oklch(0.9700 0 0);
  --secondary-foreground: oklch(0.2050 0 0);
  --muted: oklch(0.9700 0 0);
  --muted-foreground: oklch(0.5560 0 0);
  --accent: oklch(0.9700 0 0);
  --accent-foreground: oklch(0.2050 0 0);
  --destructive: oklch(0.5770 0.2450 27.3250);
  --destructive-foreground: oklch(1 0 0);
  --border: oklch(0.9220 0 0);
  --input: oklch(0.9220 0 0);
  --ring: oklch(0.7080 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.9850 0 0);
  --sidebar-foreground: oklch(0.1450 0 0);
  --sidebar-primary: oklch(0.2050 0 0);
  --sidebar-primary-foreground: oklch(0.9850 0 0);
  --sidebar-accent: oklch(92.2% 0 0);
  --sidebar-accent-foreground: oklch(0.2050 0 0);
  --sidebar-border: oklch(0.9220 0 0);
  --sidebar-ring: oklch(0.7080 0 0);
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  --radius: 0.625rem;
  --shadow-x: 0;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-spread: 0px;
  --shadow-opacity: 0.1;
  --shadow-color: oklch(0 0 0);
  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
  --tracking-normal: 0em;
  --spacing: 0.25rem;
}

.dark {
  --background: oklch(0.1450 0 0);
  --foreground: oklch(0.9850 0 0);
  --card: oklch(0.2050 0 0);
  --card-foreground: oklch(0.9850 0 0);
  --popover: oklch(0.2690 0 0);
  --popover-foreground: oklch(0.9850 0 0);
  --primary: oklch(0.9220 0 0);
  --primary-foreground: oklch(0.2050 0 0);
  --secondary: oklch(0.2690 0 0);
  --secondary-foreground: oklch(0.9850 0 0);
  --muted: oklch(0.2690 0 0);
  --muted-foreground: oklch(0.7080 0 0);
  --accent: oklch(0.3710 0 0);
  --accent-foreground: oklch(0.9850 0 0);
  --destructive: oklch(0.7040 0.1910 22.2160);
  --destructive-foreground: oklch(0.9850 0 0);
  --border: oklch(0.2750 0 0);
  --input: oklch(0.3250 0 0);
  --ring: oklch(0.5560 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.2050 0 0);
  --sidebar-foreground: oklch(0.9850 0 0);
  --sidebar-primary: oklch(0.4880 0.2430 264.3760);
  --sidebar-primary-foreground: oklch(0.9850 0 0);
  --sidebar-accent: oklch(0.2690 0 0);
  --sidebar-accent-foreground: oklch(0.9850 0 0);
  --sidebar-border: oklch(0.2750 0 0);
  --sidebar-ring: oklch(0.4390 0 0);
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  --radius: 0.625rem;
  --shadow-x: 0;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-spread: 0px;
  --shadow-opacity: 0.1;
  --shadow-color: oklch(0 0 0);
  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}

4、这样我们就默认一个主题,如果是多套配色,我们可以加上主题类名区分,例如:

/* Amber Minimal */
:root.theme-amber-minimal{
}
.dark.theme-amber-minimal{
}

/* Amethyst Haze */
:root.theme-amethyst-haze{
}
.dark.theme-amethyst-haze {
}

5、然后把 theme.css 导入到全局样式中,Next.js 项目一般是 global.css

@import "./themes.css";

到这里,我们的准备工作就算完成了,接下来我们就完成主题色的切换逻辑!

具体实现

1、这里我们需要用到 zustand 来保存主题色的状态:

pnpm add zustand

2、创建主题配色枚举:

    /**
     * @description: 主题色
     */
    export const THEME_PRIMARY_COLOR = Enum({
      DEFAULT: { value: 'default', label: 'Default', color: 'oklch(0.205 0 0)' },
      AMBER_MINIMAL: { value: 'amber-minimal', label: 'Amber', color: 'oklch(0.7686 0.1647 70.0804)' },
      AMETHYST_HAZE: { value: 'amethyst-haze', label: 'Amethyst', color: 'oklch(0.6104 0.0767 299.7335)' },
      CANDYLAND: { value: 'candyland', label: 'Candyland', color: 'oklch(0.8677 0.0735 7.0855)' },
      DARKMATTER: { value: 'darkmatter', label: 'Darkmatter', color: 'oklch(0.6716 0.1368 48.5130)' },
      ELEGANT_LUXURY: { value: 'elegant-luxury', label: 'Elegant', color: 'oklch(0.4650 0.1470 24.9381)' },
      SAGE_GARDEN: { value: 'sage-garden', label: 'Garden', color: 'oklch(0.6333 0.0309 154.9039)' },
      SUPABASE: { value: 'supabase', label: 'Supabase', color: 'oklch(0.8348 0.1302 160.9080)' },
      TWITTER: { value: 'twitter', label: 'Twitter', color: 'oklch(0.6723 0.1606 244.9955)' },
    });

3、新建 store/useAppStore.ts 文件:

    'use client'
    import { create } from 'zustand'
    import { createJSONStorage, persist } from 'zustand/middleware'

    import { THEME_PRIMARY_COLOR } from '@/enums';
    import { initializePrimaryColor } from '@/lib/utils';

    type AppState = {
      primaryColor: typeof THEME_PRIMARY_COLOR.valueType; // 主题色
      setPrimaryColor: (color: typeof THEME_PRIMARY_COLOR.valueType) => void; // 设置主题色
    }

    export const useAppStore = create(
      persist<AppState>(
        (set) => ({
          primaryColor: THEME_PRIMARY_COLOR.DEFAULT, // 默认主题色
          setPrimaryColor: (color) => {
            set({ primaryColor: color })
            initializePrimaryColor(color);
          }
        }),
        {
          name: 'app-theme', // 用于存储在 localStorage 中的键名
          storage: createJSONStorage(() => localStorage)// 指定使用 localStorage 存储
        }))

4、创建主题色初始化函数:

    /**
     * @description: 初始化主题色
     * @param {typeof} color
     */
    export const initializePrimaryColor = (color: typeof THEME_PRIMARY_COLOR.valueType) => {
      if (typeof document !== 'undefined') {
        // 清空 theme- 开头的类名
        const html = document.documentElement;
        Array.from(html.classList)
          .filter((className) => className.startsWith("theme-"))
          .forEach((className) => {
            html.classList.remove(className)
          })
        // 如果不是默认主题色,则添加对应的类名
        if (color !== THEME_PRIMARY_COLOR.DEFAULT) {
          html.classList.add(`theme-${color}`);
        }
      }
    }

5、创建主题切换按钮:

    import { type FC, useCallback } from "react";

    import { getClipKeyframes } from '@/components/animate-ui/primitives/effects/theme-toggler';
    import { Button } from '@/components/ui';
    import { THEME_PRIMARY_COLOR } from '@/enums';
    import { useAppStore } from '@/store/useAppStore';

    const PrimaryColorPicker: FC = () => {
      const primaryColor = useAppStore((s) => s.primaryColor);
      const setPrimaryColor = useAppStore((s) => s.setPrimaryColor);
      const themeModeDirection = useAppStore((s) => s.themeModeDirection);

      const [fromClip, toClip] = getClipKeyframes(themeModeDirection);

      // 点击颜色切换
      const onChangeColor = useCallback(async (color: typeof THEME_PRIMARY_COLOR.valueType) => {
        if (primaryColor === color) {
          return;
        }
        if ((!document.startViewTransition)) {
          setPrimaryColor(color);
          return;
        }
        await document.startViewTransition(async () => {
          setPrimaryColor(color);
        }).ready;
        document.documentElement
          .animate(
            { clipPath: [fromClip, toClip] },
            {
              duration: 700,
              easing: 'ease-in-out',
              pseudoElement: '::view-transition-new(root)',
            },
          )
      }, [primaryColor, setPrimaryColor, fromClip, toClip])
      return (
        <>
          <div className="grid grid-cols-3 gap-2">
            {THEME_PRIMARY_COLOR.items.map(({ value, label, raw }) => (
              <Button
                size="sm"
                aria-label="PrimaryColorPicker"
                variant={primaryColor === value ? "secondary" : "outline"}
                key={value}
                className="text-xs justify-start"
                onClick={() => onChangeColor(value)}
              >
                <span className="inline-block size-2 rounded-full"
                  style={{ backgroundColor: raw.color }} />
                {label}
              </Button>
            ))}
          </div>
          <style>{`::view-transition-old(root), ::view-transition-new(root){animation:none;mix-blend-mode:normal;}`}</style>
        </>
      )
    }
    export default PrimaryColorPicker;

这里我加了切换过渡动画,不需要的可以自行去掉!

6、页面刷新的时候需要同步,在 Provider.tsx 中初始化:

    import { initializePrimaryColor } from '@/lib/utils';
    const primaryColor = useAppStore((s) => s.primaryColor);

    // 初始化主题色
    useEffect(() => {
      if (primaryColor) {
        initializePrimaryColor(primaryColor);
      }
    }, [primaryColor])

效果预览

202512/fzqxxw5cxexwp2kqeklcevk6o2dwulqi.gif

总结

实现动态主题配色的方式多种多样——从 CSS-in-JS、Tailwind 的 class 切换,到运行时注入样式表等,各有优劣。本文分享的是基于 CSS 自定义属性(CSS Variables)HTML 根元素类名切换 的轻量级方案,配合 TweakCN 这样的可视化工具,能够快速构建出结构清晰、易于维护的主题系统。当然,这仅是我个人在项目中的一种实践思路,如果你有更优雅、更高效的实现方式,欢迎在评论区留言交流!技术因分享而进步,期待看到你的创意方案 🌈。

线上预览:next.baiwumm.com

Github 地址:github.com/baiwumm/nex…

❌
❌