Changelog Local — learning-docs

Changelog Local — learning-docs

目录

  1. 离线优先架构与数据同步方案设计
  2. PWA 学习工具产品设计全记录
  3. VitePress 知识拓展——静态站点生成器的定位与取舍
  4. 技术选型方法论——不懂的技术怎么选
  5. Phase 1 实施——VitePress 到 Next.js 脚手架搭建与 PWA 基础
  6. 知识盲区复盘——PWA display 模式、viewport 缩放、standalone 导航、客户端路由
  7. 知识盲区复盘——Server vs Client Component、IndexedDB 事务
  8. 概念补充——数据库种子(Seed)
  9. Phase 2 实施——类型系统、IndexedDB 数据层、种子数据
  10. Phase 3 实施——MVP 核心学习流程
  11. Phase 3 思考题复盘——useMemo 依赖陷阱与 URL as state 判断框架
  12. Phase 4 实施——进度面板(连续天数、活动图、课程进度条)
  13. 思考题复盘——Set vs Array 查找性能
  14. Phase B 实施——底部导航 + 任务列表页
  15. 思考题复盘——排序逻辑为什么抽成 priority() 函数
  16. Phase C 实施——PWA 本地通知 + 知识盲区:PWA/SW/推送机制
  17. 项目基础设施回顾——watch.sh / sync.sh 自动同步机制
  18. Phase 5 实施——Markdown 文档渲染 + SSG/ISR 概念澄清
  19. 知识盲区复盘——Vercel vs Next.js vs VitePress 三个概念混淆
  20. 首次部署到 Vercel——非交互模式 scope 坑 + 部署流程记录
  21. 部署链路迁移——Vercel 国内不可访问 → EdgeOne 备案限制 → Cloudflare Pages 成功落地
  22. Next.js 静态导出配置踩坑 + Git 误提交构建产物修复
  23. Git 历史被动增长问题——三方案对比与定时部署实现
  24. Bug 修复——/learn 页面按钮遮挡 + 推荐引擎时长不匹配
  25. 任务详情页 + 可点击列表 + 底部导航可视化优化
  26. 知识盲区——next dev 与 next build 共用 .next 缓存导致 MODULE_NOT_FOUND
  27. 工作流反思——"每改必 push" 是过度自动化
  28. 5 分钟学习模式 + 视觉信息密度管理(三个 W 原则)
  29. 布局 bug 链路——body safe-area + trailingSlash + BottomNav 遮挡
  30. 展示层映射 expandCourseRefs + 执行态 vs 参考态的信息分层
  31. 产品决策反思——"简洁高效" 原则下的学习历史功能取舍
  32. UX 哲学——显式信号 vs 隐式推断,"学完了"按钮为什么不能砍
  33. 章节锚点直达——parseSourceChapter + 后处理正则给 heading 加 id
  34. 思考题复盘——JSX 局部逻辑的三种写法 + 数据模型决定 UX 天花板
  35. bug 链路复盘——SSR 阶段 useState 陷阱 + Link 失灵 + sticky 返回 + trailingSlash 确认
  36. 代码块显示 bug 四连——主题切换、零宽空格污染、文字继承、pre/code 背景层级
  37. 离线文档缓存——IndexedDB bundle 方案(非 Service Worker 路线)
  38. 回顾标签系统 + 盲区自动汇总——把"写完就埋"变成"可视化复盘"
  39. 概念级 SR + 跨项目知识关联——Phase 3+4 实现
  40. 回顾流程去重——合并标签与自评两步
  41. 推荐引擎重复 bug——池子太小 + 算法 deterministic + 无冷却
  42. 文档续读——章节级 position 记忆(IntersectionObserver + 浮条 UX)
  43. sync.sh v2——代码迁移后的文档发现机制重构(多文件 *.local.md + slug 映射 + 一次误判复盘)
  44. 应用重设计——信息架构精简 + 主动回忆交互 + 纸感视觉(Phase 1+2)
  45. 部署验证假阴性——shell 命令替换装不下几 MB 的 chunk(字节数对不上 = 截断铁证)

1. 离线优先架构与数据同步方案设计

一句话结论

多设备数据同步 + 离线可用,最优解是离线优先架构(Offline-First):本地 IndexedDB 做主数据源,有网时自动同步到云端。

为什么这样设计

需求背景

  • 工作日在电脑浏览器使用,休息日在 iPad 使用
  • iPad 在户外可能没有网络
  • 只有一个用户,不会两个设备同时操作
  • 数据量极小(几百 KB)

为什么不能纯本地存储?

纯 IndexedDB 的数据是绑定在浏览器实例上的——电脑 Chrome 里的 IndexedDB 和 iPad Safari 里的 IndexedDB 是完全隔离的。你在电脑上完成了 3 个任务,iPad 上看不到。

为什么不能纯云端存储?

每次读写都依赖网络请求,户外没网就完全不能用。用户体验从"打开就用"变成"打开就转圈"。

离线优先架构的原理

核心思想:
本地数据 = 主数据(读写都走这里,保证速度和离线可用)
云端数据 = 同步副本(保证多设备一致)

数据流:
┌──────────┐    有网时同步     ┌──────────┐
│ 设备 A    │ ◄─────────────► │ 云端      │
│ IndexedDB │                  │ Vercel KV │
└──────────┘                  └──────────┘
                                   ▲
                              有网时同步
                                   │
                              ┌──────────┐
                              │ 设备 B    │
                              │ IndexedDB │
                              └──────────┘

用户操作 → 写入本地 IndexedDB(毫秒级,无感知)
         → 后台尝试同步到云端(有网就推,没网就等)
打开 App → 先显示本地数据(立即可用)
         → 后台拉云端数据合并(有网就拉,没网就跳过)

这个模式在业界叫 Offline-First,是 PWA 的标准做法。类比前端已知的知识:

你在 React 里用过 optimistic update 吗?先在 UI 上立即显示结果,后台再发请求。离线优先就是把这个思路扩展到了整个数据层——本地先改,后台再同步。

三层对比

❌ 初级做法:纯云端存储
   每次操作都 fetch API → 没网就崩 → 加 loading 和 error 处理 → 体验差
   问题:把"网络可用"当成了默认前提

⚠️ 中级做法:云端优先 + 本地缓存
   正常走 API,失败了降级到本地缓存
   问题:网络慢的时候体验也差(用户等 3 秒才看到内容),
   而且"缓存"和"主数据"的边界很模糊,容易出 bug

✅ 资深做法:离线优先
   本地是主数据源,保证任何情况下打开即用
   网络是同步通道,不是依赖项
   考虑了:冲突处理(Last Write Wins)、
   同步失败重试、数据版本控制

冲突处理:Last Write Wins

场景(概率极低但要处理):
电脑上完成了任务 A(时间戳 10:00)→ 还没同步
iPad 上也操作了(时间戳 10:05)

合并策略:比较每条记录的 updatedAt 时间戳,谁更新用谁的

// 合并逻辑伪代码
function merge(local: Task[], remote: Task[]): Task[] {
  const merged = new Map<string, Task>()

  // 先放本地数据
  local.forEach(t => merged.set(t.id, t))

  // 远端数据如果更新,覆盖
  remote.forEach(t => {
    const existing = merged.get(t.id)
    if (!existing || t.updatedAt > existing.updatedAt) {
      merged.set(t.id, t)
    }
  })

  return Array.from(merged.values())
}

为什么 Last Write Wins 够用? 因为只有一个用户,不会真正"冲突"——同一时刻你只在一个设备上操作。真正的多用户协作场景(比如 Google Docs)需要 CRDT 或 OT 算法,那是另一个量级的复杂度。

云端方案对比:为什么选 Vercel 而非 Cloudflare

前提:前端已经部署在 Vercel 上(Next.js)

Vercel 方案:
├── Next.js API Routes 直接写后端逻辑
├── Vercel KV 存数据
├── git push 一次部署前后端
├── 不需要额外注册任何账号
└── 结论:同一生态,零额外成本

Cloudflare 方案:
├── 需要另外写 Cloudflare Worker
├── 前后端分属两个平台
├── 要注册 Cloudflare 账号
├── 部署流程多一套
└── 结论:能力更强,但对这个项目是过度设计

判断原则:技术选型不是"哪个更强",是"哪个对当前场景最合适"
Cloudflare Workers 在边缘计算、大规模场景下确实更强,
但一个单用户的几百 KB 数据同步,杀鸡用牛刀了。

扩展性思考

如果将来量级变了:

  • 多用户 → Last Write Wins 不够了,需要用户隔离 + 更细粒度的冲突处理
  • 数据量大 → Vercel KV 免费额度 30MB,超了要付费或迁移到 Vercel Postgres
  • 实时同步 → 目前是打开时同步,如果需要实时推送变更,需要 WebSocket 或 Server-Sent Events

当前阶段这些都不需要,但心里有数。

面试 / 技术对话角度

面试官可能怎么问:

  • 初级追问:"你的 PWA 怎么处理离线?"
  • 资深追问:"多设备的数据冲突你怎么解决?为什么不用 CRDT?"

面试话术:"我们采用了离线优先架构,本地 IndexedDB 作为主数据源保证任何网络条件下都能使用,通过后台同步机制和 Last Write Wins 策略实现多设备数据一致性。选择 LWW 而非 CRDT 是因为单用户场景下不存在真正的并发冲突,CRDT 的额外复杂度没有收益。如果将来扩展到多用户,可以在同步层替换为 CRDT,本地存储层不需要改动。"

延伸讨论:

  • "LWW 有什么缺陷?" → 如果时钟不准会丢数据。单用户无所谓,多用户要用逻辑时钟(Lamport/Vector Clock)
  • "为什么不用 Service Worker 做同步?" → Background Sync API 可以做,但 iOS Safari 支持有限,不如在应用层自己控制可靠
  • "IndexedDB 有什么坑?" → Safari 在隐私模式下有存储限制;超过一定容量会弹授权提示;事务模型和 SQL 不同,是基于请求的异步 API

2. PWA 学习工具产品设计全记录

一句话结论

一个解决"学习启动困难"的工具,核心不是提醒,是消灭决策摩擦 + 制造进度反馈

问题本质分析

用户行为模式

有动力 → 定目标 → 开始困难 → 拖延 → 愧疚 → 破罐破摔 → 循环

这不是意志力问题,是系统设计问题:

表面归因 实际原因
没有毅力 启动摩擦力太高,每次要重新决策"学什么"
方法不对 没有反馈循环,看不到进度,动力衰减
三分钟热度 目标太大太模糊,无法拆成今天的行动
环境推你去刷手机,没有东西推你去学习

行为科学原理

  1. 意志力是消耗品:工作 + 带娃已经消耗大量脑力,靠意志力启动学习必然失败
  2. 决策疲劳:碎片时间最大的敌人是"学什么"这个选择题,想 3 分钟就刷 15 分钟手机了
  3. 动力类型:薪资焦虑和同行比较都是情绪驱动,来得猛消得快,不能作为持续燃料
  4. 解法:情绪负责"充电"(趁有动力时填任务队列),系统负责"放电"(日常推任务执行)

产品设计决策记录

核心交互:零决策启动

打开 → 选时长 → 系统推任务 → 学习 → 回顾 → 看进度

不给"换一个"按钮(方案 A),原因:给选择权就会陷入挑选,违背"消灭决策"的核心原则。这要求推荐算法必须合理。

推荐引擎:规则加权排序

不需要 AI API,不需要运行时成本。本质是从已知任务池中选最合适的,是排序问题不是生成问题。

权重因子:

  • 课程顺序(未完成的最早任务优先)
  • 时段适配(晚 9:30 后降低难度,推轻量阅读)
  • 间隔重复到期(该复习的旧任务加分)
  • 类型平衡(连续同类型后换一种)

完成后体验:回顾 + 自评 + 间隔重复

完成学习 → 回顾题(预设或模板兜底)→ 写回答 → 自评"记住了/模糊/没记住"→ 系统据此安排复习间隔

间隔重复规则:
记住了:1 → 3 → 7 → 14 → 30 天后复习
模糊:  1 → 2 → 4 → 7 → 14 天
没记住:0 → 1 → 2 → 3 → 7 天

跳过回顾允许但统计可见("本周跳过 N 次"),用数据可见性做柔性约束。

首页激励语

不是鸡汤,是思维提示——提醒用资深工程师的方式思考:

  • "这个方案,为什么不用另一种?"
  • "如果量级扩大 10 倍,哪里先崩?"
  • "你能用一句话说清楚它解决什么问题吗?"

多条轮换,每次打开随机一条,用户可自行添加。

设备与环境适配

工作日间隙(10-30 分钟,随机出现,在电脑前):
→ 电脑浏览器打开 PWA

休息日在家(孩子睡后 9:30+,或孩子被带出去 1.5h):
→ iPad 打开 PWA

户外/碎片(5-10 分钟,可能没网):
→ iPad 离线使用,回来自动同步

为什么选 iPad 不选手机:手机有微博/微信的肌肉记忆,iPad 没有这个习惯回路,可以作为专用学习设备。

技术架构

┌─────────── Next.js PWA ───────────┐
│                                    │
│  React UI                          │
│     │                              │
│  IndexedDB(本地主数据源)          │
│     │                              │
│  同步引擎(有网推/拉,没网跳过)    │
│                                    │
└────────────┬───────────────────────┘
             │
        Vercel 部署
             │
     ┌───────┴────────┐
     │ API Routes     │
     │ Vercel KV      │
     └────────────────┘

选 Vercel 而非 Cloudflare:前端本来就部署在 Vercel,后端也放 Vercel 是同一生态、一次部署、零额外账号。Cloudflare Workers 能力更强但对单用户场景是过度设计。

AI 时代的学习方向思考

任务池不应全是 API 知识点,应按 3:7 分配:

  • 知识任务 30%:技术原理(不是 API 用法,是为什么这样设计)
  • 思维任务 70%:系统设计、代码评审、方案对比

因为 AI 时代,"会写代码"不值钱了,"知道为什么这样写、能在多个方案中选最合适的"才值钱。核心竞争力是工程判断力

面试 / 技术对话角度

面试官可能怎么问: "你有没有做过什么 side project?聊聊技术选型和设计思路。"

面试话术:"我做了一个离线优先的 PWA 学习工具,解决碎片时间学习的启动困难问题。技术上采用 IndexedDB 做本地主数据源,Vercel KV 做云端同步,Last Write Wins 处理多设备数据合并。产品上参考了间隔重复算法来安排复习,用加权排序做任务推荐。这个项目让我实践了离线优先架构、PWA 的 Service Worker 缓存策略、以及行为设计在产品中的应用。"


3. VitePress 知识拓展——静态站点生成器的定位与取舍

一句话结论

VitePress 是一个专门用来把 Markdown 变成文档网站的静态站点生成器(SSG),适合做文档,不适合做应用。

VitePress 是什么

你给它:一堆 .md 文件
它给你:一个好看的文档网站(HTML/CSS/JS)

底层:基于 Vite(构建工具)+ Vue(前端框架)
定位:VuePress 的继任者,Vue 官方出品

与前端已知知识的类比

你熟悉 React 生态,对应关系:

Vue 生态               React 生态
──────────────────────────────────
Vue                    React
Vite                   同样可以用 Vite(或 Webpack)
VitePress              Docusaurus(Meta 出品,React 生态的文档工具)
Nuxt.js                Next.js(全栈框架)

VitePress 之于 Vue,就像 Docusaurus 之于 React——都是各自生态里的"文档专用工具"。

静态站点生成器(SSG)的本质

原理:
Markdown 文件 → 构建时编译 → 纯 HTML 文件 → 部署到任何静态托管

特点:
├── 没有服务器,没有数据库,没有 API
├── 构建完就是一堆 HTML 文件
├── 访问速度极快(纯静态)
├── 但不能做动态功能(登录、数据存储、实时交互...)
└── 适合:文档、博客、知识库
    不适合:应用、工具、任何需要"状态"的东西

为什么我们不继续用 VitePress

需要的功能                VitePress 能做吗?
─────────────────────────────────────────
展示 Markdown 文档        ✅ 这是它的本职
任务推荐引擎              ❌ 没有运行时逻辑
IndexedDB 数据存储        ❌ 没有应用层
云端数据同步 API          ❌ 没有后端能力
PWA 离线支持              ⚠️ 可以但很别扭
间隔重复算法              ❌ 没有状态管理
用户交互(选时长、自评)   ❌ 不是交互式应用

结论:VitePress 只能做我们需求的 10%(文档展示),
     剩下 90% 都做不了。换 Next.js 是必然。

三层对比——什么时候该用什么

❌ 初级做法:不管什么需求都用自己熟的框架
   → "我会 React 所以写博客也用 Next.js" → 杀鸡用牛刀
   → "我会 VitePress 所以做应用也用它" → 削足适履

⚠️ 中级做法:知道有不同工具,但不清楚边界
   → 知道 VitePress 做文档、Next.js 做应用
   → 但遇到"文档 + 应用"的混合需求时犹豫不决

✅ 资深做法:从需求的主次出发选框架
   → 如果 90% 是文档,10% 是交互 → VitePress + 少量 Vue 组件
   → 如果 90% 是应用,10% 是文档 → Next.js + MDX 渲染 Markdown
   → 我们的场景是后者

主流 SSG 框架全景(行业视角)

框架           生态      特点                      适合场景
─────────────────────────────────────────────────────────
VitePress      Vue       极简、快、文档专用          技术文档、API 文档
Docusaurus     React     功能丰富、插件多            开源项目文档
Astro          不绑定    可混用 React/Vue/Svelte    内容为主的网站、博客
Next.js        React     全栈框架,SSG 只是功能之一  应用 + 文档混合
Gatsby         React     GraphQL 数据层(已式微)    曾经流行,现在少用了
Hugo           Go        构建速度最快               纯静态博客、大规模文档

面试 / 技术对话角度

面试官可能怎么问: "你为什么从 VitePress 迁移到 Next.js?"

面试话术:"最初用 VitePress 是因为需求只是展示 Markdown 文档,VitePress 做这个最合适。后来需求演变成一个带任务推荐、数据同步、离线支持的应用,VitePress 作为纯静态站点生成器无法支撑这些动态功能。Next.js 既能用 MDX 渲染已有的 Markdown 内容,又能做全栈应用,所以迁移过去一个项目就能满足所有需求。核心判断是:当需求的主体从'内容展示'变成了'交互应用',框架就该跟着变。"

延伸讨论:

  • "为什么不在 VitePress 里嵌入 Vue 组件做交互?" → 可以做简单交互,但 VitePress 没有 API 层、没有服务端能力,数据同步这种事做不了。硬做等于在文档框架里造一个应用框架,不如直接用应用框架。
  • "Astro 考虑过吗?" → Astro 做内容站很好,但它的"岛屿架构"偏向静态内容 + 少量交互,我们的场景交互是主体,不是点缀,所以 Next.js 更合适。

4. 技术选型方法论——不懂的技术怎么选

一句话结论

不需要先学会再选,而是用评估框架快速做出"足够好"的决策,核心原则是"选离你最近的"。

问题本质

场景:
你不了解 Cloudflare,也不深入了解 Vercel
碰巧之前项目用过 Vercel,就问了一嘴
如果没用过 Vercel 呢?怎么办?

真正的问题不是"Cloudflare vs Vercel 哪个好"
而是"面对不熟悉的技术领域,怎么做出合理判断"

技术选型四步法

第 1 步:从需求出发,不从技术出发
  ❌ "Cloudflare 和 Vercel 哪个好?"(这是技术视角)
  ✅ "我需要存几百 KB 数据、多设备同步,最简单的方案是什么?"(这是需求视角)
  → 先定义问题的边界和约束,再找工具

第 2 步:列约束条件
  不需要了解候选技术的全部功能,只需要回答:
  ├── 我会什么?(已有技能 = 零学习成本的选项)
  ├── 我不想额外学什么?(排除项)
  ├── 预算多少?(大部分云服务有免费额度)
  ├── 有什么硬性限制?(国内能访问吗?团队统一要求?)
  └── 这个选择重要吗?(选错了切换成本大吗?)

第 3 步:最小信息量决策
  不需要深入对比两个平台的全部功能
  只需要回答一个问题:
  "哪个让我用最少的新知识解决问题?"

  → 已经用 Next.js + Vercel → 加个 API Route 就行 → 零新概念
  → Cloudflare Workers 可能更强 → 但你要学新平台、新部署流程、新概念
  → 对于"存几百 KB 数据"这个需求,两者的能力差异毫无意义

第 4 步:判断决策的可逆性
  问自己:如果选错了,切换成本多大?
  → 数据同步 API 就几十行代码
  → 将来想换平台,半天就能搞定
  → 结论:这个决策根本不重要,选哪个都行
  → 对不重要的决策花太多时间分析 = 浪费

三层对比

❌ 初级做法:面对不懂的技术就卡住,或者随大流选"最热门的"
   → 花三天研究各种方案对比文章,最后还是不确定
   → 或者看到"Cloudflare 很好"就选了,不管是否适合自己

⚠️ 中级做法:列了对比表,但不知道哪些维度对自己重要
   → 认真对比了性能、功能、价格,做了详细表格
   → 但没有从自己的约束出发,对比了很多不相关的维度

✅ 资深做法:先判断决策重要性,再决定投入多少精力
   → 可逆的、影响小的决策 → 选最熟的,快速推进
   → 不可逆的、影响大的决策(比如选数据库、选语言)→ 才值得深入调研
   → 关键心法:"不重要的决策快速做,重要的决策慎重做"

原理:决策的"可逆性"判断

Jeff Bezos 的 "单向门 vs 双向门" 模型:

单向门决策(不可逆):
├── 选了就很难换的:编程语言、数据库、核心架构
├── 影响整个团队的:技术栈统一、API 契约
└── 这类决策值得花时间深入调研

双向门决策(可逆):
├── 选错了容易换的:部署平台、CSS 方案、工具库
├── 影响范围小的:内部工具、个人项目
└── 这类决策快速选、快速试,不对就换

你的 Vercel vs Cloudflare → 典型的双向门
换一个平台只要改几十行代码
不值得纠结,选最熟的就对了

与前端已知经验的类比

你一定做过这种选择:
"状态管理用 Redux 还是 Zustand 还是 Context?"

初级:花一周看各种对比文章
资深:这个项目状态简单 → Context 够了 → 选了 → 继续写代码
     如果后来发现不够用 → 花两小时换成 Zustand → 没有任何损失

技术选型也是一样的思路。

你之前的直觉其实是对的

你说"碰巧用过 Vercel 就顺便问了下"

这个直觉翻译成方法论就是:
1. 我有需求(数据同步)
2. 我知道一个可能能做的工具(Vercel)
3. 我问了一下确认能不能做

这就是正确的决策流程。
你只是不确定这个直觉靠不靠谱——答案是:完全靠谱。
在个人项目的量级上,"我用过的"几乎总是最优选择。

面试 / 技术对话角度

面试官可能怎么问: "你怎么做技术选型?"

面试话术:"我先从需求约束出发,而不是从技术对比出发。列清楚必须满足的条件和团队已有的技术栈,然后判断这个决策是'单向门'还是'双向门'——如果选错了切换成本低,就选团队最熟悉的快速推进;如果是影响架构的核心决策,才值得深入调研。比如这个项目的云端同步方案,只有几十行代码的量,选错了半天就能换,所以我直接选了已有生态内的 Vercel,把精力放在更重要的离线同步架构设计上。"

延伸讨论:

  • "那什么时候必须深入调研?" → 数据库选型、核心语言、团队共用的基础设施。这些一旦选了,迁移成本是月级别的。
  • "怎么避免被'熟悉'绑架?" → 定期花少量时间了解新工具的定位(不是深入学,是知道它解决什么问题)。当约束条件变了(比如从个人项目变成大规模服务),重新评估。

5. Phase 1 实施——VitePress 到 Next.js 脚手架搭建与 PWA 基础

一句话结论

把 VitePress 静态文档站替换为 Next.js App Router 项目骨架,同时加入最基础的 PWA 支持(manifest + icon + standalone 模式),让应用从第一天就能安装到 iPad 主屏幕。

为什么这样设计

核心驱动力:对抗注意力劫持

这个项目的目标不只是"学习工具"——是在拿起设备的那一秒拦截打开微博/微信的冲动。这决定了一个反直觉的优先级排序:PWA 可安装性比功能完整性更重要。一个能装到主屏幕的空壳 App,比一个只能通过浏览器访问的完整应用更有价值。

为什么 Phase 1 就加 PWA 而不是等功能做完?

❌ 初级做法 → 最后加 PWA:"功能都做完了再考虑安装"
   问题:整个开发期间无法在真实设备上体验,最后发现 standalone 模式下布局崩了

⚠️ 中级做法 → 中期加 PWA:Phase 3 做完核心流程后再加
   问题:可以,但错过了从第一天就在 iPad 上验证布局和交互的机会

✅ 资深做法 → Phase 1 就加最基础的 PWA manifest
   只做可安装(manifest + icon + standalone),不做离线缓存(那是 Phase 6)
   从第一天就能在 iPad 上以 standalone 模式体验,每个 Phase 的产出都能在真实环境验证

技术细节

项目结构

codex (从 learning-docs 改造)
├── src/app/
│   ├── layout.tsx          ← 根布局(PWA meta、中文 lang)
│   ├── page.tsx            ← 首页
│   ├── globals.css         ← Tailwind CSS v4 + safe area
│   ├── manifest.ts         ← PWA Web App Manifest(Next.js 约定文件)
│   ├── icon.tsx            ← 动态生成 512px 图标
│   ├── apple-icon.tsx      ← Apple Touch Icon 180px
│   ├── learn/page.tsx      ← 学习页占位
│   ├── review/page.tsx     ← 回顾页占位
│   ├── progress/page.tsx   ← 进度页占位
│   └── docs/[slug]/page.tsx ← 文档页占位
├── docs/                   ← 4 个 Markdown 文件保留
├── next.config.ts
├── tsconfig.json
├── postcss.config.mjs
└── package.json

构建结果:First Load JS 102kB,所有路由静态生成。

PWA display 模式——环境隔离思维

display 字段控制的是浏览器 UI 的可见程度,不是屏幕尺寸:

browser      → 完整浏览器(地址栏 + 导航栏 + 标签栏)
minimal-ui   → 只保留地址栏和最少控件(能看到 URL,能刷新)
standalone   → 没有任何浏览器 UI,看起来像原生 App  ← 我们选的
fullscreen   → 连状态栏(时间、电量)都隐藏(游戏用的)

为什么选 standalone 而不是 minimal-ui? 关键不在技术,在用户心理。如果点开图标还能看到地址栏,心理暗示是"我在浏览网页"——离打开别的网站只差一次输入。standalone 没有地址栏,用户感觉在用 App,退出去的摩擦力更高。这是用环境设计对抗注意力劫持

相关代码:src/app/manifest.ts

// manifest.ts — Next.js 约定文件,导出函数自动生成 /manifest.webmanifest
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "Codex",
    short_name: "Codex",
    start_url: "/",
    display: "standalone",  // 关键:去掉所有浏览器 UI,形成封闭学习环境
    background_color: "#fafafa",
    theme_color: "#171717",
    // ...
  };
}

动态图标生成——Next.js 约定式文件路由

Next.js 15 支持用 React 组件动态生成 favicon 和 apple-touch-icon,不需要预先准备 PNG 文件:

// src/app/icon.tsx — 文件名是约定,Next.js 自动将其作为 /icon 路由
import { ImageResponse } from "next/og";

export const size = { width: 512, height: 512 };
export const contentType = "image/png";

// 这个 React 组件不是给浏览器渲染的,而是在构建时/请求时
// 由 Next.js 的 ImageResponse API 将 JSX 转换为 PNG 图片
export default function Icon() {
  return new ImageResponse(
    (
      <div style={{
        fontSize: 220,
        fontWeight: 700,
        background: "#171717",
        color: "#fafafa",
        width: "100%",
        height: "100%",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        borderRadius: 96,
      }}>
        CX
      </div>
    ),
    { ...size }
  );
}

原理ImageResponse 基于 Satori 库(Vercel 开发),它把一个 JSX 布局转成 SVG,再转成 PNG。限制是只支持 flexbox 布局和有限的 CSS 属性(不支持 grid、不支持伪元素),因为 Satori 不是浏览器渲染引擎。

为什么不用静态 PNG?

  • 脚手架阶段没有设计稿,动态生成最快
  • 一处修改所有尺寸自动更新
  • 权衡:正式发布时应该换成设计师给的精确像素图标,因为 Satori 的字体渲染和真实浏览器有细微差异

同理,apple-icon.tsx 生成 180×180 的 Apple Touch Icon,iPad 添加到主屏幕时使用。

viewport 决策——缩放与无障碍

最初设置了 maximumScale: 1 禁用双指缩放,后来改正了。

// ❌ 最初的写法
export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  maximumScale: 1,    // 禁用缩放——看起来更像原生 App
  viewportFit: "cover",
};

// ✅ 修正后
export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  // 不设 maximumScale,允许用户缩放
  viewportFit: "cover",  // 告诉浏览器内容会处理安全区域
};

为什么禁用缩放是错的? 判断链条:

  1. 学习内容有大量代码块(knowledge-hub.md 有 11195 行,数百个代码示例)
  2. iPad 上阅读代码时,字小了看不清很常见
  3. 双指缩放是最自然的放大方式
  4. 禁用了等于逼用户凑近看

Apple Human Interface Guidelines 也明确建议不要禁用缩放。App Store 审核会因此拒绝原生 App,PWA 虽然不走审核但道理相同。

资深工程师的判断路径:不看"缩放本身好不好",而是看"用户实际场景需不需要"→ 内容有代码 → iPad 读代码需要缩放 → 不能禁用。从用户场景反推技术决策,而不是从技术偏好出发。

viewportFit: "cover" 的作用:告诉浏览器"我的内容会延伸到安全区域外,我会自己用 env(safe-area-inset-*) 处理"。不设这个的话,iPhone/iPad 的刘海和底部指示条区域会自动留白,standalone 模式下看起来像没适配好。

CSS 单位:dvh vs vh

/* globals.css */
body {
  padding: env(safe-area-inset-top) env(safe-area-inset-right)
    env(safe-area-inset-bottom) env(safe-area-inset-left);
}

页面中使用了 Tailwind 的 min-h-dvh 而不是 min-h-screen

min-h-screen → min-height: 100vh  → 包含 Safari 地址栏高度,内容会被遮挡
min-h-dvh    → min-height: 100dvh → 动态视口高度,排除浏览器 UI,是真实可见区域

dvh(Dynamic Viewport Height)是 2022 年进入标准的新 CSS 单位。iOS Safari 中:

  • 100vh = 屏幕物理高度,地址栏收起和展开时都不变 → 地址栏展开时底部内容被遮挡
  • 100dvh = 当前可见区域高度,地址栏展开时变小、收起时变大 → 内容永远不被遮挡

Tailwind v4 的 min-h-dvh 直接映射到 min-height: 100dvh,不需要手写。

与前端已知知识的类比

概念 React/前端类比
manifest.ts 约定文件 类似 Next.js 的 page.tsxlayout.tsx——文件名有特殊含义,框架自动处理
ImageResponse 类似 SSR,但不是生成 HTML,而是生成图片。"服务端渲染一张 PNG"
standalone 模式 类似 Electron 包装网页为桌面应用——都是去掉浏览器 chrome 让 Web 看起来像 native
dvh 单位 类似 React 的 useEffect 清理函数——解决的是"运行环境会变化"的问题(视口高度会因浏览器 UI 变化)

数据流 / 执行流程图

npm run build
     │
     ▼
Next.js 编译
     │
     ├── src/app/manifest.ts ──→ /manifest.webmanifest(JSON)
     │
     ├── src/app/icon.tsx ────→ /icon(PNG 512×512)
     │                          ImageResponse: JSX → Satori → SVG → PNG
     │
     ├── src/app/apple-icon.tsx → /apple-icon(PNG 180×180)
     │
     ├── src/app/layout.tsx ──→ <html> 根布局
     │     含 appleWebApp meta
     │     含 viewport 配置
     │
     └── src/app/page.tsx 等 ──→ 各路由的静态 HTML
           
iPad Safari 访问
     │
     ▼
读取 manifest.webmanifest
     │ display: standalone
     │ icons: [/icon]
     ▼
"添加到主屏幕" 可用
     │
     ▼
从主屏幕打开
     │ 无地址栏、无导航栏
     │ 封闭学习环境
     ▼
首页加载(102kB JS)

相关代码位置

  • PWA manifest:src/app/manifest.ts
  • 根布局(viewport + PWA meta):src/app/layout.tsx
  • 动态图标:src/app/icon.tsxsrc/app/apple-icon.tsx
  • 全局样式(safe area + dvh):src/app/globals.css
  • 首页占位:src/app/page.tsx
  • 路由占位:src/app/learn/page.tsxsrc/app/review/page.tsxsrc/app/progress/page.tsxsrc/app/docs/[slug]/page.tsx

面试 / 技术对话角度

面试官可能怎么问: "你的 PWA 项目是怎么做的?为什么选 PWA 不选原生 App?"

初级追问:"PWA 的 manifest 里都有什么字段?" 资深追问:"standalone 模式下怎么处理导航?缩放策略怎么定的?"

面试话术:"我们做了一个 iPad PWA 学习工具。选 PWA 而非原生的原因是只有一个开发者,不需要 App Store 分发,而且 iOS 16.4 后 PWA 已经支持 Web Push。关键的设计决策是 Phase 1 就加入 manifest 和 standalone 模式——不是为了功能,而是为了从第一天就在真实设备上验证布局。standalone 模式去掉了所有浏览器 UI,形成封闭的学习环境,降低用户切出去刷社交媒体的可能性。viewport 方面,我们特意没有禁用缩放,因为学习内容包含大量代码块,iPad 上阅读代码需要双指放大。用了 100dvh 替代 100vh 解决 iOS Safari 地址栏导致的底部遮挡问题。"

延伸讨论:

  • "PWA 在 iOS 上有什么限制?" → iOS PWA 不支持后台推送(16.4 前)、不支持蓝牙/NFC、IndexedDB 在低存储时可能被清理(但对于已安装的 standalone PWA,Safari 给的配额远高于普通网页,通常 1GB+)。最大的坑是 iOS Safari 的 Service Worker 在 App 不活跃几天后会被终止,重新打开时需要重新注册。
  • "为什么不用 Capacitor/Ionic 包一层?" → 增加了构建复杂度和一层抽象,对单用户工具来说不值得。如果将来需要推送、相机等原生能力才考虑。
  • "standalone 模式下用户怎么导航?" → 没有浏览器后退按钮,必须自己实现导航。我们用 Next.js 的 <Link> 客户端路由 + 页面内返回按钮。这是 standalone PWA 的经典问题,也可以用 History API 监听实现手势返回。

6. 知识盲区复盘——PWA display 模式、viewport 缩放、standalone 导航、客户端路由

一句话结论

Phase 1 的思考题暴露了 4 个知识盲区:PWA display 模式的本质、viewport 缩放的场景化判断、standalone 模式的导航设计、以及 <a> 和 Next.js <Link> 的本质区别。这些都是 Web App 开发的基础认知,但如果只写过 SPA 页面而没做过 PWA 或移动端适配,很容易完全没接触过。

为什么会有这些盲区

前端日常开发绝大部分时间在写组件逻辑和业务代码,很少触及"浏览器怎么呈现你的应用"这个层面。PWA manifest、viewport meta、客户端路由机制——这些属于平台层知识,不是框架层知识。你熟悉 React/Next.js 的 API,但对浏览器作为宿主环境提供了什么能力、有什么限制,了解不够深。

这恰恰是中级到资深的关键跨越之一:从"框架能做什么"到"平台能做什么"。


盲区 1:PWA display 模式——不是屏幕大小,是浏览器 UI 可见度

问题display: "standalone" 意味着什么?

我的回答:猜测和屏幕大小有关。

实际答案:完全无关屏幕大小。display 控制的是浏览器 UI 元素(地址栏、导航栏、标签栏)的可见程度

四种模式对比

┌──────────────────────────────────────────────────┐
│ browser(默认)                                    │
│ ┌──────────────────────────────────────────────┐ │
│ │ ← → 🔄  https://codex.vercel.app      ☰    │ │  ← 地址栏
│ ├──────────────────────────────────────────────┤ │
│ │                                              │ │
│ │              你的 App 内容                    │ │
│ │                                              │ │
│ ├──────────────────────────────────────────────┤ │
│ │  ◁    □    ▷    📑    ⊕                     │ │  ← 底部工具栏
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ minimal-ui                                        │
│ ┌──────────────────────────────────────────────┐ │
│ │ codex.vercel.app                   🔄  ✕    │ │  ← 精简地址栏
│ ├──────────────────────────────────────────────┤ │
│ │                                              │ │
│ │              你的 App 内容                    │ │
│ │          (更大的可用空间)                    │ │
│ │                                              │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ standalone                                        │
│ ┌──────────────────────────────────────────────┐ │
│ │  9:41        Codex          🔋 85%          │ │  ← 只有系统状态栏
│ ├──────────────────────────────────────────────┤ │
│ │                                              │ │
│ │              你的 App 内容                    │ │
│ │       (最大可用空间,没有浏览器 UI)          │ │
│ │                                              │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ fullscreen                                        │
│ ┌──────────────────────────────────────────────┐ │
│ │                                              │ │
│ │              你的 App 内容                    │ │
│ │      (连状态栏都没有,完全全屏)              │ │
│ │          (游戏、沉浸式视频用)                │ │
│ │                                              │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

底层原理

浏览器本质上是一个容器应用,你的网页运行在它的"画框"里。display 模式控制的就是这个画框留多少边框给自己(地址栏、工具栏),留多少空间给你的内容。

standalone 的本质:告诉操作系统"把我当成一个独立应用来对待"。在 iOS 上,这意味着:

  • 有自己的 App 切换卡片(不和 Safari 共用)
  • 有自己的进程(Safari 崩了不影响你)
  • 没有浏览器的任何 UI 控件

这和 Electron 的思路一模一样——都是让 Web 内容"伪装"成原生应用。区别是 Electron 打包了一个 Chromium,PWA standalone 复用了系统浏览器内核。

在本项目中的意义

选 standalone 不只是"看起来更好",而是环境隔离:没有地址栏 = 用户无法在不退出 App 的情况下输入其他 URL = 切出去刷微博的摩擦力从 0(输入 URL)变成了需要按 Home 键退出 App。

行业视角

国内很多小程序(微信小程序、支付宝小程序)本质上也是一种 standalone 容器——它们都去掉了浏览器 UI,给内容最大的展示空间。差别是小程序有自己的运行时和 API 壁垒,PWA 是开放标准。Twitter(X)、Starbucks、Pinterest 的移动版都是 standalone PWA。


盲区 2:viewport 缩放——"无所谓"是错误的判断

问题maximumScale: 1 禁用了双指缩放,对学习工具来说对不对?

我的回答:无所谓对错。

为什么这个回答是错的:说"无所谓"意味着没有展开思考。资深工程师对任何技术决策都不应该说"无所谓"——要么有明确理由选 A,要么有明确理由选 B。"无所谓"说明你还没找到判断的锚点。

正确的判断方法:场景驱动决策

判断链条(不是背结论,是建立思维路径):

这个 App 的内容是什么?
  → 技术文档 + 代码块

用户在什么设备上看?
  → iPad(10-11 寸屏幕)

代码块在 iPad 上的阅读体验?
  → 代码字体小,长行会溢出,经常需要看清细节

用户放大内容的最自然方式?
  → 双指缩放(不需要学习,每个人都会)

禁用缩放后用户怎么办?
  → 凑近看 / 转横屏 / 放弃阅读 → 三个选项都增加摩擦力

结论:不能禁用

这个思维路径的关键不是"缩放好不好"的通用问题,而是**"这个具体产品的这个具体场景需不需要缩放"**。换个场景(比如一个只有大号按钮的游戏),禁用缩放可能就是对的。

无障碍视角

WCAG 2.1 的 1.4.4 条款要求:文本可以在不丢失内容的情况下放大到 200%。maximumScale: 1 直接违反这条。Apple 的 Human Interface Guidelines 同样建议不要禁用缩放。

这个知识点的通用规则:除非你有非常明确的理由(比如 Canvas 游戏、地图应用),否则永远不要禁用缩放。

三层对比

❌ 初级做法 → 复制模板时看到 maximumScale: 1 就留着了,不知道它干什么
⚠️ 中级做法 → 知道它禁用了缩放,觉得"无所谓"或"看起来更原生"
✅ 资深做法 → 从内容类型和用户场景出发判断,明确给出理由,考虑无障碍标准

盲区 3:standalone 模式的导航问题

问题:standalone 模式没有浏览器后退按钮,用户怎么"返回上一页"?

为什么这是个真实问题

普通浏览器中,用户有三种方式返回上一页:

  1. 浏览器左上角的 ← 后退按钮
  2. 键盘/鼠标后退手势(Mac 双指左滑、Android 返回键)
  3. 页面内的返回链接

standalone 模式下,方式 1 完全不存在。方式 2 在某些设备上可用(iOS Safari PWA 支持边缘左滑手势返回),但不是所有用户都知道这个手势。所以方式 3 是唯一可靠的——你必须在页面 UI 里提供导航。

解决方案

方案 1:每个页面顶部放返回按钮(我们目前的做法)
  优点:简单直接
  缺点:占用屏幕空间,每个页面都要手动加

方案 2:固定底部 Tab 导航栏(像原生 App 一样)
  优点:用户熟悉这个模式,不会迷路
  缺点:对我们只有 4-5 个页面来说可能过重
  适用:页面间是平级关系(首页/学习/进度/文档)

方案 3:顶部导航栏 + 面包屑
  优点:层级清晰
  缺点:对移动端不够友好,占空间

方案 4:History API 程序化控制
  router.back() 配合页内按钮,模拟浏览器后退
  优点:行为和浏览器后退一致
  缺点:如果用户直接从主屏幕打开某个深层页面(没有历史记录),back() 会退出 App

我们项目的最佳方案

方案 2(底部 Tab 栏)+ 方案 4(router.back)组合

┌─────────────────────────────────┐
│ ← 返回          Codex          │  ← 学习/回顾等深层页面有返回按钮
│                                 │
│          页面内容                │
│                                 │
├─────────────────────────────────┤
│  🏠 首页  📖 学习  📊 进度     │  ← 底部 Tab 栏,主要页面间切换
└─────────────────────────────────┘
  • 主要页面(首页、学习、进度)通过底部 Tab 切换——平级关系
  • 学习流程中的深层页面(回顾、自评)用顶部返回按钮 + router.back()
  • 安全处理:如果 history.length <= 1(没有上一页),返回按钮导航到首页而不是调用 back()

这是 Phase 3 实现交互流程时需要落地的设计决策。

行业做法

看看主流 PWA 怎么处理的:

  • Twitter/X PWA:底部 Tab 栏(首页/搜索/通知/消息)+ 页内返回箭头
  • Starbucks PWA:底部 Tab 栏 + 顶部面包屑
  • Spotify Web Player:侧边栏导航 + 页内 ←→ 按钮

结论:standalone PWA 几乎都需要自己实现一套导航系统。这不是可选的,是必须的。


盲区 4:<a> vs Next.js <Link> ——客户端路由的本质

问题:占位页面中 <a href="/">← 返回首页</a> 在 Next.js App Router 里有什么性能问题?

现象

用原生 <a> 标签在 Next.js 里跳转页面,页面会完整刷新——白屏一闪,重新加载所有 JS、CSS,重新渲染整个 React 组件树。

用 Next.js 的 <Link> 组件,只更新变化的部分——没有白屏,布局保持不变,只有目标页面的内容区域更新。

底层原理

这是服务端路由 vs 客户端路由的区别:

原生 <a> 的行为(服务端路由):
  
  用户点击 <a href="/learn">
       │
       ▼
  浏览器销毁当前页面的所有 JS 状态
       │
       ▼
  向服务器发起新的 HTTP 请求 GET /learn
       │
       ▼
  服务器返回完整 HTML
       │
       ▼
  浏览器解析 HTML → 加载 CSS/JS → 执行 React hydration
       │
       ▼
  页面可交互(整个过程可能 200ms-1s,有白屏闪烁)


Next.js <Link> 的行为(客户端路由):
  
  用户点击 <Link href="/learn">
       │
       ▼
  Next.js 的 router 拦截点击事件(preventDefault)
       │
       ▼
  用 fetch 只请求 /learn 页面的 RSC payload(不是完整 HTML)
       │ RSC payload 是 React Server Component 的序列化数据
       │ 体积远小于完整 HTML(通常是 HTML 的 1/5 到 1/10)
       ▼
  React 在客户端做 diff,只更新变化的组件
       │ layout.tsx 不变 → 不重新渲染
       │ 只替换 page.tsx 的内容
       ▼
  页面瞬间切换(通常 < 50ms,无白屏)

关键区别总结

维度 <a> <Link>
请求方式 完整 HTTP 请求 fetch RSC payload
页面状态 销毁并重建 保留(layout 不变)
JS 加载 重新加载所有 JS 只加载目标页面的 chunk
React 状态 丢失(Context、useState 全部重置) 保留(layout 层级的状态不变)
视觉效果 白屏闪烁 无缝切换
预加载 <Link> 可见时自动 prefetch 目标页面

对我们项目的影响

学习流程是:首页 → 学习页 → 回顾页 → 自评页 → 首页。这是一个连续的多步骤流程,如果每步都白屏闪烁,体验就像在刷网页而不是在用 App。用 <Link> + 客户端路由,步骤间的切换是瞬间的,用户感觉在用一个流畅的原生应用。

这和 standalone 模式配合:没有浏览器 UI + 无缝页面切换 = 最接近原生 App 的体验

与 React 已知知识的类比

如果你用过 React Router,<Link> 的概念是一样的——都是拦截 <a> 的默认行为,用 JavaScript 做客户端路由。区别是 Next.js 的 <Link> 还集成了:

  • 自动代码分割:每个 page.tsx 是独立的 JS chunk,只在需要时加载
  • 自动预加载<Link> 出现在视口中时,自动 prefetch 目标页面的代码和数据
  • RSC 支持:App Router 中获取的是 Server Component 的序列化数据,不是 HTML
// ❌ 当前写法:全页刷新,丢失所有 React 状态
<a href="/" className="text-blue-600 hover:underline">← 返回首页</a>

// ✅ 正确写法:客户端路由,无缝切换
import Link from "next/link";
<Link href="/" className="text-blue-600 hover:underline">← 返回首页</Link>

注意:只有站内链接用 <Link>。跳转到外部网站(比如 GitHub)仍然用 <a>,因为外部链接必须走浏览器原生导航。

三层对比

❌ 初级做法 → 全部用 <a>,不知道有 <Link>
   结果:每次跳转白屏闪烁,React 状态丢失

⚠️ 中级做法 → 知道 <Link>,但不清楚为什么要用、底层做了什么
   结果:能用对,但无法解释原理,也不知道什么时候不该用 <Link>

✅ 资深做法 → 理解客户端路由 vs 服务端路由的本质区别
   知道 <Link> 的 prefetch 行为、RSC payload 机制、什么场景用 <a>
   能根据场景选择:站内导航用 <Link>、外部链接用 <a>、
   需要强制刷新时用 router.refresh()

面试 / 技术对话角度

面试官可能怎么问: "Next.js 的 <Link> 和原生 <a> 有什么区别?"

面试话术:"<Link> 做的核心事情是把服务端路由变成客户端路由。点击原生 <a> 时,浏览器会销毁当前页面所有 JS 状态,发起完整的 HTTP 请求,重新加载和渲染整个页面。而 <Link> 拦截了这个行为,用 fetch 只请求目标页面的 RSC payload——这是 React Server Component 的序列化数据,体积远小于完整 HTML——然后 React 在客户端做 diff,只更新变化的部分。Layout 层级的组件和状态完全保留,视觉上就是无缝切换。此外 <Link> 还会在组件进入视口时自动 prefetch 目标页面的代码,所以用户点击时通常已经加载好了,切换几乎是零延迟。"

资深追问及回答:

  • "什么时候不该用 <Link>?" → 外部链接、需要强制全页刷新的场景(比如语言切换后需要重新 SSR)、或者下载链接(<a href="/file.pdf" download>)。
  • "prefetch 会不会浪费带宽?" → 默认只 prefetch 视口内可见的链接,而且只加载 JS chunk 不执行,成本很低。如果页面上有几百个链接可以设 prefetch={false} 禁用。在我们的学习工具里链接总共不超过 10 个,prefetch 的收益远大于成本。
  • "<Link> 和 React Router 的 <Link> 有什么区别?" → React Router 的 <Link> 只做客户端路由。Next.js 的还多了自动 prefetch、RSC payload 获取、和自动代码分割的集成。本质一样,但 Next.js 的更"全栈"。

7. 知识盲区复盘——Server vs Client Component、IndexedDB 事务

一句话结论

Phase 2 的思考题暴露了两个核心盲区:不知道 Next.js App Router 中 Server Component 和 Client Component 是两个完全不同的运行时环境,以及不了解 IndexedDB 事务的原子性保障。前者是 React Server Components 架构的基础认知,后者是数据层可靠性的基本功。

为什么会有这些盲区

如果之前只用过 CRA(Create React App)或 Vite 的纯客户端 React,所有组件都在浏览器运行——从来不需要思考"这个 hook 能不能在服务端用"。Server Component 是 Next.js App Router(React 18+)引入的全新心智模型,不是 React 基础知识的延伸,而是一个新的范式。

IndexedDB 事务同理——如果只用过 localStorage.setItem(同步、无事务),不会意识到异步数据库操作存在"写到一半崩了"的问题。


盲区 5:"use client" 与 Server/Client Component 的本质区别

问题:DBProvider 为什么必须标 "use client"

我的回答:不了解 "use client",猜测 useEffectuseState 在服务端也能用。

为什么错:完全错。useStateuseEffect纯客户端 API,在 Node.js 环境中调用会直接报错。

核心概念:两种组件是两个不同的运行时

Next.js App Router 中,所有组件默认是 Server Component——在 Node.js 服务器上(或构建时)执行,不在浏览器里。

Server Component(默认)              Client Component("use client")
┌─────────────────────────┐         ┌─────────────────────────┐
│ 运行在:Node.js / 构建时  │         │ 运行在:浏览器           │
│                         │         │                         │
│ 能做:                   │         │ 能做:                   │
│  ✅ 读文件 (fs)          │         │  ✅ useState / useEffect │
│  ✅ 查数据库             │         │  ✅ 事件处理 (onClick)   │
│  ✅ 访问环境变量          │         │  ✅ 浏览器 API (DOM,     │
│  ✅ async/await 直接用   │         │     IndexedDB, localStorage) │
│                         │         │                         │
│ 不能做:                 │         │ 不能做:                  │
│  ❌ useState / useEffect │         │  ❌ 读文件               │
│  ❌ 浏览器 API           │         │  ❌ 直接查数据库          │
│  ❌ 事件处理             │         │  ❌ 访问服务端环境变量     │
└─────────────────────────┘         └─────────────────────────┘

为什么 Node.js 上不能用 useState/useEffect

想想 Node.js 环境里有什么:

Node.js 服务器
├── 有:fs(文件系统)、http、数据库连接
├── 没有:window、document、DOM
├── 没有:用户在点击按钮(没有交互)
└── 没有:组件"挂载"的概念(渲染成 HTML 字符串就结束了)
  • useState 管理的是用户交互引起的状态变化。服务器上没有用户在点击,状态变化无从谈起。
  • useEffect组件挂载到 DOM 后执行副作用。服务器上没有 DOM,组件不会"挂载"——它渲染完 HTML 就结束了,不存在"挂载后"这个时间点。

去掉 "use client" 后的报错:

Error: useState only works in Client Components.
Add the "use client" directive at the top of the file.

DBProvider 为什么必须是 Client Component

三个原因,每个单独就够了:

  1. useState — 管理 readyerror 状态(客户端交互)
  2. useEffect — 组件挂载后初始化 IndexedDB(副作用)
  3. idb 库 — 底层调用 window.indexedDB(浏览器 API)

与 CRA/Vite React 的类比

如果你之前用 CRA 或 Vite:

CRA / Vite React:
  所有组件 → 全部在浏览器运行 → 不需要思考"这能不能在服务端用"

Next.js App Router:
  默认 Server Component → 在 Node.js 运行
  标 "use client"        → 在浏览器运行
  
  你需要主动决定每个组件在哪里运行

这就是为什么从 CRA 迁移到 Next.js App Router 时,很多人一开始会在每个文件顶部都加 "use client"——这本质上是把 Next.js 降级成了 CRA 的模式,丧失了 Server Component 的好处(更小的 JS bundle、直接访问数据库等)。

正确的心智模型

页面组件树:

  layout.tsx (Server)          ← 不需要交互,在服务端渲染 HTML
    │
    ├── DBProvider (Client)    ← 需要 useState/useEffect/IndexedDB
    │     │
    │     └── page.tsx (Server)  ← 页面内容,不需要交互的部分
    │           │
    │           └── TaskCard (Client) ← 需要 onClick
    │
    └── Sidebar (Server)      ← 纯展示,不需要交互

原则"use client" 的边界尽量往下推。layout 和纯展示组件保持 Server Component(更小的 JS bundle),只有需要交互和浏览器 API 的组件才标 Client。

三层对比

❌ 初级做法 → 每个文件都加 "use client"
   结果:能跑,但所有组件都打包到客户端 JS 里,bundle 变大,丧失 RSC 好处

⚠️ 中级做法 → 知道要加 "use client",但不理解为什么
   结果:凭直觉或报错驱动来决定加不加,无法解释架构决策

✅ 资深做法 → 理解 Server/Client 是两个运行时
   知道 "use client" 是一条边界线,它把组件树切成两个世界
   能解释为什么某个组件该放在哪一侧
   能设计出合理的 Server/Client 组件树结构

盲区 6:IndexedDB 事务的原子性

问题:种子逻辑用了 db.transaction("tasks", "readwrite"),如果不用 transaction 直接循环 db.put() 有什么区别?

我的回答:不懂。

核心概念:原子性(Atomicity)

Transaction(事务)保证的是"要么全部成功,要么全部不做"。

假设 46 个种子任务,循环到第 23 个时浏览器崩了:

不用 transaction:

  db.put(task1)  ← ✅ 已写入磁盘(持久化了)
  db.put(task2)  ← ✅ 已写入磁盘
  ...
  db.put(task22) ← ✅ 已写入磁盘
  db.put(task23) ← 💥 浏览器崩溃 / 用户关页面 / JS 报错
  db.put(task24) ← ❌ 没执行
  ...
  db.put(task46) ← ❌ 没执行

  结果:数据库里有 22 个任务,缺了 24 个 = 部分写入(脏数据)


用 transaction:

  tx = db.transaction("tasks", "readwrite")
  tx.store.put(task1)   ← 写入内存缓冲区(还没持久化)
  tx.store.put(task2)   ← 写入内存缓冲区
  ...
  tx.store.put(task22)  ← 写入内存缓冲区
  tx.store.put(task23)  ← 💥 崩溃
  
  事务没有 commit → 自动回滚 → IndexedDB 中什么都没写入

  结果:数据库和崩溃前一模一样,干干净净

第二个好处:性能

不用 transaction:
  每次 db.put() = 一次独立的 I/O 操作
  打开事务 → 写入 → 提交 → 关闭 × 46 次
  = 46 次磁盘 I/O

用 transaction:
  所有 put 共享一次 I/O 提交
  = 1 次磁盘 I/O(批量写入)

46 个任务差异不大,但 1000 个任务时,没有 transaction 可能要几秒,有 transaction 几十毫秒。

类比:git commit

不用 transaction = 每改一个文件就 git commit 一次
  → 改到一半电脑崩了,有些文件改了有些没改,仓库状态混乱

用 transaction = 改完所有文件再一次性 git commit
  → 要么全部改了,要么一个都没改(git reset 的效果)

这是数据库领域 ACID 中的 A(Atomicity,原子性)。你可能听过"数据库事务"这个词但觉得离前端很远——其实 IndexedDB 作为浏览器端的结构化存储,完整支持事务,这也是它比 localStorage 强大的核心区别之一。

在本项目中的应用

// src/lib/seed.ts

// 用 transaction 保护种子过程
const tx = db.transaction("tasks", "readwrite");
for (const task of seedTasks) {
  const existing = await tx.store.get(task.id);  // 读也在事务内
  if (!existing) {
    await tx.store.put(task);  // 写也在事务内
  }
}
await tx.done;  // 一次性提交

// 为什么 get 和 put 都要在同一个事务里?
// 因为如果分开:
//   const existing = await db.get("tasks", task.id);  // 事务 A
//   if (!existing) await db.put("tasks", task);        // 事务 B
// A 和 B 之间有一个时间窗口,理论上另一个操作可以插入同 ID 的数据
// 虽然单用户场景下几乎不可能,但事务本来就是为了消除"几乎不可能"而存在的

三层对比

❌ 初级做法 → 不知道 IndexedDB 有事务,用 localStorage 存一切
   结果:数据量大了 localStorage 的 5MB 限制就崩了

⚠️ 中级做法 → 用 IndexedDB 但不用事务,每次操作独立 db.put()
   结果:正常情况下能用,极端情况(崩溃、并发)数据不一致

✅ 资深做法 → 理解事务的原子性保障和性能优势
   批量操作一定用事务包裹
   知道 readwrite 事务的作用域(不能跨 store)
   理解 IndexedDB 和关系数据库事务的异同

面试 / 技术对话角度

面试官可能怎么问: "Next.js App Router 中 Server Component 和 Client Component 有什么区别?"

面试话术:"核心区别是运行时环境不同。Server Component 在 Node.js 或构建时执行,能直接读文件、查数据库、访问环境变量,但不能用 hooks 和浏览器 API。Client Component 在浏览器执行,能用 useState/useEffect 和 DOM 操作,但不能直接访问服务端资源。'use client' 指令是这两个世界的边界线——它告诉 Next.js '从这个组件开始,以下的组件树都在浏览器运行'。实际开发中的原则是把 'use client' 边界尽量往叶子节点推,让尽可能多的组件保持 Server Component,这样客户端 JS bundle 更小、首屏更快。"

资深追问及回答:

  • "Server Component 的好处是什么?" → 零 JS bundle 贡献——Server Component 的代码不会被打包到发送给浏览器的 JavaScript 中。一个只做数据获取和展示的页面,用 Server Component 可以实现零客户端 JS。
  • "能不能在 Server Component 里用 Context?" → 不能。Context 是客户端状态管理机制,依赖 React 的客户端运行时。如果 Server Component 需要共享数据,直接在服务端查询然后通过 props 传递——不需要 Context 这层抽象。
  • "IndexedDB 的事务和 SQL 数据库的事务有什么异同?" → 相同点:都保证原子性(要么全成功要么全回滚)。不同点:IndexedDB 事务是自动提交的(当所有请求完成且没有新请求时自动 commit),不需要显式 BEGIN/COMMIT;IndexedDB 没有 SQL 的隔离级别概念,但 readwrite 事务会锁住涉及的 object store,防止并发写入冲突。

8. 概念补充——数据库种子(Seed)

一句话结论

Seed(种子)是数据库领域的通用术语,意思是给空数据库预填初始数据,让应用第一次启动时不是一片空白。

为什么需要种子

没有种子:

  用户第一次打开 App
       │
       ▼
  IndexedDB 是空的
       │ 推荐引擎:没有任务可推荐
       ▼
  用户看到空白页 → "这啥也没有?" → 关掉走了


有种子:

  用户第一次打开 App
       │
       ▼
  种子逻辑检测到空数据库 → 写入 46 个预设任务
       │
       ▼
  推荐引擎立刻有数据 → 推荐第一个任务 → 用户开始学习

对我们的项目来说,没有种子 = 用户需要自己手动添加学习任务 = 启动摩擦。这直接违反"消灭决策、零摩擦启动"的设计原则。

类比

  • 新手机:买来开机通讯录是空的,但系统预装了"紧急服务 110/120/119"——这就是种子数据
  • 新安装的 VS Code:预装了默认主题和快捷键配置——这也是种子
  • 新建的 Git 仓库git init 后仓库是空的,README.md 就是第一个种子

在本项目中的实现

// src/lib/seed.ts

export async function seedIfNeeded() {
  // 1. 检查是否已经种过
  const currentVersion = await getMeta("seed-version");
  if (currentVersion === SEED_VERSION) return;  // 种过了,跳过

  // 2. 读取预设任务
  const seedTasks = getSeedTasks();  // 46 个任务,从 plan.md 提取

  // 3. 只插入不存在的(保护用户进度)
  const tx = db.transaction("tasks", "readwrite");
  for (const task of seedTasks) {
    const existing = await tx.store.get(task.id);
    if (!existing) {        // 已存在的不覆盖
      await tx.store.put(task);
    }
  }
  await tx.done;

  // 4. 标记已种过
  await setMeta("seed-version", SEED_VERSION);
}

关键设计:种子只添加,不覆盖。如果用户已经完成了某个任务(completedCount: 3),重新种子不会把它重置回 completedCount: 0。通过确定性 ID(seed-w1-1)来判断任务是否已存在。

行业通用实践

Seed 不是前端特有的概念,后端/全栈项目几乎都有:

框架 种子命令 说明
Rails rails db:seed 执行 db/seeds.rb
Laravel php artisan db:seed 执行 DatabaseSeeder.php
Prisma npx prisma db seed 执行 prisma/seed.ts
Django python manage.py loaddata 从 JSON/YAML fixture 加载

模式都一样:预定义数据 → 检查是否已存在 → 写入数据库。

面试 / 技术对话角度

这个概念不会单独被问到,但在讲项目时经常自然带出:

面试话术(嵌入到项目介绍中):"...用户第一次打开 App 时,IndexedDB 是空的,所以我们设计了一套种子机制——从学习计划中提取了 46 个预设任务,首次启动时批量写入。种子逻辑是幂等的:用确定性 ID 判断任务是否已存在,存在就跳过,不会覆盖用户的学习进度。整个种子过程包在一个 IndexedDB 事务里,保证原子性。"

这段话同时展示了你对种子设计幂等性事务原子性三个概念的理解——面试官通常不会追问"什么是种子",但会注意到你自然使用了这些术语。


9. Phase 2 实施——类型系统、IndexedDB 数据层、种子数据

一句话结论

定义完整的 TypeScript 类型系统,用 idb 封装 IndexedDB 四个 object store(tasks/sessions/prompts/meta),实现间隔重复算法和版本化种子逻辑,从 plan.md 提取 46 个预设任务 + 30 条学习提示语作为种子数据。

技术细节

数据模型设计决策

SR 状态挂在 Task 上而非单独 Progress 表:

方案 A(拆表):
  Task 表:{ id, title, type, ... }          ← 只存静态信息
  Progress 表:{ taskId, srStage, srNextDue, completedCount, ... }
  
  推荐引擎需要读:Task + Progress → 跨 store JOIN
  IndexedDB 没有 SQL JOIN,需要手动关联 → 代码复杂、性能差

方案 B(合并,我们选的):
  Task 表:{ id, title, type, ..., srStage, srNextDue, completedCount, ... }
  
  推荐引擎只读 Task → 一次查询搞定
  单用户 + 一个 Task 只有一份进度 → 没有拆表的理由

StudySession 单独一张表:Session 是不可变的历史记录(每次学习一条),Task 是可变的当前状态。进度面板从 Session 聚合统计("本周学了几次"),推荐引擎从 Task 读当前 SR 状态。职责分离。

IndexedDB 索引设计

// src/lib/db.ts

// tasks store 的 4 个索引:
taskStore.createIndex("by-course", "courseId");     // 按课程筛选
taskStore.createIndex("by-status", "status");       // 按状态筛选
taskStore.createIndex("by-sr-due", "srNextDue");    // 查询到期任务(推荐引擎核心)
taskStore.createIndex("by-sort", ["courseId", "sortOrder"]); // 复合索引:课程内排序

为什么要建索引? IndexedDB 没有索引时只能全表扫描(getAll() 然后 JS 内 filter)。有索引后 getAllFromIndex("tasks", "by-sr-due", IDBKeyRange.upperBound(today)) 直接走索引,只返回到期的任务。46 个任务差别不大,但这是正确的数据层设计习惯。

间隔重复算法

// src/lib/spaced-repetition.ts

// 核心函数:根据自评计算下次复习时间
function calculateNextReview(task, assessment):
  1. 如果自评类别变了(比如上次 "remembered" 这次 "fuzzy")→ 重置到新类别的第 02. 如果自评类别没变 → 前进一步
  3. 从对应间隔数组取天数,加到今天 → 得到下次到期日期

// 例:第一次自评 "remembered"
//   srStage: 0, srTier: "remembered"
//   intervals[0] = 1 → 明天到期

// 第二次仍然 "remembered"  
//   srStage: 1, srTier: "remembered"
//   intervals[1] = 3 → 3 天后到期

// 如果突然 "forgot"
//   tier 变了 → 重置到 forgot 的 stage 0
//   intervals[0] = 0 → 今天就到期(立刻复习)

种子数据:46 个任务的来源

docs/plan.md 的 4 周学习计划逐条提取:

来源 数量 类型
Week 1-4 阅读任务 22 个 read
Week 1-4 动手任务 13 个 practice
9 个 Bug 故事 9 个 review
面试练习 2 个 review

长任务(>30min)拆分为 30 分钟块。每个任务有确定性 ID(seed-w1-1),保证重复种子不会创建重复数据。

相关代码位置

  • 类型定义:src/types/index.ts
  • 常量(SR 间隔、课程定义):src/lib/constants.ts
  • IndexedDB 封装:src/lib/db.ts
  • 间隔重复算法:src/lib/spaced-repetition.ts
  • 种子逻辑:src/lib/seed.ts
  • 种子任务数据:src/data/seed-tasks.ts
  • 种子提示语数据:src/data/seed-prompts.ts
  • DB 初始化组件:src/components/DBProvider.tsx

10. Phase 3 实施——MVP 核心学习流程

一句话结论

实现了完整的学习闭环:首页(学习提示语 + 选时长)→ 推荐任务 → 学习 → 回顾题 → 自评 → 更新间隔重复状态。这是 codex 的 MVP——从这个版本开始,工具可以实际用来学习了。

交互流程

首页 (/)
  ├── 随机学习提示语(30 条,5 类别)
  ├── "今天已完成 N 个任务" 计数
  └── 选时长 (5/10/15/20/30 分钟)
        │
        │ 点击 → 推荐引擎评分 → 取最高分任务 → 创建 Session
        ▼
学习页 (/learn)
  ├── 任务标签(类型 / 难度 / 预估时长)
  ├── 任务标题 + 描述 + 来源
  ├── "放弃" → 回首页
  └── "学完了,进入回顾"
        │
        ▼
回顾页 (/review) — 两步式
  Step 1: 回顾题
    ├── 按任务类型自动生成问题(read/review/practice 各不同)
    ├── 文本输入框写回答
    ├── "跳过" → 记录跳过次数 → 回首页
    └── "下一步"
  Step 2: 自评
    ├── "记住了" → SR 间隔 1,3,7,14,30 天
    ├── "模糊"   → SR 间隔 1,2,4,7,14 天
    └── "没记住" → SR 间隔 0,1,2,3,7 天
        │
        │ 写 Session 到 IndexedDB + 更新 Task 的 SR 状态
        ▼
回到首页

为什么这样设计

推荐引擎:分值加法

每个任务计算总分,取最高分推荐:

SR 到期今天:         +1000  ← 碾压其他因子,到期复习永远第一
SR 逾期:             +500 × 天数(上限 2000)
新任务课程顺序:       +100 - sortOrder
时长匹配:            +50
时长严重不匹配:       -200
晚 21:30 后深度任务:  -300
连续 3 次同类型:      其他类型 +80
频繁跳过:            -30 × 次数

为什么用绝对分值而不是百分比权重?

百分比权重("课程顺序 40% + 复习到期 30%")在实现时很模糊——40% 的课程顺序到底怎么算?每个因子的值域不同,归一化逻辑纠结。

绝对分值的好处:

  • 每个数字直接对应一个产品决策(+1000 就是"到期复习碾压一切")
  • 调试时 console.log(breakdown) 一目了然为什么推了这个任务
  • 加新因子只需要定一个分值,不用重新调所有百分比
  • 这是推荐系统领域的 pointwise scoring 模式

相关代码:src/lib/recommendation.ts

回顾用两步 state 切换而非两个页面

回顾页用 useState("review" | "assess") 在同一页面切换两步,而不是拆成 /review/assess 两个路由。

方案 A(两个路由):
  /review → 回答回顾题 → router.push("/assess") → 自评
  问题:路由切换时 answer 状态丢失(除非提升到全局状态或 URL 参数)
  问题:用户刷新 /assess 页面没有上下文

方案 B(同页面两步,我们选的):
  /review → 两步用 useState 切换
  优点:answer 状态天然保留(同一个组件内)
  优点:切换瞬间(无路由跳转开销)
  优点:刷新 /review 回到第一步,不会进入无上下文的中间状态

State 和 Actions 拆成两个 Context

// src/lib/store.tsx

// 两个独立的 Context
const StateContext = createContext<AppState | null>(null);     // 数据
const ActionsContext = createContext<AppActions | null>(null);  // 操作

// 嵌套提供
<StateContext.Provider value={{ tasks, sessions, prompts, currentTask, ... }}>
  <ActionsContext.Provider value={{ startLearning, submitAssessment, ... }}>
    {children}
  </ActionsContext.Provider>
</StateContext.Provider>

这几行嵌套代码的含义:两个 Provider 各自独立跟踪 value 的变化。React 的规则是 Provider 的 value 引用变了 → 所有消费该 Context 的组件重新渲染。

如果只用一个 Context:

tasks 变了 → 一个巨大的 value 对象引用变了
  → 所有用了这个 Context 的组件全部重新渲染
  → PromptCard(只读 prompts)被迫渲染 ❌
  → TimeSelector(只调 startLearning)被迫渲染 ❌

拆成两个 Context 后:

tasks 变了 → StateContext 的 value 变了
  → 订阅 StateContext 的组件渲染(需要新数据) ✅
  
ActionsContext 的 value 没变(useCallback 引用稳定)
  → 只订阅 ActionsContext 的组件不渲染 ✅

TimeSelector 只调用 useAppActions() 获取 startLearning 函数,不订阅 StateContext。所以 tasks 数据怎么变都不会触发 TimeSelector 重渲染。嵌套的顺序无所谓——谁包谁都行,关键是它们是两个独立的订阅通道。

❌ 初级做法 → 一个大 Context 塞所有东西
   结果:任何数据变化导致所有消费组件重渲染

⚠️ 中级做法 → 知道 Context 会导致重渲染,用 React.memo 包子组件
   结果:能缓解,但治标不治本——memo 判断 props 是否变化也有开销

✅ 资深做法 → 按变化频率拆 Context
   数据频繁变化(State)和几乎不变的函数引用(Actions)分开
   组件只订阅自己需要的 Context,互不影响

什么时候该用 Zustand/Jotai 替代? 当 Context 拆到 4-5 个以上、组件间的数据依赖关系复杂时,手动管理多个 Context 变得繁琐。Zustand 的 selector 机制天然支持细粒度订阅(useStore(s => s.tasks) 只在 tasks 变化时渲染),不需要手动拆 Context。但对我们 2 个 Context 的规模来说,原生方案更轻、更透明。

推荐引擎放客户端而非 API Route

数据在 IndexedDB(客户端) + 算法是纯计算(46 个任务遍历一遍)
  → 没有需要服务端处理的理由

如果放 API Route:
  客户端读 IndexedDB → 序列化 → 发请求 → 服务端计算 → 返回
  = 纯粹增加延迟 + 离线时不能用

结论:客户端直接算

相关代码位置

  • 推荐引擎:src/lib/recommendation.ts
  • 状态管理:src/lib/store.tsx
  • 回顾题模板:src/lib/review-templates.ts
  • 首页:src/app/page.tsx
  • 学习页:src/app/learn/page.tsx
  • 回顾页:src/app/review/page.tsx
  • 进度页(基础版):src/app/progress/page.tsx
  • 学习提示语组件:src/components/PromptCard.tsx
  • 时长选择器组件:src/components/TimeSelector.tsx

构建结果

Route         Size      First Load JS
/             3.07 kB   107 kB     ← 首页
/learn        3.2 kB    110 kB     ← 学习页
/review       3.68 kB   111 kB     ← 回顾页
/progress     2.97 kB   110 kB     ← 进度页(基础版)

整个 App 的 First Load JS 仅 102kB(共享部分),每个页面增量 3-4kB。非常轻量。

面试 / 技术对话角度

面试官可能怎么问: "你这个学习工具的推荐算法是怎么设计的?"

面试话术:"推荐引擎用的是 pointwise scoring 模式——给每个候选任务计算一个绝对分值,取最高分推荐。分值由多个因子加权组成:间隔重复到期是最高优先级(+1000 碾压其他因子),其次是课程顺序、时长匹配、时段适配(晚上 9 点半后降低深度任务权重)、类型平衡和跳过惩罚。用绝对分值而不是百分比权重的好处是每个数字直接对应一个产品决策,调试时看分值明细就知道为什么推了这个任务。整个算法是纯函数,在客户端运行——数据全在 IndexedDB,没有网络请求,离线也能用。"

延伸讨论:

  • "为什么不用机器学习?" → 单用户、46 个任务,不够训练一个模型。规则引擎在这个规模下更可控、可调试、零成本。如果任务池扩大到 1000+、有多个用户的使用数据,才值得考虑 collaborative filtering 或 bandit 算法。
  • "状态管理为什么不用 Redux/Zustand?" → 只有两个 Context(State + Actions),数据流清晰。引入状态库会增加依赖和学习成本,对当前规模没有收益。如果 Context 拆到 4-5 个以上再考虑迁移。
  • "如果推荐结果不好怎么调?" → 每个因子的分值都是独立的常数,改一个数字就能调整策略。而且有 breakdown 字段记录每个因子的贡献,可以 log 出来分析。这比百分比权重好调——改一个百分比会影响所有其他因子的相对权重。

11. Phase 3 思考题复盘——useMemo 依赖陷阱与 URL as state 判断框架

背景

Phase 3 写完 MVP 后,Claude 针对两处设计细节提了思考题。本章记录完整的问答过程、暴露的知识盲区、以及正确的思维框架。


思考题 1:PromptCard 的 useMemo 依赖

问题

src/components/PromptCard.tsx 里这样写:

const prompt = useMemo(() => {
  if (prompts.length === 0) return null;
  return prompts[Math.floor(Math.random() * prompts.length)];
}, [prompts]);

原始问题:如果不用 useMemo,直接 const prompt = prompts[Math.floor(...)],每次父组件重新渲染都会选一条新的提示语。这是 bug 还是 feature?

用户回答:是 bug。引起父组件重新渲染并不是规律的,不规律的变化让人觉得莫名其妙。

评价:答对了 ✅。抓到了"不规律"这个关键词——这正是资深工程师判断 UI 稳定性的核心直觉。

延伸——"不规律"为什么是问题

用户视角:
  提示语是装饰性内容,用户不期望它变
  → 突然变了 = 要么以为是新功能(困惑),要么以为页面刷新了(不安)
  → 注意力被无故打断 = 体验成本

工程视角:
  "父组件什么时候重渲染" 是实现细节,不应该影响 UI 稳定性
  → 今天加一个 useState、明天加一个订阅,重渲染频率悄悄变
  → UI 行为耦合到实现细节 = 脆弱

所以 useMemo 这里不是"性能优化",而是保证 UI 语义稳定——"这条提示语的生命周期 = prompts 数组的生命周期"。

如果想每次打开首页都换新提示呢?

❌ 初级:不用 useMemo,靠父组件重渲染"顺便"换
   问题:换的时机不可控

⚠️ 中级:useEffect + 定时器,每 10 秒换一条
   问题:引入新的副作用,组件卸载时要清理

✅ 资深:useMemo 依赖 [mountKey] 或 [pathname]
   语义清晰——"这条提示的生命周期 = 本次挂载 / 本次进入该路由"
   把"什么时候换"变成显式的产品决策,而不是副作用

追加思考题 1:把依赖改成 [] 会怎样?

问题:如果把 useMemo(..., [prompts]) 改成 useMemo(..., [])(空数组),行为会变成什么?有什么风险?

用户回答:如果依赖是空数组,只有一开始进入页面的时候会执行,但是这个时候如果 prompts 是空的,那么返回的就是 null,风险就是 prompt 永远为 null,页面上没有提示信息出现。

评价:答对了,而且抓到了异步加载的关键时序 ✅。

延伸——这是 React 通用坑:stale closure / missed dependency

// 场景:prompts 从 IndexedDB 异步加载

// 时刻 T0(组件挂载):prompts = []
const prompt = useMemo(() => {
  if (prompts.length === 0) return null;  // ← 这一刻执行,返回 null
  return prompts[...];
}, []);                                    // ← 空依赖:永远不再执行

// 时刻 T1(100ms 后 DB 加载完):prompts = [30 条]
// 但 useMemo 的依赖是 [],不会重新计算
// prompt 永远停留在 T0 那一刻的 null ❌

这个坑不止存在于 useMemo——useEffectuseCallback 同样会中招:

// ❌ 经典 useEffect bug
useEffect(() => {
  console.log(count);  // 闭包捕获了挂载时的 count=0
}, []);                // 空依赖 → 永远打印 0

核心原理一句话

Hook 的依赖数组不是"优化手段",而是"真实描述这个计算依赖什么"。写错依赖 = 对 React 撒谎 = React 按你写的不更新 = bug。

React 官方提供 ESLint 规则 react-hooks/exhaustive-deps 自动校验依赖完整性。资深的习惯是开启这个规则并当作错误,不允许手动忽略依赖。

三层对比

❌ 初级:凭感觉写依赖,觉得"这个值不会变"就省略
   → 埋下 stale closure 地雷

⚠️ 中级:知道依赖会影响更新,但经常只想性能,不想正确性
   → 为了避免重算而故意少写依赖

✅ 资深:依赖数组 = 这个计算"真实依赖"的显式声明
   → 开 exhaustive-deps lint,少一个算错
   → 如果真的不想重算,用 useRef 存"不想触发更新"的值,而不是骗依赖

面试话术

面试官可能问:"useEffect 依赖数组写错会怎样?"

话术:"React 的依赖数组本质是在告诉 React 这个 Hook 的计算真实依赖哪些值。写错依赖会导致两类 bug——少写依赖就是 stale closure,Hook 里读到的值永远是挂载时的初始值;多写或写错类型会导致无限循环。我一般会开 react-hooks/exhaustive-deps 这个 ESLint 规则并当作 error 级别,一旦想手动忽略依赖,要么是真有特殊场景(比如只想挂载时跑一次的初始化),这时用 useRef 存不想触发更新的值;要么是依赖本身不稳定,应该用 useCallback/useMemo 稳定它的引用,而不是忽略它。"


思考题 2:回顾页两步切换

问题

src/app/review/page.tsx 里用 useState 在同一个组件内切换"回顾题"和"自评"两步:

const [phase, setPhase] = useState<"review" | "assess">("review");

另一个方案是拆成两个路由 /review/assess,点"下一步"时 router.push("/assess")

原始问题:两种方案的优缺点?

用户回答:使用上各自的优缺点说不上来,但在 React 中习惯用 useState 切换两个组件,路由用在切换大的页面跳转上,逻辑差异较大的。

评价:直觉对、习惯对,但说不出原理——这就是中级和资深的典型差距。

核心原理:URL as state

路由切换本质上是把状态存到了 URL 里。判断用 state 还是用路由,就看这个状态该不该进 URL

判断维度 该进 URL(用路由) 不该进 URL(用 state)
用户能分享吗? 能,分享链接有意义 不能,中间态分享没意义
能独立刷新吗? 能,刷新后页面完整 不能,刷新必须回到起点
浏览器后退有意义吗? 有,回到上一步符合预期 没有,后退应该回上一页
能用 SEO 索引吗? 需要被索引 不需要

用这套标准套回顾页

/review 的两步(回顾题 → 自评):

能分享吗?
  ❌ "我在回顾第二步" 对别人毫无意义

能独立刷新吗?
  ❌ /assess 刷新后,answer 没了、currentTask 没了,页面是空的
  (除非把 answer 塞进 URL query 或全局状态——又多一层复杂度)

后退有意义吗?
  ❌ 用户按后退应该回到 /learn(上一个任务),不是回到 review 第一步

结论:这两步不该进 URL → 用 useState 是对的

具体场景推演

用户答了一半刷新:

useState 方案:
  刷新 → /review 回到 Step 1 → answer 丢了
  用户预期:刷新就是重置,合理

两路由方案:
  刷新 /assess → 页面没有 currentTask、没有 answer
  用户看到:空页面或错误 → 体验差
  要修就得把 answer 塞进 URL(?answer=...)或 sessionStorage
  → 为了支持一个边缘场景引入一堆复杂度

按浏览器后退键:

useState 方案:
  后退 → 离开 /review → 回到 /learn
  符合"回上一个任务"的直觉 ✅

两路由方案:
  从 /assess 后退 → 回到 /review 第一步
  但 answer 已经写过了,现在空了 → 用户困惑"我写的东西呢"
  或者要做复杂的 answer 恢复逻辑

两个场景都指向同一结论:回顾题和自评的状态不该进 URL

三层对比

❌ 初级:凭感觉选 state 或路由,说不清为什么
   "我觉得这里用 state 顺手"

⚠️ 中级:知道路由能保留状态、能刷新,但决策时容易过度用路由
   结果:/learn/task/1/review/step1 这种路由爆炸
   或者把临时状态塞进 URL query,URL 变得很脏

✅ 资深:URL as state 的判断框架——问自己该不该分享/刷新/后退
   默认用 state,除非通过了"该进 URL"的检验

追加思考题 2:真实项目里的 URL as state 反例

问题:你之前的 React 项目有没有"一眼看上去像表单中间步骤、但其实该拆路由"的场景?或反过来,拆了路由但其实不该拆的?

用户回答:好像没有。

评价:正常——这种判断需要经历过"早知道就拆路由了"或"这根本不该拆路由"才有感觉。补充 3 个常见案例建立记忆库。

案例 A:登录/注册 wizard(常见过度拆路由

很多项目做成这样:

/register/step1 → 输手机号
/register/step2 → 输验证码
/register/step3 → 设密码

套 URL as state 检验:

问题 回答
能分享 /register/step2 吗? ❌ 别人打开就是空白——没有手机号上下文
刷新 /register/step2 保留状态吗? ❌ 手机号丢了,第 2 步逻辑崩溃
后退到 /step1 符合预期吗? ⚠️ 用户可能以为回到首页,不是回第一步

结论:wizard 内部步骤不该拆路由,用 useState 切换,整体只占一个路由 /register

案例 B:列表-详情切换(常见该拆但没拆

错误做法:

const [view, setView] = useState<"list" | "detail">("list");
const [selectedId, setSelectedId] = useState<string | null>(null);

return view === "list" ? <List onClick={...} /> : <Detail id={selectedId} />

套 URL as state 检验:

问题 回答
能分享"某个详情页"吗? ✅ 用户绝对需要——"这个订单有问题 @客服看一下"
刷新回到列表合理吗? ❌ 刷新丢失详情 = 体验糟糕
浏览器后退从详情回列表合理吗? ✅ 天然语义,但 useState 版没有
SEO 需要吗? ✅ 如果是公开内容,必须每个详情单独 URL

结论:列表-详情必须拆路由 /orders/orders/:id。拆 state 的做法几乎必然被业务方追着改。

案例 C:订单状态 tab(边界情况,用 URL query)

<Tabs>
  <Tab>待付款</Tab><Tab>待发货</Tab><Tab>已完成</Tab>
</Tabs>
问题 回答
能分享"打开已完成 tab"吗? ✅ 客服场景下有用——"看你的已完成列表"
刷新回到默认 tab 合理吗? ⚠️ 有时合理(回到默认),有时不合理(正在看的丢了)

这种边界情况,一般用 URL query 参数 而不是 path:/orders?tab=done——比 state 多了"可分享"语义,但比路由段轻,不污染路由定义。

实用诀窍:"Slack 测试"

最快的判断方法:把当前 URL 复制到 Slack 发给同事,他点开看到的,应该和你看到的一样

同事打开后一致 → 该进 URL
同事打开后看到的是另一回事(空白、默认页、错乱)→ 不该进 URL

面试话术

面试官可能问:"路由和 state 的边界怎么划?"

话术:"我的判断标准是 URL as state——把路由看作一种特殊的状态,存到 URL 里。决策就看这个状态该不该有 URL 的语义:能不能分享、能不能独立刷新、浏览器后退有没有意义、需不需要 SEO。像注册 wizard 的内部步骤,我会用 useState 切换,因为中间态刷新没意义、分享链接也没上下文;但订单列表-详情必须拆路由,因为分享链接、SEO、刷新后页面完整都成立。边界情况比如 tab 切换,可以用 URL query 做轻量的可分享状态。不问这些问题直接拆路由,URL 很容易变得冗长而且没语义。"


本章小结

两道思考题暴露了中级到资深的两个核心认知升级:

  1. Hook 依赖数组不是优化选项,是真实依赖声明——写错依赖就是在对 React 撒谎
  2. 路由不是 UI 结构,是状态的一种存储方式——URL as state 框架帮你判断"该不该进 URL"

这两个认知都不涉及新 API,完全可以用已有的 React 知识理解——但没人点破的话,中级工程师可能写好几年都没意识到这层。


延伸讨论:筛选状态存哪里——"没有人反馈"不等于"没有需求"

问题

列表页的筛选条件 + 分页 + 排序,你之前的项目是怎么存的?

用户回答:都是存 useState,没考虑分享页面,也没人反馈刷新后筛选消失——刷新后筛选项消失是在意料之内的。

评价:在这个场景下是对的

useState 存筛选状态在很多场景完全正确。判断的核心是产品类型和用户上下文

内部工具 / 运营后台:
  用户 = 内部员工,熟悉系统
  "刷新后筛选没了" = 可以接受,重新筛一遍成本低
  分享需求 = 低(坐在同一办公室,直接喊一声)
  → useState 完全够用 ✅

C 端 / 电商 / 内容平台:
  用户 = 陌生用户,路径成本高
  "刷新后筛选没了" = 用户流失(找不回来了)
  分享需求 = 高(把筛选结果链接发给朋友 / 发给客服)
  SEO = 需要(搜索引擎索引不同筛选结果)
  → URL query 才合适

认知盲区:为什么"没有反馈"不能作为判断依据

"没有人反馈刷新后筛选没了"有三种可能:

  1. 场景真的不需要:内部工具用户适应了刷新重置,这没问题
  2. 用户默默适应了限制:用户降低了期望,从不期待筛选能保留,所以不反馈
  3. 产品体量还没到暴露问题的规模:用户少、筛选场景少,问题没有集中出现

第 1 种是合理的工程判断。第 2、3 种是潜在的技术债——等业务增长了,URL 分享/SEO 变成需求时,改起来成本很高(要把所有 useState 迁移到 URL query,还要处理 URL 参数解析、类型转换、默认值等)。

资深的判断方式

不是等用户反馈再做,而是在设计阶段问自己

这个列表页的用户会有分享筛选结果的场景吗?
  有 → 从一开始就用 URL query
  没有 → useState,但在架构文档里记一句"如果业务方提分享需求,需要迁移到 URL query"

SEO 重要吗?
  重要 → URL query(搜索引擎能索引不同筛选结果)
  不重要(内部工具)→ useState

用户会在同一个 session 里频繁切换筛选,然后刷新吗?
  会(比如调试场景、数据分析场景)→ 考虑 URL query 或 sessionStorage
  不会 → useState

三种存储方式的完整对比

存储方式 刷新保留 可分享 浏览器后退 适合场景
useState 内部工具、临时视图偏好
URL query ✅(可控) C 端列表、需要 SEO、需要分享
sessionStorage ✅(同 tab) 表单草稿、向导中间步骤
localStorage ✅(跨 tab) 用户偏好设置(主题、语言)

核心结论

资深和中级的差距不是"选正确答案",而是能说出"什么时候哪个是正确答案"。你的 useState 选择在内部工具场景是对的——现在你有了完整的判断框架,下次遇到 C 端列表页时知道直接上 URL query,不用等用户反馈了。


追问:C 端列表页用 URL query + localStorage 更合适吗?

用户问:下次遇到 C 端列表页,应该使用 URL query + localStorage 更合适吗?

结论:不一定——取决于存的是筛选条件还是用户偏好,这是两类不同的状态。

区分两类状态

筛选条件("这次搜索想要什么"):
  category=phone, priceMin=1000, page=2
  → 和这次搜索意图绑定,换个场景就变了
  → URL query ✅

用户偏好("我一贯喜欢什么"):
  每页显示 48 条(而不是默认 24 条)
  默认按"销量"排序
  → 跨 session 不变,是用户设置的一部分
  → localStorage ✅

两个同时用于同一状态会产生冲突

场景:用户上次搜 "手机 1000-5000"(存了 localStorage)
     今天朋友发来链接:/products?category=headphone

用户打开链接:
  URL query 说 → 显示耳机
  localStorage 说 → 显示手机 1000-5000

谁赢?代码里要写"以谁为准"的判断逻辑
→ 平白增加复杂度,行为让用户困惑

两个数据源描述同一个状态,必然产生冲突——这是状态管理里的单一数据源(Single Source of Truth)原则被破坏的典型场景。

C 端列表页的正确组合

筛选条件 → URL query(只用这一个,不重复存)
用户偏好 → localStorage(单独管理,不和筛选条件混)

具体例子:

URL:          /products?category=phone&sort=price&page=2
localStorage: { itemsPerPage: 48 }

两个独立管理,来源清晰,互不干扰。

四种存储方式完整对比(更新版)

存储方式 刷新保留 可分享 跨 session 适合存什么
useState 内部工具筛选、临时 UI 状态
URL query ❌(链接失效就没了) C 端筛选、分页、排序
sessionStorage ✅(同 tab) 表单草稿、向导中间步骤
localStorage 用户偏好(主题、语言、每页条数)

一句话结论:C 端列表页筛选条件用 URL query 就够,localStorage 留给跨 session 不变的用户偏好,两者不要混用于同一个状态。


12. Phase 4 实施——进度面板(连续天数、活动图、课程进度条)

一句话结论

新增 src/lib/stats.ts 三个纯函数,进度页升级为:连续天数/累计次数/待复习三格统计 + 28 天活动格图 + 课程进度条。

新增文件

  • src/lib/stats.tscalcStreak / calcDailyActivity / calcCourseProgress 三个纯函数
  • src/app/progress/page.tsx:重写为完整进度面板

关键设计决策

连续天数容错:今天或昨天有学习才算 streak 连续

const todayKey = dayKey(Date.now());
const yesterdayKey = dayKey(Date.now() - 86400_000);
if (!days.has(todayKey) && !days.has(yesterdayKey)) return 0;

如果只判断今天,用户早上打开 App 还没学习就会看到 streak 归零——打击积极性。这是产品细节,不是技术细节。

活动图用 CSS Grid 动态分配格宽

<div className="grid grid-cols-[repeat(28,1fr)] gap-1">

格子数量是变量,repeat(28, 1fr) 让 CSS 自动分配等宽格子,不用 JS 计算像素。响应式自动适配屏幕宽度。

活动格颜色用强度分级(0-4 级)

const intensity = count === 0 ? 0 : Math.ceil((count / max) * 4);

以当月最高值为基准做相对分级,而不是绝对阈值。好处:无论用户每天学 1 次还是 5 次,活动图都能展示有意义的深浅差异。

构建结果

/progress    3.63 kB    111 kB    ← 新增活动图和课程进度条后仅增加约 0.7kB

13. 思考题复盘——Set vs Array 查找性能

问题

calcStreak 里用 Set<string> 存日期字符串。为什么用 Set 而不是数组?

用户回答

Set 相当于去重后的数组;Set O(1),数组 O(N)。

评价:两点都答对了 ✅

延伸——去重在这里为什么重要

同一天可能有多条 session(早上学一次、晚上学一次):

// sessions 里 "2026-04-18" 有两条
const days = new Set(sessions.map(s => dayKey(s.completedAt!)));
// Set { "2026-04-18", "2026-04-17" }  ← 自动去重

如果用数组不去重,"2026-04-18" 出现两次。has() 查找还好,但统计"学了几天"时 array.length 会多算。

延伸——O(1) 的底层机制(哈希表)

数组 includes("2026-04-18"):
  从第 0 个开始逐个比较,直到找到或遍历完 → O(N)

Set has("2026-04-18"):
  把字符串经过哈希函数算出内存地址 → 直接跳到那个位置 → O(1)

calcStreakwhile (days.has(...)) 每次循环都查一次。streak 30 天 × 历史 365 天记录:

  • 数组:30 × 365 = 10,950 次比较
  • Set:30 × 1 = 30 次

资深选数据结构的判断框架

只遍历一次 → 数组够了
需要反复查找 → 先转 Set,O(1) 摊销建 Set 的 O(N) 成本

❌ 初级:用数组 + includes,能跑通,不知道性能差异
⚠️ 中级:知道 Set 快,说不出为什么(说不出哈希)
✅ 资深:根据"查找频率"选数据结构,建 Set 一次摊销后续所有查找

面试话术

面试话术:"我把 sessions 先转成 Set<string> 存日期,因为 streak 计算需要反复查找'某天是否有学习记录'。Set 的 has() 是 O(1) 哈希查找,而数组的 includes() 是 O(N) 线性扫描。建 Set 本身是 O(N),但只做一次,后面每次查找都是 O(1),整体比每次都扫数组要高效。"


14. Phase B 实施——底部导航 + 任务列表页

一句话结论

新增全局底部导航栏(主页/任务/进度),在学习流程页面(/learn/review)自动隐藏,避免分心;新增任务列表页支持课程 + 状态双维筛选。

新增文件

  • src/components/BottomNav.tsx:全局底部导航,用 usePathname() 判断是否显示
  • src/app/tasks/page.tsx:任务列表页,课程筛选 + 状态筛选 + 优先级排序

关键设计决策

BottomNav 放 layout 而非每个页面

导航逻辑集中管理:以后新增需要隐藏导航的路由,只改 HIDDEN_PATHS 数组一处。

const HIDDEN_PATHS = ["/learn", "/review"];

export default function BottomNav() {
  const pathname = usePathname();
  if (HIDDEN_PATHS.includes(pathname)) return null;
  // ...
}

对比:在每个页面各自引入/控制导航,路由多了后逻辑分散,容易漏改。

layout 包 pb-16 避免内容被导航遮挡

<div className="pb-16">{children}</div>
<BottomNav />

BottomNav fixed 定位高度约 64px(pb-16 = 4rem),给内容留出底部空间。

Bug 修复:.next 缓存脏数据导致 layout.css 404

现象:任务列表页报 Runtime Error [object Event],控制台显示 layout.css 404

根因:修改 layout.tsx 后 Next.js dev server 生成新的 CSS chunk,但 .next 缓存目录仍有旧的 chunk 引用,导致 404,进而触发 JS 加载失败,cascade 出 [object Event]

修复rm -rf .next && npm run dev 清空缓存重启。

经验提炼:改动 layout、CSS、或全局配置后遇到莫名 404 / chunk 找不到,第一反应是清 .next 缓存。

顺带修复:store.tsx 中两处未处理的 Promise rejection

// 修复前
useEffect(() => { refresh(); }, [refresh]);  // 不处理 rejection
putTask(updated);  // 不 await,不处理 rejection

// 修复后
useEffect(() => { refresh().catch(console.error); }, [refresh]);
putTask(updated).catch(console.error);

未 catch 的 Promise rejection 在 Next.js dev 模式下会触发 onUnhandledRejection,显示为 [object Event] overlay。


15. 思考题复盘——排序逻辑为什么抽成 priority() 函数

问题

任务列表排序用了 priority() 函数而不是直接在 sort callback 里写条件判断。为什么?

用户回答

  • 直接写在 sort 里不易读
  • 抽成函数改起来也容易

评价:两点都对 ✅,背后是同一个原理:单一职责

延伸——三个更具体的好处

1. 可测试

// priority 是纯函数,可以独立测试
expect(priority({ status: "new", srNextDue: null })).toBe(2);
expect(priority({ srNextDue: todayEnd - 1 })).toBe(0);

// 写在 sort 里就没法单独测,只能测整个排序结果

2. 可复用

// 任务列表按紧急度排
tasks.sort((a, b) => priority(a) - priority(b));

// 首页推荐也可以复用同一套优先级,叠加其他权重
tasks.sort((a, b) =>
  priority(a) * 100 + recencyScore(a) -
  (priority(b) * 100 + recencyScore(b))
);

写在 sort 里的逻辑只能 copy-paste。

3. 改一处生效

以后加"VIP 任务优先级最高",只改 priority() 一处,所有依赖它的排序都生效。

三层对比

❌ 初级:逻辑直接写在 sort callback 里
   sort((a, b) => {
     if (a.srNextDue && ...) return -1;
     if (a.status === "in_progress") return -1;
     ... // 10 个条件嵌套,谁改谁头疼
   })

⚠️ 中级:知道要抽函数,但函数耦合到具体场景
   function sortForTaskList(a, b) { ... }  // 只能用在任务列表

✅ 资深:抽出纯函数,输入 Task 输出数字,不依赖任何具体场景
   function priority(t: Task): number { ... }
   // 可测试、可复用、可组合

面试话术

面试话术:"排序逻辑我把优先级计算抽成了独立的 priority() 纯函数,输入 Task 返回优先级数字。这样 sort 只负责比较,priority 只负责定义规则,单一职责。好处是可以单独写单元测试验证优先级是否正确,也可以在多个排序场景复用同一套规则,以后调整优先级逻辑只改一处。"


16. Phase C 实施——PWA 本地通知 + 知识盲区:PWA/SW/推送机制

知识盲区补充:PWA 是什么

一句话:能被"安装"到手机桌面的网站,通过三个技术获得原生 App 的外壳。

技术 解决什么问题
Web App Manifest 告诉浏览器图标/名字/全屏模式(我们的 manifest.ts
Service Worker 离线缓存 + 后台运行 + 推送通知
HTTPS 安全要求,没有 HTTPS 不能注册 SW

Service Worker 的生命周期(核心概念)

普通网页:
  打开标签页 → JS 跑起来 → 关闭标签页 → JS 死掉

Service Worker:
  首次访问 → 浏览器注册 SW(独立进程,与页面分离)
  关闭标签页 → SW 还在运行
  关闭浏览器 → SW 暂停(注册信息保留)
  服务器推送 → SW 被唤醒 → 显示通知(即使 App 没开)

SW 是浏览器维护的后台独立 JS 进程,不依附于任何标签页。

两种通知方式的本质区别

本地通知(我们用的):
  用户打开 App → 页面 JS 运行 → 检查到期任务 → Notification API 弹通知
  用户没开 App → 不发生任何事
  优点:无需服务器,实现简单
  缺点:必须用户主动打开才能提醒

服务端推送(Web Push):
  服务器主动触发
    → Push Server(Google FCM / Apple APNs)
    → 找到用户设备上注册的 SW
    → 唤醒 SW → 显示通知
  用户完全没开 App 也能收到
  需要:VAPID 密钥 + 后端服务 + Push Server 集成

微信消息提醒是后者。我们的实现是前者——个人工具够用。

新增文件

  • src/lib/notifications.tsrequestNotificationPermission / notifyDueTasks / updateAppBadge
  • 首页加"开启每日复习提醒"按钮(权限为 default 时显示)
  • store.tsx 中数据加载完后自动检查到期任务并触发通知

Bug:sessionStorage 应该用 localStorage

错误代码

sessionStorage.setItem("last-notified-date", today);

问题:sessionStorage 在标签页关闭时清空。用户早上开 App 收到通知,关掉标签页,下午再开——sessionStorage 空了,又通知一次。

正确:用 localStorage,日期跨 session 持久化,同一天不重复提醒。

localStorage.setItem("last-notified-date", today);

学习价值:四种存储方式刚总结完就在实际代码里用错——暴露了"知道规则"和"用对场景"之间的差距。判断标准永远是"这个状态需要跨 session 保留吗?"

关于单一职责术语记不住的讨论

现象:知道"函数只做一件事"的概念,但脑子里想不起"单一职责"四个字。

原因:recognition(再认)vs recall(回忆)的差异。见过这个词 → 能认出来(再认)。脑子里只有概念 → 主动提取词语(回忆)。回忆比再认难得多。

修复

  1. 看到新术语当天造一个句子用它
  2. 主动回忆练习:闭眼想"那个'函数只做一件事'原则叫什么?"
  3. 讲给别人听

间隔重复正是训练主动回忆的机制——这个 App 本身就是解法。


17. 项目基础设施回顾——watch.sh / sync.sh 自动同步机制

背景:为什么要回顾这个

Phase 5 要做"文档渲染"时,发现项目根目录有三个之前没关注的脚本:watch.sh / sync.sh / deploy.sh。搞清楚它们的存在是设计 Phase 5 的前提。

教训:接手项目时,任何非代码目录的脚本、配置、日志都要先读一遍,否则可能重复造轮子误改已经在跑的自动化

自动同步架构(原设计)

  /Users/mac/ecool/**/changelog.local.md
  /Users/mac/Research/**/changelog.local.md     ← 所有项目的技术笔记
         │
         │ fswatch 监听文件变化(watch.sh)
         │ launchd 守护,开机自启
         ▼
  sync.sh 触发
         │
         │ 1. 找出所有 changelog.local.md
         │ 2. 敏感词替换("技术笔记" → "技术笔记" 等)
         │ 3. 复制到 docs/*.md
         │ 4. 生成 VitePress 侧边栏 + 首页 index.md
         ▼
  next build(原为 vitepress build,Phase 5 改过来)
         │
         ▼
  vercel deploy

两个脚本的职责划分

watch.sh    → 长期运行的 daemon,监听文件变化(生产者)
sync.sh     → 扫描 + 复制 + 生成配置(消费者,一次性跑完退出)
deploy.sh   → 一键:sync.sh + build + deploy(手动触发入口)

防死循环机制(关键设计)

本项目自己的 changelog.local.md 也在扫描路径里。如果不处理:

本项目 changelog 改了 → watch.sh 触发 → sync.sh → 复制到 docs/ →
某些临时文件变化 → watch.sh 又触发 → 无限循环

解决方案在 watch.sh:47-50

case "$event" in
    *"/Research/learning-docs/"*) continue ;;
esac

fswatch 收到本项目路径的变化时不触发 rebuild,但 sync.sh 仍然扫描并同步(本项目 changelog 也有学习价值,应该出现在文档列表)。

三层对比

❌ 初级:只看 src/ 目录,不关心根目录脚本 → 重复造同步功能
⚠️ 中级:发现脚本但不深入读 → 误修改破坏已有自动化
✅ 资深:接手项目先读脚本 + 配置 + 日志,理解完整自动化链路

相关代码位置

  • watch.sh 根目录:fswatch 监听守护
  • sync.sh 根目录:扫描 + 生成
  • deploy.sh 根目录:一键部署(Phase 5 改为 Next.js 版本)
  • watch.log 根目录:历史运行日志

18. Phase 5 实施——Markdown 文档渲染 + SSG/ISR 概念澄清

一句话结论

把已同步到 docs/*.md 的跨项目技术笔记,用 Next.js 的 SSG 模式渲染成 /docs/[slug] 页面,完整替代原 VitePress 功能。

新增文件

  • src/lib/docs.tslistDocs() / renderDoc(),读文件系统 + Markdown 渲染
  • src/app/docs/page.tsx:文档列表页(按修改时间降序)
  • src/app/docs/[slug]/page.tsx:单文档详情页(SSG 预渲染)
  • src/components/BottomNav.tsx:新增 BookIcon + 文档入口
  • src/app/globals.css:100+ 行 Markdown 样式 + 导入 highlight.js github-dark 主题

安装的依赖

npm install marked marked-highlight highlight.js github-slugger
用途
marked Markdown 解析(GFM 支持)
marked-highlight 代码块高亮插件
highlight.js 实际的语法高亮引擎 + CSS 主题
github-slugger 生成锚点 ID(备用,当前未用)

核心决策:SSG vs ISR vs SSR

Next.js 下三种渲染模式:

SSR(Server-Side Rendering):每次请求服务器实时渲染 HTML
  优点:数据永远新鲜
  缺点:慢(每次都算),服务器有负载

SSG(Static Site Generation):构建时一次性生成所有 HTML
  优点:快(CDN 静态文件),零服务器成本
  缺点:内容变化必须 rebuild,新内容在 rebuild 前 404

ISR(Incremental Static Regeneration):SSG + 定期重新生成
  优点:保留 SSG 的快 + 不需要手动 rebuild
  机制:stale-while-revalidate,第一个访问者看旧数据
  缺点:第一个访问者看到的是旧缓存

为什么选 SSG 而不是 ISR

docs 变化频率 = 手动写 changelog(日级别)
自动 rebuild 机制 = watch.sh 已经在跑

  → SSG + watch.sh 触发的 rebuild 完美匹配
  → 没必要引入 ISR 的复杂度(revalidate 策略调优)

三层对比

❌ 初级:docs 新增了但线上 404,以为是 bug
⚠️ 中级:知道要 rebuild,但每次都手动跑命令
✅ 资深:按更新频率选策略——
   日级别更新 → SSG + watch.sh 自动 rebuild
   分钟级更新 → ISR(revalidate: 60)
   实时更新 → SSR + 动态路由

generateStaticParams 的作用

// src/app/docs/[slug]/page.tsx
export async function generateStaticParams() {
  return listDocs().map((doc) => ({ slug: doc.slug }));
}

Next.js 构建时调用这个函数,获取所有可能的 slug 值,然后为每个 slug 预渲染一个独立的 HTML 文件。构建结果:

/docs/knowledge-hub.html    ← 预生成
/docs/shopdiary-claude.html ← 预生成
/docs/plan.html             ← 预生成
...(共 9 个)

用户访问时 CDN 直接返回对应 HTML,无需服务端计算。

dangerouslySetInnerHTML 的安全判断

<article
  className="markdown-body"
  dangerouslySetInnerHTML={{ __html: doc.html }}
/>

React 故意给这个 API 起名 "dangerous" 警告开发者——因为直接插入 HTML 绕过 React 的自动 XSS 转义。但攻击面存在的前提是有不信任的输入源

我们的场景:
  内容源 = 自己写的 .md 文件
  处理时机 = 构建时静态渲染(不是运行时处理用户输入)
  → 没有"不信任的输入",零攻击面 ✅

如果换成"用户提交评论/帖子":
  必须用 DOMPurify 净化 HTML
  或用 react-markdown 组件化渲染(自动 escape)

三层对比

❌ 初级:一律禁用 dangerouslySetInnerHTML
⚠️ 中级:知道危险但不知道什么时候可以用
✅ 资深:分场景——信任源 + 构建时处理 = 安全;不信任源 → 必须净化

Marked 单例模式

// src/lib/docs.ts
let rendererInstance: typeof marked | null = null;
function getRenderer(): typeof marked {
  if (rendererInstance) return rendererInstance;
  marked.use(markedHighlight({ ... }));  // 只注册一次
  rendererInstance = marked;
  return marked;
}

为什么不每次都 marked.use()markedHighlight 的 plugin 注册到全局配置链,重复 use() 会堆叠多份高亮处理,既耗性能又可能冲突。用模块级变量做 singleton 保证只初始化一次。

构建结果

Route (app)                                 Size  First Load JS
├ ○ /docs                                  163 B         106 kB
├ ● /docs/[slug]                           163 B         106 kB
├   ├ /docs/slh_new
├   ├ /docs/shopdiary-claude
├   ├ /docs/shopdiaryapp
├   └ [+6 more paths]

● (SSG) = prerendered as static HTML (uses generateStaticParams)

9 个文档全部预渲染,每个页面仅 163B,首次加载 106kB。

面试话术

面试话术:"我用 Next.js 的 SSG 模式渲染技术笔记文档——构建时 generateStaticParams 扫描文件系统,为每个 changelog.md 预生成独立的静态 HTML。用 marked + highlight.js 做 Markdown 和代码高亮,注意把 marked 实例做成模块级 singleton 避免 plugin 重复注册。选 SSG 而不是 ISR 是因为文档更新频率是日级别,配合已有的 fswatch daemon 自动 rebuild 足够,引入 ISR 的 revalidate 策略反而是过度设计。"


19. 知识盲区复盘——Vercel vs Next.js vs VitePress 三个概念混淆

用户原问题

"上面为什么把 vercel 替换成了 next.js"

暴露的知识盲区

部署平台SSG 框架全栈框架三个不同层级的概念当成同一维度的东西。

概念分层

部署平台(你把代码放哪里跑):
  Vercel / Netlify / Cloudflare Pages / 自建 VPS

前端框架(你怎么写代码):
  VitePress / Next.js / Nuxt / Astro / Remix / Gatsby ...
  不同定位:
    VitePress / Docusaurus → 文档站(SSG)
    Gatsby / Astro         → 内容站(SSG)
    Next.js / Nuxt         → 全栈 App(SSG + SSR + API)
    Remix / SvelteKit      → 全栈 App(偏 SSR)

关系:任何前端框架构建出来的产物,都可以部署到任何部署平台。换框架 ≠ 换平台。

我们的实际变更

之前:VitePress(文档框架)→ Vercel(部署平台)
现在:Next.js(全栈框架)→ Vercel(部署平台,没变)

为什么要换框架

初衷:做一个学习工具 App,不只是文档站
  ├ 需要交互(选时长、推荐任务、自评、进度面板)
  ├ 需要本地数据(IndexedDB + 间隔重复)
  ├ 需要后台逻辑(后续同步引擎)
  └ 需要文档渲染

VitePress 只能做最后一件(文档),前三件完全做不了

Next.js 全部能做:交互 ✅ 数据 ✅ 后台 ✅ 文档 ✅

三层对比

❌ 初级:不分框架 vs 平台,术语乱用
   "我的 Vercel 项目"(其实是 Next.js 项目,只是部署在 Vercel)

⚠️ 中级:知道框架和平台不同,但不知道怎么按项目需求选
   所有项目都用 Next.js(杀鸡用牛刀)

✅ 资深:按"项目复杂度演化"选框架
   纯博客/文档 → SSG(Astro/Hugo/VitePress)
   内容站 + 少量交互 → Astro / Next.js 静态模式
   有用户交互 / 认证 → Next.js / Remix
   重度 App + 多服务 → Next.js + 独立后端

迁移影响

好处:
  ├ 文档 + 工具同一个 App,单一 PWA 图标
  ├ 可以共用组件、状态(如"文档页直接标记任务完成")
  ├ 能做任何交互功能

代价:
  ├ 原 watch.sh → vitepress build → vercel 链路断开
  ├ deploy.sh 脚本要改成 Next.js 版本
  ├ 文档页面要重新写(react-markdown 或 marked)

面试话术

面试话术:"这个项目一开始是 VitePress 纯文档站,后来迭代成 Next.js 全栈 App——因为要加交互(任务推荐、间隔重复自评)、本地数据(IndexedDB)、后台逻辑,这些 VitePress 做不了。部署平台一直是 Vercel 没变。这个迁移让我更清晰地区分了三层概念:部署平台(Vercel)、前端框架(Next.js)、渲染模式(SSG/SSR/ISR),之前混在一起叫。"


20. 首次部署到 Vercel——非交互模式 scope 坑 + 部署流程记录

部署流程(完整记录)

Step 1:登录 Vercel

npx vercel login

浏览器打开 OAuth 授权页面 → 点确认 → 终端提示 "You are now signed in"。

认证信息存哪里:用户 home 目录的 ~/.local/share/com.vercel.cli/~/.vercel/auth.json(视版本),不是项目目录。登录一次所有项目都能用。

Step 2:首次部署配置

真实终端(非 Claude ! 命令)跑:

npx vercel --prod

交互式提问:

Set up and deploy "~/Research/learning-docs"? yes
Which scope should contain your project? Dong's projects
Link to existing project? no
What's your project's name? codex
In which directory is your code located? ./
Want to modify these settings? N

为什么 Link to existing project 选 no:首次部署没有 .vercel/project.json,说明这个本地目录还没有关联任何 Vercel 项目,要新建。

为什么额外设置选 N:Vercel 自动识别 Next.js 项目,默认配置(next build、Node 运行时、默认路由等)都是最优的,无需改。

部署完成标志

✓ Deploying...
✓ Build Completed
✓ Deployment ready
https://codex-xxxx.vercel.app    ← 生产链接

踩坑:! 非交互模式下 scope 必须显式

Claude Code 的 ! 前缀跑 bash 是非交互模式,Vercel CLI 检测到没有 TTY,不敢帮用户选 scope(可能有多个 team 账号),返回错误:

{
  "status": "action_required",
  "reason": "missing_scope",
  "message": "Provide --scope or --team explicitly."
}

修复:加 --scope <team-id> --yes,或者切到真实终端跑。

.vercel/project.json 是什么

部署完成后项目根目录出现 .vercel/ 目录:

// .vercel/project.json
{
  "orgId": "team_iKKkzYeZLCmc7JqzgoDuHrjH",
  "projectId": "prj_xxxxxxxx"
}

作用:记录本地目录关联到哪个 Vercel 项目。之后 vercel 命令直接读这个文件,不再询问配置。

是否提交 git:Vercel CLI 会自动把 .vercel/ 加到 .gitignore(因为含组织信息)。检查 .gitignore 里有没有 .vercel 一行。

deploy.sh 从 VitePress 改成 Next.js

- npx vitepress build docs
- npx vercel docs/.vitepress/dist --prod --yes
+ npm run build
+ npx vercel --prod --yes

关键差异:

  • VitePress 产物在 docs/.vitepress/dist/,要显式传路径
  • Next.js 产物在 .next/,Vercel CLI 自动识别,不传路径

三层对比:部署方式

❌ 初级:每次手动 build + 手动拖文件到 Vercel 页面上传
⚠️ 中级:用 vercel CLI 命令行,但每次都加一堆参数
✅ 资深:一次配置好 deploy.sh,之后 bash deploy.sh 一条命令;
   或者配 GitHub 自动部署(push main → Vercel 自动 build)

面试话术

面试话术:"首次部署 Next.js 到 Vercel,我是用 Vercel CLI 的 link + deploy 流程:vercel login 做 OAuth,然后 vercel --prod 交互式创建项目并关联本地目录,关联信息会存在 .vercel/project.json 里。之后写了个 deploy.sh 做一键部署(sync docs → next build → vercel deploy)。踩过一个坑是 CI 或非交互环境下 Vercel CLI 要显式传 --scope--yes,否则会因为没有 TTY 报 missing_scope 错误。"

下一步

  • 拿到生产链接后在 iPad Safari 访问,添加到主屏幕验证 PWA 体验
  • watch.shvitepress buildnpm run build,恢复自动部署链路(Phase 6 时做)
  • Service Worker 离线缓存(Phase 6 核心)

21. 部署链路迁移——Vercel 国内不可访问 → EdgeOne 备案限制 → Cloudflare Pages 成功落地

背景

Vercel 部署完成后,生产链接 https://codex-sage-five.vercel.app

  • 电脑浏览器 → 能访问 ✅
  • iPad 不挂 VPN → 打不开 ❌

这不是代码问题,是 .vercel.app 顶级域名在国内网络环境不稳定,DNS 污染 + CDN 节点路由绕境外。而 iPad 是本工具的主力使用设备(外出学习、户外场景),不能依赖 VPN。

方案 A 尝试失败:DNS 换 1.1.1.1

iPad 设置 → WiFi → 配置 DNS → 手动 → 添加 1.1.1.1 / 1.0.0.1。

验证:失败。说明问题不只是 DNS 污染,可能是路由级别的问题(CNAME 链指向境外节点、边缘节点没有大陆覆盖)。

方案 B 尝试失败:腾讯云 EdgeOne Pages

为什么选 EdgeOne

  • 腾讯云 Vercel 平替,国内节点分布好
  • 免费额度对个人足够
  • 支持 Next.js 完整特性(SSG/ISR/SSR)
  • .pages.edgeone.app 子域名无需 ICP 备案

部署流程

  1. 腾讯云账号 + 实名认证(必需)
  2. GitHub 代码托管(走 OAuth 连接)
  3. EdgeOne Pages 控制台创建项目,连接 GitHub 仓库
  4. 构建配置:Next.js 框架预设 + npm run build
  5. 构建成功 → 拿到预览链接

踩的坑

预览链接 3 小时限时

https://codex-jk4irz2ipf.edgeone.cool?eo_token=a7a7ba2c36...
为保障内容合规,默认域名链接仅提供 3 小时限时预览
建议添加自定义域名以确保服务持续可用

深层原因:工信部规定,面向中国大陆公网的服务必须绑定已 ICP 备案的域名。国内所有 PaaS 平台(阿里云、腾讯云、华为云)都遵守这条。EdgeOne 给的临时预览域名是为了绕过这个限制做演示用,正式用必须:

  • 自己有域名
  • 走 ICP 备案(20 天左右)
  • 绑定到 EdgeOne

路径 A 的代价:备案流程需要身份证 + 人脸核验 + 大陆住所证明,对个人开发者是心理负担高的事。

方案 C 成功:Cloudflare Pages

为什么 Cloudflare Pages 国内能访问

  • Cloudflare 的 IP 段是全球互联网基础设施的一部分,国内运营商必须对接(否则大量境外服务访问不了)
  • .pages.dev 子域名的 DNS 污染程度远低于 .vercel.app
  • 实测电脑 + iPad 不挂 VPN 都能访问 ✅

部署流程踩的坑

  1. 新 UI 把 Pages 深埋:Cloudflare 近期在推 Workers 统一平台,入口引导都指向 Workers 流程(需要 wrangler.tomlnpx wrangler deploy),对 Next.js 不适合。必须穿过引导找到 "Workers & Pages" 的 Pages 独立入口。

  2. Framework preset 有两个 Next.js 选项

    • Next.js:用 @cloudflare/next-on-pages 适配器,支持 SSR/ISR,部署到 Workers 运行时
    • Next.js (Static HTML Export):纯静态导出,部署到 CDN

    我们选 Static HTML Export 因为项目是 SSG,没有 SSR/ISR/API Route 需求,静态导出最快最简单。

✅ 最终上线地址(务必记住)

线上:https://codex-apq.pages.dev
平台:Cloudflare Pages(项目子域 codex-apq)
部署:连接 GitHub 仓库 dongcaiying/codex,push 到 main 即自动构建(约几分钟)

已废弃的旧地址(别再用,都打不开):

  • Vercel https://codex-sage-five.vercel.app(国内不可达,现已 HTTP 000 死链)
  • EdgeOne https://codex-jk4irz2ipf.edgeone.cool(备案限制,返回 404 占位页)

排查教训:2026-05-29 "线上空白" 乌龙

有次以为"线上空白"是部署炸了,绕了一大圈(curl 产物、查客户端 seed、怀疑中文 slug 静态导出),最后发现根本不是部署问题——是 Mac 程序坞里的 Codex 快捷方式(一个 Chrome "创建快捷方式" 生成的 App)存的还是旧的 codex-sage-five.vercel.app 死链。旧 URL 同时缓存在 Codex.app/Contents/Info.plistCrAppModeShortcutURL 和 Chrome Default/Preferences 两处,可靠修法是用新地址重建快捷方式(手改 Chrome profile 风险高,不碰)。

通用纪律:用户报"看不到 / 打不开",第一句该问"你打开的具体是哪个 URL"——先确认入口(URL / DNS / 快捷方式)对不对,再怀疑服务端。和前端排"接口 404 先看请求 URL 拼对没,再看后端"完全同构。我这次恰恰是把这一步放到了最后,才多绕了几轮。

三层对比——国内访问部署方案

❌ 初级:不考虑国内网络,直接 Vercel 部署,iPad 不能用
⚠️ 中级:知道要绕开 Vercel,但选 EdgeOne 被备案卡住,停留在预览阶段
✅ 资深:按"能否免备案 + 全球 CDN 国内可达"两个维度选 Cloudflare Pages

关键学到的判断框架

部署平台国内可达性评估

平台 默认子域名 国内访问 备案要求
Vercel .vercel.app ❌ 不稳定
Netlify .netlify.app ⚠️ 看区域
Cloudflare .pages.dev ✅ 较稳定
GitHub Pages ❌ 被污染
EdgeOne .edgeone.app ✅ 稳定 预览 3h / 永久需备案
阿里云 / 腾讯云自定义域名 ✅ 最快 必须备案

对个人开发者的实用结论:免备案 + 国内可达 = Cloudflare Pages,是当前最佳免费方案。

面试话术

面试官可能问:"你的个人项目部署在哪里,怎么选的平台?"

话术:"我对比过几个方案——Vercel 部署流程最丝滑但 .vercel.app 域名在国内访问不稳定,我的项目需要在 iPad 上用就不适合。国内的 EdgeOne Pages 对 Next.js 支持好但临时预览只有 3 小时,永久使用要 ICP 备案。最后选 Cloudflare Pages,它的 .pages.dev 域名在国内能稳定访问,免费额度够个人用,免备案。部署模式选了 Static HTML Export,因为我的项目是纯 SSG,没有 SSR 需求,静态文件直接发到 CDN 最快。"


22. Next.js 静态导出配置踩坑 + Git 误提交构建产物修复

静态导出配置

Cloudflare Pages 的 Next.js (Static HTML Export) 模式需要 next.config.ts 显式开启:

// next.config.ts
const nextConfig: NextConfig = {
  output: "export",           // 关键:生成纯静态 HTML 到 out/
  images: { unoptimized: true }, // 静态导出不支持 Image 优化
  trailingSlash: true,         // /docs/plan/ 而非 /docs/plan.html,CDN 友好
};

动态路由错误:export const dynamic = "force-static" 必须显式声明

开启 output: 'export' 后第一次构建报错:

Error: export const dynamic = "force-static"/export const revalidate not configured
on route "/manifest.webmanifest" with "output: export"
Error: ... on route "/apple-icon" ...

原因:Next.js 的 convention files(manifest.tsicon.tsxapple-icon.tsx)默认是 dynamic route,在静态导出模式下必须显式声明 force-static

// src/app/manifest.ts
export const dynamic = "force-static";  // 告诉 Next.js 这个路由可以静态化
export default function manifest() { ... }

三处都加上后构建通过。

构建产物误提交 + git rm --cached 修复

犯错过程

.gitignore 最初没包含 out/(Next.js 静态导出目录)。commit 时:

git add .
git commit -m "Add static export config"
# → 提交了 60+ 个 out/ 里的构建文件

每次构建都会生成新的 chunk hash,.next/out/ 是构建产物,必须从 git 排除

修复:git rm --cached + 重新 commit

# 1. 补全 .gitignore
echo "out" >> .gitignore

# 2. 从 git 索引删除 out/(但保留本地文件)
git rm -r --cached out

# 3. 把修正应用到上一个 commit(amend,避免制造冗余 commit)
git add .gitignore
git commit --amend --no-edit

关键参数 --cached只从 git 索引删除,不删本地文件。不加 --cachedgit rm 会真的把本地 out/ 删了,下次构建又要重跑。

教训——.gitignore 的核心原则

构建产物永远不应该进 git。原则:

只 commit 输入(源代码) + 描述输入的配置(package.json、tsconfig.json)
永远不 commit 输出(.next/、out/、dist/、build/)

推导理由:

  1. 可重现:任何人 clone 代码 + 跑 npm ci && npm run build 都能生成相同产物
  2. 大小:构建产物每次都是几十到几百 MB,git 不适合存这些
  3. 合并冲突:两人并行开发时,构建产物的 hash 必冲突,无法合理解决

三层对比

❌ 初级:.gitignore 随手写,构建产物进 git 不自知
⚠️ 中级:知道要排除构建产物,但不熟悉各框架默认输出目录
✅ 资深:理解 "源 / 产物 / 缓存" 的边界,各框架输出目录了熟于胸
   git rm --cached 是标准修复手法,不会误删本地文件

面试话术

面试话术:"我曾经把 Next.js 静态导出的 out/ 目录误 commit 到 git,后来用 git rm -r --cached out 从索引删除(保留本地),再更新 .gitignore,最后用 git commit --amend 合并修正避免冗余历史。这个坑让我更重视 .gitignore 的完整性——源代码和构建产物要严格分离,构建产物能从源重新生成,没必要进版本控制。"


23. Git 历史被动增长问题——三方案对比与定时部署实现

问题

watch.sh 监听所有项目的 changelog.local.md 变化,变化时自动 sync.sh 同步到 docs/。如果每次 sync 都 git commit + push,会产生:

git log 被机械 sync commit 淹没:
  sync: update docs
  sync: update docs
  sync: update docs
  sync: update docs  ← 一天可能 20 次
  feat: 添加文档列表页  ← 你真正的工作被淹没
  sync: update docs
  sync: update docs

副作用

  • git log 噪音,难以快速回顾"真正做了什么"
  • Cloudflare 构建配额被消耗(免费版 500 次/月,虽然够但浪费)
  • 每次 sync 耗时(git commit + push + 触发构建)

三种方案对比

方案 A:取消自动提交,改为手动部署

watch.sh → 只跑 sync.sh 更新本地 docs/
用户手动 bash deploy.sh → git commit + push

✅ 完全控制节奏,git log 干净 ❌ 需要用户主动触发

方案 B:时间 debounce 批量提交

watch.sh 检测变化 → 延迟 N 分钟
  ├ N 分钟内还有变化 → 重置计时器
  └ N 分钟内无变化 → 触发一次 commit + push

✅ 自动批量 ❌ shell 写 debounce 逻辑复杂,launchd 进程间通信容易出坑

方案 C:滚动 amend + force push

第一次 sync → git commit "sync: docs"
之后每次 → git commit --amend + git push --force

✅ git log 永远只有一条 sync commit ❌ force push 有风险(私有仓库单人用还好,团队用灾难)

最终方案:定时部署 + 条件 amend(A + C 混合)

用户需求:每天 11:30 和 18:00 各自动部署一次。

launchd 定时任务

新建 ~/Library/LaunchAgents/com.codex.deploy.plist

<key>StartCalendarInterval</key>
<array>
    <dict>
        <key>Hour</key><integer>11</integer>
        <key>Minute</key><integer>30</integer>
    </dict>
    <dict>
        <key>Hour</key><integer>18</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
</array>

加载:launchctl load ~/Library/LaunchAgents/com.codex.deploy.plist

deploy.sh 的条件 amend 策略

LAST_MSG=$(git log -1 --pretty=%s)
if [[ "$LAST_MSG" == sync:* ]]; then
  # 上一个 commit 是 sync 类型 → amend 合并
  git commit --amend --no-edit
  git push --force-with-lease
else
  # 上一个 commit 是用户的真实工作 → 新建 sync commit(不覆盖)
  git commit -m "sync: update docs ($DATE)"
  git push
fi

为什么这样设计

场景 1:连续自动部署(都是 sync commit)
  11:30: "sync: update docs (2026-04-19)"
  18:00: amend 上面这条 → 合并为一条
  结果:git log 多天只有一条 sync commit ✅

场景 2:用户在两次部署之间手动 commit 了 feat
  11:30: "sync: update docs"
  用户: "feat: 添加通知功能"
  18:00: 检查到上个 commit 是 feat 不是 sync → 新建 sync commit
  结果:sync 不会 amend 覆盖 feat ✅

场景 3:用户手动部署 + 自动部署混用
  用户 `bash deploy.sh` 和定时任务走同一套逻辑,行为一致 ✅

--force-with-lease 相比 --force 更安全——它会检查远端是否还是你上次看到的状态,如果别人推了新 commit 会拒绝强推。

watch.sh 解耦

- do_deploy() {
-   bash sync.sh
-   npx vitepress build docs    # 失效的老代码
-   npx vercel docs/.vitepress/dist --prod --yes
- }
+ do_sync() {
+   bash sync.sh
+   # 不再自动部署,交给定时任务或手动触发
+ }

完整部署链路(最终版)

其他项目 changelog 更新
  ↓
watch.sh(launchd 守护) → sync.sh
  ↓ 只更新本地 docs/ *.md
  
  [等待 11:30 或 18:00]
  ↓
launchd 触发 deploy.sh
  ↓
deploy.sh:
  ├ sync.sh 再跑一次(确保 docs/ 最新)
  ├ 检查 git diff docs/ → 无变化就退出
  ├ 有变化 → 判断是 amend 还是新 commit
  ├ git push(或 push --force-with-lease)
  ↓
Cloudflare 检测 GitHub push webhook
  ↓
自动构建 + 部署到全球 CDN (3 分钟)
  ↓
iPad / 电脑刷新看到最新内容

三层对比

❌ 初级:每次 sync 都触发 commit + push,git log 爆炸
⚠️ 中级:知道要减少 commit,但用单一 debounce 或单一 amend
✅ 资深:按场景差异化策略——
   连续 sync 合并(amend)、用户工作后不覆盖(新建)、时间驱动批量(定时)
   多个机制互补,各司其职

面试话术

面试话术:"我把项目的自动部署从'每次文件变化触发'改成'定时批量触发'。用 macOS launchd 每天 11:30 和 18:00 跑 deploy 脚本,脚本里根据上一个 commit 的类型决定是 amend 还是新建 sync commit——如果上一个是用户的真实工作不会被覆盖,如果上一个是 sync 就合并进去。force push 用 --force-with-lease 而不是 --force,避免覆盖别人的 commit。这样 git 历史只保留真实开发的 commit,自动同步的 sync 被合并成单条,对面试讲故事时回看也清晰。"


24. Bug 修复——/learn 页面按钮遮挡 + 推荐引擎时长不匹配

Bug 1:/learn 页面底部按钮被屏幕底部遮挡

现象:选 5 分钟 → 进入 /learn → "学完了,进入回顾" 按钮在屏幕底部被切掉,需要下滑才能完全显示。

根因layout.tsx 给所有页面的 wrapper 加了 pb-16(给 BottomNav 腾空间):

// 错误的旧实现
<div className="pb-16">{children}</div>
<BottomNav />  // 但 BottomNav 自己在 /learn 会返回 null

BottomNav 在 /learn/review 返回 null,但 pb-16 还留着——页面整体高度 = 视口高度(min-h-dvh)+ 64px(pb-16),多出来的 64px 把底部按钮挤到视口外。

修复:抽出客户端外壳 LayoutShell

Server Component 的 layout 不能用 usePathname()。新建客户端组件:

// src/components/LayoutShell.tsx
"use client";
export default function LayoutShell({ children }) {
  const pathname = usePathname();
  const showNav = !NAV_HIDDEN_PATHS.includes(pathname);
  return (
    <>
      <div className={showNav ? "pb-16" : ""}>{children}</div>
      {showNav && <BottomNav />}
    </>
  );
}

路由在 HIDDEN_PATHS 里时:

  • 不渲染 BottomNav
  • 不加 pb-16

/learn/review 现在能用全视口,按钮贴底不被遮挡。

设计细节:导出常量共享

NAV_HIDDEN_PATHS 从 BottomNav 导出,LayoutShell 导入使用——单一数据源,以后要隐藏新路由只改一处。

// BottomNav.tsx
export const NAV_HIDDEN_PATHS = ["/learn", "/review"];

Bug 2:选 5 分钟推了 30 分钟的任务

现象:首页选 5 分钟 → 推荐引擎给了 30 分钟的"三个项目架构全景对比"。

根因分析

任务池时长分布:
  9 个 10min
  9 个 15min
  13 个 20min
  16 个 30min
  ← 最短 10 分钟,没有 5 分钟的任务

推荐算法评分(5 分钟选择,针对 30 分钟任务):
  timeMismatch: -200(estimatedMinutes > selectedMinutes * 1.5)
  courseOrder:  +100(sortOrder=0,Week 1 Day 1)
  净分:        -100

其他任务也都超时:
  15 min 任务:timeMismatch = -200,courseOrder 较低 → 净分 <-100
  所有任务 net score 都是负的,引擎挑了"最不坏"的

核心问题:算法的负面筛选太弱——当所有选项都不合适时,最高分依然是不合适的选项。

修复 1:硬过滤不合适时长的任务

// src/lib/recommendation.ts
const withinTime = candidates.filter(
  (t) => t.estimatedMinutes <= selectedMinutes * 1.2  // 20% 弹性
);
const eligible = withinTime.length > 0 ? withinTime : candidates;

先筛出"时长合适的"(≤ 选择时长 × 1.2 弹性容差),有就只在这里面推;没有才退化到所有候选。

修复 2:移除 5 分钟选项

任务池最短 10 分钟,5 分钟永远匹配不到合适的任务。硬过滤会退化到全候选,仍然返回超长任务。

- const DURATIONS = [5, 10, 15, 20, 30] as const;
+ const DURATIONS = [10, 15, 20, 30, 45] as const;

修复原则

系统设计:UI 选项应与底层数据能力匹配。允许用户选他本能满足的条件,是劣质 UX 的根源。

三层对比

❌ 初级:看到 bug 只改表层("把按钮向上移 20px"),没找根因
⚠️ 中级:改了根因但没推广到类似场景
✅ 资深:修 bug 顺便:
   - 抽共享常量消除重复
   - 审视 UI 选项与数据能力的匹配
   - 把"硬过滤 + 软评分"模式应用到所有类似算法

面试话术

面试话术:"Bug 1 的根因不是按钮样式——而是 Server Component 的 layout 不能用 usePathname,但导航的显隐必须基于路由。我抽了一个 LayoutShell 客户端组件,把 pb-16 和 BottomNav 的显隐逻辑放一起,而不是让 layout 硬编码 pb-16。Bug 2 是算法盲点——我的评分函数是加法模型,当所有选项都不合适时会挑'最不坏'的。修复是加硬过滤层:先筛时长合适的,没合适的才退化到软评分。这种'硬过滤 + 软排序'的模式在推荐系统里很常见,我之前没在自己代码里自觉应用这个模式。"


25. 任务详情页 + 可点击列表 + 底部导航可视化优化

背景

Phase B 做的任务列表只有视觉信息、没有交互——用户点击任务无任何反馈,也没有详情页。同时底部导航的激活态对比度太弱(仅字色从 neutral-400 变 neutral-900),在 iPad 上基本看不出选中了哪个 tab。

任务详情页 /tasks/[id]

架构:Server + Client 两文件拆分

静态导出要求构建时确定页面路径,但任务数据在 IndexedDB(客户端)。两层责任:

src/app/tasks/[id]/page.tsx         (Server Component)
  ├ generateStaticParams() 读 seed-tasks 获取 46 个固定 id
  ├ 预生成 47 个静态 HTML(46 任务 + shell fallback)
  └ 把 taskId 作为 prop 传给 Client 组件

src/app/tasks/[id]/TaskDetailClient.tsx  (Client Component)
  ├ "use client"
  ├ useAppState() 从 IndexedDB 拿所有 tasks
  ├ tasks.find(t => t.id === taskId) 定位当前任务
  └ 渲染:标签 + 描述 + SR 元信息 + "开始学这个"按钮

关键设计:Server 只负责"告诉 Next.js 有哪些 id",Client 只负责"用 id 从 IndexedDB 查数据"。不混在一起的好处——Server 文件可以保持极简没有状态,Client 文件专注 UI。

新增 action:startSpecificTask

startLearning(minutes) 不同,startSpecificTask(task) 绕过推荐算法直接用指定任务创建 session:

// 推荐驱动:用户选时长 → 算法推任务
startLearning: (minutes: number) => void;

// 直接指定:用户在详情页明确选这个任务 → 直接学
startSpecificTask: (task: Task) => void;

为什么两个函数不合并

❌ 初级:所有入口都走 startLearning,加个 taskId 参数分支
   问题:函数里的 if-else 暗藏推荐算法逻辑,调用方心智负担大

✅ 资深:按"调用方意图"拆成两个函数,单一职责
   推荐驱动 vs 明确指定 是两种不同的业务语义,代码里分开

可点击列表 + Tap 反馈

三层改造

1. <li> → Link 包裹整张卡片
   点击任意位置都跳转,热区覆盖整行

2. active:bg-neutral-100 + transition-colors
   按下瞬间背景变浅灰 + 150ms 渐变,手感反馈

3. -webkit-tap-highlight-color: transparent(全局)
   关闭 iOS 默认的灰色方块闪烁(覆盖整张卡片,丑)
   让我们自己的 :active 样式生效

为什么不用 onClick + useRouter?

// ❌ 中级做法
<li onClick={() => router.push(`/tasks/${task.id}`)}>
  ...
</li>

问题:

  • 无法用浏览器中键打开新标签
  • 无法右键复制链接
  • 无法被搜索引擎识别为导航(虽然我们不需要 SEO)
  • 无键盘可访问性(Tab 不能聚焦到 <li>

<Link> 是标准做法——它被编译成 <a>,所有浏览器原生能力都在。

底部导航可视化重做

问题

激活态只靠字色变化(灰→黑),对比度弱,户外强光下几乎看不出:

❌ 之前:text-neutral-400 → text-neutral-900 + strokeWidth 1.8 → 2.5
   识别难度:需要仔细看

修复:三维度同时变化

✅ 现在:
  颜色:text-neutral-400 → text-blue-600(蓝色)
  字重:normal → medium
  图标:从 line(描边)变为 fill(实心)
  指示器:激活 tab 顶部出现 2px 蓝色横条

为什么 "line → fill" 是关键:视觉心理学上,形状变化的识别速度远快于颜色变化。iOS 和 Material Design 的 tab bar 都用 line/fill 双套图标,就是这个道理。

顺便修了个隐性 bug:/tasks/xxx 不激活 /tasks tab

// ❌ 旧逻辑
const active = pathname === href;  // 精确匹配

// 问题:打开任务详情页 /tasks/seed-w1-1,"任务" tab 不亮
// 用户以为自己离开了任务区

// ✅ 新逻辑
function isActive(pathname: string, href: string): boolean {
  if (href === "/") return pathname === "/";     // 首页特例
  return pathname === href || pathname.startsWith(href + "/");
}

startsWith(href + "/")+ "/" 很关键——避免 /tasks 错误匹配 /tasksOther

inline code 配色修复

Markdown 里 `code` 元素之前用粉色文字 + 浅灰背景,对比度不足:

- background: #f3f4f6;
- color: #db2777;     /* 粉色 */
+ background: #e5e7eb; /* 更深的灰 */
+ color: #111827;     /* 近黑色 */
+ font-weight: 500;   /* 稍加粗 */

教训:inline code 的视觉语义靠"等宽字体 + 背景块"就足够,颜色应该选高对比度,不是"语义色"。GitHub / MDN / Stripe 的文档都是这么做的。

三层对比——移动端交互反馈

❌ 初级:只有 hover 样式(桌面思维),移动端点了没感觉
⚠️ 中级:加了 :active,但被 iOS 默认 tap highlight 覆盖
✅ 资深:全局关掉 -webkit-tap-highlight-color,自己控制 :active 样式
   配合 transition-colors 做渐变反馈,视觉质感更好

26. 知识盲区——next dev 与 next build 共用 .next 缓存导致 MODULE_NOT_FOUND

现象

本地跑 npm run dev 时访问 /icon 路由报错:

MODULE_NOT_FOUND
  at <unknown> (.next/server/app/icon/route.js:1:8309)
  page: '/icon'
GET /icon/?1f9774502ac54d76= 500

根因

next buildnext dev 共用同一个 .next/ 工作目录,但生成的产物结构完全不同:

next build 产出:
  .next/server/app/icon/route.js    ← 优化后的静态路由文件
  out/icon                           ← 静态导出的图片
  (基于 output: 'export' 配置,icon 被当作静态资源)

next dev 期望:
  .next/server/app/icon/...          ← 可热替换的动态模块
  dev runtime 会按自己的模块解析策略加载

部署前做过一次 next build 留下了静态化产物,启动 dev 时它读到这些产物但找不到 dev runtime 需要的动态依赖 → MODULE_NOT_FOUND

修复

rm -rf .next && npm run dev

并在 package.json 加了 dev:clean 脚本固化这个习惯:

"dev:clean": "rm -rf .next && next dev --port 3099"

三层对比

❌ 初级:build 和 dev 混着用,遇到 MODULE_NOT_FOUND 束手无策
⚠️ 中级:知道"清 .next 能解决很多 Next.js 问题",但不知道为什么
✅ 资深:理解 build/dev/start 三种模式产出的 .next 结构不同
   每次切换模式前清缓存,或用 dev:clean 脚本固化

面试话术

面试话术:"Next.js 的 build 和 dev 共用 .next/ 目录但产物不兼容——build 优化出静态产物,dev 期望动态模块,混用会导致 MODULE_NOT_FOUND。解决方法是切换模式前 rm -rf .next。我写了 dev:clean 脚本固化这个,避免以后重复踩坑。"


27. 工作流反思——"每改必 push" 是过度自动化

问题观察(用户反馈)

连续几次小改动后,我(AI)习惯性地 改完 → build → commit → push,用户指出:

"为什么每次更新都要自动部署 等我在本地验证没问题后再部署"

反思:为什么之前的做法是错的

每改一次都 push 的后果:

1. Cloudflare 构建配额浪费
   免费版 500 次/月,连续小改动轻易消耗 10-20 次

2. Git 历史被机械 commit 淹没
   "fix: inline code contrast"
   "fix: layout pb-16"
   "fix: tap feedback"
   "fix: active state"
   ← 5 个应该合并成 1 个功能性 commit 的小改动

3. 用户失去验证机会
   本应"本地预览 → 确认符合预期 → 部署"
   变成了"推了才发现不对 → 继续改 → 再推 → 陷入循环"

4. 把 Cloudflare 当成 CI 用
   CI 是为了保证正确性,不是验证"我改得对不对"
   本地跑 dev 才是验证改动的地方

修复:明确的工作流边界

场景 1:代码改动(AI 或用户手动写的)
  流程:改代码 → 本地 npm run dev 预览 → 用户确认满意 → 才 push
  触发者:人(明确确认后)

场景 2:其他项目 changelog 自动同步到 docs/
  流程:watch.sh 监听 → sync.sh 同步 → 本地文件变化
  部署:launchd 11:30 + 18:00 定时 deploy.sh
  触发者:时间

两种场景用不同触发方式——代码变更需要人验证,文档同步可以批量定时。

三层对比——CI/CD 流程设计

❌ 初级:手动 FTP 上传,没有自动化
⚠️ 中级:任何变更都自动部署,把平台当 CI 用
✅ 资深:按"变更类型"差异化流程
   代码 → 人工验证 → 主动触发部署
   内容 → 定时/批量部署
   热修 → 单独快速通道

这个反思的普适价值

自动化不是越多越好。自动化的本质是:

把"低风险 + 高频重复"的事情交给机器。
把"需要判断 + 影响面大"的事情保留给人。

代码改动属于第二类——它可能引入 bug、改变行为、影响用户。每次都需要人类眼睛至少扫一下本地预览。推到生产这步应该是一个显式的决定,不是惯性动作。

面试话术

面试话术:"之前我做 side project 时,养成了'改完就 push'的习惯,被合作者指出这是过度自动化——每次小改动都触发部署,既浪费构建配额又让 git 历史充满机械 commit,还剥夺了本地验证的机会。后来我调整成:代码改动必须本地 dev 预览 + 显式确认才 push,但'内容同步'这类低风险变更保留定时批量部署。这个区分让我理解了 CI/CD 不是'越自动越好',而是按变更风险匹配流程。"

元学习——这次反思的二阶启发

AI 协作也要保留决策权

AI 能高效执行"修一个 bug → 构建 → 部署"的完整链路,
但人类工程师不应该把"部署决策"也交给 AI。

部署 = 把改动暴露给真实用户
部署 ≠ 把改动 commit 到代码库

把两者混淆就失去了对生产环境的控制。

这个教训对于未来 AI 深度参与开发的场景更重要——让 AI 帮你写代码是工具,让 AI 替你做发布决策是风险


28. 5 分钟学习模式 + 视觉信息密度管理(三个 W 原则)

5 分钟模式的三次迭代

V1:简单粗暴移除

seed 里最短任务是 10 分钟,用户选 5 分钟时推荐算法推了 30 分钟的"三个项目架构对比"(软分数加法模型,没合适的就挑"最不坏"的)。我当时直接移除 5 分钟选项

反思:抹杀了一个合理的学习场景。5 分钟不是"选项不存在",是需要专属推荐策略

V2:5 分钟走"复习 + light"专属池

if (selectedMinutes <= 5) {
  const shortPool = candidates.filter(
    (t) => isDueToday(t, now) || overdueDays(t, now) > 0 || t.difficulty === "light"
  );
  eligible = shortPool.length > 0 ? shortPool : candidates;
}

新问题:20 分钟的 light 任务也被混进来了(只筛难度,没筛时长)。

V3:light 任务加 ≤ 10 min 上限

const shortPool = candidates.filter((t) => {
  const isDue = isDueToday(t, now) || overdueDays(t, now) > 0;
  const isQuickLight = t.difficulty === "light" && t.estimatedMinutes <= 10;
  return isDue || isQuickLight;
});

语义分层

  • SR 到期任务:复习无时长限制(1 分钟能念一个关键词复习)
  • 轻松难度任务:作为"碎片概念了解"入口,必须限制 ≤ 10 min

视觉信息密度管理——三个 W 原则

用户追问 "/learn 页标签行的 ~10 分钟 是什么意思?" 暴露了一个元素设计问题。

问题现场

/learn 页右上角显示 selectedMinutes(用户选的时间),任务内容区又显示 estimatedMinutes(任务预估时长)。

右上角 "5 分钟"   = 我的时间预算
标签行 "~10 分钟" = 任务自己要多久

→ 两个同屏出现,用户困惑"我到底看哪个?"

三个 W 判断框架

每个 UI 元素问自己三个问题:

When:什么时候用到这条信息
Who :给谁看
What:触发什么动作

套到 estimatedMinutes:

规划阶段(选任务前):有用 → 判断"能不能做完"
  → 出现在 /tasks 列表、/tasks/[id] 详情 ✅

执行阶段(学习中 /learn):无用 → 已经选了,任务长度不改变什么
  → 不该出现 ❌

修复

- <span>{typeLabel}</span>
- <span>{difficultyLabel}</span>
- <span>~{currentTask.estimatedMinutes} 分钟</span>
+ <span>{typeLabel}</span>
+ <span>{difficultyLabel}</span>

三层对比

❌ 初级:这个信息有用吗?→ 有用 → 显示出来
⚠️ 中级:这个信息重要吗?→ 重要 → 放显眼位置
✅ 资深:这个信息在【什么时候】【对谁】【触发什么决策】?
   三个 W 答不上,就是视觉噪音,删掉或搬到对的页面

面试话术

面试话术:"我在学习工具里发现同一个页面同时显示'时间预算'和'任务预估时长',两个数让用户困惑。后来我用'三个 W'框架反问——这个信息在什么时候、对谁、触发什么决策?任务预估时长是'选任务前'的信息,不该出现在'学习中'的页面。我直接把它从 /learn 页删掉,只在任务列表和详情保留。这让我理解到资深的 UI 审美不是美学偏好,是对信息密度的严格管理——每个元素都要能回答三个 W,否则是视觉噪音。"


29. 布局 bug 链路——body safe-area + trailingSlash + BottomNav 遮挡

现象链

用户反馈:选 5 分钟进 /learn,看到:

  1. "内容是空的只有标题"
  2. "按钮被遮挡,要上滑"

第一反应是布局 bug。但追根的过程暴露了三个看似独立的问题串在一条因果链上

问题分解

Bug 1:body 全局 safe-area padding 导致 min-h-dvh 溢出

/* globals.css 原错误实现 */
body {
  padding: env(safe-area-inset-top) env(safe-area-inset-right)
    env(safe-area-inset-bottom) env(safe-area-inset-left);
}
  • /learn 的 main 用 min-h-dvh = 100dvh
body 有 safe-area padding → 内容可视区 < 100dvh
main.min-h-dvh = 100dvh → 超出可视区
底部按钮被挤出视口 → 需要滚动 ❌

修复:body 去掉 padding,改为 .pb-safe / .pt-safe 工具类,只在需要的组件(BottomNav、固定底部按钮)上用。

.pb-safe { padding-bottom: max(1rem, env(safe-area-inset-bottom)); }
.pt-safe { padding-top: max(1rem, env(safe-area-inset-top)); }

max() 保证即使设备无 safe-area(电脑浏览器)也至少有 1rem 舒适间距。

Bug 2:trailingSlash 让 pathname 带斜杠,匹配失效

用户截图显示 /learn底部导航竟然在——遮挡了按钮。

pathname 实际是 "/learn/"(trailingSlash: true 导致)
NAV_HIDDEN_PATHS = ["/learn", "/review"]
NAV_HIDDEN_PATHS.includes("/learn/") → false
  → showNav = true → BottomNav 在学习页错误渲染 ❌

修复:比较前 normalize:

const normalized = pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname;
const showNav = !NAV_HIDDEN_PATHS.includes(normalized);

Bug 3(感知):"内容空"不是 bug,是视觉感受

任务描述只有 15-20 字(例:"浏览器兼容性——iOS 拍照格式浏览器不支持"),加上:

  • 大字号标题占两行
  • 描述字号小,一行完
  • 下面大片留白到按钮(被遮挡的)

合起来给人"内容空"的错觉。真修了 Bug 1+2,加上后续加的"打开学习材料"入口,感知自然改善。

链路总结

trailingSlash: true(Cloudflare Pages 配置优化)
    ↓
pathname 带末尾 /(路由规范变化)
    ↓
BottomNav 未被 NAV_HIDDEN_PATHS 命中 → 在 /learn 渲染
    ↓
遮挡页面底部按钮

叠加 body safe-area padding → min-h-dvh 溢出
    ↓
页面实际高度 > 视口,底部原生不可见 + 被 nav 盖

用户感知:"内容空" + "按钮被挡要上滑"

三个问题看似独立,实际:trailingSlash 是 CDN 配置(本来为性能优化),却通过 pathname 格式跨越多个模块影响 UI 行为。

三层对比——配置变更的系统性审视

❌ 初级:改了 trailingSlash 就改了,不知道副作用在哪
⚠️ 中级:猜可能影响路由,但想不全所有依赖 pathname 的地方
✅ 资深:改全局配置后列出"受影响的模块清单":
   - 所有使用 pathname 的组件(BottomNav、LayoutShell)
   - 所有硬编码路径的地方(NAV_HIDDEN_PATHS)
   - 所有 Link href 的匹配逻辑
   - 测试每一处都跑一遍(或写测试)

经验——URL 格式规范化的一般做法

// 工具函数:URL 路径规范化
function normalizePath(p: string): string {
  if (p.length <= 1) return p;
  return p.replace(/\/$/, "");
}

任何做路径比较的地方都应该先 normalize,避免被 /foo vs /foo/ 这种细节困住。理想情况是项目内建立"path 工具模块",所有 pathname 读取后都经过它。

面试话术

面试话术:"我在 Cloudflare Pages 部署时为了 CDN 友好开了 trailingSlash: true,结果 BottomNav 在该隐藏的 /learn 页面错误显示,遮挡了按钮。追根源——配置让 pathname 变成 /learn/ 带末尾斜杠,但我的 HIDDEN_PATHS 是 ["/learn"],精确匹配失效。修复是比较前去掉末尾斜杠。这个坑让我意识到全局配置改动要系统审视所有依赖 pathname 的模块——理想情况是建一个 path 工具模块,所有路由比较都经过 normalize,避免类似的细节 bug。"


30. 展示层映射 expandCourseRefs + 执行态 vs 参考态的信息分层

问题

种子任务的 source 字段是短缩写 "kh Ch1""sd Ch17",在任务列表 / 详情页显示不友好。用户要求改为全称"Knowledge Hub"等。

关键决策:展示层 vs 数据层

方案 A(数据层改):把 seed-tasks.ts 所有 "kh Ch1" 替换为 "Knowledge Hub Ch1"
  问题:
  1. 46 个任务全部要改,工作量大
  2. 以后若要支持多语言展示,数据层已经写死了
  3. 种子数据本身丢失了"缩写语义"(kh/sd/ma 在 plan.md 里是稳定标识)

方案 B(展示层转换):数据保留缩写,渲染时替换
  优点:
  1. 数据层稳定,符合 plan.md 规范
  2. 多场景复用(列表显示短、详情显示长都行)
  3. 以后改显示格式不用动数据

选方案 B,写一个 expandCourseRefs 工具函数:

// src/lib/constants.ts
export function expandCourseRefs(text: string): string {
  let result = text;
  for (const [abbr, title] of Object.entries(ABBR_TO_TITLE)) {
    const pattern = new RegExp(`\\b${abbr}\\b`, "g");  // \b 词边界避免误伤
    result = result.replace(pattern, title);
  }
  return result;
}

为什么要用 \b 词边界

❌ 无边界:直接 replace("kh", "Knowledge Hub")
  "khan" → "Knowledge Huban"  ❌ 误伤
  "mark" → 不影响                  ✅(因为 k 不紧跟 a-z)

✅ \bkh\b:必须是完整单词
  "kh Ch1"    → "Knowledge Hub Ch1"  ✅
  "khan"      → "khan"(不匹配)      ✅
  "sd Ch17"   → "ShopDiary Ch17"     ✅
  "kh,sd"     → "Knowledge Hub,ShopDiary" ✅

这是文本替换的必做防御——字符串 replace 永远要考虑是否会误伤更长的词。

执行态 vs 参考态——/learn 页"打开学习材料"入口

用户期望 vs 当前设计的错位

用户点 5 分钟 → 期望:直接看到学习内容(Bug 故事 5 的详细讲解)
当前设计     → 显示:"任务卡片"(标题 + 短描述 + 来源 sd Ch17)
             源内容其实在 /docs/shopdiary-claude,但入口缺失

这是"执行态缺少到参考态的跳转"问题。

信息架构修正

/learn 页职责:"任务提示卡片"
  ├ 显示做什么(title)
  ├ 为什么做(description)
  └ 去哪看材料 → 链接到 /docs/xxx ← 新增
  
/docs/[slug] 页职责:"学习材料本体"
  └ 渲染 Markdown 内容

流程闭环:
  /learn 看提示 → 点"打开学习材料" → /docs 读 → 浏览器返回 → /learn 点"学完了" → /review

实现

{currentTask.courseId && (
  <Link
    href={`/docs/${currentTask.courseId}`}
    className="mt-6 flex items-center justify-between rounded-xl border ..."
  >
    <span>
      <span className="text-neutral-500">打开学习材料 </span>
      <span className="font-medium">
        {expandCourseRefs(currentTask.source || currentTask.courseId)}
      </span>
    </span>
    <span className="text-neutral-400"></span>
  </Link>
)}

为什么不嵌入内容到 /learn? 设计边界:

❌ 把内容嵌进任务卡片:
  ├ 每个任务要存对应的内容块 → 种子数据变重
  ├ /learn 页变超长滚动页 → 阅读体验差
  └ "任务"和"材料"边界模糊 → 心智负担

✅ 分页承载 + 跳转闭环:
  ├ 任务数据只记"去哪找内容"(source 字段已有)
  ├ /learn 只给指令和入口
  ├ /docs 专门做阅读
  └ 浏览器 back 天然回到 /learn(session 状态保留)

三层对比——数据 vs 展示分离

❌ 初级:展示什么就存什么,数据 = 文本
   改名要改所有数据

⚠️ 中级:知道要分离,但抽出来的展示逻辑散落在各组件
   相同转换写好几遍

✅ 资深:建 utility 函数 / 映射表 集中管理展示转换
   数据稳定,展示灵活,新场景复用成本 0

面试话术

面试话术:"我的任务数据里 source 字段用短缩写 kh/sd/ma,但前端展示想要全称。第一反应是改数据,但想了想——数据层的短名是和学习计划文档同步的稳定标识,改了会失去和文档的一致性。我选了展示层转换方案:写一个 expandCourseRefs 工具函数,用词边界正则把缩写替换为全称,渲染时调用。好处是数据层稳定、多场景复用(列表短、详情长都行)、新需求零成本。词边界用 \b 是为了防止'khan'误匹配成'Knowledge Hub an'——字符串 replace 永远要想误伤。"


31. 产品决策反思——"简洁高效" 原则下的学习历史功能取舍

用户提问

"有没有记录 哪个内容学习过?"

第一反应 vs 深入思考

第一反应(功能驱动思维)

提出两个方案:

  • A. 全局 /history 历史页
  • B. 任务详情加"历史"tab

用户追加的约束:"简洁高效"

简洁 = "每多一个页面 / 导航入口 / 元素,都是长期维护成本"
高效 = "每个功能要直接对应一个明确的用户动作场景"

组合起来的黄金问题:
  → 这个信息在什么场景下,会直接驱动用户的下一步动作?
  → 如果答不上来具体场景,就不该建

深度分析——现有数据曝光面盘点

大部分"历史"需求已经被现有 UI 覆盖

需求 现有入口 够不够
今天学了几次? 进度页「累计学习次数」+ 活动格子
学过哪些任务? 任务页筛选"已完成"
4 周整体节奏? 进度页 28 天活动图
某任务学过几次? 详情页「完成次数」
上次自评什么? 详情页「上次自评」
下次复习时间? 详情页「下次复习」

真缺的只有两件

  1. 某任务的历次回答文字
  2. 某一天具体学了哪几个任务

四个方案重新评估

方案 简洁 高效 综合
A. 独立 /history 全局页 ❌ 新增页面+导航 ⚠️ 频率低 不推荐
B. 任务详情加"历史" tab ⚠️ 层级多 ✅ 查"认知演进"必用 可以
C. 详情页下方内嵌历史 section(无 tab) ✅ 零新增 ✅ 直接命中 推荐
D. 什么都不加,用一周再决定 ✅ 极致 ⚠️ 有缺口 备选

最终决定:方案 D(先不做)

理由:

人的习惯 > 功能设计的猜测
  → 连续用 5-7 天后再决定
  → 真需要时 → 做方案 C(最简增量)
  → 发现不需要 → 避免了过度设计

资深工程师常见坑:
  "我觉得应该有这个功能" → 建出来 → 没人用 → 但要维护
  → 精力被无关功能吞噬
  → 真正重要的功能反而没时间做

反例——什么时候应该做方案 A(全局历史页)

如果这是团队共享工具:
  ├ 需要和同事分享学习轨迹 → 全局页有价值
  ├ 需要生成周报给领导 → 全局页有价值
  └ 需要比较不同成员的进度 → 全局页必需

但这是单用户个人工具:
  ├ 没有分享对象
  ├ 学习数据只给自己看
  └ 全局历史页使用频率 ≈ 看一次"好像学了不少"

三层对比

❌ 初级:用户问功能 → 立刻设计实现
⚠️ 中级:设计 2-3 个方案让用户选
✅ 资深:设计方案 + 反问"这个需求真的存在吗?" + "不做会怎样?"
   如果"不做也能过" → 优先不做

二阶启发——AI 协作时代的功能决策

AI 能力越强,越容易"过度建设"

AI 能快速搭建任何功能 → 每个需求 30 分钟就能实现
  → 诱惑:反正实现成本低,那就做吧
  → 陷阱:功能数量多 → 系统复杂度高 → 后续每次改都牵一发动全身
  → 长期结果:工具被自己的功能堆淹没

真正珍贵的是"克制":
  → 只做真正用得到的
  → 功能少,每个都打磨深
  → 整体体验反而更好

面试话术

面试话术:"做 side project 有个反直觉的教训——能做的功能越多,越要克制。用户问我要不要加'学习历史'页面,我列了 4 个方案,但最后选了'先不做'。理由是:我盘点了现有 UI,发现 80% 的历史需求已经被现有统计页覆盖,真缺口只有 2 个。与其现在猜测做全局历史页,不如让自己用一周,等真的发现'我需要看 X 页'再做——人的真实习惯比功能设计的猜测可靠得多。这个决策方式帮我过滤了很多'听起来不错但没人真用'的功能,让我专注在高频真需求上。"


32. UX 哲学——显式信号 vs 隐式推断,"学完了"按钮为什么不能砍

用户追问

/learn 页加了"打开学习材料 →"链接把用户导到 /docs 后,用户敏锐地追问:

既然学习内容需要跳转走,那底部的"学完了"还有什么意义?

这是个非常锐利的产品设计追问,表面上看按钮确实冗余——内容都在别处了,按钮没"承担"内容。

第一层回答:系统需要一个"你学完了"的信号

系统如何知道用户学完了?

❌ 定时器:到 selectedMinutes 就自动进回顾
   问题:2 分钟可能已领悟,15 分钟可能还没看完
   时间 ≠ 理解

❌ 阅读进度:滚动到 /docs 底部就算完成
   问题:扫一眼滚到底 vs 认真读完,系统分不出
   滚动 ≠ 理解

❌ 去掉按钮,/docs 返回 /learn 就自动进回顾
   问题:用户可能只是切回来再确认标题,还要继续读
   离开页面 ≠ 完成

✅ 用户主动按按钮:我说完了就是完了
   显式信号是唯一可靠的

第二层:按钮的"宣告"本身是学习的一部分

更深的价值在行为科学——主动 close loop 比被动完成对学习效果影响巨大

"读完就读完了"(无 close 动作):
  大脑模式:被动消费 → 信息流进信息流出 → 记忆浅

"我宣告我学完了"(有 close 动作):
  大脑模式:整理收尾 → 强迫回忆刚学的内容 → 记忆深
                    ↓
               自然引发"那我学到了什么?"的反思
                    ↓
               后续的回顾题 + 自评更准确
                    ↓
               SR 间隔安排更合理

这是为什么 Anki / Duolingo / Brilliant 这些优秀学习工具都保留明确的"完成"按钮——不是技术必要,是学习仪式

第三层:按钮的三个作用

1. 技术作用
   ├ 触发 /review 跳转
   ├ 锁定 session 状态为 completed
   └ 给系统"明确信号"作决策依据

2. 心理作用(行为科学)
   ├ 给大脑"收尾"信号,从被动消费切到整理反思
   ├ 形成一个学习仪式的"小闭环"
   └ 提升后续自评的准确度和 SR 的效果

3. 数据作用
   ├ 区分"学完了"和"放弃"两种结局
   ├ 学完了 → SR 进度前进 + 统计 +1
   └ 放弃   → 不推进 SR,记录 skipped session
   如果没这个按钮,系统只能靠猜,数据失真

为什么"显式 > 隐式"是通用 UX 原则

任何时候需要用户"意图"的场景,都应该优先要求显式信号:

场景                  ❌ 隐式推断              ✅ 显式信号
删除文件            离开页面就删除          明确的"删除"按钮 + 确认
发送消息            停止输入 N 秒后发送      按"发送"按钮
提交表单            离开页面就提交          "提交"按钮
完成学习任务        离开页面就算完成        "学完了"按钮

隐式的推断永远有误判率。
显式的信号没有歧义。
多一次点击 << 错误推断造成的混乱

三层对比——按钮设计的价值判断

❌ 初级:按钮必须有"功能用途",否则是多余
   "内容不在 /learn 了,那'学完了'按钮砍掉"
   
⚠️ 中级:知道按钮是触发器,但以为可以用隐式代替
   "不如加个自动检测——5 分钟到就触发回顾"
   → 落入隐式推断的陷阱

✅ 资深:按钮的价值 ≠ 功能必要性
   有些按钮存在是为了"让用户显式表达意图"
   这个"显式表达"本身就是体验的核心部分
   哪怕它多一次点击,也值得保留

普适结论——不是每个元素都要"功能必要"

这个思考对所有交互设计都适用:

决定要不要保留某个 UI 元素时问自己:

1. 它是纯"功能承载"吗? → 只看有没有用
2. 它是"用户意图表达"吗?→ 显式比隐式好,保留
3. 它是"心理仪式"吗?   → 看是否提升核心体验,可能保留

许多看似"多余"的元素(确认弹窗、保存按钮、完成按钮)
其实都在 2 或 3 的层面承担价值。

面试话术

面试话术:"我做学习工具时遇到一个产品设计问题——把学习内容放到了单独的 /docs 页面后,原本 /learn 页的'学完了'按钮看起来就多余了。但我没砍掉它,因为我想清楚了一件事:显式信号永远比隐式推断可靠。定时器不等于理解,滚动不等于读完,离开页面不等于完成。只有用户主动按按钮才是可靠信号。更深的原因是行为科学——'主动宣告我学完了'这个动作本身就是学习仪式的一部分,大脑会从被动消费切换到整理反思模式,后续的自评更准确,SR 间隔也更合理。这是 Anki / Duolingo 等优秀学习工具都保留明确'完成'按钮的原因。"

二阶启发——产品设计要懂点行为科学

这类决策经常需要跨学科思考:

纯工程思维:这个按钮能不能用代码替代?
  → 能(定时器 / 事件监听)
  → 砍掉

产品思维:这个按钮对用户体验有什么价值?
  → 仪式感、意图表达、可靠信号
  → 保留

资深 = 工程思维 + 产品思维 + 行为科学 的交叉
  能看到"代码能实现 ≠ 该用代码实现"的边界

这是一个非常适合面试时展示"跨学科思考能力"的例子——资深工程师不只是写代码,而是能从用户心理、系统可靠性、学科原理多个维度权衡设计决策


33. 章节锚点直达——parseSourceChapter + 后处理正则给 heading 加 id

问题

用户反馈:"跳转实现了,但跳到了文档首页,还要自己从目录里找对应的知识点位置"。

任务的 source 字段精确到"sd Ch17",但 /learn 里的"打开学习材料"链接只到 /docs/shopdiary-claude/,对应的 Ch17 章节藏在长长的文档中部。

设计目标

已有数据:task.source = "sd Ch17"
目标:  点击 → 直接滚动到 ShopDiary 文档的 Ch17 章节
URL:   /docs/shopdiary-claude/#ch17

实现三件事

1. 从 source 字符串提取章节号

// src/lib/constants.ts
export function parseSourceChapter(
  source: string | undefined,
  courseId: string
): number | null {
  if (!source) return null;
  const course = COURSES.find((c) => c.id === courseId);
  if (!course) return null;

  // 匹配 "kh Ch17" / "kh Ch12-13" 两种格式,只取起始数字
  const pattern = new RegExp(`\\b${course.abbreviation}\\s+Ch(\\d+)`, "i");
  const match = source.match(pattern);
  return match ? parseInt(match[1], 10) : null;
}

关键细节:

  • \b 词边界:避免"khang"被错误匹配成"kh"
  • \\s+:处理 "kh Ch17" 和 "kh Ch17"(多空格)
  • Ch(\d+):只捕获数字,12-13 这种范围也只取 12
  • "i" flag:大小写不敏感(兼容 "KH Ch17" 手误)

2. 给 markdown heading 加 id="chN"

反例:用 marked renderer 重写 heading

// ❌ marked v18 的 renderer 签名
renderer.heading = function({ tokens, depth }) {
  const text = this.parser.parseInline(tokens);  // 需要了解 marked 内部
  // ...
};

问题:

  • tokens 是 marked 内部类型,TypeScript 类型不直观
  • this.parser.parseInline 是依赖内部 API
  • marked 版本升级可能破坏

选用:后处理字符串正则

// src/lib/docs.ts
function addChapterAnchors(html: string): string {
  return html.replace(
    /<(h[1-6])>(\s*(\d+)\.[^<]*)<\/\1>/g,
    (_match, tag, inner, num) => `<${tag} id="ch${num}">${inner}</${tag}>`
  );
}

// renderDoc 里:
const rawHtml = await renderer.parse(source);
const html = addChapterAnchors(rawHtml);

优点:

  • 零 marked 版本依赖
  • 只处理我们关心的"数字章节"格式(17. Bug 故事...
  • 其他 heading(如"目录"、"背景")不受影响
  • 简单可读

3. /learn 链接带 #chN

// src/app/learn/page.tsx
{currentTask.courseId && (() => {
  const chapter = parseSourceChapter(currentTask.source, currentTask.courseId);
  const href = chapter
    ? `/docs/${currentTask.courseId}/#ch${chapter}`
    : `/docs/${currentTask.courseId}/`;
  return <Link href={href}>...</Link>;
})()}

IIFE(立即执行函数)包起来是为了在 JSX 里塞一段临时计算——不想把逻辑散到组件外。这不是最好实践,但对"一次性的条件计算"够用。

4. scroll-margin-top 让锚点跳转更舒适

.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
  scroll-margin-top: 1rem;
}

原生 CSS 属性,专门为 anchor 跳转设计:跳转到 #chN 时,浏览器会让该元素顶部距离视口顶部 1rem 而不是贴死。

比以前的 hack(padding-top + negative margin)干净 100 倍。

关键决策——stable id vs semantic slug

为什么用 #ch17 而不是 #bug-故事-5-分页配置混乱

Slug 方案(语义标识):
  优点:URL 自带语义,别人能从 URL 猜出讲啥
  缺点:
  ├ 中文 + 标点 slug 生成复杂(需要音译或全保留?)
  ├ 章节标题改名 → slug 变 → 旧链接失效
  └ 每次 source 字段升级都要同步更新 slug 生成规则

chN 方案(稳定标识):
  优点:
  ├ 章节编号是 plan.md 定义的稳定契约
  ├ task.source 里就是 "Ch17",parser 零成本提取
  ├ 标题怎么改都不影响锚点
  └ 内部跳转够用
  缺点:URL 不自带语义(但我们不分享外部链接)

本质判断:"标识符" 应该跟随 "不变量",不跟随 "可变量"。章节序号是这份学习计划的不变量,章节标题是可变量——用序号做锚点。

三层对比

❌ 初级:跳到文档首页 → 用户自己找章节
   体验糟糕,用户付成本

⚠️ 中级:生成 slug 锚点 → 完美语义但耦合
   改标题要同步改 source 字段 → 维护噩梦

✅ 资深:用稳定契约(章节号)做锚点
   最小改动 + 最大鲁棒性
   改标题不影响跳转,改 source 不影响跳转

类似模式——稳定标识 vs 语义标识的选择

这个权衡在前端架构里很常见:

场景 稳定标识 语义标识 选择
本章锚点 章节序号 ch17 标题 slug 稳定(标题会改)
数据库主键 自增 ID / UUID 用户邮箱 稳定(邮箱会改)
React key 数组 index 业务 id 稳定(index 会错)
前端路由 /orders/[id] /orders/alice-2024-01-15 稳定(看场景)
Feature flag EXP_001 new-checkout-flow 经常两个都要(一个发布用一个分析用)

面试话术

面试话术:"我在学习工具里做了'点击任务 → 跳转到文档对应章节'的功能。source 字段格式是 'sd Ch17',我用词边界正则解析出章节号,URL 带 #ch17,Markdown 渲染时给所有 N. xxx 格式的 heading 后处理加 id='chN'。关键决策是用稳定的章节序号做锚点 id 而不是标题 slug——章节序号是学习计划的不变量,标题是可变量,让标识符跟随不变量是我做架构决策的核心原则。scroll-margin-top 让锚点跳转有舒适的上边距,省去了传统 padding + margin hack。"

思考题(留给你)

思考 1:我用 IIFE (() => { ... })() 在 JSX 里塞了一段计算。这样写紧凑但牺牲了可读性。如果换成抽成组件 <OpenMaterialLink task={task} />,或者提前计算变量(在 return 之前),各自的优缺点是什么?你的 React 项目里这种"局部逻辑"一般怎么处理?

思考 2:source 字段是 "sd Ch12-13" 这种章节范围时,我只跳到 Ch12。你觉得有没有更好的设计?(提示:UI 上能不能让用户选跳哪一章?或者只显示起点?你选哪个,理由?)


34. 思考题复盘——JSX 局部逻辑的三种写法 + 数据模型决定 UX 天花板

思考题 1:JSX 局部逻辑的三种写法

三种写法

// A. IIFE(立即执行函数)
{condition && (() => {
  const x = compute();
  return <Link href={x} />;
})()}

// B. 提前计算变量
const x = compute();
return <div>...<Link href={x} />...</div>;

// C. 抽成独立组件
function OpenMaterialLink({ task }) { ... }
<OpenMaterialLink task={task} />

用户回答

  • 一般用 B 和 C,没用过 A
  • A 不易读、难维护
  • C 比 B 好——逻辑集中,修改时只改一处
  • B 有点偷懒但可以接受

评价:判断完全正确 ✅,这是生产代码的正解。

延伸——IIFE 的正当场景(用户没用过,但值得知道)

场景 1:多分支状态映射

{(() => {
  switch (status) {
    case 'loading': return <Spinner />;
    case 'error':   return <ErrorPanel onRetry={...} />;
    case 'success': return <DataView data={...} />;
    default:        return null;
  }
})()}

if-else 梯子也行,但 switch 表达力更强。

场景 2:需要多个中间变量的一次性计算

{(() => {
  const bucketed = groupBy(data, d => Math.floor(d.v / 10));
  const max = Math.max(...Object.values(bucketed).map(b => b.length));
  const scaled = bucketed.map(...);
  return <Chart bars={scaled} max={max} />;
})()}

如果把这些变量提前算到 return 之外(写法 B),会污染外层作用域——其他 JSX 块看不清哪些变量跟自己有关。

我的错误示范

我写 /learn 页的"打开学习材料"链接用了 IIFE:

// ❌ 我的原实现:本来是简单的"算 href + 渲染 Link",没必要用 IIFE
{currentTask.courseId && (() => {
  const chapter = parseSourceChapter(currentTask.source, currentTask.courseId);
  const href = chapter ? `/docs/.../#ch${chapter}` : '/docs/.../';
  return <Link href={href}>...</Link>;
})()}

// ✅ 应该用写法 B(变量提前)或 C(抽组件)

承认偷懒:这段是标准的"1-2 行派生 → 用一次",应该直接用写法 B。

三层对比

❌ 初级:所有 JSX 里要算东西都 IIFE,代码像意大利面
⚠️ 中级:按复杂度选——简单 B、复杂 C(用户现在这里 ✅)
✅ 资深:还能认出 IIFE 的正当场景(多分支 switch、中间变量多的一次性计算)
   + 会问"这段逻辑值不值得被命名/复用"决定要不要抽组件

面试话术

话术:"我的 React 项目里 JSX 局部逻辑主要用两种写法——1-2 行的派生值在 return 前算变量;如果是一个有语义的 UI 单元就抽成组件。IIFE 我基本不用,但承认有两种场景它合理:多分支状态映射(switch + return)和需要多个中间变量的一次性计算。用 IIFE 最大的坑是它让 JSX 里混入过程式代码,阅读时需要同时切换'看结构'和'看逻辑'两种思维。"


思考题 2:章节范围跳转的设计

原问题

source 字段是 "sd Ch12-13" 时,我的代码只跳到 Ch12。如何改进?

用户回答

"如果遇到的阅读顺序刚好是反着的,比如先 13 后 10,几个方案都不满足。"

评价:用户跨过了"改 UI"层级,直接找到了数据模型缺陷 ✅✅

真正的问题——数据模型表达力不足

当前设计:task.source 是自由文本字符串
  "sd Ch12-13"           → 连续范围(隐含顺序:升序)
  "kh Ch1, sd Ch1, ma Ch18" → 多课程多章节
  "sd Ch13, sd Ch10"     → 反序?字符串无法表明"这是故意的"
  "实践"                 → 非章节
  
问题:字符串是"非结构化"数据
  ├ 章节顺序是隐式的
  ├ 同一课程还是跨课程也是隐式的
  └ 机器解析只能靠猜测,不能保证正确

正确的修复——重构数据模型

// ❌ 当前:自由文本
interface Task {
  source: string;   // "sd Ch12-13"
}

// ✅ 结构化:明确表达
interface SourceRef {
  course: string;    // "shopdiary-claude"
  chapter: number;   // 12
  order: number;     // 阅读顺序,1 = 最先
}

interface Task {
  sources: SourceRef[];
}

// 无论什么场景都能表达:
sources: [
  { course: "shopdiary-claude", chapter: 12, order: 1 },
  { course: "shopdiary-claude", chapter: 13, order: 2 },
]

// 反序场景:
sources: [
  { course: "shopdiary-claude", chapter: 13, order: 1 },
  { course: "shopdiary-claude", chapter: 10, order: 2 },
]

// 跨课程:
sources: [
  { course: "knowledge-hub", chapter: 1, order: 1 },
  { course: "shopdiary-claude", chapter: 1, order: 2 },
  { course: "market-ai-model", chapter: 18, order: 3 },
]

UI 层按 order 排列,每个 ref 一个链接。

核心原则——数据模型决定 UX 天花板

❌ 初级:UI 看起来不对 → 改 UI 组件
⚠️ 中级:UI 改不动 → 接受现状(但其实是数据结构不够用)
✅ 资深:意识到 UI 的限制 = 数据模型表达力的限制
   → 推翻数据结构假设,重构它
   → UI 的可能性才打开

这个原则在所有场景通用:

场景 简单数据 结构化数据 解锁的能力
日历事件 timestamp: number { start, end } 跨天事件、时长显示
订单金额 amount: number items: LineItem[] 部分退款、税费计算
任务列表 string[] { tasks, deps: [][] } 依赖关系、进度图
文件路径 string { dir, name, ext } 批量改后缀、按目录过滤
本项目 source "sd Ch17" { course, chapter, order }[] 反序、多章节、精确跳转

共同模式:把"隐式信息"从字符串提取出来变成显式字段,机器能理解的维度 = UX 能表达的维度

三层对比

❌ 初级:看到 UI 不完美 → 加 if-else / 加 hack 修表面
⚠️ 中级:识别出 UI 和数据不匹配 → 纠结要不要改
✅ 资深:从数据模型层面重新设计 → 一次改清
   识别规则:当"UI 表达的东西"超出"数据表达的维度"时,改数据

本项目是否要现在重构?

不需要(YAGNI 原则)

检查当前 seed 里的 source 分布:
  ├ 单章节:"sd Ch17"、"kh Ch5"(多数)
  ├ 连续范围:"sd Ch12-13"、"kh Ch6-7"(少数,本来就隐含顺序)
  └ 多课程:"kh Ch1, sd Ch1, ma Ch18"(极少,Week 1 Day 1 一个)

用户提到的"反序"场景目前不存在 → 重构是为假想需求买单

YAGNI = You Aren't Gonna Need It。预先为"可能用到"的场景重构,往往做了却最终用不到,浪费精力 + 增加复杂度。

真遇到反序任务时再重构,那时候:

  • 问题具体了,数据结构能针对真实需求设计
  • 有明确的 acceptance criteria
  • 重构有"必须完成"的动力

二阶启发——YAGNI vs 前瞻设计的边界

YAGNI 适用的场景:
  ├ 当前数据/功能能满足 90% 以上真实用例
  ├ 修改不是太难(数据只在几处用到)
  ├ 个人工具、早期产品
  └ 未来真需求还不确定

前瞻设计适用的场景:
  ├ 改数据结构涉及大规模迁移(DB 表、API 协议)
  ├ 已有业务数据沉淀,未来重构代价高
  ├ 团队协作项目,改动要协调很多人
  └ 未来需求明显可见(如多语言、多租户)

个人工具 = 倾向 YAGNI企业产品 = 倾向前瞻设计。Codex 是前者。

面试话术

话术:"用户问我'章节范围跳转如果是反序怎么办'——这让我意识到 bug 不在 UI 而在数据模型。我把 source 当成自由文本字符串,用正则解析,但字符串无法表达'阅读顺序'这个隐藏维度。真正的修复是把 source 重构成 { course, chapter, order }[] 结构化数组。但我决定当前不重构——因为 YAGNI 原则:真实数据里没有反序场景,提前重构是为假想需求买单。这个讨论让我学到两件事:UI 的限制往往是数据模型的限制;YAGNI 和前瞻设计要看具体场景——个人工具倾向 YAGNI,企业产品倾向前瞻。"


35. bug 链路复盘——SSR 阶段 useState 陷阱 + Link 失灵 + sticky 返回 + trailingSlash 确认

现象串

用户在 iPad 上点"打开学习材料 ShopDiary Ch17"时遇到一系列问题:

  1. 点击没反应(第一次报告)
  2. <a> 后:过了很长时间才跳转
  3. 跳转后没有滚动到 Ch17,停在文档顶部
  4. 看完内容想返回,必须滚回最顶部才能点"文档列表"按钮
  5. 点"文档列表"回到的是 /docs 列表,不是 /learn

这是一个根因 + 三层次级 bug 的典型 case。

根因:DBProvider 阻塞 SSR

这是所有问题的共同源头,最隐蔽也最重要。

// 原 DBProvider.tsx
export default function DBProvider({ children }) {
  const [ready, setReady] = useState(false);   // ← 关键
  useEffect(() => {
    seedIfNeeded().then(() => setReady(true));
  }, []);

  if (!ready) return <div>正在准备...</div>;   // ← SSR 时永远走这里
  return <>{children}</>;
}

SSR 阶段 useState 初始值就是 false,整个 App 在静态 HTML 里只输出 <div>正在准备...</div>,children(含 /docs/xxx 文章内容)完全没有被渲染到静态 HTML

验证:

grep -c '<h2 id="ch' out/docs/shopdiary-claude/index.html
# 修复前输出: 0
# 修复后输出: 37

这为什么导致所有症状

浏览器加载 /docs/shopdiary-claude/#ch17
  ↓
收到 HTML 只有"正在准备..."(没有任何 h2 id 锚点)
  ↓
浏览器尝试 scroll-to-hash #ch17 → 元素不存在 → 停在顶部  ❌ (症状 3)
  ↓
React JS 加载 → hydrate → DBProvider 的 useEffect 跑
  ↓
seedIfNeeded 完成 → setReady(true) → 重新渲染
  ↓
这时才出现文章内容
  ↓
但浏览器已经不会再触发 hash 滚动了

整个过程耗时长 = 症状 2 "过了很长时间才跳转"

症状 1(点击没反应):Next.js <Link> 依赖客户端路由器,当目标页面的 SSR 输出是"正在准备..."时,Link 的导航逻辑可能沉默失败。换成 <a> 是误打误撞的缓解,但根因是 DBProvider。

修复——解耦数据初始化和 UI 渲染

// 新 DBProvider.tsx
export default function DBProvider({ children }) {
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    seedIfNeeded().catch((err) => setError(err.message));
  }, []);

  // 不再根据 ready 切 UI,永远 render children
  // 需要数据的页面自己通过 useAppState().loading 管理 loading UI
  if (error) return <div>数据库初始化失败: {error}</div>;
  return <>{children}</>;
}

配套:AppProvider 里链式初始化确保 seed 在 refresh 之前跑完:

useEffect(() => {
  (async () => {
    await seedIfNeeded();      // 幂等,重复调用零成本
    await refresh();
    requestNotificationPermission().catch(console.error);
  })();
}, [refresh]);

通用教训——Client Component useState 初始值 = SSR 输出

❌ 初级:哪里要 loading 就加 useState + if 切 UI
⚠️ 中级:意识到"SSR 会渲染初始 state",但觉得"反正 client 会重渲染"
  → 忽略了:浏览器的"首屏行为"(hash 滚动、图片预加载)发生在初始 HTML
  → 等 React 重渲染时,这些行为已错过

✅ 资深:区分两类内容
  - 纯静态内容(文章、说明)→ 永远直接渲染,不要被异步状态阻塞
  - 依赖数据的 UI → 用 loading 状态,但只阻塞"自己这部分"
  → 全局 Provider 不应该用 all-or-nothing 的 ready 开关

次级 bug 1:sticky 返回按钮 + router.back()

问题 4(滚动找返回)和问题 5(返回去错地方) 是两个独立的 UX bug。

修复

// DocHeader.tsx(客户端组件)
"use client";
export default function DocHeader({ title }) {
  const router = useRouter();
  return (
    <div className="sticky top-0 z-10 ...">   // 始终可见
      <button onClick={() => router.back()}>← 返回</button>
      <span>{title}</span>
    </div>
  );
}

两个设计要点:

  • sticky top-0:滚到哪都看得到(CSS position: sticky,渐进式固定定位)
  • router.back() 代替硬编码 href="/docs":用户从哪来回哪去
    • /learn 来 → back 到 /learn
    • /docs 列表来 → back 到 /docs

硬编码链接 vs 动态回溯的语义区别:

硬编码 href="/docs" = "永远到 X"(信息架构语义)
router.back()      = "回到上一步"(时间轴语义)

返回按钮的期望永远是后者

次级 bug 2:Link → → Link 的来回

在根因 DBProvider 未修前,<Link> 点击无反应,换 <a> 能跳但速度慢(硬导航,重新加载整个 App)。

DBProvider 修好后恢复 <Link>,因为:

<a>: 硬导航,丢失 React state + 重新跑所有 JS
<Link>: 软导航,只替换页面内容,App 状态保留,大页面也快

trailingSlash: true 的确认

用户问 http://localhost:3099/learn/ 末尾的 / 是否正确。

答:正确next.config.tstrailingSlash: true,所有 URL 都以 / 结尾。

这是为了静态托管友好——CDN 收到 /learn/ 直接返回 out/learn/index.html,路径和文件结构一一对应,不用猜"是文件还是目录"。

之前的 BottomNav 错误显示在 /learn/ 页就是因为 NAV_HIDDEN_PATHS = ["/learn"] 字符串精确比较失败,已经修过(normalize 末尾斜杠)。

面试话术

话术:"我在 PWA 项目里遇到一连串看似独立的 bug——Link 点击没反应、跳转慢、hash 锚点失效、返回按钮要滚到顶部——花了点时间才意识到这些都是同一个根因:DBProvider 在 SSR 阶段用 useState(false) + if (!ready) return <Loading>,导致整个 App 的静态 HTML 都是 loading 占位,没有真实内容。浏览器加载时的 hash 滚动、Link 的客户端路由都建立在'初始 HTML 有内容'的假设上,被 DBProvider 空架子破坏。修复是让 DBProvider 永远 render children,把 loading 交给具体需要数据的页面自己管理。教训:Client Component 的 useState 初始值就是 SSR 输出,全局 Provider 不该用 all-or-nothing 的 ready 开关阻塞所有子树。"


36. 代码块显示 bug 四连——主题切换、零宽空格污染、文字继承、pre/code 背景层级

事件序列

代码块从首次部署到"终于清晰"经历了4 次修复

V1:github-dark 主题,文字偏灰(用户:"太接近了看不清")
V2:切 atom-one-dark,去掉 pre 硬编码 bg
    → 变白底(用户:"看不出是代码了")
V3:显式把 bg 放回 pre 上
    → 代码块里出现 "8203" 字样(用户:"看到奇怪的数字")
V4:sync.sh 去掉 {{ }} 转义
    → 文字"黑字黑底"混在一起(用户:"看不清文字")
V5:pre 显式设 color #abb2bf
    → 终于可读 ✅

5 次 iteration,每次只修一个维度,暴露了对"代码块样式"的维度分解不够清晰。

问题 1:主题对比度不足(github-dark → atom-one-dark)

根因:github-dark 是为"整站深色模式"设计,色彩饱和度中等;我们用的场景是"浅色页面内嵌深色代码块",要求更高对比度才显眼。

解决:换 atom-one-dark——饱和度更高,关键字紫、字符串绿、数字橙,对比度明显更强。

问题 2:去掉 pre bg 后变白底

根因:我删除了 .markdown-body pre { background: #0d1117 },本意是"让 atom-one-dark 自管背景",但同时错误地加了:

.markdown-body pre code.hljs {
  background: transparent;   // ← 把主题背景也抹了
}

修复:把 bg 显式写回 pre 上(#282c34,atom-one-dark 的默认 bg)。

为什么不让 hljs 管 bg

hljs 主题把 bg 设在 .hljs(code 元素)上
pre 有 padding 1rem
  → pre 的 padding 区(外围 1rem)是白色
  → code 的 bg 只覆盖内容区
  → 视觉效果:白框包一个小深色块,很丑

把 bg 放 pre 上
  → padding + bg + border-radius 都在同一层
  → 整体一致的深色圆角块

问题 3:代码块出现 "8203" 字样

根因sync.sh 遗留的 VitePress 兼容逻辑:

perl -pe '
  s/\{\{/{&#8203;{/g;   # {{ 中间插零宽空格,防止 VitePress 解析成 Vue 模板
  s/\}\}/}&#8203;}/g;
'

VitePress 用 Vue 引擎,{{ var }} 是模板语法。为防止 markdown 里的花括号被 Vue 解析,原作者在中间塞零宽空格(&#8203; = U+200B)打断识别。

换成 Next.js + marked 后,这个转义完全没必要,反而造成:

  • marked 把 &#8203; 当字面量输出
  • HTML 里的 & 又被 HTML 再次转义
  • 最终代码块显示 "8203" 数字

修复

  1. sync.sh 删掉 perl 转义段
  2. 重跑 bash sync.sh 清洗所有已污染的 docs/*.md
  3. 顺便清理 sync.sh 的 VitePress 残留(sidebar JSON、index.md 生成)

教训遗留代码要审视。从 VitePress 迁到 Next.js 时只改了构建目标,没系统审视"哪些 VitePress 假设不再成立"。

问题 4:文字黑字黑底混一起

根因:最微妙的一个——CSS 继承 + 同特指度后胜。

/* globals.css */
.markdown-body {
  color: #1f2937;   /* 正文深色 */
}
/* → pre 继承 → code 继承 */

/* atom-one-dark.css(import 在前)*/
.hljs {
  color: #abb2bf;   /* 想给代码块浅色 */
}

/* 特指度对比 */
.markdown-body  =  (0, 1, 0) = 10
.hljs           =  (0, 1, 0) = 10   ← 相同

/* 同特指度 → 后者胜 → .markdown-body 赢(因为 globals.css 在 atom-one-dark 之后)*/
/* 结果:code 文字变成 #1f2937(近黑)*/
/* 配合深色 bg #282c34 → 黑字黑底 ❌ */

修复:在 pre 层显式写 color: #abb2bf斩断从 .markdown-body 的继承

.markdown-body pre {
  background: #282c34;
  color: #abb2bf;    // ← 显式切断继承链
  ...
}

通用教训——CSS 继承链和特指度是两个独立维度

CSS 规则生效 = 继承 × 特指度 × 导入顺序

继承:没显式设置时,从父元素继承
特指度:不同选择器的权重(class vs id vs tag)
导入顺序:同特指度时,后者胜

三个维度互相交织,debug 时要同时看

❌ 初级:只想到"我写了颜色规则,为什么没生效"
⚠️ 中级:知道检查特指度,但忘了考虑继承链
✅ 资深:debug CSS 时,按 "继承来源 → 特指度对比 → 顺序" 三步走
   常用工具:浏览器 DevTools 的 "Computed" 面板

面试话术

话术:"我在给 Markdown 代码块做样式时踩了 4 个连环坑——主题对比度不够、去 bg 后变白、零宽空格 '8203' 污染、黑字黑底。最后一个最微妙,是 CSS 继承和特指度的交互:我的 .markdown-body 和 hljs 的 .hljs 特指度相同,但我的规则导入在后,同特指度时后者胜,把代码块文字色强行改成了黑色。修复是在 pre 层显式写文字色斩断继承链。这次踩坑让我记住:debug CSS 要三步走——先看继承来源、再对比特指度、最后看导入顺序,浏览器 DevTools 的 Computed 面板是必须用的工具。"

二阶启发——"小改动"的维度分解

5 次修改都是"改代码块样式",但实际改的是 5 个不同维度:

1. 主题包(颜色方案)——设计决策层
2. 背景承载元素(pre vs code.hljs)——结构层
3. 源数据清理(零宽空格)——数据层
4. 继承链斩断(显式 color)——样式层
5. 特指度层级——CSS 语言层

如果一开始就按这 5 个维度检查,2 次 iteration 就能完成。因为每次只改一个维度,没做全面检查,才花了 5 次。

教训:修同类问题时,先列"可能涉及的维度清单",然后一次性检查,比"改一个试一下"高效得多。


37. 离线文档缓存——IndexedDB bundle 方案(非 Service Worker 路线)

一句话结论

构建时把所有 docs/*.md 渲染成 HTML 打包成 src/data/docs-bundle.ts,客户端 DBProvider 在首次联网后把 bundle 写入 IndexedDB,规避 Service Worker 的全部复杂性,换来稳定的持久化离线缓存

为什么选方案 B 而不是方案 A(Service Worker)

iPad 学习场景的核心需求:地铁、飞机、没 WiFi 的咖啡馆要能看文章。用户的提问——"在 iPad 上文章有缓存吗"——逼我把方案对比讲清楚:

方案 A(Service Worker)的缺陷清单:
  1. SW 本身被浏览器缓存 → 修 bug 的 SW 用户几天后才更新
  2. 首次访问必须联网(SW 还没安装)
  3. 存储配额不稳定(iOS Safari 给 50MB-1GB 之间漂移)
  4. iOS PWA 对 SW 支持弱(后台更新受限)
  5. 调试困难(SW 在独立线程)
  6. Next.js output: 'export' + next-pwa 边界问题多
  
方案 B(IndexedDB bundle)的核心优势:
  1. 版本管理复用 JS bundle 的 hash(filename contenthash)
     → 浏览器 HTTP 缓存层自动处理更新
     → 没有 SW 的"旧 SW 在服务新内容"问题
  2. 存储在 IndexedDB(配额更稳定,浏览器不容易静默清除)
  3. 无独立线程,调试简单(console + DevTools 足够)
  4. 和现有的任务/提示语种子流程统一
  5. 静态导出完美兼容,不破坏 Cloudflare Pages 部署

决策本质:不是"选最强的方案",是"选最匹配当前文档量级 + 更新频率 + 部署约束的方案"。当前项目:

  • 文档量级:12 篇,总 HTML ~1.9MB(gzip 后 ~300KB)
  • 更新频率:launchd 自动 sync.sh 每天 2 次
  • 部署:静态导出到 Cloudflare Pages(无 SSR)

换成 100 篇 + 每天更新 10 次 + 有 SSR → 方案 A 的增量更新能力就值得那些复杂性。

架构图

构建时(本地 + CI 都跑)
┌──────────────────────────────────────────────────────────┐
│ sync.sh                                                   │
│   ├─ 扫描 /Users/mac/ecool/*/changelog.local.md         │
│   └─ 写到 learning-docs/docs/*.md                        │
│                                                           │
│ scripts/generate-docs-bundle.mjs                         │
│   ├─ 读 docs/*.md                                         │
│   ├─ marked + highlight.js 渲染 HTML                      │
│   ├─ 计算 sha256(所有 html) → 前 12 位作为 version        │
│   └─ 写入 src/data/docs-bundle.ts                         │
│                                                           │
│ next build                                                │
│   ├─ /docs/[slug]/index.html 从 filesystem 读渲染(SSG)  │
│   └─ main-[hash].js 把 docs-bundle.ts 打进客户端 JS       │
└──────────────────────────────────────────────────────────┘

运行时(iPad 浏览器 / PWA)
┌──────────────────────────────────────────────────────────┐
│ 用户打开 /(或任意页面)                                    │
│   ↓                                                       │
│ Layout 加载 → DBProvider 挂载                             │
│   ↓ useEffect                                             │
│ Promise.allSettled([                                      │
│   seedIfNeeded(),       ← 任务/提示语                       │
│   seedDocsIfNeeded()    ← 新增:文档                        │
│ ])                                                        │
│                                                           │
│ seedDocsIfNeeded() 逻辑:                                  │
│   ├─ 读 meta["docs-bundle-version"]                       │
│   ├─ 和 docsBundle.version 对比                           │
│   ├─ 相同 → 跳过                                          │
│   └─ 不同 → putDocs(全量) + setMeta(新 version)          │
└──────────────────────────────────────────────────────────┘

实现细节(涉及文件清单)

新增文件:
  scripts/generate-docs-bundle.mjs  ← 构建脚本
  src/data/docs-bundle.ts           ← 构建产物(git 不忽略,保证部署时存在)

修改文件:
  src/lib/db.ts                     ← 加 DocEntry 类型 + docs store + CRUD
                                      DB_VERSION 从 1 升到 2
  src/lib/seed.ts                   ← 加 seedDocsIfNeeded()
  src/components/DBProvider.tsx     ← 改用 Promise.allSettled 并行跑两个种子
  package.json                      ← 加 predev / prebuild / docs:bundle script
  sync.sh                           ← 尾部调 generate-docs-bundle.mjs

关键设计决策

决策 1:预渲染 HTML vs 保留 Markdown 源码

选择:构建时就渲染成 HTML 存进 IDB。

放弃的能力:运行时换主题、动态改字号、切换 highlight.js 配色方案。

换来的

  • 客户端无需引入 marked(~50KB)+ highlight.js(~200KB)
  • 渲染成本在构建时一次性付(CI 机器买单,不是用户 iPad 买单)
  • IDB 读出来的是 HTML 字符串,直接 dangerouslySetInnerHTML 就能显示

三层对比

❌ 初级做法:IDB 存 markdown + 运行时 marked
   → 客户端每次打开文章要重新解析 + 高亮
   → 低端 iPad 上 200KB md 文档解析卡顿明显

⚠️ 中级做法:同时存 markdown 和 html
   → 存储翻倍
   → 没有明确的"什么时候用哪个"

✅ 资深做法:只存 html(预渲染产物)
   → 构建时一次渲染,运行时零成本
   → markdown 源码保留在 docs/,git 可追溯
   → 未来需要换主题,改渲染器重新构建一次即可

决策 2:version 用内容哈希而不是时间戳

const hash = crypto.createHash("sha256");
for (const d of docs) {
  hash.update(d.slug);
  hash.update(d.html);
}
const version = hash.digest("hex").slice(0, 12);

为什么不用时间戳

  • 每次构建时间戳都变 → 即使内容没变也会触发全量同步
  • 浪费 IDB 写入 + 电量(iPad 电量敏感)

为什么不用 mtime

  • mtime 依赖文件系统,跨 OS/CI 不稳定
  • 只有一个文件动了,时间戳变,版本跟着变 → 合理
  • 但哈希更精确:内容不变就不变版本(workflow 重跑不会虚假触发)

类比前端:webpack 的 [contenthash][hash] 之差。资深工程师都知道选 contenthash——只有内容变才换文件名,能最大化缓存命中。这里用同样思路。

决策 3:allSettled 并行 vs Promise.all 串行

// ❌ 原方案:串行 + 快速失败
await seedIfNeeded();
await seedDocsIfNeeded();

// ✅ 采用:并行 + 独立失败
Promise.allSettled([seedIfNeeded(), seedDocsIfNeeded()])

理由

  • 两个操作独立(不同 store)
  • 任务种子失败不应阻止文档缓存(用户可能急着离线看文章)
  • 文档缓存失败也不应阻止任务加载(用户可能只想做任务)
  • 快速失败会导致"一个错 = 全部错",用户体验差

这是"可用性 > 一致性"的权衡——CAP 里的 AP 选择。对"学习工具"这种容忍度高的场景合理,对"银行转账"这种就不行。

决策 4:DB 版本升级用 oldVersion 而非 .onupgradeneeded 覆盖

dbPromise = openDB<CodexDB>(DB_NAME, DB_VERSION, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) { /* 创建 tasks/sessions/prompts/meta */ }
    if (oldVersion < 2) { /* 加 docs store */ }
  },
});

为什么分段判断 oldVersion

  • 老用户(已经有 v1 DB)升级后只需要执行 < 2 分支
  • 新用户(全新安装)两个分支都执行
  • 未来加 v3 store,只要加 if (oldVersion < 3) 即可

反模式(不要这么写)

// ❌ 每次都重建所有 store
upgrade(db) {
  db.createObjectStore("tasks", ...);  // 已存在会抛错
  db.createObjectStore("docs", ...);
}

这是 IndexedDB 迁移脚本的标准写法,和数据库 migration(Rails、Django)的思路完全一致——增量向前,不回头

系统思维:bundle 大小的影响

当前数据:
  12 docs × 平均 160KB HTML = 1.9MB
  minify 后 ~1.4MB
  gzip 传输 ~250KB
  Brotli 传输 ~200KB

加到 main JS bundle:
  原 main.js ≈ 100KB (gzip)
  加 bundle 后 ≈ 350KB (gzip)
  First Load JS:100KB → 950KB (uncompressed) / 350KB (gzip)

网络影响:
  4G 下首次加载:+0.5s
  Cloudflare CDN 命中 + HTTP/2:+0.2s
  二次访问(浏览器缓存):+0ms

iPad 性能:
  IDB 写入 1.9MB:~100-200ms(iPad Air 测试,不阻塞主线程)
  React 首屏不受影响(allSettled 在 useEffect 异步)

扩展性:文档量 10 倍增长后怎么办

当前 12 docs → 假设将来 120 docs(10 倍):
  bundle 体积:~20MB uncompressed / ~3MB gzip
  
瓶颈分析:
  1. 首次加载慢(+5s on 4G)→ 用户感知明显
  2. IDB 写入时间:~1-2s(还能接受)
  3. JS parse 时间:~500ms(V8 解析 20MB JS)
  4. Service Worker 反而不受影响(按需缓存)

演进路径:
  阶段 1(当前 ≤ 20 docs):单 bundle
  阶段 2(20-100 docs):按路径分片
     src/data/docs-bundle-a.ts  ← 项目 A 的所有 doc
     src/data/docs-bundle-b.ts
     动态 import 只加载当前需要的分片
  阶段 3(100+ docs):换 SW + 按需缓存
     放弃"打包所有"的思路
     SW 拦截请求,按需缓存到 Cache API
     
   → 不是"早就该上 SW",是"规模到了再换"

质量思维:怎么测试 seedDocsIfNeeded

当前没写测试,但如果要测:

// 单元测试要覆盖的边界:
describe("seedDocsIfNeeded", () => {
  beforeEach(() => indexedDB.deleteDatabase("codex-db"));
  
  test("首次调用:全量写入 + 返回 synced=true", async () => {
    const r = await seedDocsIfNeeded();
    expect(r.synced).toBe(true);
    expect(r.count).toBe(docsBundle.docs.length);
  });
  
  test("相同 version 再次调用:跳过 + 返回 synced=false", async () => {
    await seedDocsIfNeeded(); // 首次
    const r = await seedDocsIfNeeded(); // 二次
    expect(r.synced).toBe(false);
  });
  
  test("version 不同:覆盖老数据", async () => {
    // 手动塞 v1 数据
    await putDocs([{ slug: "old", title: "old", html: "<p>v1</p>", ... }]);
    await setMeta("docs-bundle-version", "oldversion");
    
    const r = await seedDocsIfNeeded();
    expect(r.synced).toBe(true);
    
    const doc = await getDoc("old");
    // 新 bundle 不含 "old" slug → IDB 里 old 应该被清理
    // 但当前实现是 put 不是 clear+put → "old" 还在 DB
    // 这是一个已知问题:删除的文档不会从 IDB 移除
    expect(doc).toBeDefined(); // 暴露 bug
  });
});

已知问题(TODO)seedDocsIfNeeded 是"新增+覆盖"而非"全量同步"。如果某个 doc 被删除,IDB 里残留。当前接受这个代价——删除的 doc 占空间但不影响功能。未来要做得更完整:版本变化时先 db.clear("docs") 再 putDocs。

安全思维:bundle 打进 JS 的风险

IndexedDB 数据对同源 JS 完全可见。这意味着:

风险 1:XSS 泄露
  如果主站有 XSS 漏洞,攻击者 JS 可以读取所有 docs
  → 但 docs 内容本来就是公开的(Cloudflare Pages 上全部可访问)
  → 无增量风险

风险 2:dangerouslySetInnerHTML
  bundle 里的 HTML 被信任,直接渲染
  构建时我们没做 sanitize(marked 默认不过滤 <script>)
  → 如果某个 changelog.local.md 被污染(比如被攻击者推到同事仓库的 changelog)
  → sync.sh 把它同步进来
  → bundle 里含恶意 script
  → 进 iPad 上执行任意代码

防御(目前未做):
  1. marked 配置 sanitize: true(过滤 script/onclick/style)
  2. 或在 sync.sh 里跑一遍 sanitize-html
  3. 或 DocPage 用 shadow DOM 隔离(复杂)

权衡:
  当前 sync 源都是自己的仓库,内容可信
  真正的防御应该在"源头"而不是"终端"
  → sync.sh 只 sync 自己 whitelist 的目录

相关代码位置

  • scripts/generate-docs-bundle.mjs:1-100 构建脚本
  • src/data/docs-bundle.ts 构建产物(每次 build 重写)
  • src/lib/db.ts:9-15 DocEntry 类型
  • src/lib/db.ts:50-58 docs store schema
  • src/lib/db.ts:77-82 DB 版本升级逻辑
  • src/lib/db.ts:176-193 Doc CRUD 函数
  • src/lib/seed.ts:56-86 seedDocsIfNeeded
  • src/components/DBProvider.tsx:26-42 allSettled 并行种子
  • package.json:5-8 predev/prebuild/docs:bundle 脚本
  • sync.sh:50-55 尾部触发 bundle 重生成

思考题讨论

Q1:如果 bundle 升级到了 v2 但用户还没联网,IDB 里是 v1,用户打开 /docs/slh_new/ 看到什么?

用户回答(假设):看到 IDB 里的 v1 内容。

正确解答

实际是看到 SSG 的 HTML(pre-rendered),不是 IDB 的内容!原因:

/docs/slh_new/index.html 的生命周期:
  构建时:renderDoc("slh_new") 从 filesystem 读 md → 渲染 → 写进 HTML
  部署时:这个 HTML 文件被上传到 Cloudflare Pages
  用户访问:浏览器拉这个 HTML → 直接显示

当前 page.tsx 的实现:
  async function DocPage({ params }) {
    const doc = await renderDoc(slug);  ← 构建时跑,不是运行时
    return <article dangerouslySetInnerHTML={{ __html: doc.html }} />;
  }

所以 IDB 的 docs 数据 *并没有被当前页面使用*!

IDB 在当前实现中的实际作用

  1. 持久化备份:HTTP 缓存会被浏览器清理,IDB 稳定得多
  2. 未来搜索功能的数据源:全文搜索需要所有 doc 内容在客户端
  3. 偏好标记持久化:以后加"标星"、"笔记"功能的基础
  4. 真正离线导航的前置条件:如果将来做 SW 或客户端 reader 路由

所以当前方案的"离线能力"来源

  • 已访问过的 HTML → 浏览器 HTTP 缓存(不是 IDB)
  • iPad PWA 模式的 start_url 被 iOS Safari 缓存(也不是 IDB)

IDB 是未来扩展的基础设施,不是当前离线的唯一路径。这点要写明,避免误解。

Q2:为什么不直接让 /docs/[slug] 从 IDB 读?

正确解答

三个原因

1. SSR/SSG 阶段没有 IDB
   IDB 是浏览器 API,Node 构建时不存在
   要从 IDB 读,必须改成纯客户端组件("use client")
   → 失去 SSG 的"首屏 HTML 直接可见"能力
   → SEO 受损(Cloudflare Pages 的 bot 爬取只能看到 loading)

2. 首次访问的 chicken-and-egg
   用户第一次打开 /docs/slh_new/
   → 如果从 IDB 读 → IDB 还没 seed → 空白
   → 要等 DBProvider seed 完成 → 再读 IDB → 可能 1-2s
   
   相比之下 SSG:
   → 打开就有内容
   → 用户感知明显更好

3. 双数据源的一致性问题
   如果 page.tsx 首屏用 SSG html,客户端切到 IDB html
   → 用户看到的内容可能切换
   → 需要处理闪烁、滚动位置丢失等

→ 当前 SSG 优先是 "首屏快 + SEO 友好" 的正确选择
→ IDB 是"如果将来需要客户端渲染,基础已经在了"

更好的做法(未来):hybrid 模式

// 未来的 /reader/[slug] 纯客户端路由(不经过 generateStaticParams)
"use client"
export default function Reader({ params }) {
  const [doc, setDoc] = useState(null);
  useEffect(() => {
    getDoc(params.slug).then(setDoc);
  }, [params.slug]);
  if (!doc) return <Loading />;
  return <article dangerouslySetInnerHTML={{ __html: doc.html }} />;
}

这种路由不依赖 HTML 存在,适合"真离线 + 未访问过"场景。代价是 SEO 和首屏。保留 /docs/[slug] 是 SSG 入口,加 /reader/[slug] 是离线入口。

Q3:为什么 DB 要升 v2 而不是直接用 meta 记录版本?

用户盲区:不理解"schema 变更"和"数据变更"的区别。

正确解答

schema 变更(需要升 DB_VERSION):
  - 新增 object store("docs")
  - 新增 index
  - 删除 store
  - 改变 keyPath
  → 必须通过 upgrade 回调

数据变更(不需要升版本):
  - 向已有 store 写数据
  - 更新 meta 键值
  - 批量导入/清理
  → 普通 CRUD 即可

混用的后果:
  如果我不升 DB_VERSION 就直接在代码里 db.transaction("docs", ...)
  → 老用户的 v1 DB 根本没有 "docs" store
  → IDB 抛 NotFoundError:
    "No objectStore named 'docs' in this database"
  → 静默失败,用户只看到"数据库初始化失败"

类比后端:Postgres 加新表要跑 migration,不能直接 INSERT。IndexedDB 的 upgrade 回调就是浏览器端的 migration 机制。

资深设计要点

✅ 好的 migration:
  if (oldVersion < 1) createStores()
  if (oldVersion < 2) addDocsStore()
  if (oldVersion < 3) addIndexToDocs()
  
  新增逻辑,向前兼容,老用户平滑升级

❌ 坏的 migration:
  upgrade(db) {
    db.deleteObjectStore("tasks")  ← 删数据!
    db.createObjectStore("tasks", { keyPath: "newId" })
  }
  
  用户进度全丢。这是灾难性错误。

所以资深工程师写 IDB migration 的铁律:
  - 只加不删
  - 改 keyPath 要先迁移数据(transaction 里 getAll + 转换 + put)
  - 上线前在旧版本数据上充分测试

面试话术

"iPad 离线缓存的需求,我没有直接上 Service Worker。因为 SW 有几个缺陷:缓存失效复杂、首次访问必须联网、iOS Safari 支持弱、调试困难。我选的是 IndexedDB bundle 方案:构建时把所有 doc 渲染成 HTML 打进一个 TS 文件,客户端在 DBProvider 种子阶段写入 IndexedDB。版本号是内容 sha256 前 12 位,和 webpack 的 contenthash 思路一致——内容不变版本不变,最大化缓存命中。这个方案的核心优势不是'最强',是'最匹配'当前约束:12 篇文档 + 静态导出 + Cloudflare Pages 部署,SW 的复杂性用不上,bundle 打包的简洁性正好覆盖需求。未来文档量到 100+ 或需要增量更新,再演进到 SW 也不迟——工程师的资深度不是'一步到位用最新技术',是'识别当前规模下 ROI 最高的方案'。"

延伸讨论

追问"为什么不把 bundle 放 public/ 目录而是 import 进 TS"

  • public/ 要单独 fetch,多一次网络请求
  • import 到 TS 后跟着 main JS 一起缓存(HTTP cache 自然覆盖)
  • TS 类型检查能保证 bundle 结构正确
  • 体积差不多,维护性更好

追问"改进空间"

  1. 构建 bundle 时做内容 sanitize(防未来 sync 源污染)
  2. version 变化时 clear docs store 再写(避免残留删除的 doc)
  3. /reader/[slug] 客户端路由做真离线入口
  4. 搜索功能(lunr.js on IDB docs)

追问"和 PWA 的 offline first 理念冲突吗"

  • 不冲突。PWA 不强制 SW,核心是"主屏图标 + 独立窗口 + 离线基础"
  • 我们通过 manifest.ts 提供主屏能力 + IDB 提供离线数据
  • SW 是一种实现 offline first 的方式,不是唯一方式

第二阶段:让离线真的生效(DocContent + prefetch)

初版(上文实现)只完成了"IDB 里有数据",但 /docs/[slug]/page.tsx 仍是纯 SSG,IDB 数据没被消费。真正离线还需要两步:

  1. DocContent 客户端组件:从 IDB 读内容替换 SSG HTML
  2. 列表页主动 prefetch 所有 doc:保证浏览器 HTTP 缓存覆盖所有 HTML

实现 1:DocContent.tsx

"use client";
export default function DocContent({ slug, initialHtml }) {
  const [html, setHtml] = useState(initialHtml);
  useEffect(() => {
    let cancelled = false;
    let attempts = 0;
    async function tryHydrate() {
      if (cancelled) return;
      try {
        const doc = await getDoc(slug);
        if (doc?.html && doc.html.length !== initialHtml.length) {
          setHtml(doc.html);
          return;
        }
        if (!doc && attempts < 6) {
          attempts++;
          setTimeout(tryHydrate, 500);
        }
      } catch {}
    }
    tryHydrate();
    return () => { cancelled = true; };
  }, [slug, initialHtml]);
  return <article dangerouslySetInnerHTML={{ __html: html }} />;
}

关键决策点

决策 为什么
保留 SSG 首屏 HTML 首屏零延迟,SEO 友好,浏览器 HTTP 缓存命中率高
hydration 后再切 IDB 保证版本一致性,将来用户标注能和内容对齐
length 而不是 === 比对 避免 100KB 字符串全量比对的主线程开销
轮询等 seed 完成 DocContent 和 seedDocsIfNeeded 无时序保证,最多重试 6×500ms=3s
失败静默降级 IDB 不可用(隐私模式、空间满)→ 继续用 SSG HTML,体验不退化

实现 2:DocListClient + requestIdleCallback prefetch

"use client";
export default function DocListClient({ docs }) {
  const router = useRouter();
  useEffect(() => {
    const idle = (cb) =>
      window.requestIdleCallback?.(cb) ?? setTimeout(cb, 300);
    idle(() => {
      for (const doc of docs) {
        router.prefetch(`/docs/${doc.slug}`);
      }
    });
  }, [docs, router]);
  // ... 渲染 Link 列表
}

为什么列表页需要主动 prefetch

默认 Next.js Link 的 prefetch 行为:
  只 prefetch "在视口内的 Link"
  → 滚动后视口才看到的 Link 要滚进来才 prefetch
  → iPad 用户打开 /docs 不滚到底就离线 → 下方 doc 没缓存 → 点不开

主动 prefetch 策略:
  用 requestIdleCallback 在浏览器空闲时
  对所有 12 个 doc 一次性 prefetch
  → 全部 HTML 进 HTTP 缓存
  → 离线时任意导航都能打开

为什么用 requestIdleCallback 而不是立即 prefetch

立即 prefetch(反模式):
  useEffect 里同步 prefetch 12 个 URL
  → 和 DBProvider 的 IDB seed 抢主线程
  → 首屏渲染被推迟

requestIdleCallback:
  浏览器告诉我们"现在有空"时才跑
  → 核心渲染路径完成后才开始预取
  → 用户感知不到

Safari 兼容:
  Safari 到 2022 才原生支持 requestIdleCallback
  老 iPad 可能没有 → fallback 到 setTimeout(300ms)
  300ms 够让首屏完成

完整的离线能力矩阵(实现后)

场景 是否可用 机制
在线打开任何 doc CDN + HTTP cache(SSG HTML)
离线打开已访问过的 doc 浏览器 HTTP 缓存
离线打开所有 doc(前提:在线访问过 /docs 列表一次) 列表页 prefetch 预热
离线打开 doc(浏览器清了 HTTP 缓存) ⚠️ 仍然失败(没 SW 兜不住)
离线状态用户标注持久化 🔜 IDB 基础已就绪,UI 待开发
真正"零缓存首次离线"访问 不可能,没 SW 做不到

新增的三层对比(第二阶段)

❌ 初级做法(第一阶段就停):
   IDB 里塞了数据,/docs/[slug] 却只用 SSG HTML
   → 自我安慰"我做了离线",实际 IDB 是摆设
   → 用户清浏览器缓存后该挂还是挂

⚠️ 中级做法(做客户端组件但不 prefetch):
   DocContent 读 IDB,但用户没访问过的 doc 离线点不开
   → 只覆盖了"在线访问过至少一次"的场景
   → iPad 典型场景是"在家提前缓存,外出看",需要 prefetch

✅ 资深做法(第二阶段):
   SSG 首屏 + IDB hydration + 列表页 idle prefetch
   → 三层联动:
     - SSG 保证首屏速度和 SEO
     - IDB 保证版本一致性和扩展性
     - prefetch 保证离线可用性
   → 每一层解决一个具体问题,层层独立,失败可降级

新增思考题

Q4:如果用户在 /docs 列表页立刻进入离线,prefetch 还没跑完,怎么办?

正确解答

分情况处理

场景 A:用户访问 /docs 列表 → 3 秒后离线
  - requestIdleCallback 通常在 100ms 内触发
  - 12 个 prefetch 并发,2-3 秒内全部完成(4G 下)
  - 结果:大概率全部已缓存,离线正常

场景 B:用户访问 /docs → 立刻离线(<100ms)
  - requestIdleCallback 还没触发
  - 只有已点击过的 doc 在缓存
  - 其他 doc 离线点击 → 浏览器报错

对用户的预期管理:
  - 列表页加个小进度指示器:"正在缓存文档..."
  - 缓存完成后显示"已离线可用"
  - 对 iPad PWA 场景用户体验有帮助

当前未实现,作为 TODO。

Q5:SSG html 和 IDB html 的"length 不同"就判定不一样,会误判吗?

正确解答

理论误判概率分析

两个不同内容但字节长度相同的概率:
  - HTML 在 100KB 量级
  - 内容修改 = 增删字符 → 长度几乎必变
  - 只有"等长替换"才会长度相同(比如 "2025" → "2026")
  - 发生这种情况的概率:修改单个等长 token 的占比 < 1%

实际场景:
  - 每次 sync.sh 同步后 bundle version 变
  - 对应 changelog 内容增量更新(大部分是新增,长度变)
  - 真正遇到等长编辑的情况:仅更新一个数字/字母
  - 即使发生,结果是"IDB 有新内容但前端显示旧 SSG"
  - 代价:用户看到的内容稍旧,不是 bug 级问题

更精确但更贵的方案:
  - 比对 mtime:IDB 存了 mtime,SSG 传入 mtime prop
  - 比对 version:SSG 构建时读 bundle.version 传入
  - 比对 md5:前端算 hash
  
→ 精确度 vs 性能权衡
→ 当前"length 比较"是 80/20 选择:覆盖 99% 场景,代码最简单
→ 未来若业务需要严格一致性,升级到 mtime 比较

资深设计原则在能保证下界(不会更糟)的前提下选最简实现,复杂方案等真实的 pain point 出现再做

Q6:prefetch 12 个 HTML 浪费流量吗?

正确解答

流量分析:
  12 个 doc HTML 平均每个 ~150KB(压缩前 500KB)
  Cloudflare Brotli 后 ~30KB/个
  总计 ~360KB 预取
  
  对比:
  - 普通页面一张高清图片 ~200KB
  - 1 分钟视频 ~10MB
  - 360KB 约等于 1/30 张微信朋友圈图
  → 对 4G/WiFi 用户几乎无感

但 4G 用户在流量套餐紧张时会在意:
  - 加 "@media (prefers-reduced-data)" 检测
  - 或者 localStorage 存个用户偏好
  - 默认激进 prefetch,提供"省流模式"选项

Cloudflare 端:
  - 12 个 HTML 全在边缘缓存
  - 对 CDN 是零增量成本
  - 反而可能提升命中率(热度统计)

→ 结论:流量代价可忽略,收益(离线可用)显著
→ 资深工程师不会为了"可能存在的流量敏感用户"放弃默认优化
→ 如果真有必要,再加开关

涉及文件变更(第二阶段)

新增:
  src/app/docs/[slug]/DocContent.tsx   客户端 hydration 组件
  src/app/docs/DocListClient.tsx       列表客户端组件 + prefetch

修改:
  src/app/docs/[slug]/page.tsx         引入 DocContent 替换内联 article
  src/app/docs/page.tsx                改为调 DocListClient(保留 server 侧 listDocs)

完整面试话术(综合两阶段)

"iPad 离线需求我分两阶段做。第一阶段打基础:构建时把 docs 渲染成 HTML 打进 bundle.ts,客户端 DBProvider 种子时写入 IndexedDB,版本号用内容 sha256。这层保证'数据进了持久化存储'。第二阶段打通 UI:把 /docs/[slug] 的正文部分抽成客户端组件 DocContent,首屏用 SSG HTML 保证速度和 SEO,挂载后读 IDB 可能替换成更新版本;同时在 /docs 列表页用 requestIdleCallback 主动 prefetch 所有 doc HTML,让浏览器 HTTP 缓存覆盖全部文章。这样的分层设计——SSG 保速度、IDB 保版本、prefetch 保覆盖——每一层解决一个具体问题,失败都能降级。权衡是:真正的'零缓存首次离线'做不到,那是 SW 才能做的事。但考虑到项目当前文档量和 SW 的复杂性(缓存失效、iOS 兼容、调试困难),这个分层方案是 ROI 最优的——工程师的资深度不是追最新技术,是识别约束下的最优解。"


38. 回顾标签系统 + 盲区自动汇总——把"写完就埋"变成"可视化复盘"

一句话结论

把 /review 页的"写一大段文字"改成"三类标签(掌握/模糊/新问题)+ 可选文字",标签跨 session 聚合到新 /reflect 页显示盲区、待研究问题、已攻克点——把分散在 N 次学习里的隐性数据变成可视的认知地图

问题背景:旧设计的致命缺陷

用户问"答完题写的内容会保存和分析吗",我 grep 代码发现:

reviewAnswer 字段的命运:

写入路径:store.tsx:206 submitAssessment()
           └─ putSession(updatedSession)
                └─ IndexedDB sessions store ✓ 保存了

读取路径:(空)

全项目 grep "reviewAnswer":
  - types/index.ts: 定义类型
  - store.tsx: 写入
  - review/page.tsx: 输入框
  - 没有任何地方读取!

用户写的文字回顾是"单程票"——写进 IDB 就永远看不到了。这是假装有功能但没有闭环的典型反模式。

心理学效应:用户发现自己写的东西永远没人看(自己都看不到)→ 后续不再认真写 → 回顾阶段变成走过场 → 自评随便点 → spaced repetition 数据不准 → 整个学习系统降级。

设计选择:为什么是标签而不是文字

三层演进路径

❌ 原方案:自由文本
   优点:表达力最强
   缺点:
   - 心理负担重(每次都要"想一段话")
   - 不可分析(NLP 成本高)
   - 写了等于埋了(没回看入口)

⚠️ 中级方案:结构化字段(选项列表)
   如"记住了什么"、"忘了什么"各一个 textarea
   优点:有结构
   缺点:多个 textarea 比一个还累

✅ 当前方案:三类标签 + 可选文本
   优点:
   - 标签是结构化数据,天然可聚合
   - 一个标签 = 一次点击 + 几个字,成本极低
   - 建议词(source + 历史高频)让打标签几乎零输入
   - 自由文本降级为"有话想说时写"(折叠在 details 里)

三类标签的设计

✅ 掌握 (confident)
   用于:能讲清楚的概念
   价值:正反馈(看到自己进步)+ 降分算法信号(别老推这个复习)

⚠️ 模糊 (fuzzy)
   用于:知道但说不清
   价值:盲区汇总的核心数据源 + 推动继续学习

❓ 新问题 (question)
   用于:学完后冒出的新疑问
   价值:避免想法流失 + 生成后续学习队列

放弃的类别(为什么不做):

  • "重要":过于主观,频率高到失去信号价值
  • "反常识":被"新问题"覆盖
  • "需要实践":应该是 Task.type 而非 tag 类别

为什么是 3 而不是 2 或 4

  • 2 类(会/不会)信息量不够(少了"新问题"这个学习队列种子)
  • 4 类认知负担上升(用户要想"这属于哪类")
  • 3 类是 Miller's Law(7±2)里的低负担区间

数据模型

// src/types/index.ts
export type TagCategory = "confident" | "fuzzy" | "question";

export interface ReviewTag {
  text: string;         // 归一化后:trim + lowercase + 全角转半角
  displayText: string;  // 用户原始输入(保留大小写)
  category: TagCategory;
  taskId: string;       // 源任务,方便反查
  createdAt: number;
}

export interface StudySession {
  // ... 原有字段
  reviewAnswer: string | null;  // 保留作可选自由文本
  tags: ReviewTag[];            // 新增
}

text vs displayText 的设计

用户输入"React Memo" / "react memo" / "React memo"
  ↓ normalize
text = "react memo"   (聚合 key 一致)
  ↓ 保留原始
displayText = "React Memo"(展示用最近一次的)

好处:
  - 大小写、空白差异不会造成"假的两个概念"
  - 展示保留用户喜好的大小写
  - 跨 session 聚合时用 text 匹配

聚合算法:BlindSpot.score

src/lib/blind-spots.ts:aggregateBlindSpots() 核心算法:

base   = fuzzy 次数 × 3
       + question 次数 × 2
       - confident 次数 × 1
recency = 最近标记在 7 天内 ? 2 : 0
signal  = 涉及 task 数 × 1

score = base + recency + signal

为什么这样设计

fuzzy 权重 3(最高):
  真正的盲区信号——学过但没掌握
  最紧迫,最需要复习

question 权重 2:
  可能是新遇到的问题(还没学)
  重要但不紧迫

confident 权重 -1(减分):
  过去模糊但现在掌握了
  score 降低 → 不再强推复习
  允许"学会了"的状态衰减

recency 加分:
  最近 7 天标过 → 记忆还在衰减关键期
  旧标签分值自动衰减,给新盲区让位

signal(task 数量):
  同一概念在 3 个不同 task 都标过
  说明是基础概念 / 跨主题关联点
  值得多关注

对比简单 count(初级方案)

❌ 按总次数排序:
   "JavaScript" 被标 20 次(过于宽泛)→ 排最前
   "CAS 指令" 被标 3 次(具体且卡壳)→ 排很后
   → 用户看不到真正该关注的

✅ 按 score 排序:
   "CAS 指令" score = 3×3 + 2 + 3 = 14(fuzzy 多 + 最近 + 跨任务)
   "JavaScript" score 可能已经因多次 confident 被拉到负值
   → 真正的"反复卡住"才浮到顶部

四个聚合视图

视图 过滤条件 用途
aggregateBlindSpots score > 0 总体地图(排除已掌握)
getFuzzyBlindSpots fuzzy >= confident 真盲区,推荐复习
getActiveQuestions question > 0 + 30 天内 + 未 confident 待研究(学习队列种子)
getConqueredSpots confident >= fuzzy + 之前有 fuzzy/question 已攻克,正反馈展示

UI 设计决策

决策 1:TagInput 先选类别再输入

UX 权衡:

❌ 初级:一个输入框 + 下拉选类别
   用户:输入 → 选类别 → 确认  (3 步)

✅ 采用:3 个大按钮切换当前类别 + 共享输入框
   用户:点类别 → 输入  (2 步)
   + 类别切换本身是视觉分组,引导思考
   + 每类别上面显示已加数量 chip

决策 2:建议词(suggestions)

从历史 session 所有标签中:

  1. 聚合同 text 的频次
  2. 排序取 top 10
  3. 展示为虚线边框的 chip,点击直接加为当前类别标签

价值

  • iPad 打中文慢 → 建议词点一下 = 敲 5-10 个字
  • 频次排序 → 高频概念一眼可选
  • 排除当前类别已加的 → 不重复

决策 3:自由文本降级为 <details> 折叠

<details>
  <summary>想写点什么(选填)</summary>
  <textarea placeholder="标签难以表达的思考写这里..." />
</details>

为什么折叠

  • 降低视觉重量(告诉用户"这是可选的")
  • 95% 场景标签够用 → 不打扰主流程
  • 有话想说的 5% 用户点开即可

决策 4:/reflect 页的四块结构

待攻克盲区(amber)
  ↓
待研究问题(blue)
  ↓
已攻克的点(green)
  ↓
全部概念(neutral)

按优先级从上到下:紧迫 → 发散 → 正反馈 → 全景

设计模式识别:这是"事件溯源(Event Sourcing)"的小型应用

传统做法(state-based):
  User 对象直接存 "会的概念列表"
  更新时 mutate 这个列表
  失去历史信息

本方案(event-based):
  每次 review 产生一条 session 记录(含 tags)
  "会不会某个概念" 通过聚合 tags 动态计算
  完整保留"什么时候标过什么"的历史

好处:
  - 可以回放:看某个概念的"掌握轨迹"
  - 可以分析:某次学习后集体性地标了什么(学习效果评估)
  - 可以迁移:将来改聚合算法不用重新打标

坏处:
  - 数据冗余(同一个 text 出现 N 次)
  - 聚合成本(每次显示要遍历所有 session)
  - 当前规模(几千 session)无压力;上万级别要考虑缓存

类比:Git 的 commit log → 每个 commit 是事件,git log 是聚合视图。事件溯源不是时髦技术,是不想丢失历史信号的朴素选择。

性能思维:聚合什么时候变慢

当前实现:useMemo + aggregate 全量 session

O(N×M) 复杂度:
  N = session 数
  M = 每 session 的 tag 数

1 年后估算:
  每天 3 session × 365 = 1095 session
  每 session 平均 5 tag = 5475 tag
  聚合耗时:~1-2ms
  → 无感

10 年(极限):
  10950 session × 5 tag = 54750 tag
  聚合耗时:~20-30ms
  → 开始能感觉到,但 useMemo 缓存后只首次算
  → 仍然可接受

真正的瓶颈(将来):
  - 标签关联图(Phase 4)涉及 O(N²) 的概念相似度
  - 那时需要:
    1. Web Worker 把算法搬出主线程
    2. 预计算 + IndexedDB 缓存
    3. 增量更新(只算新增 session 的贡献)

质量思维:怎么确认改动没破坏老数据

兼容性检查清单:
  [✓] 老 session 没有 tags 字段 → blind-spots.ts 用 s.tags ?? []
  [✓] 新 session 总是含 tags: [] → store.tsx 两处 session 创建都补了
  [✓] 类型定义向后兼容 → tags 加在 interface 末尾不破坏旧存储
  [✓] 聚合算法不依赖字段存在 → 忽略缺失 tags 的老 session

预期:
  - 老用户首次打开新版:/reflect 显示"暂无标签"(正确)
  - 完成一次新学习 → 打了标签 → /reflect 出现那个概念
  - 自然迁移,无需手动清数据库

IndexedDB 不升版本的判断:schema(store 结构)没变,只是 value 里的 JSON 多了字段,这不需要 DB migration。只升版本做新 store/index 时才要动 DB_VERSION。

相关代码位置

新增:
  src/components/TagInput.tsx               标签输入组件
  src/lib/blind-spots.ts                    聚合算法 + 4 个视图
  src/app/reflect/page.tsx                  /reflect 页面

修改:
  src/types/index.ts:39-58                  ReviewTag / TagCategory / StudySession.tags
  src/lib/store.tsx:135, 166, 196-210       创建 session 补 tags,submitAssessment 接 tags
  src/app/review/page.tsx                   改造 phase=review 用 TagInput
  src/components/BottomNav.tsx              新增 /reflect tab + 灯泡图标

思考题讨论

Q1:三类标签够吗?如果用户想打"需要实践"、"重要"怎么办?

用户盲区:以为类别越多越灵活。

正确解答

类别系统的反模式是"逐步添加满足个例需求":

每次用户问"能不能加 X 类别",都有个合理的场景
→ 加完变成 6-8 类
→ 用户每次打标要想"这属于哪类"
→ 认知负担上升,打标频率下降
→ 数据质量恶化

正确的拒绝逻辑:
  "重要" → 如果所有概念都重要,这个标签无信号
         如果只有一部分重要,"模糊"/"新问题" 已经筛选了关键
         → 不加
  "需要实践" → 这应该是 Task.type 的 practice,不是 tag 类别
            → 用任务系统解决,不用标签
  "反常识" → "新问题" 已经覆盖(能让人产生疑问的通常是反常识的)
           → 不加

Miller's Law(工作记忆 7±2):
  3 类刚好在低负担区间
  4 类开始需要思考
  7+ 类接近认知上限

资深设计原则克制比丰富更难。产品经理最大的美德是说"不"。

Q2:怎么处理"同一概念不同表述"的归一化?

用户盲区:觉得 normalize 太粗糙会误合并。

正确解答

当前 normalize 策略:

trim + toLowerCase + 全角转半角

能处理的

"React Memo" → "react memo"
"react memo" → "react memo"
"React memo " → "react memo"
"React Memo" → "react memo"  // 全角→半角

处理不了的(潜在问题)

"React.memo" vs "React memo"  → 不同(点号 vs 空格)
"react-memo" vs "react memo"  → 不同(连字符 vs 空格)
"JVM GC" vs "jvm gc"          → 同(toLowerCase 处理)
"JVM 垃圾回收" vs "JVM GC"     → 不同(同义不同词)
"JavaScript 闭包" vs "JS 闭包" → 不同(缩写)

为什么不做更激进的归一化

❌ 去除所有标点 + 空格:
   "React.useState" → "reactusestate"
   "React use state" → "reactusestate"
   → 可能把无关概念合并

❌ 同义词词典:
   需要维护一个专业词典
   还要考虑中英文对照
   工程量巨大,收益不确定

✅ 接受一定的冗余:
   "JVM GC" 和 "jvm垃圾回收" 被当作两个概念
   用户自己看到两条会意识到是同一个
   可以手动把一边改名统一

将来优化方向(非紧迫):
  1. 合并 UI:点一下"合并为同一个"
  2. 编辑距离 > 0.8 给用户提示"这两个像同一个"
  3. 用 embedding 做语义相似(矫枉过正了)

工程原则数据质量通过用户编辑迭代,不追求算法完美

Q3:为什么不把 tags 独立成 store?

用户盲区:觉得"独立 store 更规范"。

正确解答

两种设计对比:

方案 A(当前):tags 嵌套在 session 里
  sessions: [
    { id, taskId, tags: [{text, category, ...}], ... }
  ]
  
方案 B(独立 store):
  sessions: [{id, taskId, ...}]
  tags: [{id, sessionId, text, category, ...}]

方案 B 的理论优势

  • 可以 index tags 加速查询
  • 按 tag 查 session 方便
  • 关系型数据库风格

为什么选方案 A

1. 关系简单(tags 只从属于 session,不跨 session 关联)
   → 嵌套更自然,不需要 join

2. 数据规模小
   - 当前项目:几百 session × 5 tag = 几千 tag 总量
   - 遍历聚合毫秒级
   - 不需要 index 优化

3. 事务简单
   - session 和 tags 总是一起写入 / 读取
   - 嵌套 → 一次 putSession() 完成
   - 独立 → 要事务保证一致性,复杂

4. 数据迁移简单
   - 嵌套方式:加字段即可,老数据 ?? [] 兜底
   - 独立方式:要升 DB schema,写 migration

5. IndexedDB 的 value 是任意 JSON,嵌套不是反模式
   不像 SQL 表有"平铺"要求

什么时候该独立

  • tags 规模上百万
  • tags 要跨 session 做复杂查询(按时间范围 + 类别 filter)
  • 多个实体共享 tags(比如 task 也能打 tag)

当前场景不满足以上条件 → 嵌套足够

资深原则别 pre-optimize。SQL 范式化思想在 NoSQL 里要减毒,IndexedDB 更像 MongoDB 不是 MySQL。

举一反三:这个模式能应用在哪些地方

"写了无人看"的变种在很多产品里见过:

产品  |  反模式                    |  好的做法
----  |  ---------                |  --------
Todo App | 可以写备注但从不展示     | 备注变成可搜索 + 通知关键字
笔记 App | 标签功能但没聚合视图    | #tag 可以点击查看所有带此标签的笔记
健身 App | 记录感受但没趋势图      | "最近一周你的疲劳标记变多了"
读书 App | 摘抄但不检索            | 全局摘抄搜索 + 主题聚合
面试网站 | 答题记录但没错题本      | 按知识点汇总错题,复习推送

通用公式用户输入 = 数据 → 数据要有消费端 → 消费端反哺输入质量。缺任何一环都是死循环。

面试话术

"这次改造我做了两件事。第一是重新定义回顾数据模型——把原来'自由文本'改成'三类标签+可选文本'。为什么?我 grep 代码发现原 reviewAnswer 字段是只写不读的——用户写完就埋了。这种设计会形成恶性循环:用户感觉没人看→不认真写→自评不准→SR 算法失效。标签化的好处是结构化数据可聚合,且打标成本远低于写文字(iPad 上尤其重要)。第二是做聚合消费——/reflect 页按 score 算法展示盲区、待研究、已攻克三类视图。score 公式是 fuzzy×3+question×2-confident×1+recency+signal,综合考虑紧迫性、新鲜度、跨主题强度。本质上这是把事件溯源(Event Sourcing)模式应用到个人学习——每次 review 是一条事件,状态(会不会某个概念)通过聚合事件动态计算,保留完整历史信号。这也给后续的遗忘曲线(Phase 3)和跨项目关联图(Phase 4)铺路——数据在了,上层怎么玩都灵活。"

延伸讨论

追问"数据迁移是怎么做的"

  • 没做主动 migration。老 session 缺 tags 字段,读时 s.tags ?? [] 兜底
  • 为什么不 migrate:老数据没有标签信息补不回来,migrate 也是加 tags: [],读时兜底等价
  • 启示:能用 defaulting 避免的 migration 就不要 migrate(减少 DB 版本变化)

追问"这个方案的瓶颈"

  • 聚合是 O(N×M) 全量扫描,1 万 session 级别能撑
  • 10 万 session:考虑 Web Worker + 预聚合缓存
  • 100 万:考虑后端同步(这个体量需要多端同步,IDB 不够用了)

追问"怎么知道标签设计是否有效"

  • 短期指标:打标率(有多少 review 打了 ≥1 标签)→ 越高说明成本够低
  • 长期指标:tag 类别分布变化(早期 fuzzy 多,后期 confident 多)→ 表明学习在推进
  • 定性信号:用户主动来 /reflect 看→说明消费端有价值

追问"Phase 3 遗忘曲线会怎么做"

  • 概念级 SR:把 tag 视为 Task 的衍生实体,独立计算下次复习间隔
  • 当同一概念被标 fuzzy 多次 → 算法变得保守(更短间隔)
  • 当标 confident → 间隔扩大
  • 首页显示"今天该复习的 X 个概念"(不是任务,是概念)

39. 概念级 SR + 跨项目知识关联——Phase 3+4 实现

一句话结论

Phase 3 把每个 ReviewTag 升级为独立的 ConceptReview 实体(带 ease factor 的 SM-2 简化 SR);Phase 4 构建时从 docs 提取关键词,运行时把用户概念 × 关键词做交叉索引,生成 /graph 跨文档关联图。两者合起来把 Phase 2 的"静态盲区地图"变成"活的学习系统"。

Phase 3:概念级间隔重复

问题:Task SR 的颗粒度太粗

现有 src/lib/spaced-repetition.ts 的 SR 是整篇任务级

Task 级 SR:
  SR_INTERVALS = [1, 3, 7, 14, 30] 天
  用户自评 remembered/fuzzy/forgot
  → 决定下次该看这篇文章的时间

问题:
  一篇文章可能讲 5 个概念
  你对 3 个掌握,2 个模糊
  Task 级 SR 把文章当黑盒,不区分这种细粒度差异
  → 复习文章 = 把 3 个已掌握的一起"温习"(浪费时间)
  → 真正想回忆的 2 个反而淹没在全文中

改进方案把每个打过标签的概念作为独立的 SR 单元

算法:SM-2 简化版

// 新概念的初始间隔
initial(category) = {
  confident: 3 * DAY,  // 掌握的,3 天后验证
  fuzzy:     1 * DAY,  // 模糊的,明天复习
  question:  1 * DAY,  // 问题,明天跟进
}

// 已有概念再次打标时
ease_delta(category) = {
  confident: +0.1,
  fuzzy:     -0.2,
  question:  -0.1,
}
nextEase = clamp(ease + delta, 1.3, 3.0)
nextInterval = prevInterval * nextEase
nextDueAt = now + nextInterval

设计决策 1:为什么用 ease factor 而不是固定阶梯?

Task SR 用固定阶梯 [1, 3, 7, 14, 30]:
  优点:简单可预测
  缺点:对"概念级"不灵敏
  
  场景:概念 A 你标 fuzzy 3 次 vs 概念 B 标 fuzzy 1 次
  阶梯式:两者都回退到"初级间隔"
  ease 式:A 的 ease 降到 1.6,B 的 ease 是 1.8
          → A 的间隔短得多,匹配实际难度

ease factor(SM-2 思想):
  连续可调 → 算法对用户差异敏感
  边界保护(1.3 - 3.0)→ 防止极端值
  
  为什么 1.3 是下限:
    低于 1.3 意味着间隔基本不增长
    但概念级 SR 不应该"每天问一次"(用户会烦)
    1.3 保证至少每天间隔增长 30%
  
  为什么 3.0 是上限:
    高于 3.0 意味着间隔每次翻 3 倍
    太松会错过遗忘期
    3.0 对应约 "3 天 → 9 天 → 27 天 → 81 天" 的节奏,合理

设计决策 2:为什么 fuzzy 的惩罚(-0.2)比 question 大(-0.1)?

fuzzy = "学过但没掌握"
  → 真正的盲点
  → 需要强烈的复习信号
  → 大幅缩短间隔

question = "新问题"
  → 可能只是好奇,未必是弱点
  → 轻度缩短间隔
  → 不要干扰"真正的 fuzzy 盲区"

设计决策 3:为什么 confident 也会减分(-0.2 vs +0.1)不对称?

学习心理学:
  遗忘比记忆快
  多次标"模糊"积累的不安全感 > 多次标"掌握"积累的安全感

工程对照:
  fuzzy 的 -0.2 比 confident 的 +0.1 幅度大
  含义:一次 fuzzy 抵掉两次 confident
  符合"出错 1 次要练对 3 次"的训练直觉

数据模型

export interface ConceptReview {
  text: string;              // 主键(和 ReviewTag.text 对齐)
  displayText: string;
  firstSeenAt: number;
  lastReviewedAt: number;
  nextDueAt: number;         // 关键:by-due 索引用
  ease: number;              // [1.3, 3.0]
  counts: {
    confident: number;
    fuzzy: number;
    question: number;
  };
  taskIds: string[];
  updatedAt: number;
}

为什么单独存而不是每次从 sessions 计算

从 sessions 聚合(Phase 2 的盲区算法):
  - 每次 render 都扫全部 sessions → O(N×M)
  - ease factor 演化需要按时间顺序 replay
  - 首页"今日该复习"每次要扫 → 慢

独立 concepts store:
  - CRUD 各自 O(1)
  - by-due 索引让首页查询 O(log N)
  - 每次 submitAssessment 增量更新
  - Phase 2 的聚合仍然从 sessions 算(作为"真相来源")
  - concepts 是"计算产物",可以随时重建

这是典型的 CQRS 思想(Command Query Responsibility Segregation)

  • Command 端:sessions(历史事件流,不可变)
  • Query 端:concepts(聚合产物,为查询优化)

数据库升级 v2 → v3

// src/lib/db.ts
const DB_VERSION = 3;  // 从 2 升级

upgrade(db, oldVersion) {
  if (oldVersion < 3) {
    const conceptStore = db.createObjectStore("concepts", { keyPath: "text" });
    conceptStore.createIndex("by-due", "nextDueAt");  // 首页查到期概念必备
  }
}

为什么需要 by-due 索引

首页每次打开都要查 "今日该复习的概念":
  没索引:getAll() 后 filter → O(N),100 个概念要扫 100 次
  有索引:IDBKeyRange.upperBound(now) → O(log N),几次跳跃完成

IndexedDB 索引的代价:
  - 写入多 10-15%(要维护索引)
  - 存储多 5-10%(索引结构)
  - 首页读取快 O(log N)
  → 读多写少的典型场景,索引值得

首页集成

// src/app/page.tsx
const dueConcepts = useMemo(() => {
  const now = Date.now();
  return concepts.filter((c) => isDue(c, now));
}, [concepts]);

// 展示:点击跳 /reflect
<Link href="/reflect" className="...">
  💡 {dueConcepts.length} 个概念到复习期
  {dueConcepts.slice(0, 3).map(c => c.displayText).join(" · ")}
</Link>

UX 权衡

❌ 弹窗提醒:用户还没准备学就被打扰
❌ 右上角红点:信息密度低,用户不知道有啥
✅ 卡片展示前 3 个名字:立即传达"你有 X 个忘了"
   点击一下 → /reflect 完整列表
   符合"glanceable UI"原则

Phase 4:跨项目知识关联图

问题:概念和文档之间没有索引

当前状态:
  ReviewTag 里有 text = "JVM GC"
  docs/slh_new.md 里讲了 JVM GC
  但两者无关联
  
用户视角:
  - 我标了 fuzzy 的"JVM GC"
  - 我需要复习
  - 但哪篇文章讲了?要自己翻目录找
  - 挫败感 → 不打标签

目标:建立"用户概念" × "文档关键词"的双向索引。

构建时:关键词提取

scripts/generate-keywords-bundle.mjs 从 docs/*.md 提取关键词:

提取三类:
  1. 标题(## / ### / ####)
  2. 加粗文本(**xxx**)
  3. 行内代码(`xxx`)

归一化:trim + lowercase + 全角转半角
过滤:
  - 长度 < 2 或 > 50
  - stopwords(the/a/的/了/...)
  - 纯数字或纯标点
  - 只在 1 个 doc 且 count < 3(低信号)

产出 keywords-bundle.ts:
{
  version: "sha256前12位",
  keywords: [
    {
      text: "jvm gc",           // 归一化
      displayText: "JVM GC",    // 展示
      totalCount: 15,           // 总频次
      docs: [
        { slug: "slh_new", count: 8 },
        { slug: "bizsspd", count: 4 },
        { slug: "shopdiaryapp", count: 3 },
      ]
    },
    ...
  ]
}

实测结果:12 篇文档提取 765 个关键词。合理的数量级——100+ 就太少(关键概念漏),5000+ 就太杂(噪音多)。

为什么不用 NLP 分词/TF-IDF

NLP 分词(jieba/nodejieba):
  ❌ 增加 native 模块依赖(CI 环境构建痛)
  ❌ 中英混排时分词质量不稳定
  ✅ 能找到正文中的概念
  
  当前选择:不用。改用"显式标记"策略。
  显式标记的覆盖率:
  - 标题 ≈ 80% 的核心概念
  - 加粗 ≈ 10% 的强调概念
  - 行内代码 ≈ 10% 的技术名词
  - 正文隐含 ≈ 0%(损失的部分)
  
  结论:用"作者的格式化行为"作为 signal,
       比 NLP 分词更准(因为作者主动标记的就是重要的)

TF-IDF(词频-逆文档频率):
  ❌ 需要"区分度":某词在少数文档出现更有分 
  ❌ 当前 12 篇文档量少,IDF 失真严重
  ✅ 经典信息检索算法
  
  当前选择:用简单的 totalCount + doc 数量。
  为什么不用 TF-IDF:
  - 我们不是在做"搜索引擎"
  - 我们要的是"覆盖"而不是"区分"
  - 一个概念在多个 doc 出现反而是好信号(跨项目通用)
  
  如果未来要做"查找最有特色的 doc"才需要 TF-IDF。

这是显式信号 vs 隐式推断的权衡——类似之前第 32 章讨论的"学完了"按钮哲学。

运行时:交叉索引

src/lib/knowledge-graph.tsfindRelatedDocs(concept)

// 三档匹配强度:
matchStrength(conceptText, keywordText):
  完全匹配  → 1.0
  子串匹配  → 0.5(双方长度都 ≥ 3)
  无匹配    → 0

// 按 count × strength 排序推荐文档

为什么不完全依赖字符串匹配

完美方案:语义相似度(embedding + cosine)
  ❌ 需要模型(transformers.js + embedding)
  ❌ 首次加载要下几百 MB 模型
  ❌ iPad 推理慢
  
实际方案:字符串 + 子串匹配
  ✅ 零依赖
  ✅ 对精确术语(JVM GC、React memo)命中率 100%
  ✅ 对近义词("闭包" vs "closure")会漏
  
  用户对策:
  - 打标时用英文原词(和 docs 一致)
  - 或者写中文但 docs 也有同义词
  - 偶尔漏匹配 → /graph 页显示"未覆盖概念"让用户看见问题

未来优化路径:
  - 客户端 embedding(web-llm / onnx runtime)
  - 或者用 GPT 预打语义 tag(部署时一次性,存 bundle)
  - 现在不做,因为当前方案覆盖 80% 场景

跨文档关联

computeDocCorrelations() 算两两 doc 的"共享关键词强度":

for (each pair of docs) {
  shared = keywords appearing in both docs
  score = sum(min(count_in_a, count_in_b))
}

min() 而不是 sum() 的原因:

sum 的问题:
  doc A 讲 JVM GC 20 次,doc B 讲 1 次
  sum = 21 → 看起来"高度相关"
  但实际 A 在展开讲,B 只是顺便提

min 的合理性:
  min = 1 → 真实的"共同部分"
  反映"两者都深入讲了多少"
  对称性好(min(a,b) = min(b,a))

这是"Jaccard 相似度"的变种。经典的 Jaccard = |交集|/|并集|,这里用 min 加权因为关键词带频次信息。

/graph 页面

不做真正的 graph 可视化(d3/cytoscape),选列表形态:

两个 Tab:
  "我的概念":每个概念 → 相关文档列表 + "未覆盖概念"chip
  "文档关联":两两 doc 的共享关键词

为什么不做 node-edge 图:
  - iPad 竖屏小,复杂 graph 交互差
  - d3 包 ~70KB,为 MVP 不值
  - 列表形态已表达核心关联
  - 用户能"点击跳转 doc"比"看图"更实用
  - 未来真要可视化,再加桌面端专用页

设计模式识别

Phase 3 用了 CQRS

sessions = 事件流(只追加)
concepts = 物化视图(为查询优化)

每次 submitAssessment:
  写 sessions → putSession()
  派生更新 concepts → computeConceptUpdates() + putConcepts()

好处:
  - concepts 可以重建(丢了不怕)
  - 查询快(不用全扫 sessions)
  - 可以演化(改 ease 算法不用动 sessions)

这是简化版 CQRS:
  没有分布式、没有消息队列
  但"写事件 + 读聚合"的思想完整保留

Phase 4 用了构建时 + 运行时分层

构建时(CI/本地):
  - 扫描 markdown
  - 提取关键词
  - 生成 TypeScript 文件
  - 打进 JS bundle

运行时(用户浏览器):
  - 读 bundle(零延迟,已在 JS 里)
  - 读 IDB concepts
  - 内存中做交叉索引
  - 渲染列表

为什么不都放运行时:
  - 关键词提取是 CPU 密集(正则扫几 MB markdown)
  - iPad 每次打开都算会卡顿
  - 构建时一次算好 → 运行时只做查询
  
为什么不都放构建时:
  - 用户的 concepts 是动态的
  - 构建时不知道用户会打什么标签
  - 交叉索引必须运行时做

合理分层:**静态数据构建时预处理,动态数据运行时算**。

性能思维

Phase 3 性能画像(预估 1 年后):
  concepts 数量:假设 300(每天新增 1 个,累计 1 年)
  每次 render:
    - 首页 isDue filter:300 次比较 → <1ms
    - /reflect 聚合:从 sessions 算(不依赖 concepts)→ 已评估
    - /graph 交叉索引:300 × 765 keywords = 230K 次比较 → ~5-10ms

  优化时机:
  - 概念数 > 1000:把 buildGraph 搬 Web Worker
  - 关键词数 > 5000:分片索引 + 倒排表
  - 都是 100× 当前规模才需要

Phase 4 bundle 体积:
  keywords-bundle.ts:765 条,~50KB 未压缩
  gzip 后 ~10KB
  相比 docs-bundle.ts(~1.9MB)可忽略

安全思维

Phase 3:
  ConceptReview 数据在 IDB,同源 JS 可见
  tag text 是用户输入 → 理论上可能含 XSS payload
  但我们只把它显示为文本(不 dangerouslySetInnerHTML)
  → React 自动转义,安全

Phase 4:
  keywords-bundle.ts 来自 docs/*.md
  docs 来自 sync.sh 拉取的 changelog.local.md
  如果某个 changelog 源被污染(攻击者推恶意 keyword)
  → 会进入 bundle → 展示给用户
  
  但我们只把 keyword 作为文本显示
  → 没有代码执行风险
  
  真正的防御在 sync.sh 的白名单(只拉自己的仓库)

测试挑战

当前未写单元测试,但设计时已考虑可测性

// concept-sr.ts 的函数都是纯函数 + 注入时间
updateConceptFromTag(existing, tag)  // 时间来自 tag.createdAt
computeConceptUpdates(map, tags)

// knowledge-graph.ts 的函数接受数据作为参数
findRelatedDocs(concept)  // 读 keywordsBundle(可 mock)

// 测试友好的设计:
// - 不依赖 "new Date()" 直接调用
// - 所有外部数据通过参数或 import 传入
// - Map/Array 操作,不 mutate 输入

相关代码位置

新增:
  scripts/generate-keywords-bundle.mjs       构建时关键词提取
  src/data/keywords-bundle.ts                构建产物(765 条)
  src/lib/concept-sr.ts                      概念级 SR 算法
  src/lib/knowledge-graph.ts                 交叉索引
  src/app/graph/page.tsx                     /graph 页面

修改:
  src/types/index.ts                         ConceptReview 类型
  src/lib/db.ts                              schema v3 + concepts store + by-due 索引
  src/lib/store.tsx                          AppState.concepts + submitAssessment 集成
  src/app/page.tsx                           首页"今日该复习"卡片
  src/app/reflect/page.tsx                   加"关联图"入口
  package.json                               keywords:bundle 脚本
  sync.sh                                    sync 后自动重建两个 bundle

思考题讨论

Q1:为什么 ease factor 用 [1.3, 3.0] 而不是 [1.0, 5.0]?

用户盲区:以为范围越宽算法越灵敏。

正确解答

边界不是拍脑袋选的,是心理学研究 + 工程约束的结果:

下限 1.3:
  下限 = 1.0 意味着"每次间隔和上次一样"
  但 SR 的核心是"成功复习后应扩大间隔"
  间隔不增长 = 退化为周期性提醒 → 用户疲劳
  
  1.3 保证 "成功一次 = 间隔至少 +30%"
  对应节奏:1 天 → 1.3 天 → 1.7 天 → ...
  (几次迭代后间隔拉开到有意义的天数)

上限 3.0:
  上限 = 5.0 意味着"一次成功后间隔翻 5 倍"
  1 天 → 5 天 → 25 天 → 125 天
  125 天后再问 = 80% 概率已经忘了(艾宾浩斯遗忘曲线)
  → 复习成了"重新学习",浪费
  
  3.0 保证:1 天 → 3 天 → 9 天 → 27 天
  节奏匹配遗忘曲线的"临界点前复习"

Anki 的经验值:
  Anki 默认 ease 范围 [1.3, 2.5]
  我们放到 [1.3, 3.0] 略宽
  因为我们的 concepts 是"技术概念"比"背单词"记忆更持久
  
→ 不是"拍脑袋选的数字",是"领域知识 × 算法特性"的交集

Q2:Phase 4 的关键词为什么不用 embedding?

用户盲区:觉得字符串匹配太"原始"。

正确解答

embedding 的吸引力和代价:

吸引力:
  ✅ "闭包" 和 "closure" 能识别为同一概念
  ✅ "JVM 垃圾回收" 和 "JVM GC" 语义一致
  ✅ 发现隐性关联("函数式编程" 和 "高阶函数")

代价:
  ❌ 模型体积:
     - 最小的 sentence-transformers ~80MB
     - 要下 tokenizer + 模型权重
     - 首次加载极慢
  
  ❌ 推理性能:
     - 765 个关键词 × 300 个概念 = 23 万次向量运算
     - iPad 上 CPU 推理可能几十秒
     - 用 WebGL/WebGPU 加速?复杂度飙升
  
  ❌ 运维成本:
     - 模型版本管理
     - 更新 bundle 时要同步更新模型
     - 调试 embedding 不匹配很痛(黑盒)

字符串匹配的平衡:
  ✅ 零依赖
  ✅ 结果可解释(为什么匹配上/没匹配上一目了然)
  ✅ 对技术术语命中率 > 80%
  ❌ 漏掉近义词(10-20%)

工程原则:
  选方案不是"哪个理论最好"
  是"投入 × 收益" 
  
  字符串匹配 80% 效果 + 1 天工程
  embedding 95% 效果 + 2 周工程 + 持续维护
  
  当前用户规模:1 人
  → 80/20 切分:先做简单版验证需求
  → 需求被验证了,效果不够了,再上 embedding

资深设计原则MVP 不是"做得粗糙",是"最小投入验证核心假设"。当前假设是"用户愿意用关联图"——先用简版证明这件事。

Q3:为什么 Phase 3 用 SM-2 简化版而不是 FSRS?

用户盲区:不知道 FSRS 是什么。

正确解答

FSRS(Free Spaced Repetition Scheduler)是 Anki 2023 引入的新一代 SR 算法

SM-2(1987 年,SuperMemo 2):
  - 参数简单:ease factor 一个
  - 假设:每次复习是独立事件
  - 问题:不考虑"概念难度"本身的差异

FSRS(2022+):
  - 基于 DSR(Difficulty-Stability-Retrievability)三要素
  - 用神经网络学习最优调度
  - 参数多(~17 个超参)
  - 需要训练数据(你的历史复习记录)

FSRS 的门槛:
  ❌ 复杂:17 个参数不是领域专家看不懂
  ❌ 冷启动:需要至少几百条复习记录才能训练出好参数
  ❌ 实现:开源 FSRS 依赖 Python/Rust,JS 版本有限
  
SM-2 的优势:
  ✅ 一个 ease factor,心智模型简单
  ✅ 冷启动友好:刚开始用也能工作
  ✅ 代码量 < 50 行

选型逻辑:
  当前场景:
    - 个人学习工具
    - 没有训练数据
    - 用户 1 个,没 A/B 测试能力
    - 目标是"能用"而不是"极致优化"
  
  → SM-2 足够
  → 用户积累数据后,可以升级到 FSRS(迁移路径:
    把 ease factor 初始化为 FSRS 的 S/D 估计值)

工程原则:
  同样的"MVP 优先":
  - 验证"用户愿意用概念级 SR"这件事
  - 如果用户用起来了,且积累了数据
  - 再换更精准的算法(增量价值明确时才投入)

类比:创业公司不应该上来就上 Kubernetes——先用 docker-compose 验证 PMF,流量起来了再切 K8s。SR 算法选择是一样的。

举一反三

这个设计能扩展到什么场景

1. 企业知识库
   员工打标记忆点 + 公司文档关键词索引
   → 个性化学习路径
   → "你关注的 X 概念,张三写过 Y 文章"

2. 编程刷题
   题目的关键数据结构/算法 = 关键词
   用户打标"我模糊的" = 概念
   → 推荐相似题 + 复习节奏

3. 医学教育
   疾病名/检查项 = 关键词
   学生标记的弱点 = 概念
   → 案例推荐 + 错题本

4. 外语学习
   单词/短语 = 关键词
   用户标"容易忘"= 概念  
   → 场景化复习

通用公式:
  结构化内容(文档/题目/案例)
  + 用户主观标记(ReviewTag)
  + 交叉索引(knowledge-graph)
  + 时间衰减(SR)
  = 个性化学习系统

面试话术

"Phase 3+4 把学习工具从静态变成动态。Phase 3 引入概念级 SR——每个用户标签变成独立的 ConceptReview 实体,带 ease factor(SM-2 简化版),根据 fuzzy/confident 对比动态调整下次复习间隔,边界 1.3-3.0 是按艾宾浩斯遗忘曲线和 Anki 经验值定的。这解决了 Task 级 SR 太粗的问题——一篇文章可能讲 5 个概念,不该用同一个节奏复习。首页用 by-due 索引 O(log N) 查今日到期概念,形成闭环。

Phase 4 建立用户概念和文档关键词的交叉索引。构建时从 docs 的标题/加粗/代码提取 765 个关键词,归一化成 bundle;运行时把 ReviewTag.text 和这些关键词做字符串+子串匹配,三档强度,推荐相关文档。跨文档关联用 min 加权的 Jaccard 相似度,找出共享主题最多的 doc 对。这是'显式信号 + 简单算法'的典型实践——没用 NLP 分词也没用 embedding,而是让作者用 markdown 格式化行为(写标题/加粗)作为主动信号来源,命中率 80%+,零依赖。

整体设计体现两个模式:CQRS(sessions 是事件流,concepts 是查询视图)和构建时/运行时分层(静态数据 CI 预处理,动态数据浏览器算)。技术选型的关键是拒绝'过度设计'——SM-2 而非 FSRS、字符串匹配而非 embedding、列表 UI 而非 d3 graph。每一步都是'最小方案验证假设,留出升级路径'。"

延伸讨论

追问"ease factor 怎么量化效果"

  • 收集 2-4 周用户数据
  • 计算"打了 fuzzy 的概念,N 天后再次打 fuzzy"的概率分布
  • 如果高频(>60%)→ ease 算法过松,间隔太长
  • 如果极低(<20%)→ ease 算法过紧,间隔太短
  • 目标:概念下次被标为 fuzzy 的概率稳定在 30-40%(经典 SR "desirable difficulty" 区间)

追问"关键词质量怎么保证"

  • 当前靠 STOPWORDS + 过滤规则
  • 发现质量问题的信号:/graph 页面某些概念"匹配到所有 doc"→ 关键词太泛(如"系统")
  • 应对:扩充 STOPWORDS 或提升最小阈值
  • 长期:加个 "admin 页面"支持手动编辑 STOPWORDS

追问"数据膨胀怎么办"

  • sessions 无限追加,N 年后数 MB 级 → IDB 无压力
  • concepts 增长慢(去重)→ 上千量级
  • 真正的瓶颈不在存储,在"用户标签质量下降"
  • 老标签重复度高 → 聚合时合并 → 实际有效概念数收敛

追问"Phase 5 可以做什么"

  • 学习路径生成:根据用户当前概念地图 + 目标概念 → 推荐 Task 顺序
  • 群体学习:用户可以共享 concept map(需要后端)
  • LLM 增强:用 GPT 分析"你模糊的 10 个概念共性"→ 发现底层知识缺口
  • 回溯复习:看历史"某个概念从 fuzzy 到 confident 用了多久"

40. 回顾流程去重——合并标签与自评两步

一句话结论

第 38 章保留的"自我评估"步骤(记住了/模糊/没记住)和第 38 章新增的"标签系统"(掌握/模糊/新问题)语义严重重叠——本次把自评步骤删掉,从标签自动推导,回顾流程从 2 步降到 1 步。

问题:两步的语义映射几乎 1:1

用户真机测试反馈"这两个页面有点重复"。对照两步的选项:

标签步骤(phase=review):       自评步骤(phase=assess):
  ✅ 掌握 (confident)         →   记住了 (remembered)
  ⚠️ 模糊 (fuzzy)            →   模糊 (fuzzy)
  ❓ 新问题 (question)        →   没记住 (forgot)

两步实际上问的是同一件事的两种粒度

  • 标签是概念级判断(打具体知识点标签)
  • 自评是文章级整体印象

但文章级判断完全可以从概念级推导——让用户做一次判断,避免重复劳动

历史背景:为什么会有这个重复

v1(最初):
  只有 selfAssessment 三选一(没有标签系统)
  reviewAnswer 字段只写不读

v2(第 38 章 Phase 1):
  加了标签系统作为结构化数据源
  但怕改动大,保留了 selfAssessment 作"整体评价"
  → 结果:用户要做两次语义重叠的选择

v3(本章):
  承认重叠不合理,删掉自评步骤
  从标签推导 SelfAssessment → 保留 Task SR 算法不变
  → 迭代式改进,逐步消除设计债

这是产品设计的常见反模式:引入新能力时不敢删旧能力,结果 UI 堆积。一段时间后必须回头精简。

推导算法

function inferAssessment(tags: ReviewTag[]): SelfAssessment {
  if (tags.length === 0) return "fuzzy";  // 无信息 → 保守

  const hasFuzzy = tags.some((t) => t.category === "fuzzy");
  const hasConfident = tags.some((t) => t.category === "confident");
  const hasQuestion = tags.some((t) => t.category === "question");

  if (hasFuzzy) return "fuzzy";  // 有盲区就标 fuzzy
  if (hasQuestion && !hasConfident) return "forgot";  // 新问题主导 = 还没消化
  if (hasConfident && !hasQuestion) return "remembered";  // 纯掌握
  return "remembered";  // 混合默认按掌握
}

规则的设计理由

情况 推导 理由
无标签 fuzzy 信息不足,保守估计(避免高估)
任意 fuzzy fuzzy 有一个模糊就算模糊(SR 间隔保守)
仅 question(无 confident) forgot 只冒问题没掌握 = 没学会
仅 confident remembered 清晰的掌握
confident + question(无 fuzzy) remembered 掌握主干 + 留有思考,算消化

关键设计原则保守偏移(conservative bias)——任何一丝模糊信号都降级到 fuzzy,避免 SR 算法乐观估计导致遗忘。

UX 改动

旧流程(2 步):
  学完了 → [标签页: 掌握/模糊/新问题] → 下一步 → [自评页: 记住了/模糊/没记住] → 保存

新流程(1 步):
  学完了 → [标签页 + 推导出的整体评价 + "完成"按钮] → 保存

透明推导:按钮上方显示"根据你的标签推断:模糊",让用户知道整体评价怎么来的,不是黑盒偷偷决定

这是**显式优于隐式(explicit over implicit)**的体现——代码可以自动推导,但把推导结果显示给用户,保持信任。

相关改动

修改:
  src/app/review/page.tsx               删 phase=assess,加 inferAssessment()
  src/app/learn/page.tsx:116            按钮文案 "学完了,进入回顾" → "学完了,去打标签"
                                         (下一步只是打标签,不是整套"回顾"流程)

延伸思考

为什么不保留自评作为"最终确认"?

心理学研究:同一判断重复两次降低答案质量而非提升。用户会:

  • 第一次认真标标签
  • 第二次"我刚标过了"敷衍点一个
  • 最终数据反而失真

一次判断 + 显示推导结果两次独立判断更能拿到真实信号。

三层对比

❌ 初级做法:加新功能时保留所有旧功能
   产品越来越臃肿,用户困惑"到底填哪个"

⚠️ 中级做法:加完新功能后开个小会讨论要不要删旧的
   讨论通常变成"加个 toggle 让用户选"
   → UI 更复杂,问题没解决

✅ 资深做法:承认"新方案该替代旧方案"时,勇敢删
   + 推导逻辑透明展示给用户
   + 保留旧字段(SelfAssessment)在数据层
     避免下游算法(Task SR)连锁改动
   → 用户层面简化,数据层面兼容

面试话术

"我做学习工具时踩过一个典型的 UX 反模式:加了新能力但不敢删旧能力。标签系统(概念级打标)上线后,自评(文章级评价)就冗余了——两者语义几乎 1:1 映射。用户做两次同类判断会敷衍第二次,数据质量反而下降。修复是删掉自评步骤,从标签自动推导 SelfAssessment。推导规则是'保守偏移'——有任何 fuzzy 信号就降级到 fuzzy,避免 SR 算法乐观估计。同时在按钮上方显示推导结果,保持透明不让用户觉得被自动化抢了决定权。这个教训通用:加新功能时就该评估是否替代旧功能,不要堆叠。"


41. 推荐引擎重复 bug——池子太小 + 算法 deterministic + 无冷却

一句话结论

用户真机测试发现"每次选 5 分钟给的都是同一个任务"——三个叠加问题:5 分钟池只有 2 个任务(过滤太严)+ 算法完全确定性(同分按 sortOrder 排总是同一个赢)+ 无冷却机制(刚推过的下次还能推)。一次修齐三个维度。

排查过程

第一反应(错误方向)

用户报告"每次都是同一个",第一反应是怀疑 submitAssessment 没有正确更新 task 状态——如果任务标为 completed 后下次再被推荐,就是 bug。

查了 submitAssessment(store.tsx:196),确认更新逻辑正确:status = "completed"srNextDue 推到未来。不是这里的问题。

第二步:查推荐算法

recommendation.ts 的 5 分钟硬过滤:

if (selectedMinutes <= 5) {
  const shortPool = candidates.filter((t) => {
    const isDue = isDueToday(t, now) || overdueDays(t, now) > 0;
    const isQuickLight = t.difficulty === "light" && t.estimatedMinutes <= 10;
    return isDue || isQuickLight;
  });
  eligible = shortPool.length > 0 ? shortPool : candidates;
}

条件是:"SR 到期的 任意任务" ∪ "light 且 ≤10 分钟的任务"。

第三步:查数据

grep -c "difficulty: \"light\"" src/data/seed-tasks.ts   # → 8
grep -c "id:" src/data/seed-tasks.ts                     # → 48
# 检查 light 里 ≤10min 的数量
grep -B1 "difficulty: \"light\"" ... | grep "estimatedMinutes" | head
# 结果:20, 15, 15, 15, 15, 15, 10, 10 → 只有 2 个 ≤10min

根因三件套

1. 池子太小:
   48 个任务里,只有 2 个同时满足 "light + ≤10min"
   初次用户没有 SR 到期任务
   → 5 分钟池 = 2 个

2. 算法确定性:
   sort((a, b) => b.score - a.score || a.sortOrder - b.sortOrder)
   同分情况按 sortOrder 升序 → sortOrder 最小的永远赢
   
3. 无冷却:
   刚推过的任务,下次选 5 分钟时依然在候选池里
   高分数优势保持 → 继续赢

修复方案(三管齐下)

1. 放宽 5 分钟硬过滤:去掉"必须 light"

  if (selectedMinutes <= 5) {
    const shortPool = candidates.filter((t) => {
      const isDue = isDueToday(t, now) || overdueDays(t, now) > 0;
-     const isQuickLight = t.difficulty === "light" && t.estimatedMinutes <= 10;
-     return isDue || isQuickLight;
+     const isQuick = t.estimatedMinutes <= 10;
+     return isDue || isQuick;
    });
  }

理由estimatedMinutes ≤ 10 本身就是时长保护,不需要额外要求 light。difficulty 在 score 阶段通过"晚间降 deep 任务"已经调整过了。

效果:池子从 2 → ~10(所有 ≤10min 任务)

2. 加最近冷却(核心修复)

const COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 小时
const recentlyShownTaskIds = new Set(
  sessions
    .filter((s) => nowMs - s.startedAt < COOLDOWN_MS)
    .map((s) => s.taskId)
);

// 在 scoring 阶段:
if (recentlyShownTaskIds.has(task.id)) {
  breakdown.recentlyShown = -800;  // 强力降分,但不硬禁
}

关键决策

冷却范围:用 startedAt 而非 completedAt
  → 即使用户只进入 /learn 页面就退出(没完成),也算"最近见过"
  → 防止用户反复"点 5 分钟 → 看到同一个 → 退回 → 再点"的循环

冷却时长 2 小时:
  - 太短(15 分钟):用户中途离开喝水回来又推同一个
  - 太长(1 天):用户真的想连做两次同类任务被堵住
  - 2 小时 ≈ 一次专注学习 session 的完整周期

降分 -800 而非硬过滤:
  - 硬过滤(从 eligible 删掉):如果池子只有这一个任务,用户就见不到任何东西
  - 降分 -800:候选池有其他高分任务时优先其他,
              没有其他选择时 -800 的任务依然会被推(总比没有好)
  - -800 足够强:能抵掉 SR 到期(1000)以外的所有加分

3. 同分随机(tie-breaker)

- scored.sort((a, b) => b.score - a.score || a.task.sortOrder - b.task.sortOrder);
+ const jittered = scored.map((s) => ({
+   entry: s,
+   key: s.score + Math.random() * 0.5,
+ }));
+ jittered.sort((a, b) => b.key - a.key);

关键细节

为什么预计算 jitter 而不是 comparator 里调 Math.random:
  Array.sort 的 comparator 必须是"稳定函数"——同一对 (a, b) 重复比较
  必须返回相同结果。否则 sort 行为未定义(可能崩、可能乱序)。
  
  正确做法:每个元素预先计算一次随机 jitter,sort 用这个固定值。

为什么 jitter ∈ [0, 0.5) 而不是 [0, 1):
  所有 score 都是整数(+1000, +500, -30 等),最小间隔 1
  jitter < 1 保证"高分任务永远在低分之前"
  两个同分任务之间,jitter 决定顺序(50/50)
  
  用 [0, 0.5) 更保守,即使未来加小数权重也安全

三层对比

❌ 初级修法:手动把更多任务设为 light + ≤10min
   治标不治本,加了又会再次饱和
   且改变任务本身的难度标签(原来 medium 的任务怎么能变 light?)

⚠️ 中级修法:加随机 tie-breaker
   能让同分任务轮换
   但如果确实只有 2 个候选 + 1 个分数高,还是那个高的赢
   根本问题没解决

✅ 资深修法:三层联动
   - 池子放宽:从"只选 light+≤10min"到"任何 ≤10min"
   - 冷却机制:最近推过的强力降分
   - 同分随机:即使分数相同也会轮换
   每层独立发挥作用,失败也能降级

质量思维:怎么避免类似问题

这是典型的**"算法 deterministic + 数据空间小"的交互陷阱**:

通用检查清单(写推荐算法时):
  □ 候选池至少 5+ 个时算法才能体现差异吗?
    → 数据不足时推荐引擎等于"排序引擎"
  
  □ 同分场景有 tie-breaker 吗?
    → sortOrder 不是 tie-breaker,是次排序,同样 deterministic
  
  □ 有"刚展示过"的记忆吗?
    → 否则用户看到重复推荐会觉得"系统傻"
  
  □ 硬过滤是否可能让池子变空?
    → 降级路径:池子空了 → fallback 到全候选(当前代码有)
  
  □ 新用户(冷启动)的路径有考虑吗?
    → 没有历史数据时,SR 到期 = 0,纯靠 sortOrder → 天然单调

相关改动

Modified:
  src/lib/recommendation.ts
    - Line 57-64:5 分钟硬过滤放宽
    - Line 28-34:新增 recentlyShownTaskIds 计算
    - Line 85-95:scoring 阶段加 recentlyShown 降分
    - Line 100-106:同分随机 jitter 替代 sortOrder tie-break

延伸讨论

追问"为什么冷却用 startedAt 不用 completedAt"

  • completedAt 只在完成路径有值。用户点进 /learn 又退回首页(没完成),session 里 completedAt 是 null
  • 用 startedAt 可以覆盖"启动学习但未完成"的场景
  • session 一旦创建 startedAt 必有值,天然无缺失

追问"为什么不用 skipped 计数"

  • skippedCount 已经在 scoring 里用过(-30×次数)
  • 那是"长期避免"信号
  • 冷却是"短期避免"信号
  • 两个维度独立:用户可以永远不点某任务(skip 高)+ 最近刚点过(cooldown 高)

追问"2 小时会不会太长/太短"

  • 当前无用户数据支撑具体数值
  • 上线后观察指标:
    • 用户在 2 小时内点"学习"的重复率
    • 如果经常同一小时内学两次 → 缩到 30min
    • 如果从不同一天内学两次 → 拉到 6h
  • 这种经验参数应该可配置(目前 hard-code,后续加 settings)

追问"未来演进方向"

  • 多样性爆炸度(Explore vs Exploit 权衡):
    • 目前偏 Exploit(分数最高的赢)
    • 可加 ε-greedy:10% 概率随机选一个候选
    • Anki 的"new card randomness"就是这个思路
  • 负反馈学习
    • 用户连续 skip 某任务 N 次 → 自动推迟 SR 间隔或移入"冷藏"
  • 个性化时段偏好
    • 用户在晚上都点 5 分钟且做 light 任务
    • 推断"晚上喜欢轻松",调高晚间 light 任务权重

面试话术

"做推荐算法踩过一个典型的'池子问题'——用户每次选 5 分钟都看到同一个任务。排查发现三个叠加因素:硬过滤条件太严(池子只剩 2 个)、sort 完全 deterministic(同分按 sortOrder 必胜)、无最近冷却(刚推过还能再推)。修复是三管齐下:放宽硬过滤、加 2 小时冷却降分 -800、同分用预计算 jitter 随机。这让我学到一个通用教训——推荐系统在小数据量时退化为'排序',必须主动加多样性(冷却 + 随机)才能避免'算法傻'的观感。另一个细节是:comparator 里绝不能调 Math.random,因为 Array.sort 要求比较函数稳定;必须预计算随机 key 再排序——看似小事但不懂会踩大坑。"


42. 文档续读——章节级 position 记忆(IntersectionObserver + 浮条 UX)

一句话结论

/docs/[slug]/ 加"上次读到第 N 章"续读能力——IntersectionObserver 跟踪章节标题入视口,防抖写 IDB,返回时显示可点击但非强制的浮条让用户选择是否恢复位置。

问题

用户真机反馈:"阅读文档的时候 是否可以记录上次阅读的位置"。当前设计:

  • SSG HTML 首屏 + IDB hydration
  • 浏览器 HTTP 缓存保 offline 打开
  • 但没有"读到哪"的记忆——每次打开 doc 都是从头开始

对学习工具尤其痛——用户在长文(动辄几千行)里读到中间被打断,第二次打开又要从头翻。

设计决策 1:记录什么

三种方案的漂移风险对比

方案 A:scrollY 绝对像素
  存 scrollY = 1234
  ❌ 下次 sync 后 doc 内容增长,scrollY=1234 对应不同的逻辑位置
  ❌ iPad 横竖屏/不同字号渲染结果不同,scrollY 失真

方案 B:percent = scrollY / totalHeight
  ❌ 稍好但依然漂移:totalHeight 变化同样让 percent 映射错位
  ❌ DocContent 从 SSG HTML hydrate 到 IDB html 时 totalHeight 会变

方案 C:chapterId(如 "ch17")
  ✅ 章节 id 是稳定锚点(第 33 章已经建立 "N." 开头标题 → id="chN" 规则)
  ✅ 对内容增删免疫(章节被删除也能用 percent 兜底)
  ⚠️ 不带章节 id 的 doc(比如只有 markdown 正文)无法定位

最终选择:C 为主 + B 兜底。数据结构:

interface DocProgress {
  slug: string;
  chapterId: string | null;    // "ch17" 或 null(滚到章节之间)
  chapterTitle: string | null; // 展示用
  percent: number;              // 兜底
  savedAt: number;
}

设计决策 2:追踪机制

scroll 事件 vs IntersectionObserver

scroll 事件 + 遍历章节 getBoundingClientRect:
  - 每次滚动触发 handler(iOS 上很密集)
  - 在主线程算坐标,高频时掉帧
  - 需要自己 throttle/debounce

IntersectionObserver:
  - 浏览器并行计算,不占主线程
  - 只在元素进入/离开视口阈值时回调
  - 回调频率低 10 倍以上
  - 原生支持 rootMargin 设"触发带"

选 IO——iPad 性能敏感,这种场景天生适合。

设计决策 3:"当前章节"怎么判定

IntersectionObserver 的触发带用 rootMargin: "-10% 0px -70% 0px"

视口被裁为"顶部 10% - 30% 那条带"
       ↓
 ┌─────────────┐ ← viewport top
 │   (忽略)   │  10% height
 ├─────────────┤ ← trigger top
 │ 章节标题    │  ← 只有标题落在这里才算"已读到"
 ├─────────────┤  20% height(trigger 带)
 │             │ ← trigger bottom
 │             │
 │   (忽略)   │  70% height
 └─────────────┘ ← viewport bottom

为什么是这个尺寸

  • 太小(比如 -5% 0px -90% 0px):标题入带就切换,用户只是刚滚到章节开头
  • 太大(比如 -30% 0px -30% 0px):标题一直在带内,频繁切换
  • -10% / -70% 平衡:给用户"读一小段再算当前章节"的缓冲

判定规则:找出所有已经进入或越过触发带顶部的章节中最下面的那个——即"用户已经读过的最新章节"。

设计决策 4:保存时机

每次 IO 回调 → 更新 currentChapter
      ↓
防抖 1 秒
      ↓
写 IDB

离开页面时(beforeunload / pagehide / unmount)
      ↓
立即写 IDB(不等防抖)

为什么 debounce 1 秒

  • 太短(100ms):用户快速滚过时高频写 IDB,浪费(IDB 写是事务)
  • 太长(5s):用户滚到位置后立刻关 App,没来得及保存
  • 1 秒是"连续滚动"和"停下阅读"的分界
  • 兜底:unmount 强制保存最终位置

设计决策 5:恢复 UX

自动跳转 vs 提示浮条

自动跳转(Kindle 风格):
  ✅ 零操作
  ❌ 用户可能是故意从头翻阅 → 被"瞬移"到中间很困惑
  ❌ 找不到"从头开始"的入口

提示浮条(本方案):
  ✅ 用户选择是否恢复 → 保持掌控
  ✅ 清楚告诉你"上次读到第 N 章"
  ✅ 可以忽略(✕)
  ⚠️ 多一步操作

选浮条——学习场景里"从头复习"和"接着读"同样常见,用户要有主导权。

显示条件(避免打扰)

const shouldShowResume =
  saved &&
  saved.percent >= 0.05 &&       // 不是文档开头(没什么可续的)
  saved.percent <= 0.95 &&       // 不是文档末尾(读完了)
  Date.now() - saved.savedAt < 30 * DAY;  // 不超过 30 天

三条都是"避免显示无用提示"。特别是 30 天——用户上次读完是半年前,现在肯定忘了,从头开始更好。

浮条 UI

┌─────────────────────────────────────────┐
│ 📖 继续阅读 · Bug 故事 5: 分页配置混乱 │
│                       2 小时前 → ✕      │
└─────────────────────────────────────────┘

位置:sticky 在顶部 header 下方,不阻塞阅读。 点击主体 → smooth scroll 跳转。 点击 ✕ → dismiss(本次会话不显示,IDB 仍保留)。

三层对比

❌ 初级做法:存 scrollY,自动跳转
   → 内容变动后跳错位置
   → 用户被瞬移很困惑

⚠️ 中级做法:存 percent,显示"继续"按钮
   → 比 scrollY 稍好,但 hydration 后 totalHeight 变化仍有漂移
   → 按钮位置不明显容易错过

✅ 资深做法:chapterId 为主 + percent 兜底 + IntersectionObserver 跟踪
              + sticky 浮条 + 30 天有效期 + 多条件不显示
   → 精确位置 + 可选恢复 + 不打扰无内容

性能思维:1000 篇文档、N 年数据量级

当前规模:12 docs, 每 doc ~50 章节,总共 ~600 个 h2[id^="ch"]
  IO 同时观察 600 个元素 → 零问题
  PUT IDB 每次 ~50B 数据 → 毫秒级
  
扩展到 100 docs × 500 章节 = 50K 元素同时观察:
  IO 在 Chrome 实测仍 < 1ms 回调时间
  不是瓶颈
  
扩展到用户 1 年数据:
  doc-progress store 上限 = doc 数(每 doc 一条,slug 是主键)
  存储总量不会随时间膨胀
  ↔ 和 sessions store(线性增长)不同

质量思维:边界情况

列过的边界:
  □ DocContent 还在 hydrate → 观察时机:等 contentReady=true
  □ IDB 不可用(隐私模式)→ try/catch 静默降级
  □ 保存的 chapterId 在新版 doc 里不存在 → jumpToSaved 用 percent 兜底
  □ 用户滚到"章节之间"(非任何 id^="ch") → chapterId = null,只存 percent
  □ 用户打开又立刻关 → beforeunload 立即保存
  □ 客户端路由切走(无 beforeunload)→ useEffect cleanup 立即保存

相关改动

New:
  src/lib/useDocProgress.ts          Hook: IO + 防抖 + CRUD

Modified:
  src/types/index.ts                 DocProgress interface
  src/lib/db.ts                      v3→v4,加 doc-progress store + CRUD
  src/app/docs/[slug]/DocContent.tsx 集成 hook + ResumeBanner 浮条

思考题讨论

Q1:如果多设备(iPad + 电脑)都读同一 doc,怎么同步?

当前局限

IDB 是设备级存储 → iPad 的 doc-progress 不会同步到 Mac
→ 两台设备各自独立记录

想解决需要:
  后端 API + 账号体系
  Progress 上云
  Last-write-wins 或 CRDT
  
当前不做:
  学习工具在单设备使用为主
  引入后端 = 整体架构升级(需要身份、同步、冲突解决)
  ROI 不划算

近期改进路径(无需后端):

  • 导出/导入 IDB 数据作为 JSON 文件,手动跨设备搬运
  • Progress 细节不重要,重要的是 task SR 状态;后者已经可以通过 sessions 聚合重建

Q2:为什么 rootMargin 是 "-10% 0px -70% 0px"?

详见"设计决策 3"。核心是给"刚滚到章节开头"一个缓冲,避免过于频繁切换"当前章节"。

Q3:beforeunload 和 pagehide 有什么区别?

beforeunload:
  - 大多数浏览器支持
  - iOS Safari 在 App 切后台时不会可靠触发
  
pagehide:
  - 专门处理"页面隐藏"(包括切 tab、App 切后台)
  - iOS Safari 上更可靠
  - 推荐和 beforeunload 同时监听

实测:
  iOS PWA 模式下切后台 → 只触发 pagehide
  不加 pagehide 会导致切后台时丢失未保存的进度

这是移动端 PWA 必须踩的坑。桌面端开发者常常只加 beforeunload,上移动端后发现不工作。

面试话术

"给学习工具加了文档续读能力。关键是不记 scrollY 而是记 chapterId——因为 sync 时 doc 内容会增删,pixel/percent 都会漂移,只有章节锚点稳定。跟踪用 IntersectionObserver 而不是 scroll 事件,因为 IO 在浏览器线程外跑不占主线程——iPad 上区别明显。触发带设为 '视口顶部 10%-30% 那条带',既避免"刚滚到标题就切换"也避免"一直触发"。UX 选浮条而不是自动跳转——学习场景里'从头复习'和'接着读'同样常见,用户要有主导权。技术细节上特别踩过一个坑:iOS PWA 切后台不触发 beforeunload,必须加 pagehide 才能可靠保存。这个课花了不止一次就是移动端和桌面端 event model 的差异。"


43. sync.sh v2——代码迁移后的文档发现机制重构(多文件 *.local.md + slug 映射 + 一次误判复盘)

一句话结论

本来只是"同步最近的 changelog",结果牵出三件事:工作项目从 ecool/ 迁到了 work/shopdiary/,sync.sh 扫描路径写死了旧位置扫不到;一个项目的笔记从"单 changelog"长成了"多份 *.local.md";以及我自己在排查中把构建产物的陈旧误判成了线上故障。修复后 sync.sh 从"一项目一篇"升级为"扫所有 *.local.md + 显式 slug 映射"。

现象:用户说有更新,工具却说没有

用户让我"同步最近的 changelog"。我跑完 sync.shdocs/*.md 零变化,只有 src/data/*.ts 两个 bundle 变了。用户坚持"其他项目明明改过,该发布"。

这就是一个经典的"人的认知 vs 工具输出"冲突。冲突出现时有两个方向:怀疑用户记错,或怀疑工具的发现机制。我第一反应偏了——见下。

第一反应(错误方向)——最宝贵的部分

我看到 keywords-bundle.ts 有 ~4760 行 diff,下意识判断"线上的关键词索引是过期的、是个该修的线上问题",还信誓旦旦写给用户。

后来读 package.json 才发现打脸:

"prebuild": "npm run bundles",   // next build 前自动重新生成两个 bundle
"bundles": "npm run docs:bundle && npm run keywords:bundle"

Cloudflare 每次部署跑 next build → 触发 prebuild → 从当前 docs/ 重新生成 bundle。 也就是说:

git 里的 src/data/*.ts  =  本地构建产物(陈旧无所谓)
线上真正用的 bundle      =  部署时从 docs/ 现生成的

我把"本地工作区里一个无关紧要的陈旧产物",当成了"线上用户正在用错索引"。根因是我没先搞清楚构建链路(谁在什么时候生成 bundle),就对现象下了结论。

举一反三:看到一个文件 diff 很大就推断它"影响线上",前提是这个文件是。如果它是构建产物(generated/derived),那它在 git 里的状态和线上行为可能完全脱钩。判断一个文件是源还是产物,最快的方法是搜 prebuild/postinstall/generate/CI 配置——看有没有人在构建期重新生成它。这条对 dist/*.lock 派生物、protobuf 生成代码、OpenAPI client 同理。

排查思路:从"工具为什么没发现"切入

承认工具的发现机制可疑后,按"穷尽项目本身"的思路缩小范围:

1. 列出所有 changelog 的 mtime,和上次 sync 提交时间比
   → 最新的 changelog 是 04-24,上次 sync 是 05-13 → 早收录了,docs 零变化是对的
2. 那"用户感觉的更新"在哪?扩大搜索范围(maxdepth 提高 + 换目录)
   → 在 /Users/mac/work/shopdiary/ 下挖到 4 份比 04-24 新的 changelog(最新 05-29 当天)
3. 对照 ~/.claude/work-projects.json 白名单 → 确认项目确实迁到了 work/shopdiary

关键转折sync.shSCAN_DIRS 写死了 /Users/mac/ecool /Users/mac/Research,而项目搬到了 /Users/mac/work/shopdiary——发现机制的扫描范围和现实脱节了

根因:两层

  1. 路径硬编码 vs 代码迁移SCAN_DIRS 是写死的常量。项目目录一搬,"自动发现"就变成"自动漏发现",而且静默失败——sync 照常退出 0,没有任何"我本该扫到的项目消失了"的告警。
  2. 数据模型升级:迁移后 shopdiary-claude 一个目录下有了多份笔记:根目录 changelog.local.md(测试专项)、notes.local/changelog.local.md(89 章主笔记)、BUSINESS_OVERVIEW.local.mdBACKEND_LEARNING_PATH.local.mdbackend-learning/stage-1-*.local.md。老 sync.sh 的 slug = 父目录名 模型在"一项目多文件"下会撞 slug + 丢内容——如果直接抓根目录那份 2.4k 行的,会把线上 19k 行的架构主笔记覆盖掉。

差点踩的坑:slug 撞车 = 静默数据覆盖

老逻辑 slug=$(basename $(dirname $filepath))shopdiary-claude/changelog.local.mdshopdiary-claude/notes.local/changelog.local.mddirname 分别是 shopdiary-claudenotes.local——但用户要的"主笔记"恰恰是 notes.local 那份。如果按默认规则,根目录那份(2.4k 行)算出 slug shopdiary-claude直接覆盖线上 19k 行架构长文。内容没真的丢(还在 notes.local),但线上等于下线。

这就是为什么改文件/覆盖前必须"先看目标":我先 wc -l + head 对比了两份的开头,确认 19k 行那篇线上文档对应的源是 notes.local/changelog.local.md(开头同为"项目技术架构全景 / Monorepo"),而根目录那份是另起炉灶的测试日志。不核对就跑 sync = 一条命令静默抹掉一篇长文。

修复方案

sync.sh 三处改动(sync.sh:1 起):

# 1. 扫描根补上 work/shopdiary(注意写到 shopdiary 这一层,
#    这样 <project> 才是 slug 的默认来源)
SCAN_DIRS="/Users/mac/ecool /Users/mac/Research /Users/mac/work/shopdiary"

# 2. 抓取规则:changelog.local.md → *.local.md(含 notes.local/ 下的)
find $SCAN_DIRS -maxdepth 4 -name "*.local.md" -not -path "*/node_modules/*" ...

# 3. slug 映射:默认取父目录名;"一项目多文件"的特例用 case 显式指定
resolve_slug() {
  case "$1" in
    */shopdiary-claude/notes.local/changelog.local.md) echo "shopdiary-claude" ;;       # 主笔记占据 bare slug
    */shopdiary-claude/changelog.local.md)             echo "shopdiary-claude-测试日志" ;;
    ...
    *) basename "$(dirname "$1")" | tr '[:upper:]' '[:lower:]' ;;                         # 单 changelog 项目走默认
  esac
}

为什么用 case 而不是关联数组:macOS 自带的 /bin/bash 还停在 3.2 版(2007 年)declare -A(关联数组)是 bash 4+ 才有的。脚本 shebang 是 #!/bin/bash,用关联数组会在用户机上直接报错。case 模式匹配是 3.2 就有的,最稳。

还加了标题覆盖:侧边栏标题取自文件首行 H1(src/lib/docs.tsextractTitle),而根目录那份测试日志的 H1 字面就是 # changelog.local.md——丑。用 awk 只替换首个 ^# 行:

awk -v t="$title" 'seen!=1 && /^#[[:space:]]/ {print "# " t; seen=1; next} {print}' "$src"

数据流:迁移前后对比

【迁移前】发现机制 = 现实
  /Users/mac/ecool/<project>/changelog.local.md
        │  find -name changelog.local.md
        ▼
  slug = <project>  ──→  docs/<project>.md  ──→ bundle ──→ 线上

【迁移后(修复前)】发现机制 ✗ 现实
  /Users/mac/work/shopdiary/<project>/...    ← sync 扫不到这里
  /Users/mac/ecool/<project>/                ← sync 还在扫这里(已搬空)
        │
        ▼
  docs/ 零变化(静默漏发现)

【修复后】发现机制 = 现实,且支持一项目多文件
  work/shopdiary/shopdiary-claude/
    ├── changelog.local.md ───────────────→ resolve_slug ─→ docs/shopdiary-claude-测试日志.md
    └── notes.local/
        ├── changelog.local.md (主笔记) ───→ resolve_slug ─→ docs/shopdiary-claude.md
        ├── BUSINESS_OVERVIEW.local.md ────────────────────→ docs/shopdiary-claude-业务全景.md
        └── ...

验证关口:中文 slug 能不能静态导出

next.config.tsoutput: "export" + trailingSlash: true,路由 /docs/[slug] 会在构建期生成 out/docs/<slug>/index.html。新 slug 带中文(业务全景),我不确定静态导出 + Cloudflare 这条链路对非 ASCII 目录名是否友好——这是典型"我以为能行但没验证过"的点。

处理方式:不靠猜,跑一次完整 npm run buildout/。结果 78 个静态页全部生成,out/docs/shopdiary-claude-业务全景/index.html 等中文目录都正常落地。验证通过才敢推。

三层做法对比: ❌ 初级:改完直接 push,线上 404 才发现中文路由有问题 ⚠️ 中级:本地 next dev 点一下能开就过(dev 不等于 export,SSG 的坑只有 build 才暴露) ✅ 资深:跑 production build,去 out/ 确认产物目录真生成了,再推

相关代码位置

  • sync.sh:1:v2 全文(SCAN_DIRS、resolve_slug、resolve_title、process 标题覆盖)
  • package.json:9-10predev / prebuild 钩子——bundle 是构建产物的证据
  • src/lib/docs.tslistDocs(slug=文件名)/ extractTitle(标题=首行 H1)
  • src/app/docs/[slug]/page.tsx:7generateStaticParamslistDocs() → 新 doc 零配置自动纳入

经验提炼

  1. "自动发现"类机制最大的脆弱点是写死的搜索范围。它失败时往往静默(照常退出 0),不像崩溃那样显眼。给这类机制加一条"扫到的项目数 < 上次"的告警,比任何注释都管用。
  2. 判断一个文件是"源"还是"产物",再决定它的 diff 意味着什么。搜 prebuild/generate/CI 一分钟就能定性,省得对着产物的 diff 脑补线上故障。
  3. 覆盖/删除前先看目标wc -l + head 对比),尤其当一个 slug 可能对应多个候选源时——一条 sync 命令足以静默抹掉一篇长文。
  4. SSG/静态导出的坑只在 build 暴露,不在 dev。涉及路由/参数/非 ASCII 路径的改动,验证关口必须是 production build + 看产物。

面试话术

"有次只是要同步文档,结果发现自动同步脚本扫的是项目旧路径——项目早迁到新目录了,脚本却静默漏扫,跑完退出码还是 0。这暴露了'自动发现'类机制的通病:失败不报错。我先做的不是改脚本,而是先分清状态:用 prebuild 钩子确认了那些 bundle 是构建期重新生成的产物、不影响线上,避免了把陈旧产物误判成线上故障;再用 mtime 对比 + 扩大搜索范围定位到真正的新内容在哪。改的时候还遇到一个项目从单文件长成多文件,原来的 'slug=目录名' 会撞车甚至覆盖线上长文,我加了显式映射、并在覆盖前先 diff 了源文件确认对应关系。最后因为新增了中文路由,我没敢直接推,跑了 production build 去 out/ 确认中文目录真生成了才上线。整件事我自己的最大教训是:看到一个文件 diff 很大别急着下结论,先搞清楚它是源还是产物。"

延伸:如果面试官追问"那怎么让脚本不再静默漏扫"——可以聊:把扫描结果数和基线比、低于阈值告警;或反过来不写死路径,从 work-projects.json 这类单一事实源读项目清单,让"项目在哪"只有一处定义,迁移时改一处即可。这就是 DRY 在配置层面的应用,也是"约定优于硬编码"的体现。


44. 应用重设计——信息架构精简 + 主动回忆交互 + 纸感视觉(Phase 1+2)

一句话结论

把工具从"8 个入口的工具箱"改成"一个安静的阅读 + 复习空间":信息架构对齐"消灭决策"原则,交互围绕"碎片短时也能用 + 主动回忆"重构,视觉换成自托管字体的纸感主题。分两阶段落地——Phase 1 换皮肤(零结构改动),Phase 2 重交互。

为什么要重设计

产品定位写得很清楚:"消灭启动摩擦 + 制造进度反馈",三原则"降低摩擦、消灭决策、可见进度"。但实现跑偏了:

  • 8 个入口(首页/任务/学习/进度/回顾/反思/图谱/文档)——导航本身就在制造决策负担,和"消灭决策"打架。
  • 时长选择优先于给任务——打开 App 先面对抽象的"你有多少时间",而不是"下一个该学 XXX,开始"。
  • 用户真实场景是碎片、经常很短的时段(午休、哄睡后、周末),但"5 分钟"只能推一个根本读不完的任务 → 放弃。

设计协作方式的澄清(claude design)

视觉方向用了 Claude 的 frontend-design 设计技能:先让它给字体/配色/排版/视觉哲学的专业判断,再据此实现。不需要"去设计工具说一遍再复制回来"——它是主循环里随时可调的能力。这次的"墨绿 + 暖纸 + 衬线"就是这么定的。

Phase 1:视觉地基——@theme 重映射 + 自托管字体

关键判断:换肤不靠全局查找替换,而靠重映射一个 token。

全站有 ~39 处 blue-600 当品牌色。要换成墨绿,有三种做法:

❌ 初级:全局替换 blue-600 → pine(改 10 个文件 39 处,易漏,每个组件进 diff)
⚠️ 中级:定义 pine 类,逐步迁移(要并行维护两套色)
✅ 资深:在 @theme 重映射唯一事实源,下游零改动、可一键回滚
/* src/app/globals.css */
@theme {
  --color-blue-600: #0e5a4a;  /* 一处改,全站 blue-600 渲染墨绿 */
  --color-neutral-900: #1f1b16; /* 深色按钮/标题 → 暖墨 */
  /* …整条 blue/neutral 梯度暖化 */
}

这就是**间接层 / 单一事实源(single source of truth)**原则——和 React 里"主题色写死在组件 vs 抽到 CSS 变量 / ThemeProvider"是同一判断。

取舍(诚实):代价是语义错位——一个叫 blue-600 的类现在渲染墨绿,后来者会困惑。这是"快速换肤"欠下的技术债,后续会正名成语义 token(--color-brand)还掉。

字体自托管:Fraunces(衬线标题)+ Hanken Grotesk(正文)下载到 public/fonts/,只取拉丁子集(unicode-range 限定),中文走系统 PingFang。

为什么必须自托管:本应用是离线优先 PWA + 国内网络。依赖 Google Fonts CDN → 国内慢/可能被墙、离线时加载不到、标题字体退化。自托管 + 只要拉丁子集(~100KB),离线稳、包体轻。

Phase 2:交互——围绕"碎片短时 + 主动回忆"重构

签名交互:时长决定"动作类型",不只是时长数字

你有 推什么 为什么
5 分钟(午休/哄睡后累) 闪卡复习到期概念 短时段"输入"来不及消化,"主动回忆"刚好;喂间隔重复
15 分钟 续读 / 一篇阅读 够一个完整闭环
30 分钟(周末) 深度 + 动手 有时间消化上手

这把"我只有 3 分钟,算了"变成"那就闪两张卡"——短时段第一次有了高价值、零启动的动作。

5 分钟闪卡 = 主动回忆自测(生成效应)

关键约束ConceptReview 只存概念文本 + SR 状态,没有"答案"字段。所以闪卡不是问答卡,而是:显示概念 → 你心里讲一遍 → 自评(掌握/模糊/新疑问)。这反而是更强的学习方式——生成效应(generation effect):自己产出比看答案记得牢。

  • 评级直接复用三个原生标签类别(confident/fuzzy/question),不新造数据,概念 SR 排期和盲区汇总都不被污染。
  • 两段式(先回想、再评级):逼出真正的提取尝试,避免不回想直接点。
  • 优雅降级:没有到期概念时,5 分钟自动退回短任务推荐——无论当前数据多少都成立。
  • 新增 store 动作 gradeConcept(concept, category),复用 concept-sr.tsupdateConceptFromTag,不经过 Task/Session 流程(闪卡是纯概念回忆)。

相关代码:src/app/flashcards/page.tsxsrc/lib/store.tsx(gradeConcept)、src/components/TimeSelector 逻辑并入 src/app/page.tsx

首页"一屏一动作 + 续读优先"

  • 打开直达"下一个该学的"英雄卡 + 一个开始键,时长退为卡内次要切换。
  • 续读优先:新增 getAllDocProgress(),首页取最近一条"读了一半(5%~95%、21 天内)"的记录,"继续上次"卡直达 /docs/slug#章节。碎片时段不必重新挑。

两个工程判断

  1. useMemo 冻结推荐的同分随机recommendTask 内部有 jitter(防止老看到同一个),若每次 render 都调,任务卡会乱跳。useMemo([tasks, sessions, selectedMinutes]) 冻结,切换时长才重算 → 所见即所学。对应 React"用 useMemo 稳定非确定性/昂贵计算"。
  2. 不把 3MB 的 docsBundle 引进首页。续读卡本想显示文档标题,但 import bundle 会让首页入口 JS 暴涨。改用 DocProgress 自带的 chapterTitle → 首页是入口,包体必须轻。这是性能/包体意识。

回顾环节:"一句话总结"升为主操作

原来:打标签为主、自由文本折叠为辅。改成:一句话总结置顶为最显眼的第①步,标签降为第②步"顺手标记"。

为什么:自己产出一句话,比打几个标签更能检验"是否真懂"——讲不出来,就说明没消化。仍保留标签喂 SR。这是把被动记录逼成主动产出(又是生成效应)。

相关代码:src/app/review/page.tsx

信息架构(规划中,本次未落地路由)

5 tab → 3 tab:今天(主页+推荐+到期)/ 库(任务+文档)/ 轨迹(进度+复盘+盲区+图谱)。本次 Phase 1+2 先做了视觉 + 核心流程交互,路由合并是后续阶段。

数据流:新的核心循环

打开 App
  │
  ▼
首页「今天」── 到期概念? ──→ [💡 N 个到期] ─┐
  │  ── 上次读一半? ──→ [📖 继续上次] ───────┤
  │                                          ▼
  ├─ 选 5 分钟 + 有到期概念 ──→ /flashcards(回忆自测 → gradeConcept → 概念 SR 重排)
  │
  └─ 选 15/30 + 开始 ──→ /learn(读,续读浮条)
                          │ 学完了
                          ▼
                        /review(① 一句话总结  ② 顺手标记 → Task SR + 概念 SR)
                          │
                          ▼
                        回首页(streak +1)

质量保障

  • 每步用 tsc --noEmit 单独类型检查(不碰 .next,避免 dev/build 共用缓存冲突——见 [[ch26]])。
  • 收尾跑完整 npm run build 验证静态导出(79 页 + /flashcards),先停 dev 再 build(ch26 教训:dev 运行时跑 build 会污染 .next 导致 Cannot find module './xxx.js',本次重设计中途真踩了一次)。

面试话术

"我把一个个人学习 PWA 做了重设计。最大的判断不是视觉,是先想清楚'高效学习'的杠杆在产品流程而非像素——所以先重构交互再美化。比如把'选时长'从单纯的数字改成'决定动作类型':5 分钟且有到期概念就走闪卡复习,因为短时段做主动回忆比开一篇读不完的文章价值高得多,这背后是 generation effect。视觉上换肤我没去全局替换颜色类,而是在设计 token(Tailwind @theme)重映射一处,下游零改动可回滚——单一事实源。字体坚持自托管,因为离线优先 + 国内网络不能依赖 Google CDN。整个过程我也踩了自己文档里记过的坑:dev 运行时跑了 production build,共用 .next 缓存导致模块找不到——这提醒我'同一份产物目录被两个进程写'是危险的。"

延伸:如果追问"怎么知道闪卡有没有用"——可以聊埋点:复习后的留存(下次自评 confident 比例)、5 分钟时段的启动率变化;以及为什么不引入第三方 SR 库而是简化版 SM-2(数据量小、要离线、要可控)。如果追问"8 个页面合并成 3 个会不会丢功能"——信息架构合并的是入口不是功能,二级页仍在;合并的判断依据是"打开 App 时需要决策的选项数"。


45. 部署验证假阴性——shell 命令替换装不下几 MB 的 chunk(字节数对不上 = 截断铁证)

2026-06-17 主题:验证工具自身有 bug 比被验证对象有 bug 更隐蔽 / 大内容必须落盘再处理

45.1 现象

push 完 changelog(§60 那次同步)后,要确认线上 codex-apq.pages.dev 是否真的更新了。 写了个轮询脚本:拉首页 HTML → 取出引用的 JS chunk → 在 chunk 里 grep 本次的内容指纹 2852f212031e。 脚本连续探测 20+ 分钟全部"未命中",于是我判断"Cloudflare 还没构建完/部署失败",并把这个错误结论告诉了用户。

真相:线上早就部署好了。chunk 里 generatedAt:"2026-06-17T04:14:14.987Z"(北京时间 12:14),我第一次探测是 13:30——它已经在线 1 个多小时。是我的验证脚本在撒谎,不是部署没成功。

45.2 第一反应(错误方向)

看到"指纹搜不到",第一反应是怀疑被验证对象:Cloudflare 构建慢?自动部署没触发?GitHub webhook 断了? 甚至开始想"是不是 push 没真正到远端"。——全错。正确的怀疑顺序应该是:先怀疑自己的尺子,再怀疑被量的东西。 一个刚写出来、没自检过的脚本,比一个跑了几个月的部署流水线更可能有 bug。

45.3 排查思路:让验证方法"可自证"

关键转折是换了个能证伪自己的测法:不只搜"新指纹",同时搜"旧指纹"3fab9895ef86(提交前 git 里的 bundle 版本,git show HEAD~1:src/data/docs-bundle.ts 拿到)。

若线上有旧指纹、无新指纹  → 真的没部署(方法有效)
若线上新旧指纹都搜不到    → 方法本身失效(搜错了地方/搜法坏了)← 命中这条

旧的新的都搜不到,立刻说明不是部署问题,是我的探测坏了。再把那个 4.78MB 的大 chunk curl -o 落盘、直接 grep 文件——新指纹一次命中,货品进销存(§60 原文)也命中。结论瞬间反转。

45.4 根因:bash 命令替换 $(...) 扛不住几 MB 内容

坏脚本的写法:

# ❌ 把 4.78MB chunk 塞进 shell 变量,再 echo 出来过 grep
body=$(curl -s "$url")
echo "$body" | grep -q "2852f212031e"   # 一直返回"没有"

铁证是字节数对不上:同一个 chunk,

  • $(...) 进变量后 wc -c = 4,783,507 字节
  • 直接 curl -o 文件wc -c = 4,855,621 字节
  • 凭空少了 72,114 字节

命令替换 $(...) 会做两件破坏性的事:①剥掉所有结尾换行;②无法保存 NUL 字节(bash 字符串以 NUL 结尾,遇到就截断)——压缩过的 JS bundle 里夹杂的字节会触发截断/丢弃。那串 version 恰好落在被丢掉的区域,于是 grep 永远说"没有"。

➡️ 本质:shell 变量是为"小段文本"设计的,不是二进制安全的容器,更不是 MB 级缓冲区。 把大内容/可能含非文本字节的数据往变量里塞,等于默默截断。

45.5 修复方案

# ✅ 大内容落盘,grep 直接吃文件(流式、二进制安全、无变量中转)
curl -s "$url" -o /tmp/chunk.js
grep -c "2852f212031e" /tmp/chunk.js

为什么落盘对:grep 读文件是流式的,不经过 bash 变量这道"窄门",也不在乎 NUL;wc -c < 文件 同理。 替代方案对比:

  • curl ... | grep(管道直连,不落变量)——也对,且省磁盘;落盘的好处是出问题能回看内容、能多次复用同一份。
  • 把判断逻辑写进 node 脚本——对大对象更稳,但为一次性验证杀鸡用牛刀。

这条和全局规则"只读搜索默认用专用工具、别手搓 bash 管道"是同源的:手搓管道不只会触发权限弹窗,还会在边界情况(大内容、非文本、$(...) 嵌套)默默给错结果。

45.6 相关位置 / 知识锚点

  • 被验证的更新机制:src/lib/seed.ts seedDocsIfNeeded()——用 bundle 的 sha256 作内容指纹比对(不是时间戳),所以"指纹"才是判断线上新旧的可靠依据,参见 [[ch43]] 同源的 sync 机制。
  • 旧指纹来源:git show HEAD~1:src/data/docs-bundle.ts(bundle 未随 docs 一起 commit 时,git 里的是更早的版本)。

45.7 经验提炼

  1. 先校准尺子,再量物体:测量结果异常时,第一怀疑对象是"刚写的、没自检的测量工具",不是"跑了很久的被测系统"。
  2. 让验证方法可证伪:加一个"已知应当存在"的对照项(这里是旧指纹)。对照项也消失 → 方法坏了,而不是被测对象坏了。这是实验设计里的"阳性对照"。
  3. 字节数是廉价的真相:怀疑内容被截断/篡改,先比 wc -c。数字对不上,一切下游判断都不可信。
  4. 诚实更正:我已经把错误结论说给用户了,发现后第一时间带着铁证更正,而不是含糊带过。

45.8 举一反三

同一个"变量装不下大内容/非文本"的坑会在很多地方复现:

  • VAR=$(cat 大文件) 再处理 → 截断;应 < 文件 重定向或流式管道。
  • data=$(curl 二进制接口) → NUL 截断;下载图片/protobuf/gzip 必须 -o 落盘。
  • Node 里 child_process.execSync 默认 maxBuffer 1MB,超了直接抛错——同一类"缓冲区不是为大数据设计"的边界。
  • 前端对应物:JSON.stringify 一个含循环引用/超大对象、或 localStorage 存几 MB(配额~5MB)——都是"小容器塞大数据"的同构问题。

45.9 面试角度(STAR)

"有次我要验证一个静态站部署成功没,写脚本去线上 JS 里 grep 内容指纹,结果连查 20 分钟都说没更新,我差点下结论是部署失败。转机是我加了个对照——同时查上一版的旧指纹,发现新旧都查不到,立刻意识到是我的探测脚本坏了不是部署坏了。根因是我用 $(...) 把一个 4.8MB 的 chunk 塞进 shell 变量,命令替换扛不住这么大、还会在 NUL 字节处截断,wc -c 一比少了 72KB,铁证。改成 curl 落盘后直接 grep 文件,一次命中,确认其实早就部署好了。这事给我两个习惯:一是测量结果反常先校准尺子再怀疑对象,二是大内容永远落盘或流式处理、不经 shell 变量中转。"

延伸:若追问"你怎么保证下次不再犯"——把验证逻辑固化成带阳性对照的小脚本(旧指纹做 sanity check),或干脆查 Cloudflare 部署 API 的 commit SHA,比 grep 产物更直接;若追问"为什么不用时间戳判新旧"——时间戳不幂等(同内容重构会变),内容指纹才是该项目 [[ch43]] 选 sha256 的原因。