用 Next.js 和 Redis 撸一个 Markdown 笔记系统:RSC 实战与组件化拆解

如果你正在用 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 的核心优势可以总结为三点:

  1. 减少客户端 JS 体积 :服务端组件及其依赖不会打包到客户端,比如你在服务端组件里用了 ioredisdayjs 这些库,客户端完全不需要下载它们的代码。
  2. 直接访问后端资源:服务端组件可以安全地读写数据库、调用内部 API,不需要暴露接口给浏览器。
  3. 自动代码分割:Next.js 会根据路由和服务端/客户端边界自动拆包,首屏加载更快。

那数据在 RSC 的渲染过程中究竟变成了什么?

当你在服务端组件里 await 数据库拿到笔记数据后,这个数据并不会直接变成 HTML 字符串。

Next.js 会把它序列化成一种特殊的 RSC Payload (类似 JSON 的流式格式),这个 payload 包含了组件树的结构、props、以及客户端组件的引用信息。

浏览器收到这个 payload 后,React 会在客户端进行 hydration(水合) ,把静态的结构和客户端交互逻辑结合起来,最终生成可交互的 DOM。

简单理解就是:

数据库对象 → JS 对象 → RSC Payload → 客户端 React 元素 → 真实 DOM

这也是为什么服务端组件里可以直接 console.log(notes) 在终端看到数据,但页面上却能渲染出列表的原因。


二、需求拆解:两栏笔记系统

我们要做的是一个极简但完整的笔记应用:

  1. 左侧笔记列表,右侧笔记内容,两栏布局。
  2. 点击"New"新增笔记,左侧列表同步更新。
  3. 支持编辑、删除笔记。
  4. 笔记内容支持 Markdown 格式。
  5. 支持搜索功能。

看起来功能不多,但涉及路由设计、组件拆分、数据持久化、客户端交互等多个维度,非常适合作为 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:titleog:descriptionog:image,分享到社交媒体时展示更友好。
    • robots 标签:控制页面是否被索引,一般默认即可。

Next.js 也提供了更优雅的 Metadata API ,你可以在 layout.jspage.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 对。

在这个笔记系统里,我们这样设计:

  • 外层 keynotes,代表"所有笔记"这个集合。
  • 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>
  );
}

注意 Sidebarasync 组件 ,它可以直接 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 出来不就行了?

拆分的原因主要有三点:

  1. 职责单一SidebarNoteList 只负责"获取数据 + 遍历渲染",而单个笔记项如何展示(标题、时间、摘要、交互状态)是 SidebarNoteItem 的职责。如果以后要修改笔记项的样式或加点击效果,不需要动列表容器的逻辑。
  2. 服务端/客户端边界SidebarNoteList 是服务端组件,里面包含了数据获取和转换。但如果把笔记项的交互逻辑(比如高亮选中、展开收起)也写在同一个组件里,就必须把整个组件标记为 "use client",这样整个列表都会变成客户端组件,失去服务端渲染的优势。拆出 SidebarNoteItemContent 作为客户端组件后,只有这一小块需要水合,其余部分仍然在服务端静态输出。
  3. 可复用性和可测试性:单个笔记项可能在别的场景复用(比如搜索结果列表),拆分后可以单独测试每个小组件,而不是面对一个几百行的庞然大物。

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-markdownNotePreview 组件里渲染更安全,也支持自定义样式。

编辑区 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 的配合摸清楚。

如果你也在做类似的全栈小项目,不妨动手试试,踩坑的过程才是真正的成长。

相关推荐
光影少年1 小时前
react navite图片加载优化、大图卡顿、缓存策略
前端·react native·react.js
渣波1 小时前
重构旅行体验:基于 React 的 AI 旅游助手对话系统实战解析
前端·javascript
今日无bug1 小时前
列表转树:一道题搞懂 HashMap 在算法里的价值
前端·数据结构
用户921080262861 小时前
5. 数据大屏实时通信第一步:为什么选择 WebSocket,以及如何接入 Socket.IO
前端
岁月留痕1681 小时前
17 实战项目二:实现“日记”项目多页面管理
前端
李顿波1 小时前
Chrome 插件弹窗一直停留在初始的小尺寸 —— 你看到的小方块
前端·javascript·chrome
悟空瞎说1 小时前
Cesium 与 Three.js 融合实战:在数字地球上渲染自定义 3D 场景
前端
渣波1 小时前
React 移动端首页架构实战:从并发请求到防御性编程的深度解析
前端·javascript
a1117761 小时前
原生 Markdown 阅读与编辑器 开源项目
前端·开源·软件