TopWidgets⁺ 创作平台 是一套运行在浏览器中的可视化小组件创作工具,完成的作品可以由 Top Widgets⁺ Icons & Themes 安装到 iPhone 与 iPad。

它的工程重点并不只是 React 页面,也不只是一个画布组件,而是如何在浏览器中组织编辑器内核、项目系统与移动端交付链路,并把浏览器真正用成一个创作运行时:直接访问用户授权的目录,在浏览器私有文件系统中持续备份,用底层文件句柄阻止多页面同时编辑,通过 Paint Worklet 扩展 CSS 绘制能力,用 Service Worker 管理远程资源,借助 WebRTC 把编辑结果实时发送到设备,再把归档、图像与曲线计算放到 Worker 和 WebAssembly 中执行。

本文先说明整体技术架构和作品的数据流,再重点拆解这些 Web 技术在真实产品中分别解决了什么问题,以及实现时需要处理哪些边界。

整体技术架构:四层协作

TopWidgets⁺ 创作平台的技术架构可以分成四层。它们不是四个独立应用,而是通过稳定数据模型连接的不同职责边界:

交互与呈现层
画布 / 图层 / 属性面板 / 时间轴 / 预览
              ↓ Action
编辑器内核
文档树 / 布局树 / 历史记录 / 数据源 / 设置 / 动作流
              ↓ 已提交的项目状态
浏览器基础设施
OPFS / File System Access / IndexedDB / Worker / Worklet / Service Worker / WebRTC / WASM
              ↓ 归档、校验与转换
交付层
移动端视图描述 / 素材 / 本地化 / 权限 / 刷新策略 / 交互动作
              ↓
TopWidgets⁺ App 与系统小组件

交互与呈现层负责把指针、键盘和面板输入转化为明确操作。画布不是项目的事实来源,DOM 也不承担持久化职责;它只展示布局结果,并把拖动、缩放、选择和属性变更发送给编辑器内核。

编辑器内核保存可序列化的文档树。节点、父子关系和根节点分别索引,便于插入、移动、组合和解组。布局树是由文档、设备环境、用户设置和变量解析出的派生结构,负责绝对坐标、包围盒、可见性与命中测试。文档树维护删除和内容变化的失效集合,因此一次局部修改不必重建全部布局。

编辑操作统一进入 Action 流。真正改变作品的 Action 会产生新状态并进入撤销历史,选区、焦点和预览环境等临时变化不会污染项目版本。连续拖动共享同一个手势 ID,过程中的高频更新最终合并成一次可撤销操作;多素材插入等复合操作则通过批处理统一提交。

浏览器基础设施层负责文件、并发、缓存、实时通信、后台计算和绘制扩展。它不是编辑器之外的一组附加工具,而是项目系统得以成立的运行基础:OPFS 保存临时资源与恢复点,IndexedDB 提供结构化索引,File System Access API 连接用户工作区,Worker 隔离文件计算,Service Worker 接管特定资源请求,WebRTC 建立浏览器与设备之间的调试通道,Paint Worklet 扩展 CSS 绘制,WebAssembly 承担算法密集计算。

交付层把富含编辑信息的项目转换为移动端真正需要的资源。转换过程遍历可见节点,收集仍被引用的图片、变量、用户设置、动作、本地化内容与系统能力,并按组件尺寸生成运行时视图、权限和刷新策略。编辑器辅助信息在这一层被裁剪,App 接收的是经过校验的声明式产物,而不是浏览器中的 React 状态或 DOM。

这种分层的关键价值是:编辑器可以增加辅助能力和调试信息,项目文件仍保持可恢复,移动端协议也不必跟随每一次界面重构变化。

一份作品如何在架构中流动

创建或修改节点时,交互层先产生 Action,编辑器内核据此更新文档并记录历史;布局层只重新解析失效节点,再把新的几何结果交给画布。数据源、用户设置、变量和预览环境也在解析阶段参与计算,因此同一份文档能够预览不同时间、设备状态和配置组合。

当已提交版本变化时,自动保存管线克隆当前项目,过滤未使用变量,沿节点、设置与动作引用重新收集素材,再把归档任务发送给 Web Worker。生成的恢复点写入 OPFS,并在条件允许时同步到用户工作区。这个过程与主线程的下一次编辑并行,不要求画布等待文件处理完成。

再次打开项目时,加载顺序正好相反:先获取项目互斥锁,再检查工作区句柄和权限,同步浏览器与本地备份,从新到旧校验归档,恢复文档、设置、变量和素材,最后创建编辑器与布局状态。任何一步失败都有明确边界,不会直接用一个可能损坏的“最新文件”覆盖全部恢复机会。

发布时,转换器从项目模型生成移动端视图描述。数据依赖会变成运行时属性,用户可配置内容会进入设置清单,点击行为会变成动作序列,节点使用的系统能力会汇总为权限与最低版本要求。浏览器负责创作、分析和打包,App 负责设备数据、系统授权与最终渲染,两端通过产物协议解耦。

在这条数据流中,下面的 Web 技术并非孤立的 API 展示,而是分别承担并发控制、可靠保存、渲染扩展、资源缓存和计算隔离。

用 OPFS 文件句柄实现跨页面互斥锁

浏览器编辑器有一个很容易被忽略的数据一致性问题:用户可能在两个标签页中打开同一个项目。两个页面各自拥有内存状态,也都可能触发自动保存。如果没有互斥机制,后写入的页面会覆盖先写入的结果,而且保存文件本身没有足够的信息判断哪一份状态才是正确的。

平台没有把这个问题交给后端,也没有只使用页面内的布尔变量,而是利用 OPFS(Origin Private File System)和 FileSystemSyncAccessHandle 实现同源页面之间的项目锁。

锁逻辑运行在专用 Web Worker 中。Worker 首先通过 navigator.storage.getDirectory() 取得当前站点的 OPFS 根目录,再为项目创建一个锁文件,并尝试取得同步访问句柄:

const root = await navigator.storage.getDirectory()
const lockDirectory = await root.getDirectoryHandle('.lock', { create: true })
const lockFile = await lockDirectory.getFileHandle(projectId, { create: true })
const accessHandle = await lockFile.createSyncAccessHandle()

同步访问句柄在打开期间独占对应文件。当另一个标签页尝试为同一项目创建句柄时,浏览器会拒绝请求;实现中把 NoModificationAllowedError 转换为“项目已在其他页面打开”的业务结果。编辑结束后调用 close() 释放句柄,Worker 终止时句柄也会随执行环境退出而释放。

主线程与锁 Worker 之间使用带 callbackId 的消息协议:每次 lockunlock 都生成唯一 ID,主线程用 Map 保存 Promise 的 resolvereject,Worker 完成操作后带回同一个 ID。这样既保持了异步 API 的调用体验,也满足同步文件访问句柄只能在 Worker 中使用的限制。

这套锁有清晰的作用域:它协调的是同一浏览器配置、同一源下的页面,不是分布式锁,也不能阻止其他设备编辑云端副本。但对于“同一台电脑误开两个编辑页”这一最常见的冲突来源,它不需要服务器租约、心跳或超时回收,故障面更小。

OPFS 与 File System Access API 组成双层持久化

互斥锁只是 OPFS 的一种用途。创作过程中的临时资源与自动备份也保存在 OPFS 中,而用户主动选择的工作区则通过 File System Access API 读写。

两种文件系统解决的是不同问题:

  • OPFS 由浏览器管理,读写路径固定,不需要每次保存都弹出选择器,适合高频自动保存、临时素材和恢复点。
  • 用户工作区来自 showDirectoryPicker(),文件真实存在于用户可见的本地目录中,适合长期保存、迁移和外部备份。

用户首次选择目录后,平台会把 FileSystemDirectoryHandle 存入 IndexedDB。再次打开时并不假设旧权限仍然有效,而是依次执行 queryPermission()、必要时 requestPermission(),并尝试创建一个探测文件,区分“目录已移动”“句柄失效”和“权限被拒绝”等状态。

编辑中的素材会写入 OPFS 临时目录;项目状态则被归档为可移植文件。自动保存监听已提交版本的变化,以 1 秒防抖、5 秒最大等待时间合并连续操作。每次保存都会先清理未被引用的变量和素材,再生成恢复文件,优先写入 OPFS;满足账户和工作区条件时,同时写入本地目录。

恢复过程不是简单读取最后一个文件。平台会先对齐 OPFS 与本地两边的备份,再按时间从新到旧尝试校验和解包。最新文件损坏时会继续回退到更早的恢复点,所有备份都不可用时才读取工作区中的主项目文件。两侧分别只保留有限数量的版本,避免自动保存无限占用存储空间。

这里的关键不是“多存一份”,而是把浏览器私有、高频、低打扰的存储与用户可见、可迁移的文件系统组合起来:OPFS 保证创作连续性,File System Access API 保证数据所有权和可携带性。

CSS Paint Worklet:把组件绘制能力接入 CSS

项目中被称作“CSS Worker”的部分,更准确地说是 CSS Painting API 的 Paint Worklet。它不是普通 Web Worker,也不负责业务计算,而是让页面注册自定义的 CSS 图像绘制函数。

平台实现了四类绘制原语:线性渐变、边框、圆角遮罩和圆环。入口先检测 CSS.paintWorklet.addModule 是否存在,再以独立模块注册各个 Worklet;不支持该能力的环境不会执行注册逻辑。

每个 Worklet 都由两部分组成:

  1. inputProperties 声明它依赖哪些 CSS 自定义属性。
  2. paint(ctx, size, properties) 读取这些属性,并使用受限的 Canvas 2D 上下文绘制结果。

例如边框绘制会读取宽度、端点样式、拐角样式、虚线数组、虚线相位和四角半径。它不是只调用一次 strokeRect,而是自己构造圆角路径;遇到椭圆圆角时分别处理横向和纵向半径,并把半径限制在容器宽高的一半以内。实线和虚线使用不同的裁切方式,避免笔触落在视图边界外。

圆角与边框 Worklet 的输出可以作为 mask-image: paint(...) 使用,元素自身的背景决定最终颜色。这样,颜色、渐变、圆角和虚线仍然由 CSS 属性驱动,绘制细节则由 Worklet 完成。编辑器改变一个自定义属性后,浏览器能够按照 inputProperties 重新触发绘制,不需要 React 重新生成 Canvas 位图或替换图片 URL。

这项技术尤其适合设计工具:同一个视觉原语既要响应尺寸变化,又要支持连续参数调整,还要保持 DOM 元素原有的布局、变换与合成能力。相比在主线程维护一批 <canvas>,Paint Worklet 更贴近 CSS 渲染链路,也减少了组件层的绘制状态。

它的边界同样明确。Paint Worklet 可用的 API 比普通 Canvas 更受限,浏览器支持情况也需要单独判断。因此实现采用能力检测,并把它当作增强层;如果一项视觉效果是基础可用性的组成部分,还需要准备普通 CSS 或预渲染资源作为降级路径。

Service Worker:为创作资源建立可控缓存层

创作平台会反复访问快照、归档包、图标、预设和其他体积较大的远程资源。完全依赖 HTTP 缓存很难统一控制命中范围和版本清理,缓存所有 GET 请求又会带来接口数据过期、鉴权响应落盘等风险。

项目中的 Service Worker 因此采用“明确白名单 + Cache First”,而不是笼统的离线优先:

  • 只有快照、归档、资源包、图标等已知路径会进入 Cache Storage。
  • 命中白名单时先查询缓存,未命中才访问网络。
  • 网络响应只有在 2xx 状态下才会克隆并写入缓存。
  • 安装阶段调用 skipWaiting(),激活阶段清理旧版本缓存并执行 clients.claim()
  • 开发环境不注册 Service Worker,避免调试时被历史缓存干扰。

这套策略的价值在于缩小缓存语义:Service Worker 不尝试把整个应用伪装成完全离线产品,而是专门消除创作资源的重复下载。缓存版本号变化时,激活流程会删除旧 key,资源策略可以随应用版本一起升级。

当前实现中的缓存写入是异步旁路操作。它不会阻塞网络响应返回,因此首屏资源更快交给页面;但如果未来要提供“关闭页面后也必须完成写入”的强离线保证,就应把写缓存任务纳入 waitUntil() 生命周期,并补充容量、淘汰和配额异常处理。Service Worker 的价值不只是“能缓存”,而是可以精确决定哪些请求值得被浏览器长期接管。

WebRTC:把浏览器中的作品实时送到设备

浏览器里的预览无法完全替代真机。字体、系统数据、权限、刷新行为以及最终渲染都可能受到设备环境影响,如果每次调整都要先上传、发布、再到 App 中安装,创作反馈会非常缓慢。

平台因此使用 WebRTC 建立浏览器与 TopWidgets⁺ App 之间的实时调试通道。这里传输的主要内容不是摄像头和麦克风,而是组件包、配置、图标、壁纸、桌面布局和设备信息。创作者修改项目后,浏览器会重新打包当前尺寸的作品,并在防抖后直接发送到已连接设备;App 收到后安装调试版本,形成接近热更新的真机预览体验。

连接建立分成信令层和数据层:

  1. 浏览器创建连接 ID,并通过 WebSocket 向信令服务注册。
  2. App 被唤起后使用同一个 ID 接入,双方经 WebSocket 交换 SDP offer、answer 与 ICE candidate。
  3. RTCPeerConnection 进入 connected 后,WebSocket 完成使命并关闭。
  4. 后续项目和设备数据全部通过 RTCDataChannel 点对点传输。

这种设计让服务器只参与“帮助双方找到彼此”,不长期中转体积较大的创作资源。连接状态、ICE 收集、协商失败与断线都被独立监听;用户退出调试或组件卸载时,系统会停止 transceiver、receiver track 和全部 DataChannel,再关闭 PeerConnection,避免残留连接继续占用资源。

多 DataChannel 与二进制分片

组件包和壁纸可能远大于普通控制消息。发送端会同时创建 20 条 ordered: false 的 DataChannel,并按照每条通道当前的 bufferedAmount 排序。消息编码成 ArrayBuffer 后,以 100KB 为一片;每个分片都携带消息 ID、总片数和序号,再轮流分配给已经打开的通道。

接收端不依赖分片到达顺序,而是按消息 ID 暂存,在收齐后依据序号排序并重新拼接。10 秒内没有继续更新的不完整消息会被清理,防止断线或异常传输留下不断增长的内存缓存。多通道调度与显式分片共同解决了大二进制消息的排队和重组问题。

应用层协议还在每条完整消息前加入类型、时间戳和唯一 ID。类型区分“正在同步”“安装组件”“设备已就绪”“图标”“壁纸”“桌面布局”等数据;时间戳用于拒绝晚到的旧状态;唯一 ID 则关联接收确认。对于需要明确同步状态的消息,发送方先发出开始信号,接收方完成重组与解码后回传 received,发送方再结束同步状态。

这套 WebRTC 链路也是整体架构中浏览器与 App 的动态边界:发布产物协议解决最终交付,RTCDataChannel 解决创作过程中的即时反馈。两者使用相近的组件资源,但生命周期不同——前者追求稳定、可审查和可分发,后者追求低等待时间、双向状态交换和连接失败后的可恢复性。

Web Worker:把文件归档移出主线程

项目文件包含文档、设置、变量和大量二进制素材。归档时需要排序文件、读取 ArrayBuffer、拼接内容、生成索引、计算校验值,并根据版本执行加密或签名;恢复时还要识别旧格式、校验完整性、解密和拆分文件。如果这些步骤与拖动、缩放、属性输入共用主线程,编辑器很容易出现长任务和掉帧。

因此归档系统使用常驻 Web Worker,支持归档、解包、校验、提取校验值和识别资源类型等命令。它与 OPFS 锁采用相同的 callbackId 消息协议,让主线程以 Promise 调用 Worker。版本判断也位于 Worker 内部:读取文件头后选择对应的解析器,旧项目的兼容成本不会进入 UI 组件。

Worker 的意义不只是“异步”。普通 async 函数仍然在主线程执行 JavaScript,密集的字节数组处理照样会阻塞渲染;Worker 才提供独立执行上下文。对于这种计算量与项目体积相关、又不依赖 DOM 的任务,它是比拆分 setTimeout 更可靠的隔离方式。

IndexedDB:保存可查询状态,而不是堆放所有文件

文件系统适合二进制项目与素材,但编辑器还需要快速查询项目列表、更新时间、预设、模板、快照和用户选项。平台用 IndexedDB 保存这类结构化状态,并通过索引支持按项目 ID、创建时间、修改时间和资源类型查找。

数据库按用途拆分对象仓库,包括项目元数据、创作快照、预设、模板和选项。数据库升级由显式 migration 推进;当其他页面阻塞升级时,连接会主动关闭并清空单例,异常终止则进入统一恢复流程。

一个值得注意的细节是,File System Access API 返回的目录句柄也可以作为结构化克隆值保存在 IndexedDB 中。平台借此记住用户选择过的工作区,但每次使用前仍重新检查权限。也就是说,IndexedDB 保存的是“如何重新找到目录”,不是绕过浏览器授权的永久通行证。

这种分工避免了两种极端:既没有把大型二进制内容全塞入数据库,也没有为了展示一个项目列表就遍历整个文件目录。OPFS、用户工作区和 IndexedDB 分别承担临时文件、可迁移文件与可查询状态。

OffscreenCanvas 与 ImageBitmap:浏览器内的图像处理管线

小组件创作涉及壁纸裁切、图标合成、区域平均色、文字对比色和预览图生成。平台使用 createImageBitmap() 解码 Blob,再用 OffscreenCanvas 完成缩放、裁切与像素读取。

以壁纸颜色计算为例,图像会先按目标设备画面执行 cover 缩放,再从指定区域读取 ImageData。算法计算平均亮度,选择黑色或白色前景;也可以计算平均 RGB/HSL,为系统文字和滤镜生成更协调的颜色。图标与预览生成则把多张 ImageBitmap 合成到离屏画布,最后输出新的位图资源。

ImageBitmap 让解码后的图像成为适合绘制的对象,OffscreenCanvas 则让处理逻辑不依赖可见 DOM Canvas。即使部分调用目前仍由主线程发起,这种 API 选择也把图像算法与页面节点解耦,为进一步移入 Worker 保留了空间。

WebAssembly:处理密码学与复杂曲线计算

平台包含两类 Rust 编译的 WebAssembly 模块。

第一类用于项目归档的加密、解密、SHA-256、签名验证和请求签名。归档格式自身包含版本、资源类型、加密版本、文件索引偏移、签名和校验值;JavaScript 负责组织文件和协议结构,计算敏感或字节密集的部分交给 WebAssembly。

第二类用于关键帧编辑中的贝塞尔曲线。它负责曲线路径、控制点、交点、裁切和连续路径等几何运算,并被时间函数编辑器、运动路径面板和画布手势共同复用。应用会在渲染编辑器之前初始化曲线模块,避免用户开始操作后才遇到异步加载空窗。

这里没有为了技术标签把全部逻辑搬进 WebAssembly。DOM、状态管理和文件组织仍由 TypeScript 完成,Rust 模块只承接算法密集、数据边界清晰的部分。这样的切分比“把整个编辑器编译成 WASM”更容易调试,也保留了 Web UI 的开发效率。

Observer 与浏览器调度 API:让界面按需工作

大型编辑器还依赖一组不显眼但很关键的 Web API:

  • ResizeObserver 监听画布、弹层、视频和列表容器尺寸,让测量与组件真实几何变化同步,而不是只监听窗口 resize。
  • IntersectionObserver 用于列表分页、缩略图延迟加载和预览可见性判断,元素进入视口后才请求或播放资源。
  • MutationObserver 监听媒体节点属性变化,在资源地址切换后重置加载与播放状态。
  • requestAnimationFrame 把播放进度、布局后的测量和视觉状态更新对齐到浏览器绘制帧。
  • requestIdleCallback 把低优先级统计工作推迟到主线程空闲阶段,避免与编辑手势争抢响应时间。

这些 API 共同体现了 Web 编辑器的性能策略:不靠一个全局循环不断轮询,而是让浏览器在“尺寸变了”“元素可见了”“DOM 变了”或“下一帧到了”时通知应用。它们单独看都不复杂,组合起来却能显著减少无效计算和隐藏区域的资源消耗。

兼容性不是附注,而是实现的一部分

这套方案大量使用现代 Web 能力,因此不能把兼容性留到上线前再处理。实现中已经可以看到几类边界意识:Paint Worklet 在注册前做能力检测;Service Worker 只在生产环境启用;目录句柄每次恢复后重新检查权限;归档器识别多个文件版本;OPFS 锁把浏览器异常转换成明确的业务状态。

还需要继续坚持同一原则:每项能力都应明确“不可用时是否允许降级”。互斥锁和项目恢复属于数据安全能力,缺失时应阻止进入编辑或切换到可靠替代方案;绘制特效可以使用 CSS 或静态资源降级;资源缓存不可用时则回到普通网络请求。区分这些级别,比简单列一张浏览器支持表更重要。

结语

TopWidgets⁺ 创作平台最有代表性的工程价值,在于它没有把浏览器仅仅当作界面容器。OPFS 同时承担自动备份和跨页面互斥,File System Access API 把作品交还给用户,Paint Worklet 扩展 CSS 绘制,Service Worker 控制资源缓存,WebRTC 连接浏览器与真机调试,Web Worker 隔离文件计算,IndexedDB 保存可查询状态,OffscreenCanvas 与 WebAssembly 完成图像和曲线处理。

这些能力最终服务于同一个目标:让一个复杂、长时间运行、需要保护创作成果的专业工具,可以真正建立在 Web 平台之上。页面只是表层,浏览器提供的文件系统、后台执行、渲染扩展和本地数据库,才构成了这套创作环境的技术底座。