如果你正在用 Next.js 做全栈项目,或者想搞懂 RSC、App Router 和 Redis 在真实业务里怎么配合,这篇文章就是为你准备的。我们会从零开始,一步步拆解一个支持 Markdown 的笔记系统,重点不是"能跑",而是"为什么这么设计"。
一、为什么又是 Next.js?
先说清楚一个工具:npx。
很多同学以为 npx 是安装命令,其实它是 npm 自带的"包执行器"。
说白了就是:你不需要全局安装 create-next-app,直接 npx create-next-app 就能跑起来,用一次下一次,省得污染全局环境。
而 create-next-app 这个脚手架,帮你搞定了 React 全栈开发要用的几乎一切:
- SSR(服务器端渲染) :首屏 HTML 直接给到浏览器,SEO 友好。
- RSC(React Server Component) :组件默认在服务端渲染,数据获取不用
useEffect,直接async组件里await数据库。 - App Router:文件即路由,目录结构就是 API 设计。
你会发现,Next.js 把"数据流"和"渲染流"统一了:
服务端拿数据 → 服务端渲染 → 客户端水合(hydration) ,整个过程非常丝滑。
技术选型不是追新,而是看它能不能让数据流更简单。
RSC 到底好在哪里?数据在转换过程中变成了什么?
很多同学对 RSC 的理解还停留在"服务端渲染",其实它比传统 SSR 更进了一步。
RSC 的核心优势可以总结为三点:
- 减少客户端 JS 体积 :服务端组件及其依赖不会打包到客户端,比如你在服务端组件里用了
ioredis、dayjs这些库,客户端完全不需要下载它们的代码。 - 直接访问后端资源:服务端组件可以安全地读写数据库、调用内部 API,不需要暴露接口给浏览器。
- 自动代码分割:Next.js 会根据路由和服务端/客户端边界自动拆包,首屏加载更快。
那数据在 RSC 的渲染过程中究竟变成了什么?
当你在服务端组件里 await 数据库拿到笔记数据后,这个数据并不会直接变成 HTML 字符串。
Next.js 会把它序列化成一种特殊的 RSC Payload (类似 JSON 的流式格式),这个 payload 包含了组件树的结构、props、以及客户端组件的引用信息。
浏览器收到这个 payload 后,React 会在客户端进行 hydration(水合) ,把静态的结构和客户端交互逻辑结合起来,最终生成可交互的 DOM。
简单理解就是:
数据库对象 → JS 对象 → RSC Payload → 客户端 React 元素 → 真实 DOM
这也是为什么服务端组件里可以直接 console.log(notes) 在终端看到数据,但页面上却能渲染出列表的原因。
二、需求拆解:两栏笔记系统
我们要做的是一个极简但完整的笔记应用:
- 左侧笔记列表,右侧笔记内容,两栏布局。
- 点击"New"新增笔记,左侧列表同步更新。
- 支持编辑、删除笔记。
- 笔记内容支持 Markdown 格式。
- 支持搜索功能。
看起来功能不多,但涉及路由设计、组件拆分、数据持久化、客户端交互等多个维度,非常适合作为 Next.js 全栈练手项目。
路由设计
Next.js App Router 的文件即路由,让 API 设计和页面路由天然对应:
| 功能 | 路由 | 类型 |
|---|---|---|
| 首页(空状态) | / |
页面 |
| 笔记详情 | /note/[id] |
页面 |
| 新增笔记 | /edit |
页面/交互 |
| 编辑笔记 | /edit/[id] |
页面/交互 |
| 删除/新增 API | /api/... |
接口(未来扩展) |
这里我们把"新增"和"编辑"都放在 /edit 下,用动态路由区分是否带 [id],逻辑更内聚。
组件规划
先规划组件,再写代码------AI 时代,组件就是你的工作单元。
我们根据 UI 结构把页面拆成这些组件:
Sidebar:侧边栏容器,负责拉取笔记列表。SidebarSearchField:搜索框(预留)。SidebarNoteList:笔记列表(服务端组件,SEO 友好)。SidebarNoteItem:单个笔记项(展示标题、时间、摘要)。SidebarNoteItemContent:笔记项的客户端交互部分(高亮、点击等)。NoteEditor:编辑区(客户端组件)。NotePreview:预览区(Markdown 渲染)。
这样的拆分让每个组件职责单一,后面加交互或改 UI 都不至于牵一发动全身。
三、项目骨架:目录与别名
先看目录结构:
bash
├── app/
│ ├── layout.js # 根布局
│ ├── page.js # 首页
│ ├── note/
│ │ └── [id]/
│ │ └── page.js # 笔记详情
│ └── edit/
│ ├── page.js # 新增笔记
│ └── [id]/
│ └── page.js # 编辑笔记
├── components/ # 通用组件
├── lib/ # 数据逻辑
├── public/ # 静态资源
└── jsconfig.json # 别名配置
在 app/note/[id]/page.js 里,如果我们要引入 lib/redis.js,相对路径会写成:
javascript
import { getAllNotes } from '../../../lib/redis';
三层 ../ 看起来就头疼。
我们在 jsconfig.json 里配置别名:
json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
之后就可以这样写:
javascript
import { getAllNotes } from '@/lib/redis';
短路径导入,少一点
../../../,多一点开发幸福感。
四、BEM 命名与布局
样式方面,我们采用 BEM 命名规范 + Tailwind 原子类 混合的思路。
BEM 的核心是:
- Block(块) :独立的功能单元,如
note。 - Element(元素) :块内部的组成部分,用
__连接,如note__title。 - Modifier(修饰器) :状态或变体,用
--连接,如note--empty。
虽然现在我们没有用 Tailwind,但 BEM 的结构化命名对维护非常友好。
根布局 app/layout.js 长这样:
javascript
import './style.css';
import Sidebar from '@/components/Sidebar';
export default async function RootLayout({ children }) {
return (
<html>
<head>
<title>LLM Notes</title>
<meta name="description" content="一位大模型工程师的笔记系统" />
<meta name="keywords" content="llm,deepseek,rag,langchain" />
</head>
<body>
<div className="container">
<div className="main">
<Sidebar />
<section className="col note-viewer">{children}</section>
</div>
</div>
</body>
</html>
);
}
这里用了语义化标签 <section>、<nav>,并在 <head> 里放 SEO 相关的 meta。
Next.js App Router 的 layout.js 天然支持这种结构,每个页面都会作为 children 渲染到右侧内容区。
layout.js 里的 SEO 友好写法
在 app/layout.js 中,<head> 标签里的内容对 SEO 至关重要。
你需要写清楚这几个关键点:
-
<title>:页面标题,要准确描述当前页面的核心内容。比如"LLM Notes - 大模型工程师的笔记系统",而不是笼统的"首页"。 -
<meta name="description">:页面描述,搜索引擎会展示在搜索结果里,写得好能直接提升点击率。建议包含 2-3 个核心关键词,长度控制在 150 字符左右。 -
<meta name="keywords">:虽然现在 Google 已不把它作为排名因子,但其他搜索引擎或内部系统可能还在用,写了也不亏。 -
如果想更规范,还可以加:
<link rel="canonical">:告诉搜索引擎哪个 URL 是规范版本,避免重复内容。- Open Graph 标签 :
og:title、og:description、og:image,分享到社交媒体时展示更友好。 - robots 标签:控制页面是否被索引,一般默认即可。
Next.js 也提供了更优雅的 Metadata API ,你可以在 layout.js 或 page.js 里导出一个 metadata 对象,Next.js 会自动生成对应的 <head> 标签。
但用原生 <head> 也完全可以,关键是内容要准确、有针对性,而不是复制粘贴模板。
五、数据层:为什么选 Redis?
很多人觉得 Redis 只是缓存,其实它做轻量级数据存储也很香。
我们这个笔记系统,数据结构很简单:
- 每篇笔记是一个 JSON 对象:
{ title, content, updateTime } - 需要一个 key 来标识每篇笔记
这不就是 Redis 的 哈希(Hash) 吗?
json
redis key: notes
field: 1702459181837
value: '{"title":"sunt aut","content":"...","updateTime":"..."}'
Redis 特点:
- 内存数据库:读写飞快,适合高频访问。
- 哈希结构 :
hgetall/hset直接操作字段,不用 SQL。 - 零配置:启动即用,不需要建表、建索引。
Redis 不是银弹,但它能让数据库少受点罪。
实际场景中,掘金首页文章列表几分钟不变,第一个用户请求 MySQL,后续用户直接读 Redis,数据库压力骤降。
Redis 哈希数据结构解析
Redis 的哈希(Hash)是一种键值对的集合,特别适合存储对象。
你可以把它理解成一个微型的"字典":外层有一个 Redis key(比如 notes),这个 key 对应的值不是简单的字符串,而是一组 field-value 对。
在这个笔记系统里,我们这样设计:
- 外层 key :
notes,代表"所有笔记"这个集合。 - field:笔记 ID(时间戳字符串),用来唯一标识一篇笔记。
- value :笔记内容的 JSON 字符串,比如
'{"title":"sunt aut","content":"...","updateTime":"..."}'。
这样,hset('notes', '1702459181837', jsonString) 就相当于给 notes 这个哈希表添加或更新了一个字段。
hgetall('notes') 会返回一个 JS 对象,形如:
json
{
"1702459181837": '{"title":"sunt aut","content":"...","updateTime":"..."}',
"1702459182837": '{"title":"qui est","content":"...","updateTime":"..."}'
}
拿到这个对象后,我们用 Object.entries() 把它转成二维数组,方便用 map 遍历渲染列表。
哈希相比字符串类型的优势在于:你可以单独读取、更新或删除某个字段,而不需要把整个对象取出来再序列化。这对于笔记列表这种"整体读取、单独更新"的场景非常合适。
我们在 lib/redis.js 里封装数据操作:
rust
import Redis from 'ioredis';
const redis = new Redis();
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');
if (Object.keys(data).length === 0) {
await redis.hset('notes', initialData);
}
return await redis.hgetall('notes');
}
这里 hset 可以一次设置多个字段,hgetall 返回一个对象,key 就是笔记 ID,value 是 JSON 字符串。
所有数据逻辑都放在 lib/ 目录下,组件里只负责调用,干净利落。
六、核心组件实现
1. Sidebar:服务端组件直接拿数据
components/Sidebar.js:
javascript
import Link from 'next/link';
import { getAllNotes } from '@/lib/redis';
import SidebarNoteList from './SidebarNoteList';
export default async function Sidebar() {
const notes = await getAllNotes();
return (
<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>
<SidebarNoteList notes={notes} />
</nav>
</section>
);
}
注意 Sidebar 是 async 组件 ,它可以直接 await getAllNotes()。
这就是 RSC 的魅力:数据获取和组件渲染在服务端一气呵成,不需要客户端 API 请求。
2. SidebarNoteList:把哈希转成列表
components/SidebarNoteList.js:
javascript
import SidebarNoteItem from './SidebarNoteItem';
export default async function SidebarNoteList({ notes }) {
const arr = Object.entries(notes); // 哈希转二维数组
if (arr.length === 0) {
return <div className="notes-empty">No Notes created yet!</div>;
}
return (
<ul className="note-list">
{arr.map(([noteId, note]) => (
<li key={noteId}>
<SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
</li>
))}
</ul>
);
}
Redis 返回的 notes 是对象,Object.entries 转成数组方便 map。
为什么不能直接写一个 SidebarNoteList,还要拆出 SidebarNoteItem?
很多同学可能会想:列表渲染就这么点逻辑,直接在一个组件里 map 出来不就行了?
拆分的原因主要有三点:
- 职责单一 :
SidebarNoteList只负责"获取数据 + 遍历渲染",而单个笔记项如何展示(标题、时间、摘要、交互状态)是SidebarNoteItem的职责。如果以后要修改笔记项的样式或加点击效果,不需要动列表容器的逻辑。 - 服务端/客户端边界 :
SidebarNoteList是服务端组件,里面包含了数据获取和转换。但如果把笔记项的交互逻辑(比如高亮选中、展开收起)也写在同一个组件里,就必须把整个组件标记为"use client",这样整个列表都会变成客户端组件,失去服务端渲染的优势。拆出SidebarNoteItemContent作为客户端组件后,只有这一小块需要水合,其余部分仍然在服务端静态输出。 - 可复用性和可测试性:单个笔记项可能在别的场景复用(比如搜索结果列表),拆分后可以单独测试每个小组件,而不是面对一个几百行的庞然大物。
3. SidebarNoteItem:展示笔记摘要
components/SidebarNoteItem.js:
javascript
import dayjs from 'dayjs';
import SidebarNoteItemContent from './SidebarNoteItemContent';
export default function SidebarNoteItem({ noteId, note }) {
const { title, content = '', updateTime } = note;
return (
<SidebarNoteItemContent
id={noteId}
title={title}
expandChildren={
<p className="sidebar-note-excerpt">
{content.substring(0, 20) || <i>(No content)</i>}
</p>
}
>
<header className="sidebar-note-header">
<strong>{title}</strong>
<small>{dayjs(updateTime).format('YYYY-MM-DD')}</small>
</header>
</SidebarNoteItemContent>
);
}
这里我们用 dayjs 格式化时间,并截取内容前 20 个字符作为摘要。
4. SidebarNoteItemContent:客户端交互的入口
components/SidebarNoteItemContent.js:
javascript
"use client";
import { useState, useEffect } from 'react';
export default function SidebarNoteItemContent({
id,
title,
children,
expandChildren
}) {
return (
<>
{children}
</>
);
}
虽然目前它只渲染 children,但它是整个列表项中唯一一个标记 "use client" 的组件。
未来要做高亮选中、展开/收起、点击跳转等交互,都可以在这里加状态,而不影响父级的服务端渲染。
服务端组件负责拿数据,客户端组件负责搞交互,这就是 Next.js App Router 的最佳实践。
七、Markdown 预览与编辑(思路)
笔记内容在数据库里存的是 Markdown 字符串,展示时需要转成 HTML。
常用方案是 marked + DOMPurify,或者直接用 react-markdown 组件。
- 服务端:可以用
marked把 Markdown 转成 HTML 字符串,再通过dangerouslySetInnerHTML渲染(注意 XSS 过滤)。 - 客户端:直接用
react-markdown在NotePreview组件里渲染更安全,也支持自定义样式。
编辑区 NoteEditor 自然是客户端组件,用 useState 控制输入,提交时调用 API 或 Server Action 保存到 Redis。
八、总结与可优化项
这个笔记系统麻雀虽小,但五脏俱全:
- RSC + App Router:服务端组件拿数据,客户端组件做交互。
- Redis 哈希存储:轻量、快速、零配置。
- BEM 命名 + 组件拆分:代码可维护性拉满。
- 别名配置:告别相对路径地狱。
当然,还有很多可以优化的地方:
- 搜索功能:目前是预留,可以在
SidebarSearchField里做客户端过滤,或服务端搜索接口。 - 持久化:Redis 数据在内存里,重启会丢,可以配合 MySQL 做持久化。
- API Route:新增/编辑/删除都可以用 Next.js 的 Route Handlers(
app/api/)实现 RPC 风格接口。 - 部署:Vercel 一键部署,Redis 可以用 Upstash 免费额度。
好的架构不是一开始就完美,而是每一步都清晰可演进。
希望这篇文章能帮你把 Next.js 和 Redis 的配合摸清楚。
如果你也在做类似的全栈小项目,不妨动手试试,踩坑的过程才是真正的成长。