BlogRoom 的书籍和友链属于构建期数据,GitHub、RSS 和音乐属于运行时数据,玩偶文字则属于 Canvas 内的实时物理状态。三类数据的生命周期不同,代码也分别放在生成脚本、边缘 API 和场景物理层。
AstroBlog 到 BlogRoom 的数据契约
AstroBlog 构建时执行 scripts/generate-room-data/index.mjs。脚本递归读取 src/content/bangumi/ 和 src/content/friends/ 的 Markdown Frontmatter,只保留 category: book、启用的友链和合法 URL,生成:
AstroBlog/src/content/bangumi + friends ↓scripts/generate-room-data/index.mjs ↓AstroBlog/public/room-data/books.jsonAstroBlog/public/room-data/friends.json输出 envelope 固定为 { version: 1, generatedAt, items }。书籍包含 id、title、cover、description、status、score、tags 和 detailPath;友链包含 id、title、image、description、url 和 tags。生成脚本会按 ID 或 URL 去重,避免内容源中存在重复记录时污染房间。
BlogRoom 构建前运行 scripts/sync-room-data.mjs,先请求 ROOM_DATA_ORIGIN 下的线上 JSON。网络失败时读取 ROOM_DATA_LOCAL_DIR 指向的 AstroBlog 本地快照,再失败才保留 BlogRoom 当前已有快照。每次读取都验证 version、items 数组和必需字段,不接受格式错误的远程响应。
线上 AstroBlog JSON ↓ 失败本地 AstroBlog 快照 ↓ 失败BlogRoom 已有快照 ↓src/config/room-data/*.jsonsrc/config/index.ts 在构建时直接导入这些 JSON,书架和友链组件不会在浏览器再次请求它们。修改共享字段时,必须同时更新生成脚本、BlogRoom 的 validEnvelope() 字段列表、类型定义和卡片使用位置。
运行时 API
BlogRoom 的浏览器请求统一通过 src/api/http.ts 的 requestApi()。它创建 8 秒 AbortController 超时,附加 credentials: 'same-origin' 和 JSON Accept,非 2xx 响应解析为带 code、requestId、retryable 和 status 的 ApiError;成功响应必须符合 { data, meta: { cachedAt, requestId, stale } }。
本地 Vite 和生产 Pages Function 共用 src/api/edge/router.ts。当前路由是 /api/feeds、/api/github、/api/health 和 /api/music。router 先拒绝非 /api/ 路径和带静态资源扩展名的请求,再根据 pathname 与 HTTP 方法分派 handler;方法不存在返回 405,路径不存在返回 404。
第三方 token 只在边缘 handler 的环境变量中使用。GitHub handler 返回公开 profile、仓库和贡献数据,RSS handler 解析 feed,音乐 handler 访问博客提供的目录。浏览器收到的是经过 Zod schema 验证的统一结构,不会看到 GitHub token 或上游错误堆栈。
TanStack Query 缓存
app.tsx 创建的 QueryClient 关闭窗口聚焦自动刷新,失败重试一次,staleTime 为五分钟。feature 面板在打开时执行 query,关闭后保留缓存;再次打开不必重新请求未过期数据。修改 API 响应结构时,要同步更新 feature 的 data schema 和 loading/error/empty 状态,不能让组件直接访问未知 JSON。
Rapier 字形物理
字形物理位于 src/scene/doll-words/physics.tsx,使用 @react-three/rapier。DollWordPhysics 创建独立 physics world,重力为 [0, -200, 0],固定碰撞体由 StaticRoomColliders 声明;每个文字由 Drei Text 渲染,并由 RigidBody + CuboidCollider 承载。
<Physics colliders={false} gravity={[0, -200, 0]} timeStep={1 / 60}> {/* 第一次释放字形前先预热字体,避免出现一帧 fallback 字体。 */} <FontWarmup onReady={onReady} /> {/* 用固定 Rapier 碰撞体表示不会移动的房间结构。 */} <StaticRoomColliders /> <DollWordBodies /></Physics>core.ts 先根据短语、设备宽度和 reduced motion 生成 glyph plan,计算字形宽高、显示时间、释放时间、初始冲量和角速度;物理层再把 plan 转为 Three.js 世界坐标。动态字形释放后施加 impulse 和 angular velocity,睡眠时缩短可见时间,越界时移除。隐藏、清除、held、dynamic 和 clearing 是不同阶段,不能只用一个 boolean 控制。
移动端只预热主字体并减少字形数量;开启 reduced motion 时不释放动态刚体,而是显示短暂的固定文字。新增字形动画时,应先修改 core.ts 的计划和限制,再调整 physics.tsx 的刚体行为,避免把时间线规则写进渲染组件。
修改数据、API 和物理的顺序
修改书籍或友链:先改 AstroBlog 内容或生成脚本,再运行 BlogRoom pnpm sync:room-data;修改接口:先改 edge handler 和 schema,再改 requestApi() 使用方;修改字形:先改 core.ts 的纯计算,再改 Rapier 层和 reduced-motion 分支;修改第三方凭据:只改 Pages 环境变量和边缘函数,不把 key 放进 Vite 客户端配置。
# 从线上数据开始同步,失败时按脚本顺序回退到本地快照。pnpm sync:room-data# 检查 API、物理层和场景组件类型。pnpm typecheck# 运行当前测试文件。pnpm test# 执行同步、类型检查和 Vite 生产构建。pnpm build当前构建会转换约 2643 个模块,Rapier 属于较大的运行时依赖。新增场景功能时要关注首屏 bundle、Canvas 是否等待过多资源、无 WebGL fallback 是否可用,以及外部 API 失败时房间是否仍然可以交互。
API 响应和缓存语义
边缘 handler 的成功响应包含 data 和 meta。cachedAt 表示数据生成或缓存时间,stale 表示上游不可用时是否使用了旧值,requestId 用于把浏览器错误和边缘日志对应起来。feature 显示错误时应保留 requestId,方便定位;不能只显示“请求失败”而丢弃服务端提供的诊断信息。
健康接口可以检查边缘函数是否发布、运行时版本和上游配置,但不应泄露 token 或完整环境变量。GitHub/RSS/音乐 handler 需要设置超时、限制响应大小并校验上游 JSON/XML,避免一个异常上游把大响应直接传给浏览器。
Rapier 生命周期和性能
DollWordBodies 只为可见 glyph 创建刚体。hidden 阶段不挂载物理对象,clearing 阶段禁用刚体并等待缩放动画结束,dynamic 阶段按固定帧间隔检查越界。canSleep、线性/角阻尼和软 CCD 参数用于限制刚体长期运行;修改重力或碰撞体时要观察字形是否卡在墙体、是否永不休眠以及清除操作是否泄漏对象。
Rapier 会增加初始 bundle 和 wasm 加载成本,因此 DollWordLayer 需要等待 warmup,并在不支持 WebGL 或 reduced motion 时走非物理分支。不要为了一个短动画把整个房间改成每帧高精度物理模拟。