Files
WebDrop/docs/frontend.md
T

11 KiB

前端架构

web/ 的工程文档。视觉与交互见 ui-design.md,协议见 protocol.md,接口见 api.md。

1. 技术栈

项 选择 说明
构建 Vite 纯客户端 SPA,不做 SSR
语言 TypeScript,strict + noUncheckedIndexedAccess
UI React 19.3(稳定版,含 ViewTransition、Activity) 不使用 canary
状态 Zustand 按领域拆 store
本地存储 idb(IndexedDB) 发送队列、「我的频道」、草稿预览所需的本机文件
样式 原生 CSS + CSS 变量 + CSS Modules 无 Tailwind、无 CSS-in-JS
路由 自写极简导航(app/navigation.ts) 只有两三个路由,且需统一挂 view transition
测试 Vitest(单元)、Playwright(端到端)
二维码生成 自写编码器 lib/qr.ts(字节模式、纠错 M、版本 1–10,最多 213 字节),与 Python qrcode 逐模块对比验证,见 lib/qr.test.ts
扫码 浏览器自带 BarcodeDetector + getUserMedia,零依赖;不支持时退回粘贴链接
加解密 @webdrop/core,经 core-bridge 调用,在 Worker 中运行

依赖策略:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。

2. 目录与分层

web/src/
├─ app/           入口、Provider、导航、主题
├─ styles/        tokens.css(唯一的颜色来源)、base.css
├─ ui/            无业务的基础组件、图标(Button、Sheet、Menu、Toast)
├─ features/      按功能垂直切分:home / channel / join / item / settings
├─ store/         领域状态与事件流 reducer(纯逻辑)
├─ core-bridge/   对 @webdrop/core、Worker、网络传输的封装
└─ lib/           与框架无关的小工具(链接解析、退避、格式化)

依赖方向单向向下:

app → features → ui
        │
        └──→ store → core-bridge → @webdrop/core
lib 可被任何层使用,lib 不依赖其他层
规则 说明
ui/ 不依赖 features/、store/ 基础组件无业务
features/ 之间不互相导入 需共享就上移到 ui/ 或 store/;唯一例外:channel/、home/ 是组合层,可导入 item/、scan/、channel/ 里的弹层
组件不直接调用网络或加解密 只通过 store/ 的 action 与 selector
协议逻辑只在 store/、core-bridge/、lib/ 全部用纯函数,可脱离 DOM 测试
不使用 barrel(index.ts 汇总导出) 直接从具体文件导入,利于 tree-shaking 与追踪

3. 状态模型

频道是追加日志,条目状态由日志推导,不单独存储可变副本。

store 持有 说明
channels 本机已保存的频道、当前会话的频道元信息 持久化到 IndexedDB
items(按频道) 由帧装配出的条目 Map(item_id → Item) 按 item_id 建 Map,不用数组 find
transport 连接状态、last_seq、降级级别
queue 待发送帧(已加密的字节) 持久化到 IndexedDB

条目用判别联合,所有 switch 必须穷尽:

type ItemState =
  | { kind: 'receiving' }
  | { kind: 'complete' }
  | { kind: 'incomplete' }
  | { kind: 'tampered' }
  | { kind: 'removed'; reason: 'deleted' | 'expired' | 'evicted' | 'limit' };

组件订阅派生出的最小值(如 isOnline、某条目的 state.kind),不订阅整个 store。

4. 传输与重连

遵循 api.md §降级与重连:WebSocket → SSE → 长轮询;心跳 20 秒,45 秒无消息判死;退避 1–30 秒加抖动;重连携带 from=last_seq+1;收到 410 则从 from=0 全量同步,并丢弃本地已不存在的条目(从 oldest_seq 读会漏掉 seq 更小的存活条目,见 api.md)。

  • 传输层是 core-bridge/ 内的状态机,对 store/ 只暴露「记录流」和「连接状态」。
  • 上传永远走 HTTP POST,依赖协议幂等重发,不需要前端去重逻辑之外的补偿。
  • 队列里持久化的是已加密的帧。重试必须发送完全相同的字节(protocol §5 的 IV 规则),前端任何位置都不得对同一 (item_id, frame_index) 重新加密。

5. 密钥与安全处理

规则 说明
取出 fragment 后立即 history.replaceState 在任何异步代码之前完成
K 导入为不可导出的 CryptoKey 内存中不保留原始字节
降级防御 链接含 k 就只接受 enc 且认证通过的帧
不把密钥、令牌、明文写入 console、错误上报、URL、localStorage 「我的频道」只存 IndexedDB
便捷分享草稿预览 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿
条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 无内联渲染,不使用 dangerouslySetInnerHTML
严格 CSP,所有脚本样式带 SRI,不使用内联脚本 与 architecture §5 一致
不引入任何第三方脚本、统计、字体 CDN 字体自托管

6. 路由与导航

  • 路由只有:/、/c/:id、/c/:id/settings。
  • 所有跳转经 app/navigation.ts 的 navigate(to, direction),内部用 startTransition + addTransitionType 触发 view transition。禁止在组件中直接 history.pushState 或 location.assign。
  • 浏览器后退/前进不会触发 view transition(popstate 同步),这是已知限制,接受其无动画。
  • 页面级方向性 ViewTransition 写在页面组件里,不写在布局里;用一个共享的 DirectionalTransition 包装。

7. 动画实现要点

遵循 react-view-transitions 技能的约束,并与 ui-design.md §9 一致:

  • 动画 CSS 取自技能的配方(--duration-*、fade、slide、nav-forward/back),集中放在 styles/transitions.css,不在组件里写动画 CSS。
  • ViewTransition 一律 default="none",显式列出 enter / exit,成对出现。
  • Suspense 的揭示动画用简单字符串属性,不用类型映射。
  • 共享元素的 name 全局唯一(如 item-${id}),不在可同时挂载两处的复用组件里写具名 VT。

8. 性能约定

遵循 react-best-practices 技能中适用于纯客户端 SPA 的部分:

方面 约定
首屏体积 按路由 lazy 拆分;二维码、预览、设置等重组件动态加载;禁止 barrel 导入
重渲染 订阅最小派生值;事件处理中才用的状态用 ref 或 getState();高频值(上传进度)用 ref 或独立细粒度 store,不触发整列表重渲染
派生状态 渲染期计算,不用 effect 同步
列表 content-visibility: auto;条目 key 为 item_id
条件渲染 用三元,不用 && 渲染可能为 0 的值
重计算 加解密、装配、哈希一律在 Worker,主线程不处理大文件
非紧急更新 用 startTransition / useDeferredValue
模块级单例 只做一次的初始化(Worker、IndexedDB 连接)用惰性初始化,避免重复

server-* 与 Next.js 相关规则不适用本项目。

9. 可维护性约定

  1. 一个文件一个职责;组件文件约 150 行为上限,超出就拆。
  2. 不预先抽象。同样的代码第二次出现才抽,第三次必须抽。
  3. 导出面最小化:不被其他文件使用的符号不导出。
  4. 注释只写「为什么」,尤其是协议约束与安全理由,并指向文档章节(如 // protocol §5: 重试必须发相同字节)。
  5. 颜色、间距、圆角、阴影都不得写死在组件里,一律走 tokens.css。
  6. 改动协议相关行为前,先改 docs/,再改代码(与 architecture §7 一致)。
  7. 每个 store/ 与 lib/ 的模块有对应的 Vitest;每个用户可见流程有 Playwright 用例。

10. 测试

层 工具 内容
lib/、store/ Vitest 条目装配、状态推导、退避、队列、链接解析(含降级防御的负例)
core-bridge/ Vitest 传输降级、410 重同步、断线续传(用假传输)
界面流程 Playwright 双浏览器内容一致;断网恢复后无丢失无重复;抓包无明文和 K
视觉 Playwright 截图 亮暗两套、手机与桌面宽度、reduced-motion、无 backdrop-filter 回退

11. 阶段(对应 architecture §7)

阶段 前端内容
3 设计系统与骨架(静态,假数据) → 创建、加入、文本与 JSON 实时同步、重连、发送队列、「我的频道」
4 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放
6 PWA、Web Share Target、二维码与短配对码

在 @webdrop/core 未就绪前,core-bridge/ 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。

12. 与 @webdrop/core 对接备忘

core-bridge/api.ts 是前端与后端的唯一边界,现在由 mock.ts 实现。接 @webdrop/core(packages/core/README.md)时对应关系:

前端需要 core / 协议
新建频道或分享 ChannelSession.create({ mode, settings, endpointSecret }) → POST /channels;分享的 settings 为 members_can_upload=false、delete_policy=owner_only,并设 seats、ttl_seconds、file_max_downloads
草稿阶段调整设置 PATCH /channels/{id}/settings(需所有者令牌)
完成分享 客户端丢弃所有者密钥 A:用成员链接重新 ChannelSession.open(...),此后 session.can('owner') 为 false。丢弃是客户端的自觉行为,服务端不知道;若要强制,需后端提供「封存」让服务端作废 owner_hash(属于后端与协议改动,前端只提请求)
文件下载次数 每个条目的 maxDownloads 会强制 GATED,并带 X-WD-Max-Downloads,必须随第 0 帧发送。文本不要设,否则读文本也要拉取
内联渲染不烧次数 GATED 条目每次 GET /items/{id}/frames?from=0 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5)
终端密钥 session.endpointSecret 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做
创建频道、便捷分享的验证码 读 GET /info 的 captcha;需要时按需加载打包在前端的官方 cap-widget,求解后只把一次性 token 放进创建请求的 X-WD-Cap。不从 CDN 加载脚本
校验失败 reader 的 rejected 事件不能忽略,至少在界面提示「内容校验失败」
Worker ChannelSession 不能跨线程,在 Worker 里用链接和 endpointSecret 重建