← 代码课堂

第 11 课:组件化 —— React/Next.js 的模块思想

难度:★★★(需要 JS 函数/对象/解构、DOM 事件和表单基础;TypeScript 可先读第 13 课)
教材:shijing-v5-dev/src/(约 60 个源码文件)
预计时间:讲解 80 分钟 + 练习 60 分钟
学完你能:看懂 React/Next.js 项目结构,了解组件、Props、Hook、Provider,并给页面加一个自己的组件


主线导学:一个输入值应该由谁保存

本课聚焦“输入 → 状态更新 → 重新渲染”,先不同时记住 Provider、路由、语音、Tailwind 和所有 Hook。前置是 JS 函数/对象/解构、数组 map、HTML 表单;TypeScript 和 Vite 可先读 13。仅学过 Python 或“看懂一点 HTML”还不够。

以下是完整组件文件,可放入一个已有且能启动的 React/Next.js 开发工程;它不是独立 HTML,不能双击运行。如果手头只有课程 ZIP 的源码节选,请先取得对应完整工程及依赖。本轮验证了教学逻辑并更正运行边界,未宣称重新启动整个历史 Next.js 工程。

"use client";
import { useState } from "react";

function NameInput({ value, onChange }: {
  value: string;
  onChange: (value: string) => void;
}) {
  return <label>学习主题
    <input value={value} onChange={event => onChange(event.target.value)} />
  </label>;
}

export default function LessonDemo() {
  const [subject, setSubject] = useState("");
  return <main>
    <NameInput value={subject} onChange={setSubject} />
    <p>{subject.trim() ? `准备学习:${subject}` : "先输入一个主题"}</p>
    <button onClick={() => setSubject("")}>清空</button>
  </main>;
}

追一遍事件与数据的方向

用户输入 → 子组件的 onChange 被调用 → 子组件把新字符串交给父组件传来的回调 → 父组件 setSubject 安排状态更新 → 父组件再次计算界面 → 新 value 通过 props 传给输入框。

value 向下传,通知向上传。状态只有一份,因此清空按钮和输入框不会各自保留不同文本。这是“受控输入”的核心理由。子组件复用不等于每个组件都必须自己保存一份状态。

验收表

动作预期
初次打开输入框空,显示“先输入一个主题”
输入 Python同时显示“准备学习:Python”
点击清空输入框与提示都回到初始状态
仅直接修改普通局部变量不把它当成可靠的 React 更新方式

遇到界面不变,先看是否调用 setter、是否直接修改旧对象、是否传错 props。不要先加入 useMemo/useCallback;它们是优化手段,不负责修正错误的状态归属。

Hook、Context 和执行环境的边界

自定义 Hook 复用逻辑,每次调用通常拥有各自的状态,不自动成为全局共享仓库;Context 则读取对应 Provider 范围内的值,与事件总线的广播不是同一种机制。

在 Next.js App Router 中,页面默认是 Server Component;"use client" 声明客户端组件边界。客户端组件也可能参与服务端的初始 HTML 预渲染,不能把它解释成“文件只在浏览器执行”。访问 window/localStorage 仍要遵守客户端时机。

完成这个单一状态流后,再把原项目 PoemCard、ChatInput、Provider、路由分别放回地图。每添加一个机制,都回答它解决了刚才小例子没有的哪一种需求。


课前须知:从"操作页面"到"描述界面"

第 05 课的前端是原生 JS:自己拼 HTML 字符串、自己 innerHTML 塞进页面。
项目一大,这种方式就会变成"到处手动改 DOM",很难维护。

React 换了个思路:用函数描述界面长什么样,数据一变,界面自动更新。

Next.js 则是基于 React 的全栈框架:替你解决路由、服务端接口、打包优化。
shijing-v5-dev 就是用它写的。


第一步:先跑起来(或先读结构)

cd shijing-v5-dev
npm install        REM 第一次要几分钟(node_modules 很大)
npm run dev        REM 开发模式,改代码自动刷新

浏览器打开 http://localhost:3000。如果不想装依赖,也可以只读代码——
本课讲的都是结构和思想,不跑也能看懂。

观察重点:点进首页、浏览页、点开一首诗,注意地址栏变化
(/ → /browse → /poem/关雎的id),同时页面没有整页刷新。


第二步:模块地图

src/
├── app/                       页面与接口(Next.js 的"路由"靠目录结构实现)
│   ├── layout.tsx                全局布局:套在所有页面外面的"壳"
│   ├── page.tsx                  首页(对应网址 /)
│   ├── browse/page.tsx           浏览页(对应网址 /browse)
│   ├── poem/[id]/page.tsx        诗歌详情(对应网址 /poem/xxx)
│   └── api/chat/route.ts         后端接口(跑在服务器上)
│
├── components/                可复用组件(按功能分子文件夹)
│   ├── ai/                       AI 聊天相关(输入框、消息、模型选择…)
│   ├── poem/                     诗歌相关(卡片、分类筛选、每日一诗…)
│   └── ui/                       通用界面(页头、搜索框、字体选择…)
│
├── hooks/                     可复用逻辑(useXxx 开头的自定义 Hook)
├── lib/                       纯逻辑工具(搜索引擎、音频引擎、数据库…)
├── types/                     TypeScript 类型定义
├── constants/                 常量
└── data/                      数据(诗句内容)

看到规律了吗? 这是第 06 课 Python 分层的前端版:

前端Python(第 06 课)
components/ 组件函数/类(可复用单元)
hooks/ 逻辑Service 层(业务逻辑)
lib/ 工具utils/工具模块
types/ 类型类型标注 / dataclass
app/ 页面与接口cli/ 与 ui/(入口层)

思想完全互通:分文件夹 = 分职责。


第三步:逐块精讲

块 1:组件 = 返回标签的函数(components/poem/PoemCard.tsx)

interface PoemCardProps {
  poem: PoemMeta            // 传进来的参数类型
}

export function PoemCard({ poem }: PoemCardProps) {
  const { playSfx } = useAudio()
  const firstLine = poem.preview[0] || ""

  return (
    <Link href={`/poem/${poem.id}`} onClick={() => playSfx("pageFlip")}>
      <h3>{poem.title}</h3>
      <span>{poem.section}</span>
      <p>{firstLine}</p>
    </Link>
  )
}

和第 05 课的对照:原生 JS 里你写 cardHtml(c) 拼字符串;
现在写 PoemCard 组件。区别是——组件可组合、可复用、带自己的状态和样式,
而且 React 会帮你高效更新页面。

块 2:路由靠"目录结构"(App Router)

Next.js 的约定:文件夹路径就是网址,特殊文件名有特殊含义。

文件路径对应网址
app/page.tsx/
app/browse/page.tsx/browse
app/poem/[id]/page.tsx/poem/任意id(动态路由)
app/api/chat/route.ts后端接口 /api/chat

看 app/browse/page.tsx 第 32 行:

export default function BrowsePage() {

一个 page.tsx 就是一个页面,默认导出(default export)的就是这个页面组件。
完全不用自己写"原来路径是 /browse 就显示浏览页"这种判断——
框架用文件位置帮你做了路由(对比第 02 课手写 if 链、第 08 课装饰器路由,
三种层次:手写 → 装饰器 → 文件约定)。

块 3:layout.tsx —— 套在所有页面外面的壳(第 24-49 行)

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" className="h-full" suppressHydrationWarning>
      <body className="min-h-full flex flex-col bg-paper text-ink antialiased">
        <ThemeProvider attribute="data-theme" defaultTheme="day" storageKey="shijing-theme">
          <ColorInit />
          <GlowProvider>
            <PoemProvider>
              <AudioProviderWrapper>{children}</AudioProviderWrapper>
            </PoemProvider>
          </GlowProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}

所以布局只需要写一次(主题、字体、音效…),所有页面自动共享

主题(亮/暗)、颜色、光点动效、诗歌数据、音效播放

这种"包在外面的提供者"就是Context(上下文):
让任意深度的子组件都能直接拿到全局数据,不必一层层传递参数(不用 props 接力)。

和第 03、07 课对照:

机制出现在哪作用
事件总线(手写)第 06 课模块间广播通知
信号与槽第 07 课 Qt事件 → 自动调用
Context / Provider这里(React)全局数据共享给所有子组件

三个都是"解耦与共享"的不同实现,思想一脉相承。

块 4:数据驱动 —— 数据变,界面自动变(browse/page.tsx)

export default function BrowsePage() {
  const [activeCategory, setActiveCategory] = useState<string | null>(null)
  const [searchQuery, setSearchQuery] = useState("")
  ...
  const poems = useMemo(() => [...poemsMeta, ...extraPoems.map(toMeta)], [extraPoems])

你不用手动 innerHTML,也不用调用任何 refreshList()

这就是和第 03 课关系图最大的不同:

关系图(原生 JS)这里(React)
数据变了手动调 view.refreshAll()自动重渲染
更新方式拼 HTML 字符串声明式描述界面

第 03 课说过"React 是自动版的数据驱动"——现在你亲眼看到了。

其他几个常用 Hook(看到别慌,知道用途即可):

Hook作用
useMemo缓存计算结果,依赖不变就不重算(性能优化)
useCallback缓存函数,避免每次渲染都新建(性能优化)
useEffect处理"副作用":请求数据、订阅事件、操作浏览器 API
useRef存一个"不会触发重渲染"的值(如计时器 id、DOM 引用)
useContext读取上层 Provider 提供的全局数据

块 5:自定义 Hook —— 把逻辑抽出来复用(hooks/useSemanticSearch.ts)

/**
 * 语义搜索 Hook
 * 被 HomePage (page.tsx) 和 SearchByMeaning 复用,消除重复的搜索逻辑
 * - 统一的 fetch 到 /api/search
 * - loadingRef 防止竞态条件
 */
export function useSemanticSearch(options?: UseSemanticSearchOptions) {
  const [query, setQuery] = useState("")
  const [results, setResults] = useState<SearchResult[]>([])
  const [loading, setLoading] = useState(false)
  const loadingRef = useRef(false)

  const handleSearch = useCallback(async (text?: string) => {
    const q = (text ?? query).trim()
    if (!q || loadingRef.current) return      // 上一次还没结束,直接忽略
    loadingRef.current = true
    setLoading(true)
    try {
      const res = await fetch("/api/search", { method: "POST", ... })
      const data = await res.json()
      setResults(data.results || [])
    } catch {
      setResults([])
    }
    setLoading(false)
    loadingRef.current = false
  }, [query, useAI])

  return { query, setQuery, results, loading, handleSearch, reset }
}

自定义 Hook 就是把"一组相关的数据和逻辑"打包起来,谁需要就 const { ... } = useSemanticSearch()。
看它自己的注释:"被两处复用,消除重复的搜索逻辑"——这就是它的存在理由。

两个干货细节:

  1. loadingRef 防竞态:用户连点两次搜索,可能两个请求乱序返回,

先发的旧请求较晚返回,覆盖后发请求的新结果。用 useRef 记一个"正在搜索"标志,挡住重复请求。
这里的锁标志阻止并发请求,也会忽略忙碌期间的新输入;防抖仅减少触发次数,并不保证结果顺序。需要新输入优先时应采用请求编号或取消策略

  1. hooks/useSSEStream.ts(5.5 KB)就是第 09 课流式读取的 React 版:

同样是读 response.body 的流;区别是接收到的内容会自动更新到界面。
建议你去读一遍,和 script.js 的 readSSEStream 对照——你会发现自己已经能看懂了。

块 6:样式用 Tailwind(写在 className 里)

<button
  onClick={onSend}
  disabled={!value.trim() || disabled}
  className="p-2 text-ink-light hover:text-ink disabled:text-ink-light/30 disabled:cursor-not-allowed transition-colors"
>
  <FiSend size={18} />
</button>

className 里的一串英文就是样式,每个词一个小功能:

类名含义
p-2内边距 0.5rem
text-ink文字颜色(项目自定义的颜色名)
hover:text-ink鼠标悬停时变色
disabled:opacity-40被禁用时半透明
transition-colors颜色变化时带过渡动画

和第 05 课对照:那边写在 .css 文件里(.card { ... }),
这边直接写在标签上。好处是"改哪个组件就在哪个文件里改样式",
不用在 HTML 和 CSS 之间来回跳;代价是类名会很长。

块 7:前后端在同一个项目里(app/api/)

src/app/api/chat/route.ts     后端接口(跑在服务器上)
src/app/api/search/route.ts
src/lib/ai-config.ts          前端保存的 AI 配置

Next.js 让一个项目同时装前端和后端:

密钥的处理值得注意:仓库里看不到任何真实 API Key。
用户在自己的设置面板里填写(存在浏览器本地),前端把配置发给自己的后端接口,
由后端去调用 AI 服务。这样代码可以安全地开源。
(更严格的做法是密钥只存在服务器端;但"用户自带 Key"的产品这样设计也很常见。)

文件顶部那个 "use client" 是 Next.js 的标记:

layout.tsx 没有 "use client"(负责页面骨架和元信息),
而 page.tsx、PoemCard.tsx 都有(要交互)。


第四步:对照开源,别人怎么写 React

对照 Next.js 官方 Learn 教程

官方教程里你会看到一模一样的套路:app/page.tsx、layout.tsx、
"use client"、useState。项目里的写法就是官方推荐姿势,
可以直接去官方文档深入学习(推荐从 Learn 章节开始)。

对照第 05 课(原生 JS → React 的进化)

原生 JS(查资料)React(诗经)
界面描述拼 HTML 字符串 + innerHTML组件 + JSX
数据更新手动 renderList()setState 自动重渲染
逻辑复用自己写函数(common.js)自定义 Hook
全局状态全局对象 App.stateContext / Provider
组件复用复制粘贴 HTML<PoemCard poem={x} />

结论:React 不是新魔法,它把你第 05 课手动做的事自动化了。
这也再次印证:先理解原生,再学框架,顺序不能反。

对照 Vue

Vue 和 React 是同类竞品,思想相通(组件 + 响应式数据),
只是语法不同。学会 React 后,看 Vue 会很快上手。

对照 Python 项目的目录

components / hooks / lib / types 与第 06 课的
ui / core / utils / models 一一对应。
目录结构是跨语言的通用语言。


动手练习(做完才算过关)

练习 1(热身,练 Props):
把 PoemCard(components/poem/PoemCard.tsx)里显示的诗句从一行改成两行
(提示:poem.preview.slice(0, 2) 然后用 {...} 渲染)。
再试着在卡片右下角加一个显示 poem.id 的小字——这就是"加一个 props 数据"的完整动作。

练习 2(必做,练组件):
新建 src/components/ui/HelloCard.tsx:

"use client"

export function HelloCard({ name }: { name: string }) {
  return <div className="p-4 bg-card rounded-lg border border-border-light">你好,{name}!</div>
}

然后在 app/browse/page.tsx 里 import 它,放在页面顶部渲染 <HelloCard name="世界" />。
刷新 /browse 看效果。

看答案

import 路径用 @/components/ui/HelloCard

这一套"建组件 → 引入 → 用标签渲染"就是 React 开发的标准动作。

练习 3(必做,练 State):
给 ChatInput(components/ai/ChatInput.tsx)加一个"清空"按钮。
提示:组件里加了 useState 就不行了——它现在是"受控组件",
值由父组件通过 value 传入;正确做法是给 props 加一个 onClear,
由父组件去清空。体会一下"状态应该放在哪个组件里"的思考过程。
(想不出来就看父组件 AIChatPanel.tsx 怎么用 ChatInput 的。)

练习 4(必做,练路由):
新建文件 src/app/about/page.tsx:

export default function AboutPage() {
  return <main className="p-10"><h1>关于本站</h1><p>这是我自己写的诗经阅读器。</p></main>
}

访问 http://localhost:3000/about——只加了一个文件,就多了一个页面。
这就是文件路由的威力。

练习 5(对照阅读):
打开 hooks/useSSEStream.ts,找到它读取流的部分,
和第 09 课 script.js 的 readSSEStream(第 980-1005 行)对比。
写下 2-3 条相同点和不同点。(提示:相同点是 getReader + 按行解析;
不同点是收到内容后怎么更新界面。)

练习 6(思考题):
为什么 layout.tsx 里 metadata 是 export const 直接导出,
而页面组件是 export default function?想一想"元信息"和"组件"有什么区别。

练习 7(选做,进阶):
给 PoemCard 加一个"已收藏"的小星标:点一下切换状态。
提示:在 PoemCard 里用 useState(false),点击时切换,根据状态显示星标颜色。
做完你就掌握了"局部状态 + 交互"的完整闭环。


本课小结

术语表

术语人话解释
组件(Component)返回界面片段的函数,界面的积木
Props传给组件的参数(由外部给)
State组件内部会变的数据
HookuseXxx 形式的可复用逻辑单元
JSX / TSX在 JS/TS 里写标签的语法
重渲染(re-render)数据变化后 React 重新执行组件函数更新界面
Context / Provider把全局数据注入整棵组件树的机制
受控组件值由外部 props 控制的表单元素
App Router / 文件路由用文件夹和文件名决定网址
Server / Client Component在服务器渲染 / 在浏览器运行的组件
Tailwind用类名直接写样式的 CSS 框架

下节预告

第 12 课(基础工程化收尾,后面还有专题课):工程化收尾:测试、打包、发布。
我们会把前面 11 课学的东西收拢起来:用 pytest 写测试(resource_manager_v2/tests/)、
用 PyInstaller 打包 exe(worry_debate_game/build.bat)、
项目怎么变得"别人也能用"——并给你一份「一个项目从想法到发布」的完整清单。