从 0 拆解一个 Next.js 笔记系统:npx、App Router、RSC 与组件规划全记录
这是一份「带着问题学」的实战笔记。动手写代码前,先把三个最基础的疑问搞清楚,后面每个知识点都能落回它们身上。
一、三个课前疑问,先把地基打牢
① 什么是 npx?
一句话:npx 是 npm 自带的包执行工具,让你不全局安装就能直接运行一个 node 包。
它解决了一个很烦人的场景:很多命令行工具(比如脚手架 create-next-app)你只用一次,犯不着 npm install -g 装到全局污染环境。用 npx 就能「即用即下,用完即删」。
它的查找顺序是:本地 node_modules/.bin → 全局 → 远程临时下载。
perl
npx create-next-app my-app # 临时下载并执行,不污染全局
npx eslint . # 优先跑本地装的 eslint
笔记里写的
npx = npm i -g create-next-app + create-next-app,本质就是这个意思:npx 帮你把「先全局装、再执行」这两步合并成一步,更便捷。
② 什么是 create-next-app?
一句话:create-next-app 是 React 全栈开发脚手架,用来一键生成一个能直接跑的 Next.js 项目。
运行 npx create-next-app@latest 后,它会帮你选 TypeScript、ESLint、Tailwind、App Router 等配置,然后生成目录结构、装好依赖、配好基础文件。
它背后承载的是 Next.js 的几个核心能力(下面都会用到):
| 缩写 | 全称 | 一句话理解 |
|---|---|---|
| SSR | Server-Side Rendering | 服务器端渲染,HTML 在服务器上拼好再发给浏览器,首屏快 |
| SEO | Search Engine Optimization | 因为返回的是完整 HTML,搜索引擎爬得到内容,利于收录 |
| RSC | React Server Component | React 组件直接在服务器 上运行,能直接 await 拿数据 |
| hydration | 水合 | 服务器发来静态 HTML,浏览器再「浇」上 JS 让它变可交互 |
注意
use client这行标记:加了它组件才在浏览器端跑;默认不加就是 Server Component,在服务器端跑。这是理解后面async的关键。
③ 页面函数为什么要写 async?
先看这段代码:
javascript
// app/page.js
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>
)
}
为什么函数前面有 async?
因为 Page 是一个 Server Component(RSC),它在服务器端运行 。加上 async 后,这个组件函数返回一个 Promise,Next.js 会等 Promise 解析完再渲染------也就是等数据就绪再出结果。
这样我们就能在组件里直接 await 拉数据:
javascript
export default async function Page() {
const notes = await getAllNotes() // 直接在服务端拿数据
return <div>{notes.map(...)}</div>
}
而不用回到传统的客户端套路(useEffect + useState 去请求):
scss
function Page() {
const [data, setData] = useState(null)
useEffect(() => { fetch('/api').then(r => setData(r)) }, [])
// 有 loading 闪烁、首屏更慢、代码更啰嗦
}
一句话总结:async 让服务端组件能直接 await 后端数据,避免用 useEffect 的客户端写法。 组件注释里那句「为了 await 先取后端数据」正是这个意思。
二、项目需求:一个支持 Markdown 的笔记系统
- 功能:笔记的 CRUD(增删改查),支持 Markdown 格式。
- 存储与展示的分离 :数据库里存的是 Markdown 原文 ,页面上展示的是渲染后的 HTML (这里用
marked这个库把 markdown 转成 html)。 - 界面 :两栏布局------左侧笔记列表,右侧笔记内容。
这是典型的「存原格式、展示渲染结果」设计,能保证数据源干净、可复用。
三、路由设计:App Router 的「文件即路由」
Next.js App Router 的核心思想是 目录结构 = 路由结构,是 RESTful 风格的映射:
bash
/ → app/page.js 首页(右侧空状态)
/add (POST) → 新增一条笔记
/note/[id] → app/note/[id]/page.js 笔记详情(动态路由)
/edit/[id] → 修改某条笔记
page.js代表「这个路径有一个可访问的页面」。[id]是动态路由段 ,方括号里的id会作为参数传给页面组件,用来访问/note/123、/note/456等任意 id。
路由结构图:
css
graph TD
ROOT["app/ 根路径 /"] --> L["layout.js 根布局(包裹所有页面)"]
ROOT --> P["page.js 首页 /"]
ROOT --> N["note/[id]/page.js 详情 /note/:id"]
ROOT --> A["/add (POST) 新增"]
ROOT --> E["edit/[id]/page.js 修改 /edit/:id"]
四、组件规划:规范驱动编程
笔记里强调一个重要的开发姿势:开发之前不要急着动手写代码。
先做三件事:分析需求 → 定技术方案(Next.js)→ 拆任务细节(路由 + 组件) 。这种「规范驱动编程」把组件当成工作单元,规划好再写(也方便交给 AI 逐块生成)。
规划出的组件树:
css
graph TD
RootLayout["RootLayout 根布局"] --> Sidebar["Sidebar 左侧栏"]
RootLayout --> Page["Page 右侧内容"]
Sidebar --> SSF["SidebarSearchField 搜索框"]
Sidebar --> EB["EditButton 编辑按钮(复用)"]
Sidebar --> SNL["SidebarNoteList 笔记列表"]
SNL --> NI["NoteItem 单条笔记"]
Page --> Note["Note 笔记容器"]
Note --> NE["NoteEditor 编辑"]
Note --> NP["NotePreview 预览"]
要点:
EditButton被标注为「复用」------搜索、列表等多处都会用到同一个编辑按钮,抽成独立组件避免重复。Note再拆成NoteEditor(编辑)和NotePreview(预览) ------读和写是两个职责,分开组件各自维护。
五、目录结构:四个目录各司其职
bash
next-blog/
├── app/ # 页面主目录(路由)
│ ├── page.js
│ ├── layout.js
│ └── note/[id]/page.js
├── components/ # 组件
├── lib/ # 数据库操作、常用函数
├── public/ # 静态资源(static server,直接托管,如 logo.svg)
app决定路由和页面。components放可复用组件。lib放数据访问逻辑(比如后面要写的 redis 操作)。public放静态资源,直接由静态服务器托管,不经过编译。
六、配置 alias:告别 ../../../ 地狱
动态路由页面(app/note/[id]/page.js)要引入 lib/redis.js,如果用相对路径会写成:
javascript
import { getAllNotes } from '../../../lib/redis.js' // 丑,还容易数错层级
通过 jsconfig.json 配置路径别名 后,就能用 @ 直接指向项目根目录:
json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"]
}
}
}
于是引入变成清爽的短链接:
javascript
import { getAllNotes } from '@/lib/redis'
baseUrl: "." 指定相对基准是项目根目录,paths 把 @/lib/* 映射到 lib/*。 @ 直接来到根目录,路径清晰且不会因为目录层级变动而改代码。
七、布局 layout 与 SEO metadata
layout.js 是根布局,定义了整个页面的「骨架」:
javascript
import './style.css';
import Sidebar from '../components/Sidebar';
export const metadata = {
title: 'djz的大模型工程师博客',
description: '这是一位未来大模型工程师的笔记......',
keywords: ['llm', 'claude', 'deepseek', 'rag', 'langchain'],
};
export default function RootLayout({ children }) {
return (
<html lang="zh-CN">
<body>
<div className="container">
<div className="main">
<Sidebar />
<section className="col note-viewer">{children}</section>
</div>
</div>
</body>
</html>
);
}
嵌套结构对应笔记里画的:
css
graph TD
html --> head["head (title / meta)"]
html --> body
body --> container["div.container"]
container --> main["div.main"]
main --> Sidebar["Sidebar 侧边栏"]
main --> viewer["section.col.note-viewer"]
viewer --> children["children = page.js 当前页面"]
三个要点:
layout.js包裹children:{children}就是当前路由的page.js。切到/note/1时,children就变成note/[id]/page.js的内容,而Sidebar保持不动------这正是 App Router 嵌套布局的核心价值:共享部分不重渲染。metadata导出对象是 SEO 的体现 :Next.js 会把这些字段渲染成<head>里的<title>、<meta name="description">、<meta name="keywords">。因为是服务端渲染,搜索引擎能直接爬到完整 HTML,利于收录。<html lang="zh-CN">声明了页面语言,对无障碍和 SEO 都有帮助。
八、其余代码逐文件解析
1. components/Sidebar.js ------ 左侧栏
javascript
import React from 'react';
import Link from 'next/link';
export default async function Sidebar() {
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">
{/* SidebarSearchField 未来干 */}
</section>
</section>
</>
);
}
Link来自next/link,是 Next.js 内置的路由跳转组件,href="/"点击回到首页。它比原生<a>好在对内部路由做客户端跳转,不整页刷新。role="presentation"是 ARIA 无障碍属性,告诉读屏器这个logo图片只是装饰、没有语义,可忽略------所以标题LLM Notes才是真正有意义的内容。role="menubar"声明这是一块菜单栏容器,未来搜索框SidebarSearchField会放在里面(注释「未来干」就是预留的占位)。- 类名
sidebar、sidebar-header、sidebar-menu都遵循 BEM 命名(见下一节)。
2. lib/redis.js ------ 数据层(当前是桩)
javascript
export async function getAllNotes(){
// 目前是空实现,后续在这里从 Redis/数据库拿笔记列表
}
它是 lib 目录的职责体现------数据操作单独收口 。页面组件只管 await getAllNotes(),具体数据从哪来、怎么查,都封装在这里,将来换成 Redis、数据库都不用改页面。
3. app/note/[id]/page.js ------ 动态路由详情页
javascript
// alias
import { getAllNotes } from '@/lib/redis'
- 这个文件目前只写了一行,但它身上同时演示了两个知识点 :动态路由(
[id])+ 路径别名(@/lib/redis)。 - 真正的详情页会通过
params.id拿到路由里的 id,再去查对应笔记、渲染 markdown。
4. app/page.js ------ 首页空状态(已在前文问题③解析)
类名 note--empty-state 是「块 + 修饰符」的 BEM 写法,表示「笔记这个块处于空状态」。
九、BEM 命名规范:让 CSS 类名可读、可维护
BEM 是国际通用的 CSS 命名规范,把类名拆成三部分:
| 部分 | 含义 | 符号 | 例子 |
|---|---|---|---|
| Block | 块(独立模块) | 无 | note、sidebar |
| Element | 元素(块的组成部分) | __ |
sidebar__header |
| Modifier | 修饰器(状态/变体) | -- |
note--empty-state |
它解决的是「类名起得乱、看不懂谁属于谁」的问题。看到 note--empty-state 一眼就知道:这是 note 块的一个「空状态」变体。
搭配 原子类 Tailwind CSS 一起用:Tailwind 负责高频的小样式(间距、颜色),BEM 负责大块的结构化命名,两者互补维护。
十、总结:一条完整的认知链路
把整份笔记串起来,其实是一条清晰的链路:
- 用 npx 跑 create-next-app 生成 Next.js 项目(问题①②)。
- Next.js 基于 App Router 的「文件即路由」和 RSC 服务端组件 (问题③的
async)。 - 动手前先做规范驱动编程 :拆需求 → 定方案 → 规划路由和组件。
- 用 layout.js 搭骨架、metadata 做 SEO、alias 管路径、BEM 管样式。
- 数据层收口在 lib ,页面专注
await拿数据渲染。
这篇文章对应的是一套「先想清楚、再动手」的 Next.js 全栈开发范式,比单纯记住 API 重要得多。