从VitePress到NextJS-构建AI阅读笔记站的技术演进

在线演示:https://cearl.cc/ai-reading/

1. 项目背景

AI 阅读(ai-reading)是一个个人知识管理项目,用于整理和分享 AI 相关书籍的阅读笔记。项目始于简单的 Markdown 文件收集,随着内容的积累,逐渐演变成一个功能完善的静态站点。

书籍详情页
图:书籍详情页展示 - 左侧文件树,中间正文,右侧 TOC

项目规模:

  • 📚 书籍笔记:93 篇 Markdown 文档
  • 💻 代码量:约 1,800 行 TypeScript/TSX
  • 🏗️ 分类体系:多级目录结构(商业管理、个人成长、思维方式等)
  • 📝 Git 提交:146 次,最近重构涉及 93 次提交
  • ⏱️ 构建产物:97 个静态页面,构建耗时 4.3 秒

核心功能:

  • 📁 文件树浏览(多级分类,支持展开/折叠)
  • 🔍 全文搜索(书名、作者、标签、内容)
  • 🏷️ 标签系统
  • 📖 文章大纲(TOC)
  • 🎨 Markdown 渲染(代码高亮、表格、引用等)
  • 📱 响应式设计(桌面端 + 移动端)

2. 技术演进:三次迁移的故事

2.1 第一代:VitePress

选择理由:开箱即用的文档站,Markdown 渲染优秀,配置简单。

遇到的问题:

  • 定制化受限,更像是”文档站模板”而非”框架”
  • 默认布局难以调整,不适合书籍库的展示需求
  • 侧边栏配置需要手动维护,无法自动从文件系统生成

结论:VitePress 适合传统技术文档,但不适合需要高度定制的书籍库。

2.2 第二代:Astro

选择理由:

  • “内容优先”的设计理念
  • Islands Architecture,零 JS by default,性能极佳
  • 高度可定制,可以使用 React/Vue/Svelte

实施效果:

  • ✅ 成功实现自定义布局和样式
  • ✅ 实现基于文件系统的自动分类
  • ✅ 添加标签系统和搜索功能

致命问题:侧边栏状态无法保持

这是一个看似简单但难以解决的问题:

  • 用户在侧边栏展开某个分类,点击一本书
  • 阅读完毕后返回,想继续浏览同一分类下的其他书籍
  • 但页面刷新后,侧边栏又回到了初始折叠状态

尝试的解决方案:

  1. localStorage 持久化:页面刷新时有明显闪烁,用户体验差
  2. View Transitions API:只能保持 DOM 的视觉连续性,无法保持 JavaScript 状态
  3. Persistent Islands:Astro 的页面导航本质是浏览器原生导航(MPA),每次都是全新页面加载

根本原因:Astro 的设计理念是真正的 MPA(多页面应用),每次导航都是完整的页面刷新,JavaScript 状态无法跨页面保持。

2.3 第三代:Next.js

选择理由:

  • 支持静态导出(output: 'export'),可以部署到 GitHub Pages
  • 本质上是一个 React SPA,客户端路由可以保持组件状态
  • App Router 的 Layout 机制,完美支持常驻侧边栏

实施效果:

  • ✅ 侧边栏状态完美保持
  • ✅ 页面导航流畅,类似 SPA 的体验
  • ✅ 所有功能都能实现
  • ✅ 静态部署,无需服务器

结论:Next.js 是目前最适合我们需求的技术方案。


3. 为什么 Next.js 能做到而 Astro 不行?

这是整个技术选型中最关键的问题。表面上看,两者都支持静态导出,都能生成多个 HTML 页面,但底层机制完全不同。

3.1 构建产物对比

Next.js 的构建产物:

1
2
3
4
5
6
7
8
9
10
11
out/
├── _next/static/chunks/
│ ├── a874ea8ae22a6799.js (119KB - React runtime)
│ ├── 4e7b2a2ab5a8cd20.js (484KB - 应用组件代码)
│ └── ...
├── books/
│ ├── book1/
│ │ ├── index.html (内容 + script 引用)
│ │ └── __next.*.txt (路由数据)
│ └── book2/
└── index.html

关键特征:

  • 所有页面共享相同的 JavaScript chunks
  • HTML 只包含内容,交互逻辑在 JS 中
  • __next.*.txt 文件用于客户端路由的数据获取

Astro 的构建产物:

1
2
3
4
5
6
7
8
dist/
├── books/
│ ├── book1.html (完整独立页面)
│ └── book2.html (完整独立页面)
├── _astro/
│ ├── sidebar.abc123.js (每个页面独立加载)
│ └── ...
└── index.html

关键特征:

  • 每个 HTML 是完整独立的页面
  • JavaScript 按需加载,每个页面可能加载不同的 JS
  • 没有客户端路由

3.2 运行时行为对比

Next.js:SPA with SSG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户访问 /books/book1
↓
1. 浏览器加载 book1/index.html
2. 加载共享的 React runtime chunks
3. React 接管页面(Hydration)
4. 页面变成一个完整的 React 应用
↓
用户点击链接到 /books/book2
↓
5. Next.js 客户端路由拦截点击事件
6. 通过 fetch 获取 book2 的数据
7. React 更新组件树
├─ Sidebar 组件保持挂载 ✅
├─ 只更新 <main> 中的内容
└─ 展开状态保持在 React state 中
8. URL 更新,但页面没有刷新

关键点:

  • Hydration 后,页面变成一个 React SPA
  • 后续导航完全由 React Router 控制
  • Layout 组件(包括 Sidebar)在应用级别挂载,永不卸载

Astro:传统 MPA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户访问 /books/book1.html
↓
1. 浏览器加载 book1.html
2. 加载页面特定的 JS(如果有)
3. JavaScript 初始化 Sidebar 组件
4. 用户展开某个分类(状态存在 JS 内存中)
↓
用户点击链接到 /books/book2.html
↓
5. 浏览器触发原生导航(页面刷新)
6. 卸载 book1.html 的所有内容和 JavaScript
7. 加载 book2.html(全新的页面)
8. JavaScript 重新初始化 Sidebar 组件
9. Sidebar 状态丢失 ❌(回到初始折叠状态)

关键点:

  • 每次导航都是浏览器原生导航
  • 页面刷新 = 所有 JavaScript 状态丢失
  • 即使使用 View Transitions API,也只是视觉平滑,无法保持内存状态

3.3 为什么 View Transitions 无法解决问题?

Astro 支持 View Transitions API,可以实现页面切换的平滑动画。

View Transitions 做了什么:

  1. 拦截链接点击
  2. 通过 fetch 获取新页面的 HTML
  3. 对比新旧 DOM,找到变化的部分
  4. 使用 CSS 动画平滑过渡
  5. 替换 DOM

问题在于:

  • View Transitions 只处理 DOM,不处理 JavaScript 状态
  • Sidebar 的展开状态存在 JavaScript 的内存中(如 useState)
  • DOM 替换后,JavaScript 重新执行,状态重置

类比:View Transitions 就像是拍了一张照片,然后平滑地切换到另一张照片。但照片里的人不记得上一张照片里发生了什么。

3.4 Next.js App Router 的 Layout 机制

Next.js 的 App Router 引入了 Layout 的概念,这是实现常驻侧边栏的关键:

1
2
3
4
5
6
7
8
9
10
11
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
<Sidebar /> {/* Layout 级别,永不卸载 */}
<main>{children}</main> {/* 页面级别,会更新 */}
</body>
</html>
);
}

运行时行为:

1
2
3
4
5
组件树结构:
RootLayout (挂载一次,永不卸载)
├─ Sidebar (状态保持)
└─ children
└─ BookPage (根据路由切换)

当用户从 book1 导航到 book2:

  1. RootLayout 保持挂载 ✅
  2. Sidebar 组件不卸载 ✅
  3. 只有 children 部分更新(BookPage 组件)
  4. Sidebar 的 React state 保持不变 ✅

3.5 技术架构本质差异

特性Next.js (Static Export)Astro
架构模式SPA with SSGMPA
运行时React 应用原生 HTML + 可选 JS
导航方式客户端路由浏览器原生导航
状态管理React state(跨页面)页面级别(无法跨页面)
代码共享所有页面共享 chunks每个页面独立
Hydration完整 React 应用Islands(局部)
初始加载较大(React runtime)较小(零 JS)
后续导航极快(无刷新)较慢(页面刷新)
状态保持✅ 完美支持❌ 需要 hack

总结:

  • Next.js:虽然生成静态文件,但本质是一个 React SPA
  • Astro:真正的 MPA,追求最小 JS,不保持客户端状态

这就是为什么我们最终选择了 Next.js。


4. AI 协助开发的实践

在整个项目开发过程中,Claude Code 扮演了重要角色。这里分享一些实践经验。

4.1 AI 协助的场景

场景 1:技术选型咨询

问题:Astro 无法保持侧边栏状态,是否有解决方案?

AI 的帮助:

  • 分析了 Astro 的 Islands Architecture 原理
  • 解释了 View Transitions API 的局限性
  • 对比了 Next.js 的 Layout 机制
  • 给出了明确的技术选型建议

价值:避免了在错误的方向上浪费时间。

场景 2:需求实现

任务:实现文章大纲(TOC)功能。

协作流程:

  1. 需求讨论:

    • 我:想要一个像 VitePress 那样的 TOC
    • AI:建议桌面端固定右侧,移动端用抽屉
    • 我:认可,并提到 Obsidian 的移动端体验不错
  2. 设计决策:

    • AI:提议使用 shadcn/ui 的组件
    • 我:不想引入新依赖,用原生实现
    • AI:同意,使用 Tailwind CSS 实现
  3. 迭代优化:

    • 第一版:TOC 按钮放在顶部导航
    • 我:不太合适,之前看到过浮动按钮的方案
    • AI:改为右下角浮动按钮
    • 我:很好,就这样
  4. Bug 修复:

    • 我:点击”返回顶部”没反应
    • AI:检查后发现滚动容器是 main 元素而非 window
    • 修复:添加了容器检测逻辑

场景 3:代码重构

任务:将分类系统从 frontmatter 驱动改为文件系统驱动。

背景:

  • 原先每个 Markdown 文件都有 category: 商业管理/市场营销 字段
  • 但文件本身就在 books/商业管理/市场营销/ 目录下
  • 存在”双重真相源”:文件路径和 frontmatter 可能不一致
  • 移动文件时需要同步更新 frontmatter,容易出错

AI 的帮助:

  1. 理解需求后,制定了重构计划:

    • 修改 lib/books.ts 的逻辑,从文件路径提取分类
    • 编写批量处理脚本移除 frontmatter 中的 category 字段
    • 验证分类正确性
  2. 编写了两个自动化脚本:

    • remove-category-field.js:移除 93 个文件的 category 字段
    • fix-frontmatter-spacing.js:清理多余空行
  3. 执行并验证:

    • 处理了 93 个文件
    • 确保 Git 正确识别为内容修改(而非删除+新建)
    • 测试分类系统正常工作

价值:

  • 自动化了繁琐的批量操作
  • 避免了手动编辑可能出现的错误
  • 实现了”单一真相源”:文件系统即分类系统

4.2 AI 协助的优势

  1. 知识广度:

    • 熟悉多种框架(VitePress、Astro、Next.js)
    • 了解最佳实践和常见陷阱
    • 能够对比不同方案的优劣
  2. 快速迭代:

    • 立即生成可运行的代码
    • 根据反馈快速调整
    • 无需查阅大量文档
  3. 自动化能力:

    • 编写批量处理脚本
    • 自动化测试和验证
    • 减少重复劳动
  4. 问题诊断:

    • 分析 Bug 的根本原因
    • 提供多种解决方案
    • 解释背后的技术原理

4.3 AI 协助的局限

  1. 需要明确的需求:

    • AI 不能替你做决策(如技术选型)
    • 需要你明确表达期望的效果
    • 对模糊的需求可能给出不合适的方案
  2. 需要验证和测试:

    • AI 生成的代码不一定完美
    • 需要实际运行和测试
    • 可能需要多次迭代
  3. 依赖你的反馈:

    • AI 看不到实际效果(除非你截图)
    • 需要你描述问题和期望
    • 协作质量取决于沟通质量

4.4 高效协作的技巧

  1. 清晰的需求描述:

    • ✅ “实现一个像 VitePress 那样的 TOC,桌面端固定右侧,移动端用抽屉”
    • ❌ “加个目录功能”
  2. 及时的反馈:

    • 发现问题立即指出:”点击按钮没反应”
    • 说明期望的行为:”应该滚动到页面顶部”
  3. 允许迭代:

    • 不要期望一次就完美
    • 通过多轮对话逐步优化
    • 每次改进一个问题
  4. 技术决策由你主导:

    • AI 可以提供建议,但最终决策权在你
    • 你更了解项目的长期目标和约束
    • AI 是助手,不是替代品

4.5 协作统计

在这个项目中:

  • 💬 对话轮数:约 100+ 轮
  • 📝 生成代码:约 2,000 行(包括迭代和调整)
  • 🐛 修复 Bug:10+ 个
  • 📚 编写脚本:2 个自动化脚本
  • ⏱️ 节省时间:估计节省了 20+ 小时的开发时间

最有价值的协助:

  1. 技术选型建议(避免了在 Astro 上继续浪费时间)
  2. 分类系统重构(自动化处理 93 个文件)
  3. TOC 功能实现(从零到完成只用了 1 小时)

5. 经验总结与思考

5.1 技术选型的思考

教训 1:不要被”性能”迷惑

Astro 的性能确实很好(零 JS by default),但:

  • 对于这个项目,性能不是瓶颈
  • 用户体验(状态保持)更重要
  • 800KB 的 JS 在现代网络下可以接受

教训 2:理解框架的设计理念

  • VitePress:文档站,不是通用框架
  • Astro:内容站,MPA 架构
  • Next.js:全栈框架,支持 SPA 和 SSG

选择框架时,要理解其设计理念是否匹配你的需求。

教训 3:不要过早优化

最初担心 Next.js 的包体积,想用 Astro 来”优化”。但实际上:

  • 用户体验 > 包体积
  • 开发效率 > 极致性能
  • 可维护性 > 技术炫技

5.2 AI 协助开发的思考

AI 的价值:

  • 不是替代开发者,而是提升效率
  • 不是写代码的机器,而是协作伙伴
  • 不是万能的,但在特定场景下非常有用

何时使用 AI:

  • ✅ 实现标准功能(文件树、搜索、TOC)
  • ✅ 代码重构和批量操作
  • ✅ Bug 诊断和修复
  • ✅ 技术方案对比
  • ❌ 核心业务逻辑设计
  • ❌ 架构设计决策
  • ❌ 产品需求定义

AI 协助的最佳实践:

  1. 保持清晰的沟通
  2. 及时反馈和迭代
  3. 验证 AI 生成的代码
  4. 主导技术决策
  5. 把 AI 当作有经验的同事,而不是工具

5.3 开发过程的反思

什么是真正重要的:

  • 不是代码写得多漂亮
  • 不是技术栈多先进
  • 而是解决了实际问题
  • 而是提升了用户体验

AI 时代的开发者:

  • 不需要记住所有 API
  • 不需要从零手写所有代码
  • 但需要:
    • 清晰的需求理解能力
    • 技术方案的判断能力
    • 与 AI 协作的沟通能力
    • 验证和测试的严谨态度

6. 结语

从 VitePress 到 Astro,再到 Next.js,这个项目经历了三次技术栈迁移。每一次迁移都是对需求的重新理解,对技术的深入探索。

核心收获:

  1. 技术选型没有银弹:适合的才是最好的
  2. 用户体验优先:技术指标要服务于用户体验
  3. 理解底层原理:才能做出正确的决策
  4. AI 是强大的助手:但决策权始终在开发者手中

希望这篇文章能帮助你:

  • 理解 Next.js、Astro 等框架的本质差异
  • 了解如何与 AI 高效协作
  • 在类似项目中做出更好的技术选型

技术栈:

  • Next.js 16.1.6 (App Router + Static Export)
  • React 19.2.4
  • Tailwind CSS v4
  • Pagefind (全文搜索)
  • GitHub Pages (部署)

开发环境:

  • Claude Code (AI 协助)

统计数据(截至 2026 年 2 月):

  • 📚 书籍笔记:93 篇
  • 💻 代码行数:1,800 行
  • 🏗️ 静态页面:97 个
  • 📝 Git 提交:146 次
  • ⏱️ 构建时间:4.3 秒