从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 秒