上几节课我们把 Next.js 的概念(App Router、RSC、'use client'、水合)和基础设施(Docker、MySQL、Nginx)都过了一遍,这节课终于来真的------做一个完整的笔记系统,一个笔记博客,名字就叫 LLM Notes。界面分两列:左边是笔记列表,右边是笔记内容;数据存在 Redis 里(key:value 的内存数据库);组件按职责分层:RSC 负责拉数据 + SEO,'use client' 负责交互。除了写代码,这节课还教了一堆"工程化心法":规范驱动编程(写代码前先规划组件)、注释大法(把未来要做的事写进注释)、@ alias 别名(告别一长串 ../../../)。 a
一、开工之前:规范驱动编程
动手写代码之前,老师先讲了个方法论------规范驱动编程。readme 原话:
"开发之前不要急着写代码。分析需求,技术方案(next.js)任务细节 路由 + 组件"
"组件时工作单元,AI 生成的工作单元"
在 AI 时代,组件就是"工作单元"------每个组件都是一个可以被 AI 独立生成的模块。所以写代码前要先规划好需要哪些组件。老师规划的组件树长这样:
text
Sidebar
├── SidebarSearchField # 搜索框(未来干)
├── EditButton # 编辑按钮(复用)
└── SidebarNoteList
└── NoteItem # 单条笔记
Node
├── NoteEditor # 编辑区
└── NotePreview # 笔记预览
配套的目录结构:
text
app # 页面主目录:page.js、layout.js、[id] 动态路由
components # 组件
lib # 数据库操作、常用函数
public # 静态资源 static server
先规划,再动手------这就是"规范驱动编程",也是 AI 协作开发的正确姿势。
二、npx 与 create-next-app:脚手架的打开方式
项目怎么来的?readme 补了两个基础概念:
"npx 是 npm 自带工具,可直接运行 node 包,无需全局安装依赖。" "npx = npm i -g create-next-app + create-next-app"
npx create-next-app 一行命令,等于"全局安装 create-next-app + 运行它",用完即走,不污染全局。它是 React 全栈开发的脚手架,自带 SSR(服务器端渲染)、SEO、RSC(React Server Component)、'use client' 水合这些全套能力------前面几节课学的概念,全被这个脚手架包圆了。
三、全局观:布局与 SEO(layout.js)
先看根布局。注意这个函数是 async 的------服务器组件,可以在服务器上等数据:
js
import "./style.css";
import Sidebar from "@/components/Sidebar";
export default async function RootLayout({children}) {
return (
<html>
<head>
<title>qxh の Next Blog</title>
<meta name="description" content="这是一位未来大模型工程师的笔记,多年心血,深入讨论llm学习心得体会" />
<meta name="keywords"
content="llm, claude, deepseek, rag, langchain" />
</head>
<body>
<div className="container">
<div className="main">
<Sidebar />
<section className="col note-viewer">{children}</section>
</div>
</div>
</body>
</html>
)
}
这就是 SEO 三件套的"实体版":title(博客名)、description("未来大模型工程师的笔记,多年心血,深入讨论 llm 学习心得体会")、keywords(llm、claude、deepseek、rag、langchain)------一眼看出这是个 AI 学习博客,这关键词选得,爬虫看了都得点个收藏。
布局结构:左边 <Sidebar />(笔记列表),右边 <section className="note-viewer">{children}</section>(笔记内容),正是两列布局的骨架。类名 note-viewer 这种写法是有讲究的------BEM 命名规范:
"Block 块,Element 元素 _,Modifier 修改器 __"
sidebar-note-header 就是 Block(sidebar-note)+ Element(header)。规范驱动,连命名都规范。
四、数据服务:为什么选 Redis?
笔记数据存哪?老师选了 Redis------key:value 的 NOSQL 内存数据库。readme 讲得很通俗:
"6379 端口 没有数据表,不是关系型,不用SQL 驱动,在内存中。有点像localStorage 直接key:value 开搞。" "高级的地方是 对不同类型的数据 有优化的存储方式 不同的方法:字符串 直接get/set,哈希 hget/hset。缓存、计数器、榜单。"
Redis 最经典的应用就是给 MySQL 当缓存层,readme 里的例子是掘金:
"掘金首页,文章列表 几分钟之内,不变的。第一个用户来的时候 查mysql 数据库 posts 列表 key:value 存到 redis中。下一个用户来,从redis 中读取。"
第一个用户来了查数据库、写进 Redis;后面所有用户直接读内存------这就是"缓存"的本质,也是解决数据库读写 I/O 瓶颈的常用套路。
数据访问代码在 lib/redis.js:
js
// node redis 客户端,驱动
import Redis from 'ioredis';
const redis = new Redis();// 默认 NOSQL
// hash key 字符串ID,值 note 的序列化字符串
// redis key:value value 特别支持hash 类型
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() {
// hash 数据类型
const data = await redis.hgetall('notes');
if(Object.keys(data).length == 0) {
await redis.hset("notes", initialData);
}
return await redis.hgetall('notes');
}
几个要点:
| 点 | 说明 |
|---|---|
ioredis |
Node 的 redis 客户端驱动 |
hgetall('notes') |
一次性取出 hash 类型的所有字段 |
hset("notes", initialData) |
第一次访问没有数据时,写入初始数据 |
| 序列化字符串 | hash 的 value 是笔记 JSON 序列化后的字符串 |
下次启动项目,Redis 里没有笔记?hset 一发入魂,初始数据直接灌进去。 这就是"懒加载初始化"。
五、RSC 组件流水线:数据一路流到界面
数据拿到之后,怎么流到界面上?这条链路是这节课的精华,一共四层,层层分工:
text
Sidebar(RSC,async,await 数据)
→ SidebarNoteList(RSC,遍历列表)
→ SidebarNoteItem(RSC,单条笔记)
→ SidebarNoteItemContent('use client',交互)
第一层 Sidebar------async 服务器组件,直接 await 从 Redis 拿数据:
js
export default async function Sidebar() {
const notes = await getAllNotes();
console.log(notes);
return (
<>
<section className="col sidebar">
<Link href="/" className="sidebar-header">
<img className="logo" src="/logo.svg" ... />
<strong>LLM Notes</strong>
</Link>
<section className="side-menu" role="manubar">
{/* SideSearchField 未来干 */}
</section>
<nav>
{/* SidebarNoteList */}
<SidebarNoteList notes={notes} />
</nav>
</section>
</>
)
}
注意那两个注释------"SideSearchField 未来干"、"SidebarNoteList"。这就是"注释大法":
"to be continue 注释大法:规划未来做的,有利于团队协作,记忆,维护,注释好要做的事情。"
还没做的功能,先写进注释占个位,代码里永远有"TODO 地图"。
第二层 SidebarNoteList------RSC,把 hash 对象转成数组再遍历:
js
import SidebarNoteItem from "@/components/SidebarNoteItem";
// SidebarNoteList(RSC SEO) -> 拆出来 SidebarNoteItem (交互 CSR)
export default async function SidebarNoteList({ notes }) {
const arr = Object.entries(notes);// hash 转成二维数组 方便map 组件
if (arr.length == 0) {
return <div className="motes-empty">
No Notes created yet!
</div>
}
return (
<ul className="notes-list">
{
arr.map(([noteId, note]) => {
return (
<li key={noteId}>
<SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
</li>
)
})
}
</ul>
)
}
Object.entries(notes) 把 Redis 的 hash 对象转成 [[id, note], ...] 二维数组,才好 map 渲染。代码注释里还有老师的思路:"SidebarNoteList(RSC SEO) -> 拆出来 SidebarNoteItem (交互 CSR)"------列表保持 RSC 利于 SEO,把可能有交互的部分拆到下一层去。
第三层 SidebarNoteItem------RSC,单条笔记的标题 + 时间 + 摘要:
js
import dayjs from 'dayjs';
import SidebarNoteItemContent from
'@/components/SidebarNoteItemContent';
export default async 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>(No excerpt)</i>}
</p>
}
>
<header className="sidebar-note-header">
<strong>{title}</strong>
<small>{dayjs(updateTime).format('YYYY-MM-DD')}</small>
</header>
</SidebarNoteItemContent>
)
}
摘要取内容前 20 个字(content.substring(0, 20)),没有内容就显示 (No excerpt);时间用 dayjs 格式化成 YYYY-MM-DD。把纯展示的部分留在 RSC,把"可能会交互"的部分包给下一层。
第四层 SidebarNoteItemContent------'use client',交互的"前线":
js
"use client";
import { useState, useEffect } from "react";
export default function SidebarNoteItemContent({
id,
title,
children,
expandChildren
}) {
return (
<>
{children}
</>
)
}
目前只渲染 children,但已经提前声明了 expandChildren(展开后的内容)------未来做"点击展开摘要"交互时,直接在这里用 useState 控制显隐。这就是"先把壳造好,交互后面填"。
六、@ alias:告别一长串 ../../../
层级深了,相对路径就会变成灾难。readme 举了个例子:
"/app/notes/id/page.js 引入 lib/redis.js,相对路径 ../../../lib/redis.js,短连接 @/lib/redis.js"
老师还单独写了个 page.js 演示 alias 的用法:
js
// alias
import { getAllNotes } from "@/lib/redis.js";
配置也写在了 readme 里:
"baseURL . path "@/components/": "components/* ", "@/lib/": "lib/*"。@ 直接来到根目录"
@ 直接指向项目根目录,@/lib/redis.js 就是根目录下的 lib/redis.js。再也不用数 ../../../ 有多少个点了。
七、RSC 的 async:服务器上等数据
首页 page.js 的注释是老师的原话,把 RSC 的精髓讲透了:
js
// RSC 组件 async 异步 为了 await先去 后端数据
export default async function Page() {
return (
<div className="note--empty-state">
<span className="note-text--empty-state">
Click a note on the left to view something.
</span>
</div>
)
}
"RSC 组件 async 异步,为了 await 先去后端数据" ------服务器组件声明成 async,直接 await 拿数据,数据到位了再渲染 HTML 发给浏览器。这就是为什么 RSC 天生适合 SSR + SEO。
八、总结:LLM Notes 的技术地图
| 技术 | 在这节课的角色 |
|---|---|
| npx / create-next-app | 脚手架,自带 SSR / SEO / RSC / 水合 |
| 规范驱动编程 | 先规划组件树,再写代码 |
| layout.js | async 根布局 + SEO 三件套(title/description/keywords) |
| BEM | 命名规范:Block _ Element __ Modifier |
| Redis | key:value 内存数据库,hash 存笔记,给 MySQL 当缓存层 |
| ioredis | Node 的 redis 驱动,hgetall / hset |
| RSC 流水线 | Sidebar → SidebarNoteList → SidebarNoteItem → SidebarNoteItemContent |
| 'use client' | 最内层交互组件,未来做展开交互 |
| @ alias | @/lib/redis.js 代替 ../../../lib/redis.js |
| 注释大法 | "SideSearchField 未来干"------把未来写进注释 |
这节课是前面所有 Next.js 知识的"总装车间":RSC 拉数据、'use client' 做交互、Redis 当仓库、SEO 进 head,一个真实的笔记博客就立起来了。
写在最后
这节课最大的收获,是看到了一个"正经项目"是怎么一步步长出来的:先规范驱动规划组件树,再 npx 起脚手架,layout 布局 + SEO,Redis 接数据,RSC 四层流水线把数据一路送到界面,'use client' 在最内层等着做交互,连命名(BEM)和路径(@ alias)都有讲究。最妙的是"注释大法"------代码里写着"SideSearchField 未来干",就像游戏里的主线任务提示,下次打开项目就知道下一步该干嘛。笔记系统还没写完(动态路由 id、编辑、删除、搜索都在 TODO 里),学完继续更!
下次面试官问你:"Next.js 里 RSC 和 'use client' 怎么分工?Redis 为什么适合做缓存层?"
你可以淡定地说:
"在 Next.js 的笔记系统里,组件按职责分层:Sidebar、SidebarNoteList、SidebarNoteItem 都是 RSC(React Server Component),async 函数直接在服务器上 await 从 Redis 拉数据,输出完整 HTML,利于 SEO;最内层的 SidebarNoteItemContent 用 'use client' 声明,负责展开收起这类交互------这就是'能服务器渲染的尽量服务器渲染,非交互不可的才到客户端'的分工。数据层用 ioredis 连接 Redis,笔记存成 hash 类型,hgetall 一次性取出。Redis 是 key:value 的内存数据库,6379 端口,没有表结构,读写极快,适合做 MySQL 前面的缓存层------比如掘金首页文章列表几分钟内不变,第一个用户来了查 MySQL 存进 Redis,后面的用户直接从 Redis 读,避免每次都打数据库。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。