第 3 章:React:声明式 UI (告别 Jinja2)
作为一名 Python 后端开发者,你最熟悉的可能是 Jinja2。在 Flask 或 Django 中,你从数据库获取数据,将其““注入””到一个 HTML 模板中,服务器““渲染””出一个 HTML 字符串,然后发送给浏览器。这个过程是 命令式 (Imperative) 的——你告诉模板引擎““在这里循环,在...
第二部分:Next.js 核心范式:App Router 与 RSC
第 3 章:React:声明式 UI (告别 Jinja2)
作为一名 Python 后端开发者,你最熟悉的可能是 Jinja2。在 Flask 或 Django 中,你从数据库获取数据,将其““注入””到一个 HTML 模板中,服务器““渲染””出一个 HTML 字符串,然后发送给浏览器。这个过程是 命令式 (Imperative) 的——你告诉模板引擎““在这里循环,在这里插入这个变量””。
欢迎来到 React 的世界。React 是 声明式 (Declarative) 的。
- Jinja2 (命令式):
{% for item in items %} <li>{{ item.name }}</li> {% endfor %}。你描述了 如何 构建列表。 - React (声明式):
items.map(item => <li key={item.id}>{item.name}</li>)。你只描述了 你想要什么(一个基于items数组的li列表),React 会““自动””在数据变化时,最高效地更新 DOM。
你不再是““操作 HTML””,你是在““将 UI 描述为状态的函数””。
3.1. JSX 与组件化思维
你首先会注意到的就是 JSX。它看起来像 HTML,但它在 JavaScript 文件里。
// 这不是 HTML 字符串!
const greeting = <h1 className="title">Hello, World!</h1>;- 它不是 HTML: 注意
class变成了className。JSX 实际上是React.createElement()函数的““语法糖””。 - 它不是模板: 它拥有 JavaScript 的全部能力。你可以
map,filter,if/else(使用三元运算符)。
组件化思维
在 Jinja2 中,你可能会用 {% include "nav.html" %}。在 React 中,万物皆组件。
组件 (Component) 就是一个返回 JSX 的 JavaScript 函数(或类,但我们项目中只用函数)。
-
Python 类比: React 组件就是一个 Python 函数,只不过它的
return值是 UI 描述。 -
实战项目: 我们的
src/components/ui/button.tsx就是一个Button组件。你可以在任何地方复用它:JavaScriptimport { Button } from '@/components/ui/button'; function MyPage() { return ( <div> <Button variant="primary">Click Me</Button> <Button variant="destructive">Delete</Button> </div> ); }
3.2. 客户端核心:State, Props 与 "use client"
这是 React 乃至整个 Next.js App Router 的 核心。
Props (属性)
Props (Properties) 是数据从父组件传递给子组件的方式。
-
Python 类比: Props 就是 函数参数。
-
Jinja2 类比: 类似于
{% include "nav.html" with username=user.name %}。 -
React 示例: 在上面的
Button示例中,variant="primary"就是一个prop。TypeScript// `src/components/ui/button.tsx` (简化版) // 注意看类型定义,这就是 TS 的“契约” export function Button(props: { variant: string, children: React.ReactNode }) { // 'children' 是一个特殊的 prop,它代表 <Button>标签之间的内容 return ( <button className={...}>{props.children}</button> ); }数据流是 单向的,从上(父)到下(子)。
State (状态)
State 是组件““记住””信息的方式。这是 React 交互性的来源。
- Python 类比:
State就像一个函数内的局部变量,但有一个““魔法””:当这个变量改变时,React 会自动重新运行该函数(重新渲染组件)。 - 如何使用: 你不能直接修改
State。你必须使用useState这个““Hook””(钩子)。
JavaScript
import { useState } from 'react';
function Counter() {
// useState 返回一个数组:[当前值, 更新值的函数]
const [count, setCount] = useState(0); // 初始值为 0
return (
<div>
<p>You clicked {count} times</p>
{/* onClick 是一个事件处理器。
它调用 setCount,触发 React 重新渲染此组件
*/}
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}"use client" (客户端边界)
现在,我们将上面两个概念与 Next.js App Router 结合起来。
默认情况下,Next.js App Router 中的所有组件都是 React Server Components (RSC)。
- RSC (服务器组件): 它们 只 在服务器上运行。它们就像““超级 Jinja2 模板””。它们可以 直接访问数据库(如
src/db/),但它们 不能 使用useState或onClick。它们没有交互性。 - Client Components (客户端组件): 为了添加交互性(使用
useState,onClick,useEffect),你 必须 将一个文件标记为 客户端组件。
你只需要在文件的 最顶部 添加一行字符串:
TypeScript
"use client"; // 这不是注释,这是必须的指令
import { useState } from 'react';
// 现在这个组件可以在客户端运行,可以使用 State
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}关键思维转变:"use client" 不 意味着““这个组件只在客户端运行””。它意味着这个组件是 ““混合型”” 的:它会在服务器上进行预渲染(生成初始 HTML),然后““激活””(Hydrate) 在客户端,使其具有交互性。
3.3. [深度解析:next-devtools-mcp/src/resources/(nextjs-fundamentals)/01-use-client.md]:深入 "use client" 边界
"use client" 是一个 边界 (Boundary)。一旦你定义了一个客户端组件,它就像““病毒””一样,它导入的 所有 其他组件 也会成为客户端模块图的一部分。
核心规则:
- 服务器组件 (RSC) 可以导入 RSC。 (服务器 -> 服务器)
- 服务器组件 (RSC) 可以导入 客户端组件。 (服务器 -> 客户端)
- 客户端组件 可以导入 客户端组件。 (客户端 -> 客户端)
- 客户端组件 绝对不能 导入 服务器组件! (❌ 客户端 -> 服务器 ❌)
为什么?因为客户端组件的代码最终会在浏览器中运行。浏览器 不可能 运行一个需要 直接访问数据库 或 读取服务器文件系统 的服务器组件。
那么,如何在交互组件中显示服务器内容?答案:使用““插槽””(Slot) 模式,即 children prop。
这是 Next.js 中最高级的模式之一。假设我们的 src/components/ui/dialog.tsx(一个弹窗)是一个客户端组件(因为它需要 useState 来管理““打开/关闭””状态),但我们想在弹窗里显示 从数据库中获取 的服务器内容。
1. 服务器组件 (page.tsx):
JavaScript
// 这是个 RSC,它可以访问数据库
import { db } from '@/db';
import { Modal } from '@/components/ui/modal'; // 这是一个客户端组件
import { ServerContent } from '@/components/server-content'; // 这是一个 RSC
export default async function Page() {
// RSC 可以在顶层 'await'
const data = await db.query.users.findFirst(...);
return (
<main>
<h1>My Page</h1>
{/* 我们将一个 RSC (<ServerContent />)
““塞进””了客户端组件 <Modal> 的 'children' prop 中
*/}
<Modal>
<ServerContent data={data} />
</Modal>
</main>
);
}2. 客户端组件 (src/components/ui/modal.tsx):
TypeScript
"use client";
import { useState } from 'react';
// 'children' 的类型是 React.ReactNode
export function Modal({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<div className="modal-content">
{/* Modal 组件完全不知道 'children' 是什么。
它只是按原样渲染了从服务器传递过来的 HTML。
这就是““插槽””。
*/}
{children}
</div>
)}
</div>
);
}总结: 我们的架构目标是““将交互性尽可能深地推向组件树的叶子节点””。保持 page.tsx 为 RSC,只在你 真正需要 onClick 或 useState 的地方(比如 Button 或 Modal)才使用 "use client"。
3.4. [代码解析]:分析实战项目中的 src/components/ui/ (Radix UI) 和 src/stores/ (Zustand)
src/components/ui/ (Radix UI + Tailwind)
这是我们 SAAS 模板的““设计系统””。
- Radix UI: 它是一个 Headless (无头) UI 库。它提供了所有复杂组件(如
DropdownMenu,Dialog,Select)的 功能和可访问性(键盘导航、ARIA 属性),但不提供 任何 样式。 - Tailwind CSS: 我们用它来为 Radix 提供的““骨架””添加样式。
src/components/ui/button.tsx****: 打开这个文件,你会发现它不是一个简单的<button>。它使用了cva(Class Variance Authority) 来管理不同的样式变体(primary,destructive,ghost)并应用 Tailwind 类。- 连接
use client****: 像Dialog或DropdownMenu这样的组件,内部需要useState来管理开关状态,因此它们 都是 客户端组件。
src/stores/ (Zustand)
问题: useState 非常适合组件 内部 的状态。但如果我们的 Header 组件和 SettingsPage 组件都需要知道并能修改用户的 credits(积分)怎么办?
““Props Drilling (属性钻探)””: 我们可以把 credits 状态放在顶层的 Layout,然后通过 props 一层一层传递下去。但这非常痛苦和脆弱。
解决方案: Zustand,一个极简的 全局状态管理器。
- Python 类比: 如果
useState是一个函数内的局部变量,那么Zustandstore 就像一个 全局单例 (Singleton) 或一个你可以import的模块级变量。 - 它如何工作:
1. 创建 Store (状态仓库) (src/stores/useCreditStore.ts)
TypeScript
import { create } from 'zustand';
// 1. 定义“契约” (第 2 章)
interface CreditStoreState {
credits: number;
setCredits: (amount: number) => void;
deductCredits: (amount: number) => void;
}
// 2. 创建 store
export const useCreditStore = create<CreditStoreState>((set) => ({
credits: 0, // 初始状态
// 'set' 函数用于更新状态
setCredits: (amount) => set({ credits: amount }),
deductCredits: (amount) => set(
(state) => ({ credits: state.credits - amount })
),
}));2. 在组件中使用 Store (必须是客户端组件!)
TypeScript
"use client";
import { useCreditStore } from '@/stores/useCreditStore';
import { useEffect } from 'react';
// 组件 A:显示积分
function HeaderCreditDisplay() {
// 1. "订阅" store 中的 `credits` 值
const credits = useCreditStore((state) => state.credits);
return <span>Credits: {credits}</span>;
}
// 组件 B:修改积分 (例如,从 API 加载初始值)
function UserProfile() {
// 1. "订阅" store 中的 `setCredits` 方法
const setCredits = useCreditStore((state) => state.setCredits);
useEffect(() => {
// 假设我们在页面加载时从 API 获取了用户数据
fetch('/api/user')
.then(res => res.json())
.then(data => {
// 2. 调用 action 来更新全局状态
setCredits(data.credits);
});
}, [setCredits]);
// ...
}总结: Zustand 允许我们的客户端组件在 不通过 props 的情况下,共享和修改 全局状态。它是 useState 的完美补充,也是 src/credits/ 功能得以实现的核心。
分类
src/components/ui/ (Radix UI + Tailwind)src/stores/ (Zustand)更多文章
第 17 章:性能监控与可观测性
到目前为止,我们的 SAAS 应用已经功能完备,并通过了严格的 CI/CD 流程(第 14 章)和自动化测试(第 16 章)。它已经部署到了 Vercel 上的生产环境。但是,一旦应用“走出”了我们的开发和测试环境,进入了真实用户的、不可预测的设备和网络中,我们如何知道它是否运行良好?
第 19 章:集成 AI 能力 (Vercel AI SDK)
在本书的前 18 章中,我们已经构建了一个极其坚实的 SAAS 基础。我们拥有了支付 (Stripe)、认证 (better-auth)、数据库 (Drizzle)、DevOps (GitHub Actions) 和功能发布 (Feature Flags) 的所有核心组件。现在,是时候为我们的 SAAS 注入“智...
第 4 章:App Router:为 SAAS 性能而生
如果你在 1.4 节建立的算法思维是““计算原语””,那么 Next.js App Router 就是一个将这些原语““工程化””的集大成者。它是一个为解决 SAAS 应用(尤其是数据驱动和 I/O 密集型)性能瓶颈而设计的、高度优化的架构。