这篇算是一篇补记,也算是把前面两张架构图揉成一篇更容易入口的说明。
之前只看 Pixi(浏览器里的 2D WebGL 渲染引擎)渲染与输入时,很容易把注意力放在“画布怎么画出来”上。但这次把 最近文件与 Open Project 加载架构图 也放进来以后,整条线就更完整了:用户从 Home 点最近文件或 Open Project,系统要先确认工程身份、保存目标、草稿桶和 ready 条件,然后才把文档交给编辑器主状态;进入画布之后,Pixi、输入、历史、自动草稿再各自接棒。
所以这篇不打算把两份架构图逐节点搬过来。那样很像把说明书 A 和说明书 B 订在一起,厚度有了,读者的咖啡也快凉了。我更想按一次真实打开和编辑的路径,把 Vectania Editor 现在这套设计背后的几个关键判断捋一遍。
先说结论
Vectania Editor 的架构可以先记住一句话:
打开工程先过 ready gate(打开就绪门,用来阻断未完整准备的文档进入画板),进入画板后由 Zustand store(由 Zustand 管理的编辑器内存状态仓库)保存业务真相;Pixi 只负责可重建的显示结果,输入先进入预览,最终通过命令提交。
这句话听起来有点像工程师给自己贴在显示器边上的便签,但它解决的是编辑器里非常日常的问题:
- 用户点最近文件时,不能只凭一个 recent 记录就直接冲进画板,句柄、权限、工程身份和草稿基线都要确认。
- 打开
.labpixi(项目主工程包格式)或兼容 JSON(只用于导入导出的兼容格式)时,文档必须在 loading 期间完整 ready,不能半开半编辑。 - 用户拖动一个节点时,画面要立刻动,右侧 X/Y 数值也要跟着变。
- 但拖动中的每一帧不能都变成正式历史记录,否则 Cmd+Z 会像翻账本一样,翻到人开始怀疑人生。
- 动画和 State Machine(状态机播放系统,用状态和 transition 驱动动画预览)播放时,节点显示可以被临时覆盖,但不能把原始节点属性偷偷改掉。
- 图片、矢量、mesh(用顶点、UV 和三角形索引描述的可变形贴图网格)、mask(渲染裁剪遮罩)、overlay(编辑辅助覆盖层)最终都要画到 Pixi 里,但它们大部分都不应该进入保存文件。
这些约束最后收束成两条主链路:打开链路和画布链路。
flowchart LR home["Home 入口"] launchIntent["Launch Intent<br/>打开意图"] readyGate["Ready Gate<br/>打开就绪门"] store["Zustand Store<br/>业务真相"] commands["Command Layer<br/>命令提交"] preview["Interaction / Viewport Preview<br/>交互和视口预览"] renderer["Pixi Renderer<br/>渲染同步"] pixi["Pixi Scene Graph<br/>显示对象树"] draft["Autosave / Draft<br/>自动草稿"] home --> launchIntent launchIntent --> readyGate readyGate --> store store --> renderer store --> draft commands --> store preview --> renderer renderer --> pixi这里的 Pixi scene graph(Pixi 显示对象树)只是一棵由 Container、Graphics、Sprite、mesh 和 mask 组成的显示树。它很重要,但它不是文档户口本。
第一站:Home 不是传送门
最近文件与 Open Project 加载架构图 里最值得记住的点是:Home 页面不是直接把用户扔进编辑器,而是把点击动作转成 launch intent(打开意图,描述“打开最近文件、打开本地文件、恢复自动草稿”等来源和目标)。
这一步看起来像礼貌寒暄,其实很关键。因为最近文件可能没有权限了,工程包句柄可能失效了,JSON 可能需要重新选择,自动草稿也可能和当前工程身份不匹配。Home 如果直接进入画板,就等于还没验票就上车,短程可能没事,长途迟早补票补到手忙脚乱。
flowchart TD user["用户点击"] home["Home 页面"] recent["最近文件卡片"] openProject["Open Project 按钮"] access["访问检查"] picker["文件选择器"] recentIntent["Open Recent"] fileIntent["Open File"] draftIntent["Restore Autosave"] launchIntent["Launch Intent"] filePanel["文件管理面板"] user --> home home --> recent home --> openProject recent --> access access --> recentIntent access --> draftIntent access --> picker openProject --> picker picker --> fileIntent recentIntent --> launchIntent fileIntent --> launchIntent draftIntent --> launchIntent launchIntent --> filePanel这张图放到博客里,主要是为了提醒自己:Recent files 和 Open Project 的职责不是“加载文档所有细节”,而是把来源分支收敛成一个类型明确的打开意图。真正的打开动作,还要继续往下走。
第二站:打开工程要先过 ready 门
打开主链路有几个来源:最近 .labpixi 工程包、最近 JSON、working document cache(本地工作缓存)、用户新选的工程包、用户新选的 JSON。它们最后都要走到标准化文档、工程身份和 ready 门禁。
这里的 DocumentWorker(负责文档解析、签名、序列化等后台任务的 Web Worker)可以帮忙解析文件,DraftWorker(负责自动草稿写入和恢复的 Web Worker)可以准备草稿上下文,Workspace Asset Store(工作区资源缓存,用来保存图片等二进制资源)可以恢复图片资源。但这些都只是准备工作。
真正能不能进入画板,要看 ready gate(打开就绪门,确认文档、身份、保存目标、草稿和首屏条件都满足)有没有放行。
flowchart TD launchIntent["Launch Intent"] currentDraft["当前草稿检查"] openAction["打开动作"] loading["Loading 遮罩"] subgraph sources["来源分支"] recentPackage["最近 .labpixi"] recentJson["最近 JSON"] workingCache["Working Cache"] pickedPackage["选择 .labpixi"] pickedJson["选择 JSON"] end subgraph parse["解析与标准化"] manifest["Manifest / Shell Document"] assets["Package Assets"] fullDoc["Full Document"] workerOpen["DocumentWorker Open"] migration["Asset Migration"] refs["Resource References"] loadStore["Load Into Store"] end subgraph identity["身份与持久化"] projectIdentity["Project Identity"] saveTarget["Save Target"] recentRecord["Recent Record"] draftBucket["Draft Bucket"] end ready["DocumentOpenReadiness<br/>Ready Gate"] interactive["进入可交互画板"] launchIntent --> currentDraft currentDraft --> openAction openAction --> loading openAction --> sources recentPackage --> manifest pickedPackage --> manifest recentJson --> fullDoc workingCache --> fullDoc pickedJson --> workerOpen workerOpen --> migration fullDoc --> migration manifest --> assets assets --> refs migration --> refs refs --> loadStore openAction --> projectIdentity projectIdentity --> saveTarget projectIdentity --> recentRecord projectIdentity --> draftBucket loadStore --> ready projectIdentity --> ready saveTarget --> ready draftBucket --> ready ready --> interactive这条链路里有几个项目专有词,第一次看会有点像打开保险箱时同时转三把钥匙:
- Project Identity(工程身份)回答“这到底是哪一个工程”,recent、保存目标和草稿都要认它。
- Save Target(保存目标)回答“保存时写回哪里”,比如
.labpixi文件句柄、JSON 句柄、download-only 或另存路径。 - Draft Bucket(草稿桶)回答“自动草稿写到哪个本地命名空间”。
- Merkle baseline(文档签名基线,用来判断相对保存点是否 dirty)和 Patch Base(自动草稿增量补丁的起点)回答“保存状态和草稿增量从哪里算起”。
这些信息不齐,loading 就不能结束。deadline log(超时等待记录)可以告诉我们还卡在哪里,但不能假装已经 ready。这个设计看起来有点严格,但比把用户放进一个“看起来能编辑,保存时才发现身份不明”的状态好太多。
第三站:进入 store,画布才真正活起来
打开 ready 后,标准化文档会通过打开恢复命令进入 Zustand store。这里的 store 是当前会话的业务真相,保存节点树、选择态、工具态、历史栈、动画运行态入口、工程身份、草稿辅助状态等。
之后 React UI(React 面板和应用壳)和 CanvasInput(画布输入总调度器)都可以接收用户意图,但正式修改业务状态时必须回到命令层。Renderer(Pixi 渲染同步器,项目里主要是 EditorRenderer)读取 store、runtime evaluated(动画或状态机求值后的当前帧显示覆盖)和 preview,再同步 Pixi 显示对象。
flowchart LR user["用户操作"] reactUi["React UI<br/>面板和应用壳"] canvasInput["CanvasInput<br/>画布输入总调度器"] commands["Command Layer<br/>业务命令"] store["Zustand Store<br/>业务真相"] viewportPreview["Viewport Preview<br/>视口平移缩放预览"] interactionPreview["Interaction Preview<br/>交互期临时预览"] runtime["Runtime Evaluated<br/>运行时显示覆盖"] renderer["Pixi Renderer<br/>渲染同步器"] pixiObjects["Pixi Derived Objects<br/>可重建派生显示对象"] caches["Derived Caches<br/>派生缓存"] user --> reactUi user --> canvasInput reactUi --> commands canvasInput --> commands canvasInput --> viewportPreview canvasInput --> interactionPreview commands --> store store --> reactUi store --> runtime store --> renderer viewportPreview --> renderer interactionPreview --> reactUi interactionPreview --> renderer runtime --> renderer renderer --> pixiObjects renderer --> caches这条线是整篇的核心:业务真相只能从 store 出发,Pixi 只是显示结果。Renderer 可以很聪明,缓存可以很积极,但都不能抢 store 的饭碗。
为什么 Pixi 不能成为真相
做编辑器时很容易被一个诱惑带走:既然用户看到的是 Pixi 对象,那能不能直接改 Container、Graphics、Sprite,然后从它们反推文档?
短期看很顺手,长期会很痛。就像把便签贴在冰箱上,然后宣布冰箱是知识库,气势很足,检索很难。
比如一个图片节点,在文档里保存的是资源引用、裁剪参数、fit 模式、mesh 数据、描边和填充。Renderer 里为了显示它,可能会派生出 Sprite(Pixi 图片显示对象)、TilingSprite(Pixi 平铺图片显示对象)、MeshSimple(Pixi 网格贴图显示对象)、mask、纹理缓存、Object URL(浏览器为 Blob 生成的临时可加载 URL),甚至还有 LOD(Level of Detail,按缩放和预算选择的图片清晰度层级)和 tile 策略。用户真正想保存的是前者,不是后者。
矢量节点也是同理。store 里保存路径点、contour、region fill、stroke、reveal path;Renderer 可能为了性能生成 GraphicsContext(Pixi 可复用图形上下文)、raster texture(栅格化纹理)或 Skia / CanvasKit(用于复杂矢量离屏栅格化的图形后端)缓存。缓存可以释放,可以重建,可以换策略,但路径数据必须稳定。
所以 Vectania Editor 把这些东西拆成三层:
- 第一层是 store 节点数据:矩形的圆角、图片的资源引用、矢量的路径、Frame 的 children。
- 第二层是资源桥:workspace asset Blob、Object URL、可加载图片 src、纹理加载状态。
- 第三层是渲染派生对象:NodeView(一个文档节点在 Pixi 里的派生视图)、Pixi Texture、Graphics、Sprite、mesh、mask、overlay。
只有第一层是业务真相。第二层是浏览器和文件系统之间的桥。第三层是为了显示和交互而生成的结果。
这样做的好处很直接:Renderer 可以大胆做 culling / materialization(按视口裁剪和按需物化 Pixi 对象)、texture release、vector cache、tile snapshot(视口活动时复用的临时画面快照)。只要 store 和资源引用还在,显示对象丢了也能重建。
交互为什么要先预览再提交
拖动节点是一个很好的例子。
用户按下鼠标后,CanvasInput 会根据当前工具、overlay 命中、内容命中和选择状态,决定这次交互是不是拖拽、框选、resize、钢笔编辑、裁剪、mesh 点编辑,或者只是 pan。真正进入拖拽后,pointermove 不会每一帧都正式写 nodes[id].x/y。
它会先写 interactionPreview(交互期临时预览快照)。
Renderer 读取这份 preview,让画面跟手。右侧属性栏也读取同一份 preview,把临时 X/Y 叠到当前选中节点上,所以用户会看到数值同步变化。与此同时,对齐吸附会生成 canvasGuides(拖动时的对齐参考线数据),overlay 层画出参考线。
等 pointerup 时,输入层才把最终位置交给命令层,由命令写入 store,并合并成一条 History Transaction(连续交互合并成一条可撤销记录的历史事务)。
flowchart TD down["pointerdown<br/>命中和规则分发"] state["Interaction State<br/>当前交互类型"] move["pointermove<br/>更新预览"] preview["interactionPreview<br/>临时节点状态"] guides["canvasGuides<br/>吸附参考线"] renderer["Renderer Preview<br/>画布跟手"] inspector["Inspector Preview<br/>属性栏临时值"] up["pointerup / cancel<br/>收尾"] command["Command Commit<br/>命令提交"] history["History Transaction<br/>历史合并"] store["Zustand Store<br/>最终状态"] down --> state state --> move move --> preview move --> guides preview --> renderer preview --> inspector guides --> renderer state --> up up --> command command --> history history --> store store --> renderer store --> inspector这就是架构图里反复强调的“预览不是业务真相”。它带来的体验差异很大:
- 拖动时足够跟手,因为画面可以先按 preview 更新。
- 历史干净,因为一次拖动只提交一条记录。
- 取消交互可以回滚,因为事务开始时有快照。
- 属性栏和画布一致,因为它们消费同一份 preview,而不是各算各的。
视口 pan / zoom 也是类似思路。高频阶段先走 viewportInteraction(视口平移缩放的临时预览快照),Renderer 直接更新 viewportLayer(承载画布 pan / zoom 的 Pixi 容器)的 position 和 scale;交互结束或 idle 后,再把最终 viewport 提交回 store。
这套机制的关键不是“临时状态”本身,而是临时状态有明确边界:它可以驱动画面和面板预览,但不能混进保存、历史和工程文件。
输入层像一个交通路口
画布输入看起来只是 pointer event,实际上是整个编辑器里最容易长成大 if-else 的地方。
同一个 pointerdown,可能意味着很多事:
- 当前是 Rectangle 工具,那就应该开始画矩形。
- 当前在编辑钢笔路径,那就应该优先命中锚点和贝塞尔手柄。
- 点到裁剪框或渐变手柄,那就应该编辑 overlay 控件,而不是拖动节点。
- 点到普通节点内容,才进入选择、深选、拖拽或 transform。
- 空白处拖动,可能是框选。
- 按住空格,又可能是 pan。
Vectania Editor 在这里用了一个比较克制的设计:pointerdown 先过规则执行器,按优先级尝试工具态、钢笔态、overlay 命中、内容命中。第一条成功的规则接管本次交互,后续 pointermove / pointerup 都按当前 interactionState.kind 继续。
这比“每个 move 都重新猜一次用户想干什么”稳定很多。人都已经开始拖裁剪框了,系统还在问“你是不是其实想选中图片”,就有点像会议快结束才开始确认议题。
Spatial Index(空间索引,用节点包围盒加速 hover、点击和框选候选查找)和 precise hit testing(精确命中,用路径、mesh、手柄等几何规则做最终判断)也放在这条路上。大文档下不能每次 hover 都遍历所有节点,所以先用 bounds 和可见范围粗筛,再进入 vector path、image mesh、deformer handle、selection handle 等精确命中。空间索引只是加速结构,不保存业务状态。
输入层的职责到这里为止:解释输入、维护交互状态、生成 preview、最后提交命令。它不应该绕过命令层直接深改 store,也不应该让 Pixi 对象反向成为业务判断来源。
Renderer 做的是同步,不是决策
Renderer 的角色更像一个很忙的同步器。
它拿到 store snapshot(store 在某一刻的状态快照),再叠加 runtime evaluated 和 interaction preview,生成当前这一帧应该显示的状态。然后它决定哪些 NodeView 可以复用,哪些只要改 transform,哪些要重画几何,哪些要刷新纹理,哪些可以因为离开视口而释放。
Pixi 舞台本身也有固定分层:
- canvas background 负责编辑器工作区底色。
- viewportLayer 承载 pan / zoom。
- grid layer 负责网格。
- tile snapshot layer 在视口活动时作为临时快照代理。
- node layer 承载文档节点。
- overlay layer 画选框、手柄、参考线、钢笔点、裁剪框、mesh 控制点等编辑辅助。
flowchart TD storeSnapshot["Store Snapshot"] runtime["Runtime Overrides"] preview["Preview Snapshots"] sync["Renderer Sync Coordinator"] scheduler["Frame Scheduler"] culling["Culling / Materialization"] nodeViews["NodeViews"] nodeRenderers["Node Renderers"] caches["Texture / Vector Caches"] overlay["Overlay Sync"] subgraph stage["Pixi Stage"] background["Canvas Background"] viewport["Viewport Layer"] grid["Grid Layer"] tiles["Tile Snapshot Layer"] nodes["Node Layer"] overlays["Overlay Layer"] end storeSnapshot --> sync runtime --> sync preview --> sync sync --> scheduler scheduler --> culling culling --> nodeViews nodeViews --> nodeRenderers nodeRenderers --> caches sync --> overlay background --> viewport viewport --> grid viewport --> tiles viewport --> nodes viewport --> overlays nodeViews --> nodes overlay --> overlays这个分层的好处是,业务节点和编辑辅助不会混在一起。选框和参考线是帮助编辑的视觉,不应该成为文档 children;Frame、Group、Image、Vector 这些才是业务节点。
Renderer 还要处理动画、State Machine 和 Model rig(模型绑定和求值关系,用参数、骨骼、权重或网格点位驱动显示)的运行时覆盖。这里有个很重要的边界:runtime evaluated 表示“当前帧看起来是什么样”,不表示“用户已经修改了原始节点”。只有显式 apply、bake 或命令提交,预览结果才会回写业务数据。
也就是说,Renderer 可以非常复杂,但它复杂的是显示策略,不是业务所有权。
Worker 负责后台活,不负责画布真相
这套架构里还有一个容易误解的点:既然 Pixi、React、store、history 都在主线程,那 Worker(浏览器后台线程,用来处理可结构化克隆的数据任务)到底干什么?
答案是:Worker 处理可以结构化克隆的后台任务,比如文档解析、签名、序列化、草稿写入、日志文件 IO。它不持有 DOM,不持有 Pixi,不持有 WebGL,也不直接恢复当前画布。
打开文件时,.labpixi 或兼容 JSON 会先进入 DocumentWorker,产出 normalized document(标准化文档,完成版本迁移、资源引用整理和结构校验后的数据)。然后还要凑齐工程身份、保存目标、草稿桶、签名基线、autosave patch base 等 ready 条件,才能通过打开恢复命令写入 store。
自动草稿也是类似边界。DraftWorker 可以写 IndexedDB(浏览器本地数据库),可以 compact operation patch(整理自动草稿增量补丁),可以帮助恢复。但它不是撤销重做栈,也不是当前画布真相。
一句话:Worker 可以帮忙搬东西、整理东西、保存东西,但不能绕过主线程 store 和命令边界决定“编辑器现在是什么”。
历史和草稿是两套东西
架构图里单独画了撤销重做历史,这一点我觉得很有必要。
很多编辑器问题都来自把“用户要撤销什么”和“系统要自动保存什么”混在一起。Vectania Editor 里这两件事是分开的:
- Cmd+Z / redo 回退的是 store 里的
history.past和history.future。 - 自动草稿依赖 Operation Journal(DocumentEngine 的文档操作日志,用于草稿、签名和恢复辅助)、autosave bridge 和 DraftWorker。
用户拖动节点时,连续 pointermove 会被合并成一个 history transaction。取消时用事务开始快照恢复,提交时生成一条可撤销记录。这是面向用户理解的历史。
自动草稿关心的是如何把文档增量安全写到本地恢复投影里。它可以节流,可以合并,可以后台 compact,但不能替代 undo/redo,也不能直接决定画布状态。
这个分离让两套语义都更干净。撤销重做服务编辑体验,草稿服务崩溃恢复和持久化辅助。一个管“我刚才那步能不能撤”,一个管“电脑刚才如果突然休息,我还能不能回来”,不要互相抢戏。
flowchart LR commands["Commands"] store["Zustand Store"] history["Undo / Redo History"] journal["Operation Journal"] autosave["Autosave Bridge"] draftWorker["DraftWorker"] indexedDb["IndexedDB Drafts"] save["Manual Save"] package[".labpixi / JSON"] commands --> store store --> history commands --> journal journal --> autosave autosave --> draftWorker draftWorker --> indexedDb store --> save save --> package性能策略不是一个开关
画布编辑器的性能问题很少能靠一个“优化一下”解决。
Vectania Editor 在 Pixi 层做了几类分工:
- viewport 活动时优先 preview render,比如先移动
viewportLayer。 - 大文档下通过 culling 和 materialization 只物化需要显示或需要保护的节点。
- 图片纹理按 owner retain / release,离开视口可以释放。
- 矢量路径可以按复杂度选择 Graphics、GraphicsContext、raster texture 或 Skia bake。
- pan / zoom 期间可以复用 tile snapshot,等空闲后再回到真实 NodeView 同步。
- texture prewarm(纹理预热)、vector prewarm(矢量缓存预热)、autosave work 这些重活要经过 budget gate(视口活动期间判断重活是否应该延后的预算门)。
这里的关键词是 budget gate。它不只是为了快,而是为了让主线程知道当前更应该做什么。
用户正在拖动视口时,第一优先级是手感。复杂矢量缓存、纹理预热、空间索引重建、自动草稿整理都可以等一等。等 viewport idle 之后,再把延后的同步补回来。
不过这些优化都有同一条红线:缓存可以缺,预热可以延后,fallback 可以显示,但不能把“暂时没画出来”当成正确业务结果,更不能改 store。
这套架构真正保护的东西
把两张架构图合起来看,我感觉它保护的不是某个具体模块,而是几个长期不变的判断。
第一,入口不能跳过 ready。Recent / Open Project 不是直接进入画板,而是生成 launch intent,再完成工程身份、保存目标、草稿桶、签名基线和首屏条件。
第二,业务真相必须集中。store 是当前会话里“文档是什么”的答案。UI、Pixi、Worker、缓存、历史、草稿都围绕它派生。
第三,用户操作必须有正式入口。React 面板和 CanvasInput 都不能为了方便直接改深层结构,正式修改要通过命令层,这样历史、autosave、runtime 标记和错误处理才有统一位置。
第四,高频交互要允许临时性。拖动、resize、path edit、mesh/deformer(通过控制点、路径、旋转或骨骼影响内容显示的变形控制器)点编辑、pan/zoom 都需要 preview,否则手感会很差。但 preview 必须清楚知道自己只是 preview。
第五,渲染对象必须可重建。NodeView、Texture、GraphicsContext、spatial index、stats、tile snapshot 都是派生层。可释放、可延后、可重建,才有性能优化空间。
第六,持久化入口要守住边界。.labpixi、JSON、IndexedDB 草稿都是投影,不是画布运行时本身。进入画板前必须完成标准化、身份、保存目标和草稿基线。
这些原则听起来像规矩,但实际是在给复杂功能留余地。后面无论继续做 State Machine、ModelBoard(模型编辑和绑定相关的工作区)、Live2D、mesh 变形,还是大文档性能,只要不打破这些边界,新功能就更容易接进来。
小结
如果只看表面,Vectania Editor 的 Pixi 架构是在解决“怎么把节点画到画布上”。但把 Open Project 加载链路一起看,就会发现它其实在解决一个更根本的问题:一个编辑器如何从打开文件开始,就在可交互、可撤销、可保存、可恢复、可扩展之间保持一致。
我的理解是,这套设计最有价值的地方不是用了 Pixi,也不是用了 Zustand,而是它坚持了几条朴素的边界:
- 没 ready 的,不进画板。
- 看见的,不一定是真的。
- 跟手的,不一定要立刻保存。
- 快的,不应该绕过正确性。
- 后台的,不应该直接接管前台真相。
- 可重建的,就不要变成业务来源。
有了这些边界,复杂度不会消失,但会被放到更合适的位置。Home 负责把入口变成 launch intent,ready gate 负责守门,store 和 commands 守住业务语义,Input 解释交互,Renderer 处理显示,Worker 做后台任务。
这大概就是我理解的 Vectania Editor 当前架构设计:它不是为了“层很多”而分层,而是为了让每个复杂问题都有一个不会互相抢方向盘的位置。