一、这个项目到底在做什么
打开项目目录,你会看到一张"全家福"------这是一个用 Next.js 构建的全栈笔记应用。它不需要你单独再写一个后端服务器,前端页面和后端接口都在同一个项目里搞定。
这个笔记系统的核心需求很清晰:
- 界面分两列:左侧是笔记列表,右侧是笔记内容
- 支持笔记的增删改查(也就是 CRUD:Create、Read、Update、Delete)
- 数据库里存的是 Markdown 格式的文本,页面展示时渲染成 HTML
- 支持搜索功能
简单来说,这就是一个带数据库的、能写能改能删的笔记工具。接下来我们逐层拆解它的实现。
二、项目是怎么搭起来的
2.1 npx:不用安装就能用的工具
创建一个 Next.js 项目,最直接的方式是:
bash
lua
npx create-next-app@latest
npx 是 npm 自带的工具,它的作用很直接:直接运行一个 npm 包,不需要先把它全局安装到电脑上 。
以前你要跑一个脚手架工具,得先 npm i -g create-next-app 全局安装,再执行 create-next-app。用 npx 一步到位,用完即走,不会在你电脑上留下全局依赖。
create-next-app 就是 Next.js 官方提供的脚手架------你可以把它理解成一个"项目生成器",帮你把目录结构、配置文件、基础依赖都搭好,你直接在这个骨架上写业务代码就行。
2.2 为什么选 Next.js
Next.js 是一个 React 全栈开发框架。它跟普通的 React 项目(比如用 Vite 或 Create React App 创建的项目)最大的区别在于:它默认在服务器端运行。
普通的 React 应用是典型的 SPA(单页应用)。用户访问页面时,浏览器先下载一个几乎空的 HTML,然后下载并执行 JavaScript,JavaScript 再去请求数据、渲染界面。这个过程有几个问题:
- 首屏加载慢------用户要等 JavaScript 下载完才能看到内容
- 搜索引擎的爬虫抓取页面时,可能只能抓到空的 HTML,抓不到实际内容
Next.js 的解决思路是:在服务器上就把组件跑完,生成完整的 HTML 再发给浏览器 。用户一打开页面就能看到内容,搜索引擎也能直接抓取到完整页面。这对内容型网站(比如博客、文档、笔记系统)来说很重要。
三、路由:文件就是路由地址
Next.js 的 App Router 采用了一套"文件即路由"的约定。你在 app 目录下创建什么文件夹和文件,它就对应什么 URL 路径。
3.1 基础路由
text
bash
app/
├── page.js → 访问 /
├── layout.js → 所有页面的外壳
└── notes/
└── page.js → 访问 /notes
page.js 定义了一个可访问的页面,layout.js 定义了页面的公共外壳(比如侧边栏、页头)。
3.2 动态路由:用 [id] 匹配任意值
笔记系统里,每篇笔记都有一个唯一的 ID。访问 /notes/123 要看 ID 为 123 的笔记,访问 /notes/456 要看 ID 为 456 的笔记------这两个 URL 共用同一个页面组件,只是 ID 不同。
在 Next.js 里,用方括号包裹文件名就能实现:
text
bash
app/
└── notes/
└── [id]/
└── page.js → 匹配 /notes/任意值
[id] 就是一个动态路由段,它会捕获 URL 中对应位置的值,然后作为参数传给页面组件。
3.3 这个项目的路由设计
text
bash
app/
├── page.js # 首页:左侧笔记列表 + 右侧内容
├── layout.js # 根布局:侧边栏 + 内容区
├── notes/
│ └── [id]/
│ └── page.js # 单篇笔记的详情页
├── edit/
│ ├── page.js # 新增笔记
│ └── [id]/
│ └── page.js # 编辑已有笔记
└── api/
└── notes/
└── route.js # 后端 API 接口
不需要手动维护路由配置文件------目录结构本身就是路由地图。
四、布局:所有页面共享的外壳
javascript
javascript
import './style.css';
import Sidebar from '@/components/Sidebar';
export default async function RootLayout({ children }) {
return (
<html>
<head>
<title>小夏的大模型工程师博客</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>
);
}
这里的 children 就是当前路由页面的内容。访问首页时它是 page.js,访问 /notes/123 时它是 notes/[id]/page.js。侧边栏 Sidebar 在所有页面都显示,不用重复写。
layout.js 还是 async 的------这意味着它可以在服务端异步获取数据。上面这段代码里,<title> 和 <meta> 标签直接写在 HTML 里,搜索引擎爬虫能直接读到。
五、别名配置:告别 ../../../
在 app/notes/[id]/page.js 里引入 lib/redis.js,如果用相对路径要写:
javascript
javascript
import { getAllNotes } from '../../../lib/redis';
目录深了之后,../../../ 会越来越多,既难看又容易出错。项目里配置了路径别名:
javascript
javascript
// 配置后,@ 直接指向项目根目录
import Sidebar from '@/components/Sidebar';
import { getAllNotes } from '@/lib/redis';
@ 就是根目录的快捷方式,不管在哪个文件里写 @/xxx,都会从项目根目录开始找。
六、组件规划:先想清楚再动手
这个项目的组件树是这样的:
text
markdown
Sidebar(服务端组件)
├── SidebarSearchField(待实现)
└── SidebarNoteList(服务端组件)
└── SidebarNoteItem(服务端组件)
└── SidebarNoteItemContent(客户端组件)
为什么要拆这么多层? 因为 Next.js 的 App Router 默认所有组件都在服务端运行。但有些东西必须在浏览器里做------比如点击事件、状态管理、展开收起这类交互。'use client' 就是用来标记"这个组件需要在浏览器端运行"的。
6.1 Sidebar:服务端组件,直接读数据库
javascript
javascript
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 (
<section className="col sidebar">
<Link href="/" className="sidebar-header">
<img className="logo" src="/logo.svg" width="22px" height="20px" />
<strong>LLM Notes</strong>
</Link>
<nav>
<SidebarNoteList notes={notes} />
</nav>
</section>
);
}
Sidebar 是一个 async 函数组件 ------它可以直接 await 数据库查询。在服务端执行完、拿到数据之后,再把渲染好的 HTML 发给浏览器。用户不需要等 JavaScript 下载就能看到笔记列表。
6.2 SidebarNoteList:遍历笔记列表
javascript
javascript
import SidebarNoteItem from '@/components/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="notes-list">
{arr.map(([noteID, note]) => (
<li key={noteID}>
<SidebarNoteItem noteID={noteID} note={JSON.parse(note)} />
</li>
))}
</ul>
);
}
Object.entries(notes) 把 Redis 返回的哈希对象转成 [[key1, value1], [key2, value2], ...] 这样的二维数组,方便用 map 遍历渲染。每个笔记的 value 是 JSON 字符串,需要 JSON.parse 解析成对象再传给子组件。
6.3 SidebarNoteItem:单条笔记的展示
javascript
javascript
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={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>
);
}
这个组件做了几件事:
- 从
note对象里解构出title、content、updateTime - 用
dayjs库格式化时间 - 取
content的前 20 个字符作为摘要 - 把标题和时间作为
children传给SidebarNoteItemContent,把摘要作为expandChildren传过去
这种插槽式传参(把 JSX 当 props 传)是 React 组件的常用模式。
6.4 SidebarNoteItemContent:客户端交互的入口
javascript
javascript
"use client";
import { useState, useEffect } from 'react';
export default function SidebarNoteItemContent({
id,
title,
children,
expandChildren,
}) {
return (
<>
{children}
</>
);
}
目前这个组件只是简单透传 children,但它用 'use client' 标记了自己是客户端组件。未来可以在里面加点击高亮、展开收起、删除确认弹窗等交互逻辑------这些都只能在浏览器里做。
为什么要拆一个单独的客户端组件出来,而不是直接把交互写在 SidebarNoteItem 里?
因为 SidebarNoteItem 是服务端组件,不能使用 useState、useEffect 这些 React Hooks。把需要交互的部分单独抽成一个客户端组件,其他部分仍然在服务端渲染------这就是 App Router 的"最小化客户端边界"策略。服务端组件负责数据获取和静态内容,客户端组件只负责交互。
七、数据服务:Redis 做存储
7.1 为什么选 Redis
这个项目选择了 Redis 作为数据库。Redis 是一个 key-value 内存数据库,数据存在内存里,读写速度非常快。
跟 MySQL 这类关系型数据库不同,Redis 没有"表"的概念,就是简单的键值对------有点像浏览器的 localStorage,但它是跑在服务器上的。
对于笔记系统这种数据结构不复杂的场景,Redis 足够用,而且速度快。
7.2 连接 Redis
javascript
javascript
import Redis from 'ioredis';
const redis = new Redis(); // 默认连接 localhost:6379
ioredis 是 Node.js 的 Redis 客户端。new Redis() 默认连接本地的 Redis 服务(端口 6379)。
7.3 数据结构设计
Redis 里存的是 Hash 类型------一个 key 对应多个 field-value 对:
text
json
key: "notes"
field: "1702459181837" → value: '{"title":"sunt aut","content":"...","updateTime":"..."}'
field: "1702459182837" → value: '{"title":"qui est","content":"...","updateTime":"..."}'
每个笔记的 ID 是 field,笔记的完整数据(标题、内容、更新时间)是一个 JSON 字符串作为 value。
7.4 获取所有笔记
javascript
rust
const initialData = {
"1702459181837": '{"title":"sunt aut","content":"quia et suscipit...","updateTime":"2023-12-13T09:19:48.837Z"}',
"1702459182837": '{"title":"qui est","content":"est rerum tempore...","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');
}
hgetall('notes') 获取整个 Hash 的所有 field-value 对。如果数据库是空的(第一次运行),就用 hset 写入初始数据。
八、BEM 命名规范
项目里用到了 BEM 命名规范来写 CSS 类名。
BEM 是 Block Element Modifier 的缩写:
- Block (块):独立的组件,比如
sidebar、note - Element (元素):块的组成部分,用
__连接,比如sidebar__header、note__title - Modifier (修饰符):状态或变体,用
--连接,比如note--active、button--large
项目代码里出现的类名:
html
xml
<section className="col sidebar">
<Link className="sidebar-header">
<strong>LLM Notes</strong>
</Link>
</section>
sidebar 是 Block,sidebar-header 是 Block 加 Element(用连字符,不是严格的 BEM 双下划线,但思路一致)。这种命名方式让 CSS 类名一目了然------看到 sidebar-header 就知道它是 sidebar 的一部分。
九、一条完整的数据流
把上面所有的知识点串起来,一条笔记从数据库到用户屏幕的完整路径是:
- 用户访问页面,请求到达 Next.js 服务器
- 服务器执行
layout.js,渲染侧边栏Sidebar Sidebar是个 async 组件,直接await getAllNotes()从 Redis 读取数据- 数据一层层往下传:
Sidebar→SidebarNoteList→SidebarNoteItem - 服务器把整个页面(包括笔记列表)渲染成完整的 HTML
- HTML 发送到浏览器,用户立刻看到内容
- 浏览器下载 JavaScript,
SidebarNoteItemContent(标记了'use client')完成水合(hydration),变得可交互
这就是 Next.js App Router 的核心工作方式------服务端优先,客户端按需增强。
十、总结
这个 Next.js 笔记应用虽然不大,但把 App Router 的核心概念都串起来了:
| 概念 | 在这个项目里的体现 |
|---|---|
| 文件即路由 | app/notes/[id]/page.js 自动匹配 /notes/任意值 |
| 服务端组件 | Sidebar、SidebarNoteList 默认在服务端运行,直接读 Redis |
| 客户端组件 | SidebarNoteItemContent 用 'use client' 标记,为交互预留 |
| 布局嵌套 | layout.js 提供全局侧边栏,children 插入不同页面内容 |
| 数据获取 | getAllNotes() 在服务端组件里 await,数据直接渲染进 HTML |
| 路径别名 | @/lib/redis 代替 ../../../lib/redis |
理解了这个项目的结构,你就掌握了 Next.js App Router 开发的基本范式------剩下的就是在骨架里填业务逻辑了。