为什么写代码前要先规划组件树?Next.js + Redis 笔记系统实战

为什么写代码前要先规划组件树?Next.js + Redis 笔记系统实战

重生之我在 Vibe Coding 时代当程序员 · Next.js 全栈实战篇

上一课我搞明白了一件事:AI 产品的官网为什么都选 Next.js------SSR 伺候 SEO,RSC 伺候性能。这一课直接上手,从零搭一个笔记系统 next-blog

这个项目从一张页面原型图和一张组件架构图开始完成这个项目,开发之前不要急着写代码。开发的顺序是:分析需求 → 技术方案(Next.js)→ 任务细节(路由 + 组件)→ 最后才是写代码。

这个顺序本身就是这节课最大的知识点。下面按这个顺序复盘。

一、项目创建:npx 和脚手架

npx :npm 自带的工具,可以跳过安装步骤直接执行一个 node 包,不需要把它装到全局。npx create-next-app 等价于 npm i -g create-next-app 再执行 create-next-app,但一步到位,不留全局污染。特别适合「只想试用一下某个包」的场景,比如在测试电脑上跑项目。

bash 复制代码
npx create-next-app

create-next-app 是 React 全栈开发脚手架。

脚手架:建筑工程里的词,指搭楼之前先立起来的临时支架。在前端领域,它帮你生成一个带好目录结构、配置、依赖的可运行项目骨架,你直接在这个骨架上写业务。

它默认给的这套方案,正好接上上一课的理论:

  • SSR(服务器端渲染),SEO(搜索引擎优化)良好
  • 默认使用 RSC(React 服务器端组件)
  • 加上 "use client" 之后,才启用客户端组件,走 hydration(水合)流程

hydration(水合):服务端先把组件渲染成 HTML 发给浏览器(用户第一时间能看到内容),然后 React 在客户端把事件、状态这些"交互能力"注水到这份 HTML 上,让它变成可交互的应用。给干巴巴的 HTML"注水"。

这次用的版本挺新:Next.js 16.3.1 + React 19.2.8。

二、需求先摆桌上:笔记系统要做什么

界面分两列:左侧是笔记列表,右侧是笔记内容

技术方案:

  • 数据层对笔记做增删改查
  • 存入数据库的是 markdown,页面显示的是 html,用 marked 这个库做转换

CRUD:Create、Read、Update、Delete,增删改查。绝大部分业务系统的底层就是这四个动作。

功能需求:

  • 点击 new 按钮,新增一篇笔记,左侧列表同步更新(Create)
  • 打开笔记能查看内容,markdown 渲染成 html(Read)
  • 可以编辑当前笔记,支持 markdown 格式(Update)
  • 可以删除笔记,左侧列表同步更新(Delete)
  • 搜索功能,模糊匹配查询

需求就这些。接下来是把需求翻译成技术方案的第一步:路由。

三、路由设计:文件即路由,一切皆资源

App Router 的核心心智模型就一句话:文件即路由,RESTful 一切皆资源

RESTful :一种 URL 设计风格,把系统里的一切都看作"资源",用 URL 定位资源、用 HTTP 方法表达对资源的操作。笔记是一种资源,所以 /add 配 POST 请求,就能表达"新增一篇笔记"。

按这个思路,目录结构直接规划出来:

bash 复制代码
app/
  page.js              # / 界面分为两列 左侧为笔记列表,右侧是笔记内容
  note/
    [id]/              # 动态路由
      page.js          # note 的显示页面(详情)
    edit/
      [id]/
        page.js        # 修改 note
      page.js          # 新增 note

[id] 是动态路由,/note/123/note/456 共用同一个页面组件,id 从 URL 里取。

整棵路由树没写一行代码就定下来了。这在以前写配置式路由的年代不敢想。

四、组件规划:开发之前不要急着写代码

这是全课的核心一节,核心动作就一个:写代码之前,先把组件架构规划清楚------要拆出哪些组件、每个组件负责什么、谁复用谁,先画成一棵组件树,再动手。这也正是上一篇结尾埋下的**规范驱动编程(SDD)**在组件架构层面的一个缩影:先定规范,再写代码。

规划出的组件树长这样:

文字版:

text 复制代码
Sidebar
  SidebarSearchField   侧边栏搜索区域
    EditButton(复用)  编辑按钮
  SidebarNoteList      侧边栏笔记列表
    NoteItem           笔记 item
Note
  NoteEditor            笔记的编辑
  NotePreview          负责笔记的预览界面

老师说了一句我觉得可以直接裱起来的话:

组件是工作单元,也是 AI 生成的工作单元。

在 AI 辅助开发的时代,你规划出来的每一个组件,就是一个可以丢给 AI 的任务单元。组件边界清晰,AI 生成的东西才可控。所以"先规划再写码"不只是工程习惯,它直接决定了你和 AI 协作的效率------这也解释了为什么 Next.js 16 干脆自己动手给 AI 写 AGENTS.md规范化机器可读的任务,是 Vibe Coding 时代的基础设施。

组件定了,整个项目的目录职责也跟着定了:

  • app:页面主目录,page.js 主页面、layout.js 布局、note/[id] 文章
  • components:组件
  • lib:数据库操作、常用的函数
  • public:静态资源,static server 直接伺服

static server(静态资源服务):图片、svg 这类文件不需要经过任何逻辑处理,服务器原样返回即可,所以叫静态资源。

五、layout.js:SEO 的第一现场

Next.js 的布局文件 layout.js 包住所有页面。一个页面从 HTML 层面长什么样,在这里看得一清二楚:

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

export default function RootLayout({ children }) {
  return (
    <html>
      <head> 
        <title>大模型工程师博客</title>
        <meta name="description" content="大模型工程师博客" />
        <meta name="keywords" content="大模型工程师, 大模型, 博客,LLM,ChatGPT,Claude,deepseek" />
      </head>
      <body>
        <div className="container">
          <div className="main">
            <Sidebar/>
            <section className="col note-viewer">{children}</section>
          </div>
        </div>
      </body>
    </html>
  );
}

对照着 HTML 结构看:

text 复制代码
html
  head
    title(SEO)
    meta(SEO)
      description(描述)
      keyword(关键词)
  body
    page.js(通过 children 进来)

{children} 就是当前路由的 page.js------布局不动,内容随路由变。

两个语义化标签值得记:

  • nav:侧边栏、导航栏
  • section:语义化标签,表示一个区域/区块

Sidebar.js 里有一段注释,把 section 的语义讲得很具体:

jsx 复制代码
{/* sidebar
区块 电商网站,商品介绍,评论,购物车,订单,用户中心,售价
语义是独立的一块内容区域 幻灯片区域 */}

电商网站里的商品介绍、评论、购物车、订单、用户中心、售价------每一块都是独立的、有意义的内容区域,都配得上一个 section

layout 里还藏着一个工程习惯:to be continue 注释法 。在代码里用注释写好"接下来要做的事",表明等待未来完成。好处是团队协作、记忆、维护都靠它------你随时能从注释里接上昨天的进度。Sidebar.js 里就有实例:

jsx 复制代码
<section className="sidebar-menu" role="menubar">
    {/* SideSearchField 未来再完成 */}
</section>

六、BEM:给类名一点语义

样式方案有两种流派:

  • 原子类:tailwindcss,一个类只干一件事,拼出来
  • BEM 国际命名规范:有语义,好维护

BEM :Block(块)、Element(元素,用 __ 连接)、Modifier(修改器,用 -- 连接)三段式命名法。比如 sidebar-note-header 是一个块,sidebar-note__title--active 一眼就能读出"侧边栏笔记块的标题元素,处于激活状态"。
原子类 :一个 class 只负责一条样式(比如 .flex.p-4),tailwindcss 是代表。写起来快,但类名不再承载语义。

这个项目的 style.css 里还有一份完整的 CSS Reset(适配自 a-modern-css-reset),重置掉浏览器默认的 margin、padding、list-style,让各浏览器起点一致。

七、alias:把 ../../../ 干掉

组件里要引入 lib/redis.js,按相对路径写是:

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

三个 ../,数错一层就炸。配置 alias(别名)之后:

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

alias(别名) :为一个路径设置一个更短、更易记的替代名称。@ 直达项目根目录。

配置就在 jsconfig.json

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

baseUrl: "." 把基准点定在项目根目录,paths 里声明两条短链接映射。以后无论组件嵌套多深,@/ 开头永远是根。

八、数据服务:为什么是 Redis

技术选型:Redis,key:value 的 NOSQL 内存数据库

先把 Redis 的画像画清楚:

  • 没有数据表,不是关系型,不用 SQL 驱动
  • 存储在内存中,有点像 localStorage,直接存 key:value
  • 速度快,存储小
  • 端口 6379(MySQL 是 3306)

NOSQL:泛指不使用传统关系型表格模型的数据库,Redis(键值型)是其中一种,此外还有文档型(MongoDB)等。

典型使用场景:缓存、计数器、榜单(投票)

Redis 的高级之处在于:不同类型的数据,有对应的优化存储方式,对应不同的方法:

  • 字符串:直接 get / set
  • 哈希表:hget / hset

哈希表:key → field → value 的两层结构。一个 key 下面可以挂多个 field:value 对。JS 里的对象、JSON 本质上就是哈希表思想。

掘金首页的例子

为什么缓存这么值钱?拿掘金首页举个例子:

掘金首页的文章列表,几分钟之内是不变的,之后才改变。

  • 第一个用户来的时候,查看文章,服务器去查 MySQL 数据库,得到文章列表(posts 列表),并且会把文章通过 key:value 存到 Redis 中
  • 下一个用户来的时候,如果数据差别不大(数据还在有效期内),就直接从 Redis 中获取------因为 Redis 基于内存,读写速度快

数据库只扛第一枪,后面的流量让内存去挡。

什么时候 Redis 不够用(或者说单个数据库不能满足需求)

超过 1k 人(并发)的情况下:

  • 数据过大,单个数据库不够用
  • 数据库有读写的 I/O 瓶颈

I/O 瓶颈:数据读写速度跟不上请求速度。磁盘 I/O 慢于内存 I/O 数量级,请求全砸到磁盘上,队列就堵住了。

所以生产环境一般是 Redis + MySQL 组合:MySQL 做持久化存储,Redis 做热数据缓存。

redis.js 落地

Next.js 的约定:数据业务逻辑都放在 lib 目录下lib/redis.js

js 复制代码
// node redis 客户端,驱动
import Redis from 'ioredis';
const redis = new Redis();// 默认没有密码 NOSQL 数据库

// 哈希表,JSON 也是哈希表的一种
// key :字符串 ID
// value :NOTE 的序列化过后的字符串
// redis 在存储一个值的时候,存储的是 key:value,对 value 特别支持 hash 类型
// initialData 对象(js),哈希表(其他)
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");// 获取到一个 hash 表
  if(Object.keys(data).length === 0) {
    // hset :设置 hash 表的字段 
    // 对于不同的数据类型提供不同的方法
    await redis.hset("notes", initialData);
  }
  return await redis.hgetall("notes");
}

几个细节:

  • ioredis 是 Node 的 Redis 客户端(驱动),new Redis() 默认无密码直连本机
  • 存储设计:key 是字符串 ID(时间戳),value 是 NOTE 序列化后的 JSON 字符串,整体挂在 "notes" 这个 hash 表下
  • getAllNotes 的逻辑:先 hgetall 拿全部,如果是空库就 hset 灌入初始数据,再 hgetall 返回

序列化 :把 JS 对象转成可存储/可传输的字符串(这里是 JSON.stringify),取回来再 JSON.parse 还原。

数据流全景

这节课笔记里有一条数据流线索,值得单独拎出来:

text 复制代码
用户 -> / -> lib notes -> sidebar -> seo 良好的导航

用户访问 /,服务端在 lib 里调用数据函数拿到 notes,直接渲染进 sidebar,最终 HTML 里就带着完整的笔记列表导航------爬虫第一眼就能看到,SEO 良好。

笔记里还有一个带问号的延伸:/app/api/route.js?------route.js 也能做接口,用于 RPC 远程调用。但这个项目里数据是在 RSC 里直接 await lib 函数拿的,不需要自己另写一层 HTTP 接口。

RPC(Remote Procedure Call,远程过程调用):调用远端服务器的函数就像调用本地函数一样。写 HTTP 接口再 fetch 是一种朴素实现。

九、组件落地:RSC 到 CSR 的边界

规划做完了,开始写组件。这一节的主角是"服务端组件和客户端组件怎么拆"。

根页面 page.js

jsx 复制代码
// RSC 组件 async 异步,为了 await 先去获取到后端数据
export default async function Page() {
  return (
    <div className="note--empty-state">
      <span className="note-text--empty-state">暂无笔记</span>
    </div>
  );
}

注意最上面那行注释:RSC 组件可以直接标 async,为了 await 先去获取后端数据。这是服务器组件的专属能力------客户端组件可不能是 async 的。类名 note--empty-state 正好是 BEM 的 Modifier 用法(-- 修饰空状态)。

Sidebar.js:服务端拿数据

jsx 复制代码
import React from "react";
import Link from "next/link";
import { getAllNotes } from "@/lib/redis";
import SidebarNoteList from "./SidebarNoteList";

export default async function Sidebar() {
  const notes = await getAllNotes();
  return (
    <>
    {/* sidebar
    区块 电商网站,商品介绍,评论,购物车,订单,用户中心,售价
    语义是独立的一块内容区域 幻灯片区域 */}
     <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 className="sidebar-nav">
            {/* SiderbarNoteList 未来再完成 */}
            <SidebarNoteList notes={notes} />
        </nav>
     </section>
    </>
  );
}

async 组件里直接 await getAllNotes(),数据在服务端就拿好了,往下传给 SidebarNoteList。侧边栏里的笔记列表,从此是服务端渲染出来的 HTML------这就是"lib notes -> sidebar -> seo 良好的导航"的代码实现。

SidebarNoteList.js:先有一个"不拆"的版本

我笔记里存了两个版本的 SidebarNoteList,对比着看才明白拆分的意义。

SidebarNoteList2.js,不拆子组件的直给版:

jsx 复制代码
import dayjs from "dayjs";

export default async function SidebarNoteList({ notes }) {
    const arr = Object.entries(notes);// hash 转成二维数组,方便使用 map 方法(数组才能使用 map 方法)
    if(arr.length === 0) {
      return <div className="notes-empty">
        暂无笔记哦
      </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>
  );
}

能跑,能用。但整列表都是服务器组件,将来想给某个笔记项加交互(比如点击展开摘要),就没地方下手。

正式版 SidebarNoteList.js,拆出 SidebarNoteItem

jsx 复制代码
import SidebarNoteItem from "./SidebarNoteItem";

// SidebarNoteList(RSC SEO)-> 拆出来 SidebarNoteItem(小组件,可以进行交互 CSR)
export default async function SidebarNoteList({notes}) {
    const arr = Object.entries(notes);// hash 转成二维数组,方便使用 map 方法(数组才能使用 map 方法)
    if(arr.length === 0) {
      return <div className="notes-empty">
        暂无笔记哦
      </div>
    }

  return (
    <ul className="notes-list">
      {
        arr.map(([noteId,note]) => {
            return (<li key={noteId}>
                <SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
            </li>)
        })
      }
    </ul>
  );
}

拆分的理由就写在第一行注释里:SidebarNoteList(RSC SEO)-> 拆出来 SidebarNoteItem(小组件,可以进行交互 CSR)。外层留在服务端保 SEO,内层拆成客户端组件保交互。

两个技术点:

  • Object.entries(notes) 把 hash 转成二维数组,因为数组才能用 map 方法
  • JSON.parse(note) 把 Redis 里序列化的字符串还原成对象
  • key={noteId} 用稳定 ID 做 key,不用 index

SidebarNoteItem.js:插槽式传参

jsx 复制代码
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>(无内容)</i>}
            </p>
          }
          content={content}
          updateTime={updateTime}
        >
          <header className="sidebar-note-header">
            <strong>{title}</strong>
            <small>{dayjs(updateTime).format("YYYY-MM-DD HH:mm:ss")}</small>
          </header>
        </SidebarNoteItemContent>
    )
}

这里用了 React 的组合(children)模式children 传标题头部,expandChildren 传展开后的摘要(content.substring(0, 20) 截前 20 个字符,空内容就显示 (无内容))。组件不关心内容长什么样,只负责把插槽摆到正确的位置。

dayjs :轻量时间处理库,用来把 2023-12-13T09:19:48.837Z 这种 ISO 时间戳格式化成 YYYY-MM-DD HH:mm:ss。这属于"业务服务"层的时间戳处理需求。

SidebarNoteItemContent.js:跨过边界

jsx 复制代码
"use client";

import { useState, useEffect } from "react";

export default function SidebarNoteItemContent({id,title,children,expandChildren}) {
    const [isExpanded, setIsExpanded] = useState(false);
    return (
        <>
            {children}
        </>
    )
}

文件第一行 "use client",RSC 和 CSR 的边界就在这里。

此刻它只是个"壳":接了 expandChildren 参数、定义了 isExpanded 状态,但渲染只输出 {children}。展开/收起的交互逻辑还没接上------useEffect 引了但还没用。课堂进行时,见下一节。

拆分之后的好处已经能预见了:将来笔记项要展开收起、要高亮选中,改这个客户端小组件就行,外层的列表渲染和数据获取完全不用动。

十、课堂进行时:还没写完的部分

这节课是进行时,好几个地方停在"to be continue":

  • app/note/[id]/page.js 目前只有两行:

    jsx 复制代码
    // alias 
    import { getAllNotes } from "@/lib/redis";
  • app/note/edit/[id]/page.js 是空文件,等着实现编辑功能

  • SidebarNoteItemContent 的展开交互没接完

  • 搜索(SidebarSearchField)、新增(new 按钮 + /add POST)、删除、marked 渲染 markdown,都还在注释和需求清单里

但项目骨架已经完整立住了:路由树规划好了,组件树规划好了,数据服务通了,SEO 关键路径(layout meta + 服务端渲染的侧边栏导航)已经生效。

我现在的理解

这节课我没学什么新 API,最大的收获是一个顺序问题。

以前的我是打开编辑器就开写,写到哪算哪,组件命名随手来,最后文件互相 import 成蜘蛛网。这节课的操作顺序是:需求 → 路由树 → 组件树 → 目录职责 → 然后才是代码。每一层都是下一层的规范。

在 AI 辅助开发的语境下,这套顺序的价值被放大了。组件是工作单元,也是 AI 生成的工作单元------组件树画清楚,每个组件的职责写明白,AI 的产出才可控、可拼装。Next.js 16 甚至自己给 AI 生成了 AGENTS.md。规范不再只是给人看的,它成了人机协作的接口。

技术上的 takeaway 也记一笔:

  • RSC 拆 CSR 的原则:数据在服务端拿(async + await lib),交互在客户端做("use client" 小组件)
  • Redis 选型逻辑:内存快、key:value 够用、hash 类型正好存"ID → 序列化笔记";流量大了上 Redis + MySQL 组合
  • alias、BEM、语义化标签、to be continue 注释法,都是小习惯,叠起来就是可维护性

下节课补完 CRUD 和搜索,再回头看这篇,应该能看到"规划先行"省下的返工。

术语速查

  • marked:把 markdown 文本转换成 html 的 JS 库。数据库存 markdown 原文,展示时转 html。
  • 脚手架(scaffold):生成项目骨架的工具,create-next-app、create-vite 都是。
  • hydration(水合):服务端输出 HTML 后,React 在客户端注入事件与状态,使页面可交互的过程。
  • RESTful:一切皆资源的 URL 设计风格,用 HTTP 方法(GET/POST/PUT/DELETE)表达对资源的操作。
  • CRUD:Create / Read / Update / Delete,增删改查。
  • RPC(远程过程调用):像调用本地函数一样调用远端逻辑,HTTP API 是其常见实现形式。
  • NOSQL:非关系型数据库统称,Redis 是键值型代表。
  • 哈希表(hash) :field → value 的二层存储结构,Redis 的 hget/hset 操作它。
  • 序列化 :对象 ↔ 字符串的互转(JSON.stringify / JSON.parse),为了能存进 Redis。
  • 原子类:一个类名只干一件事的样式方案,tailwindcss 是代表;与 BEM 的语义化路线相对。
  • static server:静态资源服务,图片、svg 等文件不经逻辑处理直接返回。
  • ioredis:Node.js 的 Redis 客户端驱动。
  • dayjs:轻量日期格式化库。
  • Object.entries :把对象的键值对转成 [key, value] 二维数组,转完才能用 map
相关推荐
袅沫1 小时前
Nginx中部署多个前端项目——区分路径
服务器·前端
meilindehuzi_a1 小时前
Next.js 全栈基础实战:从 SPA、SSR 到 App Router 与 Todo API
开发语言·javascript·ecmascript
用户921080262861 小时前
6. 数据大屏 WebSocket 稳定性优化:心跳检测、断点续传和消息去重
前端
柚yuzumi1 小时前
CSS 定位布局:让元素各就各位
前端·css
光影少年1 小时前
react navite图片加载优化、大图卡顿、缓存策略
前端·react native·react.js
小林ixn1 小时前
用 Next.js 和 Redis 撸一个 Markdown 笔记系统:RSC 实战与组件化拆解
前端·redis·next.js
渣波1 小时前
重构旅行体验:基于 React 的 AI 旅游助手对话系统实战解析
前端·javascript
今日无bug1 小时前
列表转树:一道题搞懂 HashMap 在算法里的价值
前端·数据结构
用户921080262861 小时前
5. 数据大屏实时通信第一步:为什么选择 WebSocket,以及如何接入 Socket.IO
前端