读懂一个 Next.js 全栈笔记应用

一、这个项目到底在做什么

打开项目目录,你会看到一张"全家福"------这是一个用 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 接口

不需要手动维护路由配置文件------目录结构本身就是路由地图

四、布局:所有页面共享的外壳

layout.js 是整个应用的根布局

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 对象里解构出 titlecontentupdateTime
  • 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 是服务端组件,不能使用 useStateuseEffect 这些 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 (块):独立的组件,比如 sidebarnote
  • Element (元素):块的组成部分,用 __ 连接,比如 sidebar__headernote__title
  • Modifier (修饰符):状态或变体,用 -- 连接,比如 note--activebutton--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 的一部分。

九、一条完整的数据流

把上面所有的知识点串起来,一条笔记从数据库到用户屏幕的完整路径是:

  1. 用户访问页面,请求到达 Next.js 服务器
  2. 服务器执行 layout.js,渲染侧边栏 Sidebar
  3. Sidebar 是个 async 组件,直接 await getAllNotes() 从 Redis 读取数据
  4. 数据一层层往下传:SidebarSidebarNoteListSidebarNoteItem
  5. 服务器把整个页面(包括笔记列表)渲染成完整的 HTML
  6. HTML 发送到浏览器,用户立刻看到内容
  7. 浏览器下载 JavaScript,SidebarNoteItemContent(标记了 'use client')完成水合(hydration),变得可交互

这就是 Next.js App Router 的核心工作方式------服务端优先,客户端按需增强

十、总结

这个 Next.js 笔记应用虽然不大,但把 App Router 的核心概念都串起来了:

概念 在这个项目里的体现
文件即路由 app/notes/[id]/page.js 自动匹配 /notes/任意值
服务端组件 SidebarSidebarNoteList 默认在服务端运行,直接读 Redis
客户端组件 SidebarNoteItemContent'use client' 标记,为交互预留
布局嵌套 layout.js 提供全局侧边栏,children 插入不同页面内容
数据获取 getAllNotes() 在服务端组件里 await,数据直接渲染进 HTML
路径别名 @/lib/redis 代替 ../../../lib/redis

理解了这个项目的结构,你就掌握了 Next.js App Router 开发的基本范式------剩下的就是在骨架里填业务逻辑了。

相关推荐
古茗前端团队1 小时前
代码越改越乱?来试试前端领域模型驱动设计(DDD)
前端·agent·ai编程
Hilaku1 小时前
为什么跳槽涨薪 30% 的时代彻底结束了?聊聊 2026 前端薪资的天花板
前端·javascript·程序员
愚公搬代码2 小时前
【愚公系列】《Web应用安全》006-HackBar插件的使用
前端·安全
超爱吃香菜的菜鸟2 小时前
关于pnpm 部分使用(一)
前端·vue.js
Vuji2 小时前
Pi 插件解剖|git-checkpoint.ts:只用 53 行,让 fork 恢复代码状态
前端·agent
大家的林语冰2 小时前
✌️ 让 Rust 再次伟大,pnpm 12 抛弃 TypeScript,移植 Rust 原地起飞!
前端·javascript·node.js
XPoet3 小时前
AI 编程工程化:实战——从 0 到 1 搭建 AI 编程工作流
前端·后端·ai编程
-今昭-4 小时前
Logstash 管理
java·服务器·前端
名字还没想好☜4 小时前
React 用自定义 useDebounce Hook 治好搜索框频繁请求:防抖、竞态取消与卸载清理
前端·javascript·react.js·react·hooks