🏠 「LLM Notes」:我用 Next.js + Redis 给自己造了个笔记博客

上几节课我们把 Next.js 的概念(App Router、RSC、'use client'、水合)和基础设施(Docker、MySQL、Nginx)都过了一遍,这节课终于来真的------做一个完整的笔记系统,一个笔记博客,名字就叫 LLM Notes。界面分两列:左边是笔记列表,右边是笔记内容;数据存在 Redis 里(key:value 的内存数据库);组件按职责分层:RSC 负责拉数据 + SEO,'use client' 负责交互。除了写代码,这节课还教了一堆"工程化心法":规范驱动编程(写代码前先规划组件)、注释大法(把未来要做的事写进注释)、@ alias 别名(告别一长串 ../../../)。 a


一、开工之前:规范驱动编程

动手写代码之前,老师先讲了个方法论------规范驱动编程。readme 原话:

"开发之前不要急着写代码。分析需求,技术方案(next.js)任务细节 路由 + 组件"
"组件时工作单元,AI 生成的工作单元"

在 AI 时代,组件就是"工作单元"------每个组件都是一个可以被 AI 独立生成的模块。所以写代码前要先规划好需要哪些组件。老师规划的组件树长这样:

text 复制代码
Sidebar
├── SidebarSearchField   # 搜索框(未来干)
├── EditButton           # 编辑按钮(复用)
└── SidebarNoteList
    └── NoteItem         # 单条笔记
Node
├── NoteEditor           # 编辑区
└── NotePreview          # 笔记预览

配套的目录结构:

text 复制代码
app           # 页面主目录:page.js、layout.js、[id] 动态路由
components    # 组件
lib           # 数据库操作、常用函数
public        # 静态资源 static server

先规划,再动手------这就是"规范驱动编程",也是 AI 协作开发的正确姿势。

二、npx 与 create-next-app:脚手架的打开方式

项目怎么来的?readme 补了两个基础概念:

"npx 是 npm 自带工具,可直接运行 node 包,无需全局安装依赖。" "npx = npm i -g create-next-app + create-next-app"

npx create-next-app 一行命令,等于"全局安装 create-next-app + 运行它",用完即走,不污染全局。它是 React 全栈开发的脚手架,自带 SSR(服务器端渲染)、SEO、RSC(React Server Component)、'use client' 水合这些全套能力------前面几节课学的概念,全被这个脚手架包圆了。

三、全局观:布局与 SEO(layout.js)

先看根布局。注意这个函数是 async 的------服务器组件,可以在服务器上等数据:

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

export default async function RootLayout({children}) {
  return (
    <html>
      <head>
        <title>qxh の Next Blog</title>
        <meta name="description" content="这是一位未来大模型工程师的笔记,多年心血,深入讨论llm学习心得体会" />
        <meta name="keywords"
        content="llm, claude, deepseek, rag, langchain" />
      </head>
      <body>
        <div className="container">
          <div className="main">
            <Sidebar />
            <section className="col note-viewer">{children}</section>
          </div>
        </div>
      </body>
    </html>
  )
}

这就是 SEO 三件套的"实体版":title(博客名)、description("未来大模型工程师的笔记,多年心血,深入讨论 llm 学习心得体会")、keywords(llm、claude、deepseek、rag、langchain)------一眼看出这是个 AI 学习博客,这关键词选得,爬虫看了都得点个收藏。

布局结构:左边 <Sidebar />(笔记列表),右边 <section className="note-viewer">{children}</section>(笔记内容),正是两列布局的骨架。类名 note-viewer 这种写法是有讲究的------BEM 命名规范:

"Block 块,Element 元素 _,Modifier 修改器 __"

sidebar-note-header 就是 Block(sidebar-note)+ Element(header)。规范驱动,连命名都规范。

四、数据服务:为什么选 Redis?

笔记数据存哪?老师选了 Redis------key:value 的 NOSQL 内存数据库。readme 讲得很通俗:

"6379 端口 没有数据表,不是关系型,不用SQL 驱动,在内存中。有点像localStorage 直接key:value 开搞。" "高级的地方是 对不同类型的数据 有优化的存储方式 不同的方法:字符串 直接get/set,哈希 hget/hset。缓存、计数器、榜单。"

Redis 最经典的应用就是给 MySQL 当缓存层,readme 里的例子是掘金:

"掘金首页,文章列表 几分钟之内,不变的。第一个用户来的时候 查mysql 数据库 posts 列表 key:value 存到 redis中。下一个用户来,从redis 中读取。"

第一个用户来了查数据库、写进 Redis;后面所有用户直接读内存------这就是"缓存"的本质,也是解决数据库读写 I/O 瓶颈的常用套路。

数据访问代码在 lib/redis.js

js 复制代码
// node redis 客户端,驱动
import Redis from 'ioredis';
const redis = new Redis();// 默认 NOSQL
// hash key 字符串ID,值 note 的序列化字符串
// redis key:value value 特别支持hash 类型
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() {
    // hash 数据类型
    const data = await redis.hgetall('notes');
    if(Object.keys(data).length == 0) {
        await redis.hset("notes", initialData);
    }
    return await redis.hgetall('notes');
}

几个要点:

说明
ioredis Node 的 redis 客户端驱动
hgetall('notes') 一次性取出 hash 类型的所有字段
hset("notes", initialData) 第一次访问没有数据时,写入初始数据
序列化字符串 hash 的 value 是笔记 JSON 序列化后的字符串

下次启动项目,Redis 里没有笔记?hset 一发入魂,初始数据直接灌进去。 这就是"懒加载初始化"。

五、RSC 组件流水线:数据一路流到界面

数据拿到之后,怎么流到界面上?这条链路是这节课的精华,一共四层,层层分工:

text 复制代码
Sidebar(RSC,async,await 数据)
  → SidebarNoteList(RSC,遍历列表)
    → SidebarNoteItem(RSC,单条笔记)
      → SidebarNoteItemContent('use client',交互)

第一层 Sidebar------async 服务器组件,直接 await 从 Redis 拿数据:

js 复制代码
export default async function Sidebar() {
    const notes = await getAllNotes();
    console.log(notes);
    return (
        <>
            <section className="col sidebar">
                <Link href="/" className="sidebar-header">
                    <img className="logo" src="/logo.svg" ... />
                    <strong>LLM Notes</strong>
                </Link>
                <section className="side-menu" role="manubar">
                    {/* SideSearchField 未来干 */}
                </section>
                <nav>
                    {/* SidebarNoteList */}
                    <SidebarNoteList notes={notes} />
                </nav>
            </section>
        </>
    )
}

注意那两个注释------"SideSearchField 未来干"、"SidebarNoteList"。这就是"注释大法":

"to be continue 注释大法:规划未来做的,有利于团队协作,记忆,维护,注释好要做的事情。"

还没做的功能,先写进注释占个位,代码里永远有"TODO 地图"。

第二层 SidebarNoteList------RSC,把 hash 对象转成数组再遍历:

js 复制代码
import SidebarNoteItem from "@/components/SidebarNoteItem";
// SidebarNoteList(RSC SEO) -> 拆出来 SidebarNoteItem (交互 CSR)
export default async function SidebarNoteList({ notes }) {
    const arr = Object.entries(notes);// hash 转成二维数组 方便map 组件
    if (arr.length == 0) {
        return <div className="motes-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) 把 Redis 的 hash 对象转成 [[id, note], ...] 二维数组,才好 map 渲染。代码注释里还有老师的思路:"SidebarNoteList(RSC SEO) -> 拆出来 SidebarNoteItem (交互 CSR)"------列表保持 RSC 利于 SEO,把可能有交互的部分拆到下一层去。

第三层 SidebarNoteItem------RSC,单条笔记的标题 + 时间 + 摘要:

js 复制代码
import dayjs from 'dayjs';
import SidebarNoteItemContent from 
    '@/components/SidebarNoteItemContent';

export default async 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 excerpt)</i>}
                </p>
            }
        >
            <header className="sidebar-note-header">
                <strong>{title}</strong>
                <small>{dayjs(updateTime).format('YYYY-MM-DD')}</small>
            </header>
        </SidebarNoteItemContent>
    )
}

摘要取内容前 20 个字(content.substring(0, 20)),没有内容就显示 (No excerpt);时间用 dayjs 格式化成 YYYY-MM-DD。把纯展示的部分留在 RSC,把"可能会交互"的部分包给下一层。

第四层 SidebarNoteItemContent------'use client',交互的"前线":

js 复制代码
"use client";
import { useState, useEffect } from "react";
export default function SidebarNoteItemContent({
    id,
    title,
    children,
    expandChildren 
}) {
    return (
        <>
            {children}
        </>
    )
}

目前只渲染 children,但已经提前声明了 expandChildren(展开后的内容)------未来做"点击展开摘要"交互时,直接在这里用 useState 控制显隐。这就是"先把壳造好,交互后面填"。

六、@ alias:告别一长串 ../../../

层级深了,相对路径就会变成灾难。readme 举了个例子:

"/app/notes/id/page.js 引入 lib/redis.js,相对路径 ../../../lib/redis.js,短连接 @/lib/redis.js"

老师还单独写了个 page.js 演示 alias 的用法:

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

配置也写在了 readme 里:

"baseURL . path "@/components/": "components/* ", "@/lib/": "lib/*"。@ 直接来到根目录"

@ 直接指向项目根目录,@/lib/redis.js 就是根目录下的 lib/redis.js再也不用数 ../../../ 有多少个点了。

七、RSC 的 async:服务器上等数据

首页 page.js 的注释是老师的原话,把 RSC 的精髓讲透了:

js 复制代码
// RSC 组件 async 异步 为了 await先去 后端数据
export default async 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>
  )
}

"RSC 组件 async 异步,为了 await 先去后端数据" ------服务器组件声明成 async,直接 await 拿数据,数据到位了再渲染 HTML 发给浏览器。这就是为什么 RSC 天生适合 SSR + SEO。


八、总结:LLM Notes 的技术地图

技术 在这节课的角色
npx / create-next-app 脚手架,自带 SSR / SEO / RSC / 水合
规范驱动编程 先规划组件树,再写代码
layout.js async 根布局 + SEO 三件套(title/description/keywords)
BEM 命名规范:Block _ Element __ Modifier
Redis key:value 内存数据库,hash 存笔记,给 MySQL 当缓存层
ioredis Node 的 redis 驱动,hgetall / hset
RSC 流水线 Sidebar → SidebarNoteList → SidebarNoteItem → SidebarNoteItemContent
'use client' 最内层交互组件,未来做展开交互
@ alias @/lib/redis.js 代替 ../../../lib/redis.js
注释大法 "SideSearchField 未来干"------把未来写进注释

这节课是前面所有 Next.js 知识的"总装车间":RSC 拉数据、'use client' 做交互、Redis 当仓库、SEO 进 head,一个真实的笔记博客就立起来了。


写在最后

这节课最大的收获,是看到了一个"正经项目"是怎么一步步长出来的:先规范驱动规划组件树,再 npx 起脚手架,layout 布局 + SEO,Redis 接数据,RSC 四层流水线把数据一路送到界面,'use client' 在最内层等着做交互,连命名(BEM)和路径(@ alias)都有讲究。最妙的是"注释大法"------代码里写着"SideSearchField 未来干",就像游戏里的主线任务提示,下次打开项目就知道下一步该干嘛。笔记系统还没写完(动态路由 id、编辑、删除、搜索都在 TODO 里),学完继续更!

下次面试官问你:"Next.js 里 RSC 和 'use client' 怎么分工?Redis 为什么适合做缓存层?"

你可以淡定地说:

"在 Next.js 的笔记系统里,组件按职责分层:Sidebar、SidebarNoteList、SidebarNoteItem 都是 RSC(React Server Component),async 函数直接在服务器上 await 从 Redis 拉数据,输出完整 HTML,利于 SEO;最内层的 SidebarNoteItemContent 用 'use client' 声明,负责展开收起这类交互------这就是'能服务器渲染的尽量服务器渲染,非交互不可的才到客户端'的分工。数据层用 ioredis 连接 Redis,笔记存成 hash 类型,hgetall 一次性取出。Redis 是 key:value 的内存数据库,6379 端口,没有表结构,读写极快,适合做 MySQL 前面的缓存层------比如掘金首页文章列表几分钟内不变,第一个用户来了查 MySQL 存进 Redis,后面的用户直接从 Redis 读,避免每次都打数据库。"

然后看着面试官满意的表情,心里默念:这波,又稳了。


本文所有代码示例均来自课堂学习资料,真实可运行。

相关推荐
风骏时光牛马21 分钟前
AI开发平台异常指标实时监控告警
前端