Next.js 笔记系统(二):Redis 数据服务与侧边栏组件拆分实战
上一篇我们把「路由 + 布局」的骨架搭好了。这一篇往下走一层:数据从哪来(Redis),数据怎么流到页面上(组件拆分) 。过程中回答了今晚的三个疑问。
一、数据服务:为什么选 Redis?
在动手写 lib/redis.js 之前,先回答一个更底层的问题------为什么用一个「内存数据库」而不是 MySQL?
1. Redis 是什么
Redis 是一种 NOSQL 内存数据库:
- 数据存在内存里,读写极快;
- 默认跑在 6379 端口;
- 没有数据表、不是关系型、不用写 SQL;
- 结构极简:key: value 直接开搞。
笔记里有个特别贴切的比喻:它有点像浏览器的 localStorage------给个 key 存进去,用 key 取出来,没有任何「建表、建关系」的仪式感。
2. 「高级」在哪:对不同类型有不同的优化方法
Redis 不是只有最简单的字符串,它针对不同数据类型提供了不同的存储方式和命令:
| 数据类型 | 用途 | 命令 |
|---|---|---|
| 字符串 String | 存一个值 | get / set |
| 哈希 Hash | 一个 key 下存多组 field→value | hget / hset / hgetall |
经典应用场景是:缓存、计数器、榜单。
3. Redis + MySQL:解决读写 I/O 瓶颈
单用 MySQL 时,磁盘读写(I/O)是瓶颈。Redis 的典型用法是挡在 MySQL 前面当缓存,笔记里用「掘金首页」举了个特别好的例子:
vbnet
文章列表几分钟内基本不变
↓
第一个用户来 → 查 MySQL → 结果存进 Redis(key:value)
↓
后面的用户 → 直接从 Redis 读,不再打 MySQL
因为首页文章列表变化不频繁,所以缓存命中率极高,大部分请求根本不用碰磁盘,性能提升几个数量级。这就是 缓存 的价值。
4. 数据逻辑放哪:lib 目录
Next.js 的约定是:数据业务逻辑统一放在 lib 目录 。所以数据访问函数 getAllNotes 就写在 lib/redis.js 里,形成一条清晰的链路:
vbnet
/(路由) → lib(取数据 notes)→ sidebar(展示)→ 良好 SEO 导航
二、redis.js:数据层代码逐行解析
rust
// node redis 客户端, 驱动
import Redis from 'ioredis'
const redis = new Redis();
// hash key 字符串ID, 值 note 的序列化字符串
// redis key:value value 特别支持 hash 类型
const initialData = {
"1702459181837": '{"title":"sunt aut","content":"quia et...","updateTime":"..."}',
"1702459182837": '{"title":"qui est","content":"est rerum...","updateTime":"..."}',
"1702459188837": '{"title":"ea molestias","content":"et iusto...","updateTime":"..."}'
}
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');
}
问题①:const data = await redis.hgetall('notes') 在取什么?
先看数据结构设计:这里用了一个 hash ,key 是 notes,里面每条笔记是「字符串 ID → 序列化 JSON 字符串」:
css
graph LR
subgraph hash["key: notes (一个 hash)"]
A["field: 1702459181837<br/>value: {title:sunt aut,...}"]
B["field: 1702459182837<br/>value: {title:qui est,...}"]
C["field: 1702459188837<br/>value: {title:ea molestias,...}"]
end
redis:ioredis库创建的客户端实例(「驱动」------代码和 Redis 之间的桥梁)。hgetall:对应 Redis 的 HGETALL 命令,一次性取出 hash 里的所有 field 和 value,返回一个对象。await:因为这是网络 I/O,要等 Redis 返回结果再往下走。
所以这一行的意思就是:异步地取出 notes 这个 hash 里的全部笔记,存进 data。
问题②:为什么开头要先判断空、再 hset?
ini
if(Object.keys(data).length === 0){
await redis.hset('notes', initialData);
}
这是**「空则初始化」的种子数据逻辑**:
Object.keys(data).length === 0------Object.keys()把data的 key 收成数组,.length === 0判断 hash 里一条笔记都没有 (空 hash 的hgetall返回{})。redis.hset('notes', initialData)------ 对应 HSET 命令,把initialData这些示例笔记写入notes。作用是:第一次运行、数据库是空的,就把示例数据灌进去,否则页面空空如也没法演示。
最后一行 return await redis.hgetall('notes'):不管有没有初始化,都把最新的全部笔记查出来返回。
整个
getAllNotes是async函数,页面组件才能await getAllNotes()直接拿到数据------这正是上一篇「服务端组件 async」的落地。
三、侧边栏组件树:为什么要拆成四层?
拿到数据后,怎么展示到左侧列表?这里体现了「规范驱动编程」------先规划组件,再动手写。规划出的组件树:
css
graph TD
Sidebar["Sidebar(RSC,await 取数据)"] --> SNL["SidebarNoteList(RSC,遍历列表)"]
SNL --> SNI["SidebarNoteItem(每条笔记)"]
SNI --> SNIC["SidebarNoteItemContent(use client,交互)"]
核心设计思想(这是今晚最值钱的一个点):
代码注释写得很直白:
arduino
// SidebarNoteList (RSC SED) -> 拆出来 SidebarNoteItem 交互
SidebarNoteList是 RSC(服务端组件) ,负责拿数据、遍历渲染列表骨架。SidebarNoteItemContent是"use client"客户端组件 ,负责单条笔记的交互 (比如未来点开展开expandChildren)。
为什么要这样拆? 因为服务端组件(RSC)不能有交互 ------不能 useState、不能 useEffect、不能绑事件。如果未来每条笔记要「点击展开」这种交互,就必须有客户端组件。所以把「能放服务端的(列表结构)」和「必须放客户端的(交互)」切开,服务端渲染骨架、客户端承载交互,各取所长。
SidebarNoteList2.js 就是「拆分之前」的原始版本,后面用它做对比。
四、Sidebar.js:取数据 + 传给列表
javascript
import { getAllNotes } from '@/lib/redis';
import SidebarNoteList from './SidebarNoteList';
export default async function Sidebar() {
const notes = await getAllNotes(); // 1. 服务端直接拿数据
console.log(notes); // 2. 调试打印
return (
<>
<section className="col sidebar">
<Link href="/" className="sidebar-header"> ... </Link>
<section className="sidebar-menu" role="menubar">
{/* SidebarSearchField 未来干 */}
</section>
<nav>
{/* SidebarNoteList */}
<SidebarNoteList notes={notes} /> {/* 3. 把数据交给列表组件 */}
</nav>
</section>
</>
);
}
三个新要点:
await getAllNotes():Sidebar本身是async服务端组件,所以能直接在组件里await拉数据------和上一篇讲的async Page是同一个道理。<nav>语义化标签 :列表放在<nav>里,表示这是导航区域(配合之前的<section>,都是语义化标签,对 SEO 和可访问性友好)。{/* SidebarSearchField 未来干 */}:这就是笔记里的「to be continue 注释大法」------把未来要做的功能用注释占位,利于团队协作、记忆和维护。先写好要做什么,之后慢慢补。
五、SidebarNoteList.js:把 hash 转成列表
javascript
import SidebarNoteItem from '@/components/SidebarNoteItem';
export default async function SidebarNoteList({ notes }) {
const arr = Object.entries(notes); // hash 转成二维数组 方便 map 组件
if (arr.length == 0) {
return <div className="notes-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):notes是hgetall返回的对象{ id: 'json字符串' },Object.entries把它转成二维数组[["id1","json1"], ["id2","json2"], ...]。为什么?因为数组才能map,对象不能直接遍历成组件------注释「方便 map 组件」就是这个意思。- 空判断
arr.length == 0:没笔记时返回一个「No Notes created yet!」的空状态提示,而不是渲染一个空<ul>。 arr.map(([noteId, note]) => ...):这里用了解构赋值 ,[noteId, note]直接拆出每一项的「id」和「值」。JSON.parse(note):note现在是字符串 (因为存进 Redis 时序列化了),JSON.parse把它还原成真正的 JS 对象,才能拿到.title、.content等字段。key={noteId}:React 列表必须给key,用唯一 id,帮助 React 高效更新列表。
六、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={note.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>
)
}
问题③:pnpm i dayjs 是干什么的?
这里就用到了。dayjs 是一个约 2KB 的轻量日期库 ,用来格式化时间。笔记里存的 updateTime 是长串 ISO 格式("2023-12-13T09:19:48.837Z"),直接展示很难看,用:
lua
dayjs(updateTime).format('YYYY-MM-DD')
// "2023-12-13T09:19:48.837Z" → "2023-12-13"
就能变成可读的日期。pnpm i dayjs 就是用 pnpm 包管理器把它装进来。
其他要点:
const { title, content='', updateTime } = note:解构时给content设了默认值空字符串 ,防止某条笔记没 content 字段时substring报错。content.substring(0, 20):截取正文前 20 个字符做摘要 。|| <i>(No content)</i>是「如果摘要为空,显示斜体提示」------这又是空值兜底。expandChildren这个 prop :注意它传的是一个 JSX 片段(摘要<p>) ,而不是字符串。这是「预留的展开内容」------配合下一节的客户端组件,未来点击展开时显示的就是这段摘要。这正是「to be continue 注释大法」在代码结构上的体现:先把插槽留好。
七、SidebarNoteItemContent.js:客户端组件的边界
javascript
"use client";
import { useState, useEffect } from 'react';
export default function SidebarNoteItemContent({
id,
title,
children,
expandChildren
}) {
return (
<>
{children}
</>
)
}
这一层是整个拆分的关键,虽然现在几乎是空的,但信息量很大:
"use client":这是客户端组件的声明边界。有了它,这个组件(及其子树)才会在浏览器端运行,才能有交互。它是「服务端组件」和「客户端组件」的分水岭。import { useState, useEffect }:虽然现在还没用到,但提前 import 了 ------这是在「占位」表明:这里未来要放交互逻辑(比如expandChildren的展开/收起状态)。children/expandChildren两个 prop :这就是插槽(slot)机制 。SidebarNoteItem往children里塞了header,往expandChildren里塞了摘要------这个组件是「内容容器」,负责未来决定「默认显示 children、展开时再显示 expandChildren」。
一句话理解拆分 :SidebarNoteItemContent 是给「交互」预留的客户端边界,SidebarNoteItem 是服务端负责准备数据,两者通过 children 插槽组合。
八、SidebarNoteList2.js:拆分前的样子(对比)
javascript
import dayjs from 'dayjs';
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="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>
)
}
对比两个版本,差异一目了然:
| SidebarNoteList2(拆分前) | SidebarNoteList(拆分后) | |
|---|---|---|
| 职责 | 拿数据 + 遍历 + 格式化 + 渲染,全塞一个组件 | 只负责拿数据 + 遍历,渲染细节交给子组件 |
| 单条笔记 | 直接 <li> 里内联 header |
抽成 SidebarNoteItem 复用 |
| 交互 | 无(纯服务端) | 通过 SidebarNoteItemContent 预留客户端边界 |
| 时间格式 | 精确到秒 YYYY-MM-DD HH:mm:ss |
只到天 YYYY-MM-DD(拆分后更简洁) |
为什么要拆? 还是那句话:单一职责 + 可复用 + 可交互 。拆开后,SidebarNoteItem 可以被别处复用,交互逻辑集中在客户端组件里,SidebarNoteList 保持纯粹的服务端渲染。
九、完整数据流串联
把今晚写的所有代码串起来,是一条清晰的「取数 → 遍历 → 渲染」链路:
rust
sequenceDiagram
participant P as 页面(RSC)
participant S as Sidebar(async)
participant R as lib/redis.js
participant RD as Redis
P->>S: 渲染 Sidebar
S->>R: await getAllNotes()
R->>RD: hgetall('notes')
RD-->>R: 返回 hash 对象
R-->>S: notes(若空则先 hset 初始化)
S->>S: <SidebarNoteList notes={notes} />
S->>S: Object.entries 转数组 → map
S->>S: 每条 JSON.parse → SidebarNoteItem
S->>S: dayjs 格式化 → SidebarNoteItemContent("use client")
S-->>P: 渲染出左侧笔记列表
十、总结
这一篇我们往下沉了一层,重点掌握四个「底层」认知:
- Redis 是内存 NOSQL ,
key:value极简,hash 类型用hget/hset/hgetall;经典场景是缓存,挡在 MySQL 前解决读写 I/O 瓶颈。 hgetall取整个 hash,配合「空则初始化」的种子数据逻辑,让数据层开箱即用。- 服务端组件不能有交互 ,所以把「列表渲染(RSC)」和「单条笔记交互(
use client)」拆开,这是 Next.js 组件拆分的核心思路。 dayjs轻量格式化时间 、Object.entries转数组方便map、JSON.parse还原对象、children/expandChildren插槽留口------这些是日常数据渲染的基本功。
对照 SidebarNoteList2(拆分前)和 SidebarNoteList(拆分后),你能最直观地感受到「为什么好的代码是拆出来的」。