Next.js 16 笔记应用实战:Redis 数据链路与组件两版拆分详解

Next.js 16 笔记应用实战:从 Redis 数据读取到组件拆分

学习 Next.js 时,真正需要建立的不是"记住多少 API",而是一套清晰的代码组织方式:

  • 路由和页面应该如何规划?
  • Redis 数据应该在哪里读取?
  • Server Component 与 Client Component 如何分工?
  • 一个能够展示数据的列表,为什么还要继续拆成多个组件?

本文围绕一个双栏笔记应用展开。当前代码已经建立"Redis → 服务端组件 → 侧边栏列表"的读取链路,并规划了笔记详情、新增、编辑、删除、搜索和 Markdown 预览等能力。

其中最值得分析的是侧边栏列表的两次实现:

  • 第一版把数据转换、JSON 解析、日期格式化和 JSX 渲染全部放在列表组件中;
  • 第二版把代码拆成列表、笔记项和客户端交互外壳三个层次。

后文会完整展示两种写法,重点解释第二版的拆分逻辑、实际收益和当前尚未完成的部分。

核心依赖如下:

json 复制代码
{
  "dependencies": {
    "dayjs": "^1.11.23",
    "ioredis": "^6.0.0",
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

需要先说明功能边界:目前真正完成的是应用布局、首页空状态、Redis 初始化与查询、侧边栏笔记展示,以及组件拆分骨架。CRUD、搜索和 Markdown 转 HTML 仍处在规划阶段,不能仅凭目录、注释或预留参数就认为这些功能已经完成。

一、从需求笔记到技术方案

1. npxcreate-next-app

创建 Next.js 项目时经常使用:

bash 复制代码
npx create-next-app

这条命令包含两个角色:

  • npx 负责直接执行 npm 包提供的命令,不需要先把脚手架全局安装到系统中;
  • create-next-app 负责生成 Next.js 项目的初始结构。

可以把它理解为:临时获取脚手架,然后立即执行。脚手架只是起点,进入业务开发后,更重要的是先分析需求、技术方案、路由和组件,再开始写具体功能。

笔记系统可以先拆成两组问题:

text 复制代码
数据问题
├── 笔记保存在哪里
├── 如何读取全部笔记
├── 如何按 ID 读取笔记
└── 如何新增、修改和删除

界面与交互问题
├── 左侧笔记列表
├── 右侧笔记详情
├── 笔记编辑器
├── Markdown 预览
└── 搜索与新增入口

当前阶段优先解决的是第一条读取链路:先让左侧列表从 Redis 获得数据并渲染出来,再逐步增加详情和写入功能。

2. SSR、RSC 与 hydration

几个经常一起出现的术语需要分开理解:

  • SSR 关注服务端生成页面 HTML;
  • RSC 指 React Server Component,关注组件在哪一侧执行,以及服务端结果如何进入 React 组件树;
  • hydration 是浏览器为已有 HTML 接上状态和事件处理,使 Client Component 变得可交互。

"use client" 也不等于"整个页面改成纯客户端渲染"。它声明的是客户端模块边界。首次加载时,Next.js 仍然可以先提供服务端生成的 HTML,再让浏览器加载 Client Component 所需的 JavaScript 并完成 hydration。

二、需求、组件与目录规划

笔记系统的目标包括:

  1. 左侧展示笔记列表,右侧展示当前笔记;
  2. 新建笔记后,左侧列表同步出现新数据;
  3. 支持删除和编辑笔记;
  4. 正文按 Markdown 保存,展示时转换成 HTML;
  5. 支持搜索。

按照职责,可以先规划以下组件:

text 复制代码
Sidebar
├── 搜索与新增区域
└── SidebarNoteList
    └── SidebarNoteItem
        └── SidebarNoteItemContent

Note
├── NoteEditor
└── NotePreview

当前实际实现集中在 Sidebar 这一支;NoteEditorNotePreview、搜索和 CRUD 仍是待办。

核心目录如下:

text 复制代码
app/
├── layout.js
├── page.js
└── note/
    ├── [id]/
    │   └── page.js
    └── edit/
        ├── page.js
        └── [id]/
            └── page.js
components/
├── Sidebar.js
├── SidebarNoteList.js
├── SidebarNoteItem.js
└── SidebarNoteItemContent.js
lib/
└── redis.js

这套目录把三类职责分开:

  • app 管理路由、页面和共享布局;
  • components 管理 UI 工作单元;
  • lib 管理 Redis 等服务端数据逻辑。

"组件是工作单元"是一个很实用的理解。开发前先确定每个组件接收什么、返回什么、负责什么,后续维护时更容易控制修改范围。

三、App Router 如何表达页面结构

App Router 使用文件系统路由。文件夹定义 URL 片段,page.js 让路由拥有页面,layout.js 提供多个页面共享的 UI。

页面 地址 目标职责 当前状态
首页 / 右侧显示空状态 已实现
笔记详情 /note/[id] 按 ID 展示笔记 仅有路由骨架
新建笔记 /note/edit 创建笔记 空页面
编辑笔记 /note/edit/[id] 修改指定笔记 空页面

1. [id] 是动态路由段

访问 /note/1702459181837 时,1702459181837 会成为页面参数中的 id。在 Next.js 16 中,页面收到的 params 是 Promise,需要先等待:

js 复制代码
export default async function NotePage({ params }) {
  const { id } = await params;

  return <div>{id}</div>;
}

目录存在不代表页面逻辑已经存在。一个有效的 page.js 至少要默认导出 React 组件;详情页还需要根据 id 查询数据。只有 import、没有默认导出的文件,以及完全为空的页面,都只能算路由规划。

2. 页面路由与 HTTP 方法不要混为一谈

/note/edit 是页面地址,POST、PUT、DELETE 是 HTTP 方法。如果需要对外提供请求处理器,可以创建 route.js 并导出 GETPOST 等函数。

但 Server Component 读取 Redis 时,不必先请求自己应用里的 API。组件可以直接调用服务端数据函数,减少一次没有必要的内部 HTTP 绕行。

四、根布局与共享组件

侧边栏在首页、详情页和编辑页中都需要存在,因此适合放进根布局:

js 复制代码
import "./style.css";
import Sidebar from "@/components/Sidebar";

export const metadata = {
  title: "落月的LLM工程师博客",
  description: "这是一位新手的笔记,学习LLM的心得体会",
  keywords: ["LLM", "codex", "rag", "langchain"],
};

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body>
        <div className="container">
          <div className="main">
            <Sidebar />
            <section className="col note-viewer">{children}</section>
          </div>
        </div>
      </body>
    </html>
  );
}

children 是当前路由页面的插槽:

text 复制代码
RootLayout
├── Sidebar       所有页面共享
└── children      随当前路由变化

访问首页时,children 是空状态;访问详情页时,它会变成详情内容。这样不需要在每个页面中重复写侧边栏。

标题、描述和关键词使用 metadata 对象声明。当前 App Router 不建议在根布局中手写 headtitlemeta

首页没有数据请求,只负责提示用户选择左侧笔记:

js 复制代码
export default 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>
  );
}

这个组件不需要 async。Server Component 并不是通过 async 判断的;只有组件内部真的需要 await 时,才需要声明为异步函数。

五、Server Component 直接读取 Redis

侧边栏需要等待后端数据,因此它是异步 Server Component:

js 复制代码
import Link from "next/link";
import Image from "next/image";
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">
        <Image
          className="logo"
          src="/logo.png"
          width={22}
          height={22}
          alt=""
        />
        <strong>LLM Notes</strong>
      </Link>

      <section className="sidebar-menu" role="menubar">
        {/* 搜索与新增入口后续实现 */}
      </section>

      <nav>
        <SidebarNoteList notes={notes} />
      </nav>
    </section>
  );
}

数据链路非常直接:

text 复制代码
RootLayout
  → Sidebar
  → await getAllNotes()
  → Redis
  → SidebarNoteList

ioredis 只出现在服务端数据模块中,不会因为组件渲染而把 Redis 客户端发送到浏览器。当前查询也没有声明 Next.js 数据缓存策略,所以不能把"服务端执行"理解为"查询结果一定自动缓存"。

六、Redis Hash 的数据模型与初始化逻辑

js 复制代码
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");
}

Redis 中使用一个名为 notes 的 Hash:

text 复制代码
notes
├── 1702459181837 → 一段 JSON 字符串
├── 1702459182837 → 一段 JSON 字符串
└── 1702459188837 → 一段 JSON 字符串

Hash 的 field 是笔记 ID,value 是序列化后的笔记对象。getAllNotes 的执行顺序是:

  1. 使用 hgetall("notes") 读取整个 Hash;
  2. 通过 Object.keys(data).length 判断是否为空;
  3. 如果为空,就通过 hset 写入种子数据;
  4. 最后再次读取并返回完整数据。

第二次 hgetall 保证首次初始化时返回刚写入的数据;如果一开始就有数据,它会重复读取一次。可以将变量改为 let,只在写入后重新查询。不过原写法很直观地表达了"检查 → 初始化 → 返回最终数据"。

new Redis() 没有传入连接参数,会使用默认的本地 Redis 配置。运行和构建之前必须保证 Redis 可连接,否则服务端读取会失败。

七、Redis 返回值为什么要转换

hgetall 返回的结构近似:

js 复制代码
{
  "1702459181837": "{\"title\":\"sunt aut\", ...}",
  "1702459182837": "{\"title\":\"qui est\", ...}"
}

外层是以笔记 ID 为 key 的普通对象,内层 value 仍然是 JSON 字符串。React 列表通常使用数组 map,所以先执行:

js 复制代码
const arr = Object.entries(notes);

得到:

js 复制代码
[
  ["1702459181837", "{...}"],
  ["1702459182837", "{...}"]
]

之后就能在 map 中解构出 noteIdnote。读取 titlecontentupdateTime 前,还要执行 JSON.parse(note)

八、组件第一版:一个列表组件完成全部工作

第一版的实现很直接:拿到 notes 后,在同一个组件中完成对象转换、空状态判断、JSON 解析、日期格式化和列表项渲染。

js 复制代码
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>
  );
}

这段代码的执行过程可以拆成五步:

text 复制代码
notes 对象
  → Object.entries(notes)
  → map 遍历每个 [noteId, note]
  → JSON.parse(note)
  → 渲染标题和更新时间

1. 第一版的优点

第一版并不是错误写法。对于只展示标题和时间的小列表,它有明显优点:

  • 文件少,阅读路径短;
  • 数据转换和输出位置相邻;
  • 不需要设计额外的组件参数;
  • 功能很小时可以快速验证 Redis 数据是否成功进入页面。

如果需求永远只有"显示标题和时间",继续保留这一版也没有问题。

2. 第一版的问题出现在需求增长以后

笔记列表项后续不只要显示标题和时间,还计划支持:

  • 点击后打开笔记详情;
  • 展示正文摘要;
  • 展开与收起;
  • 当前选中状态;
  • 编辑和删除后的列表更新;
  • 可能出现的加载或高亮状态。

如果继续在这一层添加代码,SidebarNoteList 将同时负责:

  1. 集合数据转换;
  2. 空状态;
  3. 单条数据解析;
  4. 单条笔记展示;
  5. 日期和摘要处理;
  6. 点击、展开等客户端交互。

它的名字叫"列表",职责却会逐渐变成"整个侧边栏笔记系统"。问题不是代码行数本身,而是多个变化原因集中到了同一个组件中。

例如,修改日期展示需要改列表组件,增加摘要需要改列表组件,增加点击事件仍然要改列表组件。任何一个列表项需求都会触碰负责遍历整个集合的代码。

3. 第一版还有一个小细节

组件被声明为:

js 复制代码
export default async function SidebarNoteList({ notes }) {

但函数内部没有 await。它依然可以运行,但 async 没有实际作用。真正需要异步的是上层 Sidebar,因为它执行了 await getAllNotes()

九、组件第二版:按职责拆成三个层次

第二版没有改变 Redis 数据结构,也没有改变上层 Sidebar 的调用方式。它主要重构了列表内部:

text 复制代码
SidebarNoteList
  → 负责集合
SidebarNoteItem
  → 负责单条笔记
SidebarNoteItemContent
  → 负责未来的客户端交互

下面逐层分析。

1. SidebarNoteList:只处理集合

js 复制代码
import SidebarNoteItem from "@/components/SidebarNoteItem";

export default 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]) => (
        <li key={noteId}>
          <SidebarNoteItem
            noteId={noteId}
            note={JSON.parse(note)}
          />
        </li>
      ))}
    </ul>
  );
}

第二版的列表组件只保留四件事:

  1. 使用 Object.entries 把对象转换为数组;
  2. 处理空状态;
  3. 使用 map 遍历集合;
  4. 为每个列表项提供稳定的 key

Redis 中的 JSON 字符串在这里被解析成普通对象:

js 复制代码
note={JSON.parse(note)}

这是一个很自然的数据边界:列表组件知道上层传来的 value 是 Redis 字符串,下层笔记项只需要面对正常的 JavaScript 对象。

2. SidebarNoteItem:只处理一条笔记

js 复制代码
import dayjs from "dayjs";
import SidebarNoteItemContent from "@/components/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>
  );
}

这个组件不再关心数组、map 和空列表,只处理一条已经解析好的笔记。

js 复制代码
const { title, content = "", updateTime } = note;

content 设置空字符串默认值,是为了保证下面的调用安全:

js 复制代码
content.substring(0, 20)

如果正文为空,就显示:

jsx 复制代码
<i>(No content)</i>

这比第一版多处理了正文摘要,也把"单条笔记该展示什么"从列表组件中抽离出来。

日期格式需要注意大小写:

js 复制代码
dayjs(updateTime).format("YYYY-MM-DD")

MM 表示月份,mm 表示分钟。如果写成 YYYY-mm-DD,中间展示的不是月份,而是分钟。第一版显示到秒,第二版改为只显示年月日,这是展示粒度的变化,并不是组件拆分本身带来的要求。

3. SidebarNoteItemContent:建立客户端边界

js 复制代码
"use client";

export default function SidebarNoteItemContent({
  id,
  title,
  children,
  expandChildren,
}) {
  return <>{children}</>;
}

"use client" 表示从这个模块开始进入客户端边界。未来的 useStateuseEffect、点击事件、展开状态和浏览器 API 应该放在这里。

不过,当前组件只是一个骨架:

  • id 没有使用;
  • title 没有使用;
  • expandChildren 没有渲染;
  • 没有状态;
  • 没有事件;
  • 最终只返回 children

因此,当前能够看到的是标题和日期,正文摘要并不会显示,展开功能也尚未实现。预留了参数不等于功能已经完成。

现阶段甚至还不需要导入 useStateuseEffect。等真正写入状态或 Effect 时再导入,会让代码表达的完成度更加准确。

十、第一版与第二版的完整对比

对比维度 第一版 第二版
组件数量 一个列表组件 列表、笔记项、交互外壳
集合转换 列表组件处理 列表组件处理
JSON 解析 map 内解析后直接展示 列表边界解析,再传给笔记项
单条展示 写在 map 内部 SidebarNoteItem 负责
日期格式化 写在列表组件中 写在单条笔记组件中
正文摘要 没有 已生成摘要节点,但暂未显示
客户端边界 没有 下沉到 SidebarNoteItemContent
扩展交互 继续修改整个列表 主要修改单条笔记的交互外壳
当前完整度 标题和完整时间可展示 标题和日期可展示,交互仍是骨架

两版最重要的区别,不是"代码从一个文件搬到了三个文件",而是每一层开始拥有明确的输入和责任:

text 复制代码
SidebarNoteList
输入:Redis 返回的 notes 对象
输出:一组 SidebarNoteItem
责任:集合转换、空状态、遍历、key

SidebarNoteItem
输入:noteId 和解析后的 note 对象
输出:单条笔记所需的展示节点
责任:标题、日期、摘要

SidebarNoteItemContent
输入:id、title、children、expandChildren
输出:可交互的列表项外壳
责任:未来的状态、事件、展开和导航

这样的拆分让数据从上到下逐步变得更具体:

text 复制代码
Redis Hash
  → notes 对象
  → [noteId, JSON 字符串]
  → note JavaScript 对象
  → title/content/updateTime
  → 可交互列表项

十一、第二版拆分写法具体好在哪里

1. 集合逻辑和单项逻辑不再互相干扰

列表关心的是"有多少条、如何遍历、为空怎么办",笔记项关心的是"一条笔记显示什么"。两者的变化原因不同:

  • Redis 返回结构变化时,主要修改列表的数据转换;
  • 标题、日期或摘要变化时,主要修改笔记项;
  • 点击、展开和收起变化时,主要修改交互外壳。

这就是单一职责带来的直接收益。它不是为了追求更多组件,而是让一次需求变化尽量只影响一个层次。

2. 客户端 JavaScript 边界更小

SidebarSidebarNoteListSidebarNoteItem 都可以保留为 Server Component。只有真正计划使用状态和事件的 SidebarNoteItemContent 声明 "use client"

如果在第一版列表中直接加入 useState 或点击事件,就需要把整个列表标记为 Client Component。这样集合遍历、日期展示等本来不需要浏览器参与的代码也会进入客户端模块图。

第二版把边界压到单条笔记的交互外壳:

text 复制代码
服务端
Sidebar
└── SidebarNoteList
    └── SidebarNoteItem
        └── 客户端边界
            SidebarNoteItemContent

这样做的核心不是"客户端组件越少越好",而是让服务端数据读取和浏览器交互各自待在合适的位置。

3. Redis 的存储格式不会泄漏到每个子组件

Redis 保存的是 JSON 字符串,但 SidebarNoteItem 收到的是普通对象:

js 复制代码
<SidebarNoteItem
  noteId={noteId}
  note={JSON.parse(note)}
/>

这一步相当于数据标准化。下层组件不需要知道数据来自 Redis,也不需要自己反复 JSON.parse

如果以后数据层调整,只要上层继续给出相同形状的 note 对象,单条笔记组件的展示逻辑就不必跟着变化。

4. childrenexpandChildren 形成两个内容插槽

第二版没有把标题和摘要直接写死在客户端组件内部,而是从服务端组件传入两个 React 节点:

jsx 复制代码
<SidebarNoteItemContent
  expandChildren={<p>正文摘要</p>}
>
  <header>标题与日期</header>
</SidebarNoteItemContent>

其中:

  • children 表示默认显示的标题和日期;
  • expandChildren 表示展开后显示的摘要。

未来客户端组件只需要决定"是否展开",不需要重新负责标题、日期和摘要的生成。可以把它理解为:

text 复制代码
服务端组件决定:显示什么
客户端组件决定:何时显示

这是一种组合式设计。SidebarNoteItemContent 提供交互容器,外层通过插槽传入内容。

需要注意,传给 Client Component 的普通数据必须能够被 React 序列化。这里的 idtitle 都是字符串,适合跨越边界;由服务端准备的 React 节点也可以作为插槽参与 Server/Client Component 组合。

5. 后续需求有了明确落点

以规划中的功能为例:

新需求 更适合修改的位置
改变列表为空时的提示 SidebarNoteList
调整单条笔记展示字段 SidebarNoteItem
增加正文摘要 SidebarNoteItem
增加展开、收起状态 SidebarNoteItemContent
点击后打开详情 SidebarNoteItemContent
从 Redis 增加新的查询 lib 数据函数
根据动态 ID 展示详情 /note/[id] 页面

第一版中,这些列表相关需求大多会继续进入同一个 map。第二版则已经为它们准备了不同的工作单元。

6. 组件更容易独立理解

拆分以后,阅读者不必一次在脑中保存"Redis 对象、数组遍历、JSON 解析、日期格式和点击状态"所有上下文。

阅读 SidebarNoteList 时只思考集合;阅读 SidebarNoteItem 时只思考一条笔记;阅读 SidebarNoteItemContent 时只思考浏览器交互。

这会降低单个组件的理解成本。后续定位问题时,也更容易从现象反推责任层:

  • 整个列表为空,检查列表或数据读取;
  • 只有日期不正确,检查笔记项;
  • 展开按钮无效,检查客户端交互外壳。

7. 拆分也有成本

第二版并不是在任何场景下都优于第一版。它增加了:

  • 更多组件文件;
  • 更多 props;
  • 更长的跳转阅读路径;
  • 对组件边界的设计成本。

如果列表永远只展示标题和时间,第一版更直接。第二版更适合当前场景,是因为需求笔记已经明确规划了详情、摘要、展开、编辑、删除和同步更新等后续行为。

换句话说,拆分的理由应该来自变化方向,而不是"组件越多越专业"。

十二、第二版目前仍然只是拆分骨架

第二版的方向更适合扩展,但当前实现还存在几个明确的未完成点。

1. expandChildren 没有渲染

SidebarNoteItem 已经创建摘要节点:

jsx 复制代码
expandChildren={
  <p>
    {content.substring(0, 20) || <i>(No content)</i>}
  </p>
}

SidebarNoteItemContent 只返回:

jsx 复制代码
return <>{children}</>;

因此摘要不会出现在页面上。要把它称为"展开功能",至少还需要状态、触发事件和对 expandChildren 的条件渲染。

2. idtitle 尚未参与交互

这两个参数已经跨过服务端与客户端边界,但当前没有被消费。id 未来可以用于确定详情地址,title 可以用于辅助描述或交互逻辑;在它们真正被使用前,它们只是接口预留。

3. Hook 被提前导入

客户端外壳已经导入 useStateuseEffect,但没有使用。当前更准确的写法是不导入它们。等真正加入展开状态或 Effect 后,再增加对应 Hook。

4. 第二版列表本身不需要 async

SidebarNoteListSidebarNoteItem 内部都没有等待异步操作,因此可以保持普通函数。异步读取已经在 Sidebar 中完成:

js 复制代码
const notes = await getAllNotes();

async 留给真正发生异步 I/O 的组件,可以让数据等待点更容易识别。

十三、路径别名让深层路由保持可读

动态路由目录较深时,相对路径可能写成:

js 复制代码
import { getAllNotes } from "../../../lib/redis.js";

通过 jsconfig.json 配置:

json 复制代码
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

之后可以统一使用:

js 复制代码
import { getAllNotes } from "@/lib/redis";
import SidebarNoteItem from "@/components/SidebarNoteItem";

@/ 指向项目根目录,减少了多层 ../ 带来的维护成本。

路径别名只改变模块如何被解析,不改变模块运行环境。@/lib/redis 仍然是服务端数据模块,不能因为路径更短就把它导入 Client Component。

十四、Markdown 需求现在完成到哪一步

需求规划中,笔记正文的处理方式是:

text 复制代码
编辑器输入 Markdown
  → 数据库存储 Markdown 字符串
  → 页面读取 Markdown
  → 转换成 HTML
  → 预览或详情页面展示

这个设计把"原始内容"和"展示结果"分开:数据库保留 Markdown,页面展示 HTML。

不过当前依赖中没有 marked,也没有 NoteEditorNotePreview 的组件实现。因此目前只能说 Markdown 的数据形式和展示方向已经确定,转换逻辑尚未落地。

同样,详情、新增和编辑路由虽然已经规划出来,但空页面或只有 import 的页面还不能承担业务。后续需要先让每个 page.js 默认导出有效组件,再逐步接入查询和表单。

十五、当前端到端数据流

现在可以把已经建立的读取链路完整串起来:

text 复制代码
1. 用户访问首页
2. RootLayout 渲染共享 Sidebar 和当前页面 children
3. Sidebar 在服务端执行 getAllNotes
4. getAllNotes 通过 ioredis 读取 notes Hash
5. Redis 为空时写入 initialData
6. SidebarNoteList 使用 Object.entries 转换数据
7. JSON.parse 把字符串还原为笔记对象
8. SidebarNoteItem 生成标题、日期和摘要节点
9. SidebarNoteItemContent 建立客户端交互边界
10. 当前只输出标题和日期,摘要与交互尚未完成

这条链路中的职责分布是:

text 复制代码
lib/redis
  数据读取与初始化

Sidebar
  等待服务端数据

SidebarNoteList
  处理集合

SidebarNoteItem
  处理单条笔记

SidebarNoteItemContent
  预留浏览器交互

十六、继续开发前需要留意的逻辑细节

1. Day.js 的月份是大写 MM

正确的年月日格式是:

js 复制代码
dayjs(updateTime).format("YYYY-MM-DD")

小写 mm 表示分钟。这个问题不会导致语法错误,却会产生错误日期。

2. Redis 是运行前置条件

new Redis() 使用默认连接配置。静态检查通过不代表运行时数据依赖一定可用;开发和构建前都需要确认 Redis 可连接。

3. ESLint 与生产构建承担不同检查

项目提供了以下命令:

bash 复制代码
pnpm dev
pnpm lint
pnpm build
pnpm start

pnpm lint 检查静态规则,pnpm build 会进入编译和页面数据生成流程。Next.js 16 的生产构建不会替代单独的 lint,所以两者都需要执行。

4. 规划中的路由仍要补默认导出

详情和编辑目录已经存在,但页面内容尚未完成。路由骨架只能说明 URL 设计,不能证明查询、表单或写入逻辑已经实现。

十七、当前实现与后续目标

已经建立的能力包括:

  • App Router 根布局;
  • 首页空状态;
  • Redis Hash 初始数据;
  • Server Component 直接读取 Redis;
  • 空列表判断;
  • Object.entriesJSON.parse 数据转换;
  • 笔记标题和日期展示;
  • 第一版到第二版的组件拆分;
  • 局部 Client Component 边界。

仍待完成的目标包括:

  • 点击笔记项打开 /note/[id]
  • 新增、编辑和删除;
  • 数据变化后刷新侧边栏;
  • 搜索;
  • 摘要展开与收起;
  • Markdown 转 HTML;
  • 编辑器和预览组件。

总结

这个阶段最重要的收获不是完成了多少功能,而是建立了一条清晰的数据链路和一组可以继续扩展的组件边界。

第一版适合快速验证数据:

text 复制代码
一个组件
  → 转换集合
  → 解析 JSON
  → 格式化日期
  → 输出列表项

第二版适合需求继续增长:

text 复制代码
SidebarNoteList
  → 管理集合
SidebarNoteItem
  → 管理单条展示
SidebarNoteItemContent
  → 管理客户端交互

第二版真正的好处可以归纳为四点:

  1. 列表、单项和交互拥有不同职责;
  2. "use client" 被限制在真正需要交互的位置;
  3. Redis JSON 字符串在清晰的边界被转换为普通对象;
  4. 详情、摘要、展开和导航都有明确的扩展位置。

但也要保持准确:第二版目前只是更好的结构,不是已经完成的交互功能。expandChildrenidtitle 和 Hook 仍然等待后续实现。

当拆分能够对应真实的变化方向时,多几个组件是在降低未来复杂度;如果没有后续变化,第一版反而可能更加简单。这才是比较两个版本时最值得掌握的判断标准。

相关推荐
YIAN42 分钟前
告别等后端!React+Vite 前端独立开发全流程:接口封装 + Mock 方案 + 状态管理
前端·react.js·vite
PedroQue991 小时前
@meng-xi/vite-plugin v1.3.0:generateUni 一键流水线
前端·vite
胡萝卜术1 小时前
权限系统的四道防线:从数据库事务锁到操作级保护规则的完整设计
前端·javascript·面试
IMPYLH1 小时前
HTML 的 <legend> 元素
java·前端·html
真夜1 小时前
RN热更新安全问题
前端·react native
Zeroplucky1 小时前
Binder 线程池耗尽案例:持锁同步调用 VHAL 导致 SystemUI 卡顿或 ANR
前端
Zeroplucky1 小时前
从 registerContentObserver 剖析 Android Binder 流程
前端
Yinlin1241 小时前
深入悬浮组件实现
前端
yangzheui1 小时前
nvue页面事件穿透到下层元素解决办法
前端