为什么写代码前要先规划组件树?Next.js + Redis 笔记系统实战
重生之我在 Vibe Coding 时代当程序员 · Next.js 全栈实战篇
上一课我搞明白了一件事:AI 产品的官网为什么都选 Next.js------SSR 伺候 SEO,RSC 伺候性能。这一课直接上手,从零搭一个笔记系统 next-blog。
这个项目从一张页面原型图和一张组件架构图开始完成这个项目,开发之前不要急着写代码。开发的顺序是:分析需求 → 技术方案(Next.js)→ 任务细节(路由 + 组件)→ 最后才是写代码。
这个顺序本身就是这节课最大的知识点。下面按这个顺序复盘。
一、项目创建:npx 和脚手架
npx :npm 自带的工具,可以跳过安装步骤直接执行一个 node 包,不需要把它装到全局。
npx create-next-app等价于npm i -g create-next-app再执行create-next-app,但一步到位,不留全局污染。特别适合「只想试用一下某个包」的场景,比如在测试电脑上跑项目。
bash
npx create-next-app
create-next-app 是 React 全栈开发脚手架。
脚手架:建筑工程里的词,指搭楼之前先立起来的临时支架。在前端领域,它帮你生成一个带好目录结构、配置、依赖的可运行项目骨架,你直接在这个骨架上写业务。
它默认给的这套方案,正好接上上一课的理论:
- SSR(服务器端渲染),SEO(搜索引擎优化)良好
- 默认使用 RSC(React 服务器端组件)
- 加上
"use client"之后,才启用客户端组件,走 hydration(水合)流程
hydration(水合):服务端先把组件渲染成 HTML 发给浏览器(用户第一时间能看到内容),然后 React 在客户端把事件、状态这些"交互能力"注水到这份 HTML 上,让它变成可交互的应用。给干巴巴的 HTML"注水"。
这次用的版本挺新:Next.js 16.3.1 + React 19.2.8。
二、需求先摆桌上:笔记系统要做什么
界面分两列:左侧是笔记列表,右侧是笔记内容。
技术方案:
- 数据层对笔记做增删改查
- 存入数据库的是 markdown,页面显示的是 html,用
marked这个库做转换
CRUD:Create、Read、Update、Delete,增删改查。绝大部分业务系统的底层就是这四个动作。
功能需求:
- 点击 new 按钮,新增一篇笔记,左侧列表同步更新(Create)
- 打开笔记能查看内容,markdown 渲染成 html(Read)
- 可以编辑当前笔记,支持 markdown 格式(Update)
- 可以删除笔记,左侧列表同步更新(Delete)
- 搜索功能,模糊匹配查询
需求就这些。接下来是把需求翻译成技术方案的第一步:路由。
三、路由设计:文件即路由,一切皆资源
App Router 的核心心智模型就一句话:文件即路由,RESTful 一切皆资源。
RESTful :一种 URL 设计风格,把系统里的一切都看作"资源",用 URL 定位资源、用 HTTP 方法表达对资源的操作。笔记是一种资源,所以
/add配 POST 请求,就能表达"新增一篇笔记"。
按这个思路,目录结构直接规划出来:
bash
app/
page.js # / 界面分为两列 左侧为笔记列表,右侧是笔记内容
note/
[id]/ # 动态路由
page.js # note 的显示页面(详情)
edit/
[id]/
page.js # 修改 note
page.js # 新增 note
[id] 是动态路由,/note/123 和 /note/456 共用同一个页面组件,id 从 URL 里取。
整棵路由树没写一行代码就定下来了。这在以前写配置式路由的年代不敢想。
四、组件规划:开发之前不要急着写代码
这是全课的核心一节,核心动作就一个:写代码之前,先把组件架构规划清楚------要拆出哪些组件、每个组件负责什么、谁复用谁,先画成一棵组件树,再动手。这也正是上一篇结尾埋下的**规范驱动编程(SDD)**在组件架构层面的一个缩影:先定规范,再写代码。
规划出的组件树长这样:
文字版:
text
Sidebar
SidebarSearchField 侧边栏搜索区域
EditButton(复用) 编辑按钮
SidebarNoteList 侧边栏笔记列表
NoteItem 笔记 item
Note
NoteEditor 笔记的编辑
NotePreview 负责笔记的预览界面
老师说了一句我觉得可以直接裱起来的话:
组件是工作单元,也是 AI 生成的工作单元。
在 AI 辅助开发的时代,你规划出来的每一个组件,就是一个可以丢给 AI 的任务单元。组件边界清晰,AI 生成的东西才可控。所以"先规划再写码"不只是工程习惯,它直接决定了你和 AI 协作的效率------这也解释了为什么 Next.js 16 干脆自己动手给 AI 写 AGENTS.md:规范化机器可读的任务,是 Vibe Coding 时代的基础设施。
组件定了,整个项目的目录职责也跟着定了:
app:页面主目录,page.js主页面、layout.js布局、note/[id]文章components:组件lib:数据库操作、常用的函数public:静态资源,static server 直接伺服
static server(静态资源服务):图片、svg 这类文件不需要经过任何逻辑处理,服务器原样返回即可,所以叫静态资源。
五、layout.js:SEO 的第一现场
Next.js 的布局文件 layout.js 包住所有页面。一个页面从 HTML 层面长什么样,在这里看得一清二楚:
jsx
import './style.css'
import Sidebar from "@/components/Sidebar";
export default function RootLayout({ children }) {
return (
<html>
<head>
<title>大模型工程师博客</title>
<meta name="description" content="大模型工程师博客" />
<meta name="keywords" content="大模型工程师, 大模型, 博客,LLM,ChatGPT,Claude,deepseek" />
</head>
<body>
<div className="container">
<div className="main">
<Sidebar/>
<section className="col note-viewer">{children}</section>
</div>
</div>
</body>
</html>
);
}
对照着 HTML 结构看:
text
html
head
title(SEO)
meta(SEO)
description(描述)
keyword(关键词)
body
page.js(通过 children 进来)
{children} 就是当前路由的 page.js------布局不动,内容随路由变。
两个语义化标签值得记:
nav:侧边栏、导航栏section:语义化标签,表示一个区域/区块
Sidebar.js 里有一段注释,把 section 的语义讲得很具体:
jsx
{/* sidebar
区块 电商网站,商品介绍,评论,购物车,订单,用户中心,售价
语义是独立的一块内容区域 幻灯片区域 */}
电商网站里的商品介绍、评论、购物车、订单、用户中心、售价------每一块都是独立的、有意义的内容区域,都配得上一个 section。
layout 里还藏着一个工程习惯:to be continue 注释法 。在代码里用注释写好"接下来要做的事",表明等待未来完成。好处是团队协作、记忆、维护都靠它------你随时能从注释里接上昨天的进度。Sidebar.js 里就有实例:
jsx
<section className="sidebar-menu" role="menubar">
{/* SideSearchField 未来再完成 */}
</section>
六、BEM:给类名一点语义
样式方案有两种流派:
- 原子类:tailwindcss,一个类只干一件事,拼出来
- BEM 国际命名规范:有语义,好维护
BEM :Block(块)、Element(元素,用
__连接)、Modifier(修改器,用--连接)三段式命名法。比如sidebar-note-header是一个块,sidebar-note__title--active一眼就能读出"侧边栏笔记块的标题元素,处于激活状态"。
原子类 :一个 class 只负责一条样式(比如.flex、.p-4),tailwindcss 是代表。写起来快,但类名不再承载语义。
这个项目的 style.css 里还有一份完整的 CSS Reset(适配自 a-modern-css-reset),重置掉浏览器默认的 margin、padding、list-style,让各浏览器起点一致。
七、alias:把 ../../../ 干掉
组件里要引入 lib/redis.js,按相对路径写是:
js
import { getAllNotes } from "../../../lib/redis.js";
三个 ../,数错一层就炸。配置 alias(别名)之后:
js
import { getAllNotes } from "@/lib/redis.js";
alias(别名) :为一个路径设置一个更短、更易记的替代名称。
@直达项目根目录。
配置就在 jsconfig.json:
json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"]
}
}
}
baseUrl: "." 把基准点定在项目根目录,paths 里声明两条短链接映射。以后无论组件嵌套多深,@/ 开头永远是根。
八、数据服务:为什么是 Redis
技术选型:Redis,key:value 的 NOSQL 内存数据库。
先把 Redis 的画像画清楚:
- 没有数据表,不是关系型,不用 SQL 驱动
- 存储在内存中,有点像 localStorage,直接存 key:value
- 速度快,存储小
- 端口 6379(MySQL 是 3306)
NOSQL:泛指不使用传统关系型表格模型的数据库,Redis(键值型)是其中一种,此外还有文档型(MongoDB)等。
典型使用场景:缓存、计数器、榜单(投票)。
Redis 的高级之处在于:不同类型的数据,有对应的优化存储方式,对应不同的方法:
- 字符串:直接
get/set - 哈希表:
hget/hset
哈希表:key → field → value 的两层结构。一个 key 下面可以挂多个 field:value 对。JS 里的对象、JSON 本质上就是哈希表思想。
掘金首页的例子
为什么缓存这么值钱?拿掘金首页举个例子:
掘金首页的文章列表,几分钟之内是不变的,之后才改变。
- 当第一个用户来的时候,查看文章,服务器去查 MySQL 数据库,得到文章列表(posts 列表),并且会把文章通过 key:value 存到 Redis 中
- 下一个用户来的时候,如果数据差别不大(数据还在有效期内),就直接从 Redis 中获取------因为 Redis 基于内存,读写速度快
数据库只扛第一枪,后面的流量让内存去挡。
什么时候 Redis 不够用(或者说单个数据库不能满足需求)
超过 1k 人(并发)的情况下:
- 数据过大,单个数据库不够用
- 数据库有读写的 I/O 瓶颈
I/O 瓶颈:数据读写速度跟不上请求速度。磁盘 I/O 慢于内存 I/O 数量级,请求全砸到磁盘上,队列就堵住了。
所以生产环境一般是 Redis + MySQL 组合:MySQL 做持久化存储,Redis 做热数据缓存。
redis.js 落地
Next.js 的约定:数据业务逻辑都放在 lib 目录下 。lib/redis.js:
js
// node redis 客户端,驱动
import Redis from 'ioredis';
const redis = new Redis();// 默认没有密码 NOSQL 数据库
// 哈希表,JSON 也是哈希表的一种
// key :字符串 ID
// value :NOTE 的序列化过后的字符串
// redis 在存储一个值的时候,存储的是 key:value,对 value 特别支持 hash 类型
// initialData 对象(js),哈希表(其他)
const initialData = {
"1702459181837": '{"title":"sunt aut","content":"quia et suscipit suscipit recusandae","updateTime":"2023-12-13T09:19:48.837Z"}',
"1702459182837": '{"title":"qui est","content":"est rerum tempore vitae sequi sint","updateTime":"2023-12-13T09:19:48.837Z"}',
"1702459188837": '{"title":"ea molestias","content":"et iusto sed quo iure","updateTime":"2023-12-13T09:19:48.837Z"}'
}
export async function getAllNotes() {
const data = await redis.hgetall("notes");// 获取到一个 hash 表
if(Object.keys(data).length === 0) {
// hset :设置 hash 表的字段
// 对于不同的数据类型提供不同的方法
await redis.hset("notes", initialData);
}
return await redis.hgetall("notes");
}
几个细节:
ioredis是 Node 的 Redis 客户端(驱动),new Redis()默认无密码直连本机- 存储设计:key 是字符串 ID(时间戳),value 是 NOTE 序列化后的 JSON 字符串,整体挂在
"notes"这个 hash 表下 getAllNotes的逻辑:先hgetall拿全部,如果是空库就hset灌入初始数据,再hgetall返回
序列化 :把 JS 对象转成可存储/可传输的字符串(这里是
JSON.stringify),取回来再JSON.parse还原。
数据流全景
这节课笔记里有一条数据流线索,值得单独拎出来:
text
用户 -> / -> lib notes -> sidebar -> seo 良好的导航
用户访问 /,服务端在 lib 里调用数据函数拿到 notes,直接渲染进 sidebar,最终 HTML 里就带着完整的笔记列表导航------爬虫第一眼就能看到,SEO 良好。
笔记里还有一个带问号的延伸:/app/api/route.js?------route.js 也能做接口,用于 RPC 远程调用。但这个项目里数据是在 RSC 里直接 await lib 函数拿的,不需要自己另写一层 HTTP 接口。
RPC(Remote Procedure Call,远程过程调用):调用远端服务器的函数就像调用本地函数一样。写 HTTP 接口再 fetch 是一种朴素实现。
九、组件落地:RSC 到 CSR 的边界
规划做完了,开始写组件。这一节的主角是"服务端组件和客户端组件怎么拆"。
根页面 page.js
jsx
// RSC 组件 async 异步,为了 await 先去获取到后端数据
export default async function Page() {
return (
<div className="note--empty-state">
<span className="note-text--empty-state">暂无笔记</span>
</div>
);
}
注意最上面那行注释:RSC 组件可以直接标 async,为了 await 先去获取后端数据。这是服务器组件的专属能力------客户端组件可不能是 async 的。类名 note--empty-state 正好是 BEM 的 Modifier 用法(-- 修饰空状态)。
Sidebar.js:服务端拿数据
jsx
import React from "react";
import Link from "next/link";
import { getAllNotes } from "@/lib/redis";
import SidebarNoteList from "./SidebarNoteList";
export default async function Sidebar() {
const notes = await getAllNotes();
return (
<>
{/* sidebar
区块 电商网站,商品介绍,评论,购物车,订单,用户中心,售价
语义是独立的一块内容区域 幻灯片区域 */}
<section className="col sidebar">
<Link href="/" className="sidebar-header">
<img
className="logo"
src="/logo.svg"
width="22px"
height="20px"
role="presentation"
/>
<strong>LLM Notes</strong>
</Link>
<section className="sidebar-menu" role="menubar">
{/* SideSearchField 未来再完成 */}
</section>
<nav className="sidebar-nav">
{/* SiderbarNoteList 未来再完成 */}
<SidebarNoteList notes={notes} />
</nav>
</section>
</>
);
}
async 组件里直接 await getAllNotes(),数据在服务端就拿好了,往下传给 SidebarNoteList。侧边栏里的笔记列表,从此是服务端渲染出来的 HTML------这就是"lib notes -> sidebar -> seo 良好的导航"的代码实现。
SidebarNoteList.js:先有一个"不拆"的版本
我笔记里存了两个版本的 SidebarNoteList,对比着看才明白拆分的意义。
SidebarNoteList2.js,不拆子组件的直给版:
jsx
import dayjs from "dayjs";
export default async function SidebarNoteList({ notes }) {
const arr = Object.entries(notes);// hash 转成二维数组,方便使用 map 方法(数组才能使用 map 方法)
if(arr.length === 0) {
return <div className="notes-empty">
暂无笔记哦
</div>
}
return (
<ul className="notes-list">
{
arr.map(([noteId, note]) => {
const {title, updateTime} = JSON.parse(note);
return <li key={noteId}>
<header className="sidebar-note-header">
<strong>{title}</strong>
<small>{dayjs(updateTime).format("YYYY-MM-DD HH:mm:ss")}</small>
</header>
</li>
})
}
</ul>
);
}
能跑,能用。但整列表都是服务器组件,将来想给某个笔记项加交互(比如点击展开摘要),就没地方下手。
正式版 SidebarNoteList.js,拆出 SidebarNoteItem:
jsx
import SidebarNoteItem from "./SidebarNoteItem";
// SidebarNoteList(RSC SEO)-> 拆出来 SidebarNoteItem(小组件,可以进行交互 CSR)
export default async function SidebarNoteList({notes}) {
const arr = Object.entries(notes);// hash 转成二维数组,方便使用 map 方法(数组才能使用 map 方法)
if(arr.length === 0) {
return <div className="notes-empty">
暂无笔记哦
</div>
}
return (
<ul className="notes-list">
{
arr.map(([noteId,note]) => {
return (<li key={noteId}>
<SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
</li>)
})
}
</ul>
);
}
拆分的理由就写在第一行注释里:SidebarNoteList(RSC SEO)-> 拆出来 SidebarNoteItem(小组件,可以进行交互 CSR)。外层留在服务端保 SEO,内层拆成客户端组件保交互。
两个技术点:
Object.entries(notes)把 hash 转成二维数组,因为数组才能用map方法JSON.parse(note)把 Redis 里序列化的字符串还原成对象key={noteId}用稳定 ID 做 key,不用 index
SidebarNoteItem.js:插槽式传参
jsx
import dayjs from "dayjs";
import SidebarNoteItemContent from "./SidebarNoteItemContent";
export default function SidebarNoteItem({noteId,note}) {
const {title,content = '',updateTime} = note;
return (
<SidebarNoteItemContent
id={noteId}
title={note.title}
expandChildren={
<p className="sidebar-note-excerpt">
{content.substring(0, 20) || <i>(无内容)</i>}
</p>
}
content={content}
updateTime={updateTime}
>
<header className="sidebar-note-header">
<strong>{title}</strong>
<small>{dayjs(updateTime).format("YYYY-MM-DD HH:mm:ss")}</small>
</header>
</SidebarNoteItemContent>
)
}
这里用了 React 的组合(children)模式 :children 传标题头部,expandChildren 传展开后的摘要(content.substring(0, 20) 截前 20 个字符,空内容就显示 (无内容))。组件不关心内容长什么样,只负责把插槽摆到正确的位置。
dayjs :轻量时间处理库,用来把
2023-12-13T09:19:48.837Z这种 ISO 时间戳格式化成YYYY-MM-DD HH:mm:ss。这属于"业务服务"层的时间戳处理需求。
SidebarNoteItemContent.js:跨过边界
jsx
"use client";
import { useState, useEffect } from "react";
export default function SidebarNoteItemContent({id,title,children,expandChildren}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<>
{children}
</>
)
}
文件第一行 "use client",RSC 和 CSR 的边界就在这里。
此刻它只是个"壳":接了 expandChildren 参数、定义了 isExpanded 状态,但渲染只输出 {children}。展开/收起的交互逻辑还没接上------useEffect 引了但还没用。课堂进行时,见下一节。
拆分之后的好处已经能预见了:将来笔记项要展开收起、要高亮选中,改这个客户端小组件就行,外层的列表渲染和数据获取完全不用动。
十、课堂进行时:还没写完的部分
这节课是进行时,好几个地方停在"to be continue":
-
app/note/[id]/page.js目前只有两行:jsx// alias import { getAllNotes } from "@/lib/redis"; -
app/note/edit/[id]/page.js是空文件,等着实现编辑功能 -
SidebarNoteItemContent的展开交互没接完 -
搜索(SidebarSearchField)、新增(new 按钮 +
/addPOST)、删除、marked 渲染 markdown,都还在注释和需求清单里
但项目骨架已经完整立住了:路由树规划好了,组件树规划好了,数据服务通了,SEO 关键路径(layout meta + 服务端渲染的侧边栏导航)已经生效。
我现在的理解
这节课我没学什么新 API,最大的收获是一个顺序问题。
以前的我是打开编辑器就开写,写到哪算哪,组件命名随手来,最后文件互相 import 成蜘蛛网。这节课的操作顺序是:需求 → 路由树 → 组件树 → 目录职责 → 然后才是代码。每一层都是下一层的规范。
在 AI 辅助开发的语境下,这套顺序的价值被放大了。组件是工作单元,也是 AI 生成的工作单元------组件树画清楚,每个组件的职责写明白,AI 的产出才可控、可拼装。Next.js 16 甚至自己给 AI 生成了 AGENTS.md。规范不再只是给人看的,它成了人机协作的接口。
技术上的 takeaway 也记一笔:
- RSC 拆 CSR 的原则:数据在服务端拿(async + await lib),交互在客户端做("use client" 小组件)
- Redis 选型逻辑:内存快、key:value 够用、hash 类型正好存"ID → 序列化笔记";流量大了上 Redis + MySQL 组合
- alias、BEM、语义化标签、to be continue 注释法,都是小习惯,叠起来就是可维护性
下节课补完 CRUD 和搜索,再回头看这篇,应该能看到"规划先行"省下的返工。
术语速查
- marked:把 markdown 文本转换成 html 的 JS 库。数据库存 markdown 原文,展示时转 html。
- 脚手架(scaffold):生成项目骨架的工具,create-next-app、create-vite 都是。
- hydration(水合):服务端输出 HTML 后,React 在客户端注入事件与状态,使页面可交互的过程。
- RESTful:一切皆资源的 URL 设计风格,用 HTTP 方法(GET/POST/PUT/DELETE)表达对资源的操作。
- CRUD:Create / Read / Update / Delete,增删改查。
- RPC(远程过程调用):像调用本地函数一样调用远端逻辑,HTTP API 是其常见实现形式。
- NOSQL:非关系型数据库统称,Redis 是键值型代表。
- 哈希表(hash) :field → value 的二层存储结构,Redis 的
hget/hset操作它。 - 序列化 :对象 ↔ 字符串的互转(
JSON.stringify/JSON.parse),为了能存进 Redis。 - 原子类:一个类名只干一件事的样式方案,tailwindcss 是代表;与 BEM 的语义化路线相对。
- static server:静态资源服务,图片、svg 等文件不经逻辑处理直接返回。
- ioredis:Node.js 的 Redis 客户端驱动。
- dayjs:轻量日期格式化库。
- Object.entries :把对象的键值对转成
[key, value]二维数组,转完才能用map。