BlogRoom 是独立部署的 React + Three.js 个人主页。它把房间场景作为导航和交互层,但没有把所有界面都绘制到 WebGL:家具、相机和空间动画在 Canvas 中运行,文章、书籍详情、搜索、GitHub 信息、RSS 和播放器面板由 React DOM 渲染。两层通过 Zustand 状态和命令函数连接。
工程和依赖
项目使用 Vite 8 作为开发服务器和生产构建工具,React 19、React DOM 19 负责界面,React Three Fiber 9 把 Three.js 场景映射为 React 组件。Three.js 0.185 提供渲染基础,@react-three/drei 提供 Text、MapControls 等常用对象,@react-three/postprocessing 和 postprocessing 实现线稿场景的后处理。
状态和数据层分别使用 Zustand 5、TanStack Query 5 和 Zod 4。GSAP 负责镜头和物件时间线,@react-three/rapier 负责字形物理。Node.js 版本范围是 >=24.9.0 <25,pnpm 版本范围是 >=10.33.0 <12,当前本地验证使用 Node.js 24.18.0 和 pnpm 11.17.0。
目录结构
src/├─ app/ React 根组件、错误边界、主题同步├─ scene/ Canvas、相机、房间壳体、家具和后处理├─ features/ 资料、GitHub、RSS、音乐、搜索和链接面板├─ stores/ room-store、player-store 等 Zustand 状态├─ api/ 浏览器请求封装和边缘 API 路由├─ config/ profile、主题、房间数据和运行参数├─ hooks/ 媒体查询、降级和 reduced-motion hooks├─ types/ 房间、API 和 feature 的类型└─ utils/ 命令、数据、存储、音频和 WebGL 工具src/app/app.tsx 是运行时入口。它创建 QueryClient、错误边界和主题同步组件,再挂载 RoomExperience。RoomExperience 判断浏览器是否支持 WebGL,支持时渲染 RoomCanvas,不支持时渲染 RoomFallback;无论哪种情况,React DOM 的弹窗和播放器都可以继续工作。
Canvas 和 DOM 的边界
RoomCanvas 创建 R3F <Canvas>,开启正交相机、按主题设置 DPR 和背景色,并使用 frameloop="demand" 减少没有状态变化时的渲染。Suspense 包住 RoomScene,场景资源尚未准备好时不输出半成品对象;SceneReady、字形 warmup 和 line reveal 共同决定房间何时显示。
// 限制 DPR 并按需渲染,避免静态线稿场景持续占用 GPU。<Canvas orthographic dpr={[1, theme === 'dark' ? 1.5 : 1.75]} frameloop="demand" camera={{ far: 140, near: 0.1, position: [20, 14.5, 22], zoom: 58 }}> <Suspense fallback={null}> {/* 场景挂载和字形预热都完成后,才允许移除房间加载层。 */} <RoomScene ... /> <SceneReady onReady={onSceneReady} /> </Suspense></Canvas>Canvas 使用 aria-hidden,因为它的点击入口同时有命令和 DOM 面板。RoomFallback 提供静态 SVG 房间预览,WebGL 不可用时不能只留下空白区域。新增交互时,应该为 Canvas 物件提供对应的 DOM 操作入口或 fallback 行为,不能只依赖 hover 和鼠标点击。
App 的状态编排
createQueryClient() 将窗口聚焦重新请求关闭,失败只重试一次,数据五分钟内视为新鲜。面板打开后,feature 组件通过 TanStack Query 请求 API;请求失败只影响面板,不卸载 Canvas。AppErrorBoundary 负责捕获 React 渲染异常,防止一个外部数据卡片把整个房间变成白屏。
RoomExperience 根据 panel 和 isDoorExitPromptOpen 推导 activeCameraFocus。桌面端通过 focusObject() 让相机移动到面板对应物件,移动端不强制聚焦,而是保留 MapControls。场景、面板和相机没有互相直接调用,而是通过 store 中的状态完成协调。
构建和边缘部署
pnpm build 先运行 sync:room-data,再执行 TypeScript 项目构建和 Vite bundle。生产环境部署到 Cloudflare Pages,functions/api/[path].ts 将 /api/* 请求转给与本地相同的 edge router。GitHub token 等凭据只保存在 Pages 环境变量,浏览器只能得到经过校验的公开 JSON。
静态资源、字体和房间数据在构建时进入产物,RSS、GitHub、音乐和健康检查在运行时由边缘函数请求。修改部署配置时要区分这两类资源:构建时错误会让 bundle 失败,边缘接口错误则应返回统一的 ApiError,不影响静态房间加载。
维护入口
改 React 外壳看 src/app/;改房间渲染看 src/scene/;改弹窗和业务功能看 src/features/;改房间交互状态看 src/stores/room-store.ts 和 src/utils/room-commands.ts;改 API 看 src/api/http.ts、src/api/edge/ 和 Pages Function;改共享书籍/友链数据看同步脚本和 src/config/room-data/。
验证命令
# 运行当前 Vitest 工具用例。pnpm test# 检查 React、R3F、API 和 TypeScript 类型。pnpm typecheck# 运行 ESLint 与 Stylelint,禁止遗留 warning。pnpm lint# 同步房间数据、编译 TypeScript 并生成 Vite 生产 bundle。pnpm build测试当前只有 1 个 Vitest 文件、2 个用例,主要覆盖工具函数。涉及 Canvas、浏览器 pointer、音频或 Pages Function 的修改,需要额外在桌面端、移动端和无 WebGL 环境检查。
渲染循环和资源加载
Canvas 使用 frameloop="demand",静态场景不会像默认模式一样每帧渲染。相机动画、线稿揭示、字形物理和正在播放的物件需要通过 R3F 的 invalidate 或 useFrame 请求新帧;动画结束后应停止请求。新增一个会持续变化的组件时,如果直接在 render 中创建定时器,容易导致重复计时和高 CPU 占用,应把时序放进 effect 并在 cleanup 中取消。
Suspense 只负责等待 React Three Fiber 可挂载的异步资源,不能替代业务层的 ready 状态。房间揭示前需要确认 Canvas 已创建、字体 warmup 完成、场景组件已挂载,RoomLoader 才移除加载层。慢速设备上如果把这些条件合并为一次 onCreated,容易出现字体突然跳入或线稿动画提前结束。
配置和环境变量
src/config/ 中的 profile、主题、房间 JSON 和默认 camera zone 会进入客户端 bundle;它们不能保存密钥。GitHub token、RSS 上游地址中需要保护的参数和音乐访问凭据只在 Cloudflare Pages Function 环境变量中使用。.env.example 只列出变量名称和说明,不应填入真实值。
生产构建通过 functions/api/[path].ts 进入 edge router,本地 Vite 通过 vite.config.ts 复用同一分发函数。修改路由路径时要同时检查两种入口和 Cloudflare Pages 的 Function 文件名。