从零读懂一个 Next.js 全栈笔记应用
这篇文章基于一个 next-blog 笔记项目,逐文件讲解它的技术背景、目录结构、路由设计、组件拆解和数据服务。
一、技术背景:为什么用 Next.js
README 里开篇就点明了这个项目为什么选 Next.js 和它背后的关键概念。
1. npx 的作用
lua
npx 是 npm 自带工具,可直接运行 node 包,无需全局安装依赖
npx = npm i -g create-next-app + create-next-app
npx是 npm 自带的工具,可以直接运行 node 包,不需要全局安装依赖。- 用
npx create-next-app就能快速搭建项目,等价于「先全局装create-next-app,再运行它」。 - 好处是便捷,适合快速试用、测试电脑能不能跑项目。
2. 为什么是 Next.js(React 全栈脚手架)
Next.js 是一个 React 全栈开发脚手架,README 里列出了几个关键概念:
| 概念 | 含义 |
|---|---|
| SSR | 服务器端渲染(Server-Side Rendering) |
| SEO | 搜索引擎优化(Search Engine Optimization) |
| RSC | React Server Component(React 服务端组件) |
- SSR 让页面在服务器渲染,利于 SEO。
use client用来标记客户端组件,配合 hydration(水合)机制。
这些概念在后面的代码里会反复出现------比如组件里是否写 "use client",决定了它是服务端组件还是客户端组件。
二、项目需求:一个 markdown 笔记系统
README 明确了这个项目要做什么:
- 做一套笔记系统 ,支持笔记的 CRUD(增删改查),支持 markdown 格式。
- 关键设计:数据库里存的是 markdown 文本,页面显示的是 HTML ,中间用
marked库做转换。
具体功能点拆解如下:
- 界面分两列 :左侧笔记列表,右侧笔记内容(对应
/的page.js)。 - 点击 new 增加一条笔记,增加后左侧列表同步更新。
- 编辑功能,可以删除一条笔记,左侧同步更新。
- 编辑当前笔记,支持 markdown。
- 搜索功能。
路由上也对应了这些需求(App Router 文件即路由):
bash
/add POST 新增
/note/[id] 动态路由,page.js 为笔记详情
/note/[id]/edit 修改
/edit page 新增一条
三、目录结构
项目采用 Next.js 的约定式目录:
diff
- app 页面主目录
page.js 首页
layout.js 布局
[id] 动态路由
- components 组件
- lib 数据库操作、常用函数
- public 静态资源(static server)
核心约定:数据业务逻辑放在 lib 目录 ,组件放在 components,页面放在 app。
四、配置 alias:@ 直达根目录
在 app/notes/[id]/page.js 里引入 lib/redis.js 时,如果用相对路径要写 ../../../lib/redis.js,很麻烦。于是配置了 alias 短链接:
vbnet
baseUrl: .
path:
@/components/*
@/lib/*
配置后,@ 直接指向根目录,就能写成:
javascript
import Sidebar from '@/components/Sidebar'
import { getAllNotes } from '@/lib/redis'
这在我们实际代码里(app/layout.js、components/Sidebar.js、SidebarNoteItem.js)都已经用上了。
五、布局:app/layout.js
layout.js 是页面的根布局,代码本身就是一个 async 组件:
javascript
import './style.css'
import Sidebar from '@/components/Sidebar'
export default async function RootLayout({ children }) {
return (
<html>
<head>
<title>HHGZ的博客</title>
<meta name="description" content="..." />
<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>
)
}
从这个文件能看到几个知识点:
-
layout 的层级结构(README 里有梳理):
csslayout └── html ├── head │ ├── title │ └── meta └── body ├── nav 侧边栏、导航栏 └── children page.js -
两列布局 :左侧
<Sidebar/>(笔记列表),右侧<section className="col note-viewer">{children}</section>(笔记内容),正好对应需求里的「左右两列」。 -
children就是子页面 :{children}的位置会渲染对应的page.js。
六、首页占位:app/page.js
javascript
// 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 先去后端取数据。当前这个首页还没写取数据的逻辑,只是返回一个空状态提示。
七、组件拆解:规范驱动编程 + BEF
1. 规范驱动编程
README 强调了一个重要的工作方法------开发之前不要急着写代码,而是先:
- 分析需求
- 确定技术方案(next.js)
- 拆解任务细节:路由 + 组件
- 规划需要哪些组件
其中「组件是工作单元,AI 生成的工作单元」------把大任务拆成一个个小组件,再由 AI 逐个生成。
项目规划出的组件树:
markdown
Sidebar
SidebarSearchField
EditButton(复用)
SidebarNoteList
NoteItem
Note
NoteEditor 编辑
NotePreview 负责笔记的预览界面
2. BEF 命名规范
配合原子类 tailwindcss,项目采用 BEF 命名规范(Block / Element / Modifier):
- Block:块
- Element :元素,用
_连接 - Modifier :修改器,用
__连接
对应到代码里的 class 命名,例如 note--empty-state、note-text--empty-state,都遵循这套约定,方便维护。
3. 「to be continue」注释大法
README 提到,在代码里写注释来规划未来要做的事 (to be continue),有利于团队协作、记忆和维护------把「要做的事情」写在注释里。在 Sidebar.js 里就能看到这样的占位注释:
css
<section className="sidebar-menu" role="menubar">
{/*SidebarMenu*/}
</section>
以及解释区块语义的注释:
arduino
{/* sidebar
区块 电商网站,商品介绍,评论 图片,售价...
语义是独立的一块内容区域 幻灯片 */}
八、侧边栏组件逐文件拆解
1. Sidebar.js ------ 侧边栏容器
javascript
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">
{/*SidebarMenu*/}
</section>
<nav>
<SidebarNoteList notes={notes} />
</nav>
</section>
)
}
知识点:
Sidebar也是 async 组件 ,先用await getAllNotes()从后端(redis)取笔记数据。- 使用
next/link的<Link>做站内导航,href="/"回到首页。 - 图片
role="presentation"表示纯装饰性图片,不对读屏器暴露语义。 <nav>里渲染<SidebarNoteList notes={notes} />,把取到的笔记传给子组件。
2. SidebarNoteList.js ------ 笔记列表
javascript
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]) => (
<li key={noteId}>
<SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
</li>
))
}
</ul>
)
}
关键点:
Object.entries(notes)把 hash 对象转成 二维数组[[key, value], ...],方便用map遍历生成组件。- 数组为空时渲染「No Notes created yet!」空状态。
- 每条笔记的 value 是从 redis 里拿到的 JSON 字符串 ,所以要
JSON.parse(note)还原成对象再传给子组件。 - 用
noteId作为key。
3. SidebarNoteItem.js ------ 单条笔记
javascript
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>
)
}
知识点:
- 解构笔记字段:
title、content(默认空字符串'')、updateTime。 - 摘要:用
content.substring(0, 20)截取前 20 个字符,没有内容则显示<i>(No content)</i>。 - 时间格式化:用
dayjs(updateTime).format('YYYY-MM-DD')(dayjs 是引入的日期库)。 - 这里体现了组件复用/抽取 的思路:把列表项拆分成了
SidebarNoteItem+SidebarNoteItemContent两层。
4. SidebarNoteItemContent.js ------ 客户端组件
javascript
"use client";
import { useState, useEffect } from 'react';
export default function SidebarNoteItemContent({ id, title, children, expandChildren }) {
return (
<>
{children}
</>
)
}
知识点:
- 顶部
"use client"标记这是一个客户端组件(区别于前面默认的服务端组件)。 - 引入了
useState、useEffect两个 React Hook(虽然当前代码里还没用到)。 - 组件接收
id、title、children、expandChildren四个 props。当前实现只渲染了{children},expandChildren(展开后的摘要)目前还没被渲染出来------这是一个「留有扩展空间」的占位设计,正好呼应 README 里的「to be continue 注释大法」。
5. SidebarNoteList2.js ------ 对照:内联写法
项目里还有一个 SidebarNoteList2.js,它没有把单条笔记抽成组件,而是直接在 map 里内联渲染:
javascript
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>
)
})
对比 SidebarNoteList.js,两者的差异正好说明「组件抽取 vs 内联」的区别:
| SidebarNoteList.js | SidebarNoteList2.js | |
|---|---|---|
| 单条结构 | 抽成 SidebarNoteItem 组件 |
直接内联在 map 里 |
| 摘要字段 | 有 content 摘要 |
没有摘要,只有标题 |
| 时间格式 | YYYY-MM-DD |
YYYY-MM-DD HH:mm:ss |
抽组件的版本更利于复用和后续扩展,内联版本更简单直接。
九、数据服务:lib/redis.js(Redis)
1. 为什么选 Redis
README 介绍,这个项目的数据服务选择了 Redis:
- Redis 是一个 NOSQL 内存数据库 ,默认 6379 端口。
- 没有数据表 ,不是关系型数据库,不用 SQL 驱动 ,数据存在内存里。
- 用法「有点像 localStorage」,直接
key: value开搞。
它「高级的地方」在于:对不同类型的值有优化的存储方式和对应的方法:
| 数据类型 | 方法 |
|---|---|
| 字符串 | get / set |
| 哈希 | hget / hset |
典型用途:缓存、计数器、榜单。
2. Redis + MySQL 的缓存场景
README 用「掘金首页文章列表」举了个很直观的例子:
- 首页文章列表「几分钟之内是不变的」。
- 第一个用户 来访问时,查 MySQL 数据库拿到
posts列表,以key: value形式存进 Redis。 - 下一个用户再来,直接从 Redis 读取,不再查 MySQL。
这样解决了「数据可读写的 I/O 瓶颈」,用 Redis 做缓存挡在 MySQL 前面。
3. lib/redis.js 代码
javascript
// node redis 客户端,驱动
import Redis from 'ioredis';
const redis = new Redis(); // 默认 NOSQL
// hash key 字符串ID,值 note 的序列化字符串
const initialData = {
"1702459181837": '{"title":"sunt aut",...}',
"1702459182837": '{"title":"qui est",...}',
"1702459188837": '{"title":"ea molestias",...}'
}
export async function getAllNotes() {
// hash 数据类型
const data = await redis.hgetall('notes');
if (Object.keys(data).length == 0) {
await redis.hmset("notes", initialData);
}
return await redis.hgetall('notes');
}
知识点:
- 用
ioredis作为 node 的 redis 客户端(驱动)。 new Redis()连接默认配置的 Redis 实例。- 数据用 hash 类型 存:外层 key 是
notes,里面的 field 是「字符串 ID」,value 是「note 的序列化字符串(JSON)」。 initialData是初始化用的三条种子笔记。getAllNotes逻辑:先hgetall('notes')取所有笔记;如果为空,就hmset("notes", initialData)写入初始数据;最后返回全部笔记。
这个函数正是前面 Sidebar.js 里 await getAllNotes() 调用的数据来源,也印证了「lib 目录放 next.js 的数据业务逻辑」。
4. 关于接口:RPC
README 末尾还提到 /app/api/route.js ------ 接口的 RPC 远程调用(这个文件当前项目里还没实现,属于待补充的部分)。
十、总结
项目总结
这是一个基于 Next.js + Redis 的 markdown 笔记应用,整体架构清晰:
- 前端展示 :Next.js 的 App Router,采用「文件即路由」,
layout.js负责左右两列布局,page.js负责各页面。 - 组件体系 :用「规范驱动编程」先规划再实现,按 BEF 命名规范拆分出
Sidebar→SidebarNoteList→SidebarNoteItem→SidebarNoteItemContent的组件树;通过"use client"区分客户端/服务端组件。 - 数据层 :用
ioredis连接 Redis(NOSQL 内存数据库),用 hash 类型存储笔记(field 为 ID、value 为 JSON 字符串),lib/redis.js统一封装数据访问。 - 路径优化 :通过 alias 让
@/直达根目录,避免冗长的相对路径。
知识点总结
- npx :npm 自带工具,无需全局安装即可运行 node 包,
npx create-next-app快速脚手架。 - Next.js 核心概念 :SSR(服务端渲染)、SEO、RSC(服务端组件)、
use client客户端组件、hydration 水合。 - App Router 文件即路由 :
/addPOST、/note/[id]动态路由、/edit等。 - async 组件 :RSC 组件写成
async,可以await先去后端取数据。 - 规范驱动编程:开发前先分析需求、拆任务(路由 + 组件),组件是工作单元。
- BEF 命名规范 :Block / Element(
_)/ Modifier(__),配合 tailwindcss 原子类。 Object.entries:把 hash 对象转成二维数组,方便map渲染列表。- JSON.parse:redis 存的是字符串,取出来要先 parse 成对象。
- dayjs :日期格式化库,
format('YYYY-MM-DD')等。 - ioredis + Redis :NOSQL 内存数据库(6379 端口),hash 类型用
hgetall/hmset,可做缓存、计数器、榜单,常用于 Redis + MySQL 的缓存场景。 - alias 配置 :
@/components/*、@/lib/*,@直达根目录。 - 「to be continue」注释大法:用注释规划待做事项,利于协作与维护。
这个项目麻雀虽小、五脏俱全:从脚手架、路由、组件拆解,到数据服务和缓存设计,正好是一条完整的 Next.js 全栈开发学习路径。