Next.js 笔记系统(二):Redis 数据服务与侧边栏组件拆分实战

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
  • redisioredis 库创建的客户端实例(「驱动」------代码和 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);
}

这是**「空则初始化」的种子数据逻辑**:

  1. Object.keys(data).length === 0 ------ Object.keys()data 的 key 收成数组,.length === 0 判断 hash 里一条笔记都没有 (空 hash 的 hgetall 返回 {})。
  2. redis.hset('notes', initialData) ------ 对应 HSET 命令,把 initialData 这些示例笔记写入 notes。作用是:第一次运行、数据库是空的,就把示例数据灌进去,否则页面空空如也没法演示。

最后一行 return await redis.hgetall('notes')不管有没有初始化,都把最新的全部笔记查出来返回

整个 getAllNotesasync 函数,页面组件才能 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 交互
  • SidebarNoteListRSC(服务端组件) ,负责拿数据、遍历渲染列表骨架。
  • 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>
    </>
  );
}

三个新要点:

  1. await getAllNotes()Sidebar 本身是 async 服务端组件,所以能直接在组件里 await 拉数据------和上一篇讲的 async Page 是同一个道理。
  2. <nav> 语义化标签 :列表放在 <nav> 里,表示这是导航区域(配合之前的 <section>,都是语义化标签,对 SEO 和可访问性友好)。
  3. {/* 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>
  )
}

逐行拆解:

  1. Object.entries(notes)noteshgetall 返回的对象 { id: 'json字符串' }Object.entries 把它转成二维数组 [["id1","json1"], ["id2","json2"], ...]。为什么?因为数组才能 map,对象不能直接遍历成组件------注释「方便 map 组件」就是这个意思。
  2. 空判断 arr.length == 0 :没笔记时返回一个「No Notes created yet!」的空状态提示,而不是渲染一个空 <ul>
  3. arr.map(([noteId, note]) => ...) :这里用了解构赋值[noteId, note] 直接拆出每一项的「id」和「值」。
  4. JSON.parse(note)note 现在是字符串 (因为存进 Redis 时序列化了),JSON.parse 把它还原成真正的 JS 对象,才能拿到 .title.content 等字段。
  5. 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 包管理器把它装进来。

其他要点:

  1. const { title, content='', updateTime } = note :解构时给 content 设了默认值空字符串 ,防止某条笔记没 content 字段时 substring 报错。
  2. content.substring(0, 20) :截取正文前 20 个字符做摘要|| <i>(No content)</i> 是「如果摘要为空,显示斜体提示」------这又是空值兜底
  3. 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}
    </>
  )
}

这一层是整个拆分的关键,虽然现在几乎是空的,但信息量很大:

  1. "use client" :这是客户端组件的声明边界。有了它,这个组件(及其子树)才会在浏览器端运行,才能有交互。它是「服务端组件」和「客户端组件」的分水岭。
  2. import { useState, useEffect } :虽然现在还没用到,但提前 import 了 ------这是在「占位」表明:这里未来要放交互逻辑(比如 expandChildren 的展开/收起状态)。
  3. children / expandChildren 两个 prop :这就是插槽(slot)机制SidebarNoteItemchildren 里塞了 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: 渲染出左侧笔记列表

十、总结

这一篇我们往下沉了一层,重点掌握四个「底层」认知:

  1. Redis 是内存 NOSQLkey:value 极简,hash 类型用 hget/hset/hgetall;经典场景是缓存,挡在 MySQL 前解决读写 I/O 瓶颈。
  2. hgetall 取整个 hash,配合「空则初始化」的种子数据逻辑,让数据层开箱即用。
  3. 服务端组件不能有交互 ,所以把「列表渲染(RSC)」和「单条笔记交互(use client)」拆开,这是 Next.js 组件拆分的核心思路。
  4. dayjs 轻量格式化时间Object.entries 转数组方便 mapJSON.parse 还原对象、children/expandChildren 插槽留口------这些是日常数据渲染的基本功。

对照 SidebarNoteList2(拆分前)和 SidebarNoteList(拆分后),你能最直观地感受到「为什么好的代码是拆出来的」。

相关推荐
Heo5 小时前
大厂前端调试不能只会debugger
前端·javascript·面试
用户938515635075 小时前
从 0 拆解一个 Next.js 笔记系统:npx、App Router、RSC 与组件规划全记录
前端·后端·全栈
GitLqr5 小时前
玩转 Flutter 中的 Stack 与 Positioned:解决 UI 重叠问题的实战指南
flutter·面试·全栈
windliang6 小时前
Claude Code 源码分析(十二):错误处理与自动恢复:让 Agent 稳定运行
前端·javascript·面试
默_笙6 小时前
🛬 前端路由的"高级玩法":懒加载、404、鉴权路由,一个都不能少(下篇)
前端·javascript
sunly_6 小时前
TypeScript:3、类型声明与类型推断
javascript·ubuntu·typescript
奥莱维7 小时前
【无标题】
java·前端·javascript
To_OC7 小时前
后端接口还没交付,前端如何独立把整套业务跑通
前端·react.js·全栈
黄金决明子8 小时前
浏览器Window底层操作全解
前端·javascript