这篇算是一篇补记,也算是把前面的加载与 Pixi 两条架构链路揉成一篇更容易入口的说明。
之前只看 Pixi(浏览器里的 2D WebGL 渲染引擎)渲染与输入时,很容易把注意力放在“画布怎么画出来”上。但把 Recent / Open Project 加载链路也放进来以后,整条线就更完整了:用户从 Home 点最近文件或 Open Project,系统要先确认工程身份、保存目标、草稿桶和 ready 条件,然后才把文档交给编辑器主状态;进入画布之后,Pixi、输入、历史、自动草稿再各自接棒。
所以这篇不打算把综合架构图逐节点搬过来。那样很像把说明书直接贴进导读,厚度有了,读者的咖啡也快凉了。我更想按一次真实打开和编辑的路径,把 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
user["用户操作<br/>(● User Actions)"]
subgraph entry["进入编辑器<br/>(● ◇ Entry And Readiness)"]
home["Home / Recent / Open Project<br/>(● ◇ Launch Entry)"]
openFlow["打开与资源标准化<br/>(● ○ ◇ Open Flow)"]
readyGate{"就绪门禁<br/>(● ★ Readiness Gate)"}
end
subgraph truth["业务真相和提交<br/>(● Business Truth)"]
commands["领域命令<br/>(● ★ Commands)"]
store[("Zustand Store<br/>(● Store Truth)")]
history[("撤销重做历史<br/>(● History)")]
end
subgraph projections["同一状态的派生消费者<br/>(● Derived Projections)"]
layerTree["图层树和面板<br/>(● Layer Tree And Panels)"]
runtime[("动画 / State Machine 求值<br/>(● ■ Runtime Evaluated)")]
renderer["Pixi Renderer<br/>(● ★ Renderer)"]
end
subgraph interaction["交互期临时状态<br/>(● Interaction Preview)"]
canvasInput["CanvasInput<br/>(● Canvas Input)"]
viewportPreview[("Viewport Preview<br/>(● Viewport Interaction)")]
nodePreview[("Node / Path Preview<br/>(● Interaction Preview)")]
end
pixi["Pixi Scene Graph<br/>(● ◆ Derived Display)"]
user --> home
home --> openFlow
openFlow --> readyGate
readyGate -->|"完整 ready 后写入"| store
user -->|"面板意图"| commands
user -->|"画布事件"| canvasInput
canvasInput -->|"最终提交"| commands
commands -->|"受控写入"| store
commands --> history
store --> layerTree
store --> runtime
store --> renderer
runtime --> renderer
canvasInput --> viewportPreview
canvasInput --> nodePreview
viewportPreview --> renderer
nodePreview --> renderer
nodePreview --> layerTree
renderer --> pixi
这里的 Pixi scene graph(Pixi 显示对象树)只是一棵由 Container、 Graphics、 Sprite、mesh 和 mask 组成的显示树。它很重要,但它不是文档户口本。
第一站:Home 不是传送门 综合架构图的 Recent / Open Project 部分最值得记住的点是:Home 页面不是直接把用户扔进编辑器,而是把点击动作转成 launch intent(打开意图,描述“打开最近文件、打开本地文件、恢复自动草稿”等来源和目标)。
这一步看起来像礼貌寒暄,其实很关键。因为最近文件可能没有权限了,工程包句柄可能失效了,JSON 可能需要重新选择,自动草稿也可能和当前工程身份不匹配。Home 如果直接进入画板,就等于还没验票就上车,短程可能没事,长途迟早补票补到手忙脚乱。
入口分流已经并入下一节的综合 ready 主图。这里先记住职责边界:Recent files 和 Open Project 不负责“加载文档所有细节”,只负责把来源收敛成类型明确的 launch intent;真正的解析、身份绑定和 ready 门禁由打开链路继续完成。
第二站:打开工程要先过 ready 门 打开主链路有几个来源:最近 .labpixi 工程包、最近 JSON、working document cache(本地工作缓存)、用户新选的工程包、用户新选的 JSON。它们最后都要走到标准化文档、工程身份和 ready 门禁。
这里的 DocumentWorker(负责文档解析、签名、序列化等后台任务的 Web Worker)可以帮忙解析文件,DraftWorker(负责自动草稿写入和恢复的 Web Worker)可以准备草稿上下文,Workspace Asset Store(工作区资源缓存,用来保存图片等二进制资源)可以恢复图片资源。但这些都只是准备工作。
真正能不能进入画板,要看 ready gate(打开就绪门,确认文档、身份、保存目标、草稿和首屏条件都满足)有没有放行。
图表将在进入视口后显示 flowchart TD
entry["Recent 卡片 / Open Project / 恢复草稿<br/>(● ◇ Launch Intent)"]
access{"句柄、权限和来源检查<br/>(● ◇ Access Check)"}
subgraph sources["来源分支<br/>(◇ Sources)"]
package[".labpixi 工程包<br/>manifest / shell / chunks / assets"]
json["兼容 JSON / working cache<br/>完整文档"]
draft["自动草稿<br/>Draft Snapshot / Patches"]
end
subgraph normalize["解析和标准化<br/>(● ○ ◇ Parse And Normalize)"]
worker["Document Worker<br/>解析 / 签名 / 序列化"]
document["标准化文档<br/>(● Normalized Document)"]
assets["Workspace Assets<br/>资源引用和二进制可恢复"]
identity["工程身份 / 保存目标 / Recent 记录 / 草稿桶"]
end
subgraph readiness["DocumentOpenReadiness<br/>(● ★ Ready Gates)"]
storeReady["Document Store Ready"]
merkle["Merkle Baseline Ready"]
recovery["Recovery Draft Cleared"]
patchBase["Patch Base Registered"]
draftContext["DraftWorker Context Ready"]
firstViewport["First Viewport Settled"]
end
interactive["允许进入可交互编辑器<br/>(● Interactive)"]
blocked["阻断并保留 Loading / 错误<br/>(● Open Blocked)"]
entry --> access
access --> package
access --> json
access --> draft
package --> worker
json --> worker
draft --> document
worker --> document
package --> assets
document --> assets
access --> identity
identity --> storeReady
document --> storeReady
assets --> storeReady
storeReady --> merkle
merkle --> recovery
recovery --> patchBase
patchBase --> draftContext
draftContext --> firstViewport
firstViewport --> interactive
access -.->|"不可访问"| blocked
worker -.->|"解析失败"| blocked
storeReady -.->|"引用不可恢复"| blocked
这条链路里有几个项目专有词,第一次看会有点像打开保险箱时同时转三把钥匙:
• 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 显示对象。
这条线已经在开头的端到端主图里完整表达:业务真相只能从 store 出发,Pixi 只是显示结果。Renderer 可以很聪明,缓存可以很积极,但都不能抢 store 的饭碗。
图层栏为什么也是 store 的投影 store 的单一真相不只约束画布,也约束左侧图层栏。图层栏会把同一份业务树投影成反向视觉顺序、虚拟行、chunk 占位和 clip 影子分组,但正式排序、重挂载、显隐、锁定和 clip usage 仍由命令提交。
图表将在进入视口后显示 flowchart TD
subgraph storeState["Store 业务树<br/>(● Business Tree)"]
nodes[("nodes Map")]
children[("page.children / node.children")]
selection[("selection / hoveredId")]
clipUsage[("Animation Clip Usage<br/>(■)")]
chunks[("Document Chunk State<br/>(◇)")]
end
subgraph sidebar["左侧栏投影<br/>(● ★ Layer Tree Projection)"]
panelSnapshot["Panel Snapshot"]
visibleIndex["展开 / 过滤 / 虚拟可见索引"]
rows["图层行 / Chunk 占位 / Clip 影子分组"]
uiDraft["折叠、重命名、拖拽、菜单临时态"]
end
subgraph actions["图层操作<br/>(● Layer Actions)"]
select["选择 / 范围选择 / Hover"]
patch["重命名 / 显隐 / 锁定"]
move["排序 / 重挂载 / 移入 Deformer"]
clip["Clip Usage 修改<br/>(■)"]
end
subgraph commit["命令与历史<br/>(● ★ Commands And History)"]
editorCommands["editorCommands"]
modelCommands["modelCommands"]
animationCommands["animationCommands<br/>(■)"]
history["History Entry"]
end
subgraph consumers["顺序消费者<br/>(● ★ Order Consumers)"]
sidebarOrder["左侧栏视觉顺序<br/>同级 children 反向展示"]
pixiOrder["Pixi Child Order<br/>按存储树 reconcile"]
hit["Hit Testing / Range Selection"]
end
nodes --> panelSnapshot
children --> panelSnapshot
selection --> panelSnapshot
clipUsage --> panelSnapshot
chunks --> panelSnapshot
panelSnapshot --> visibleIndex
visibleIndex --> rows
uiDraft --> rows
select --> editorCommands
patch --> editorCommands
move --> editorCommands
move --> modelCommands
clip --> animationCommands
editorCommands --> history
modelCommands --> history
animationCommands --> history
history --> nodes
history --> children
history --> selection
history --> clipUsage
children --> sidebarOrder
children --> pixiOrder
sidebarOrder --> hit
pixiOrder --> hit
左侧栏为了符合设计工具习惯,会把同级 children 反向展示,让越靠上的行代表越上层的图层;Renderer 仍按 store 中的 children 协调 Pixi child container。两种顺序只是不同投影,不是两套数据。
为什么 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
events["Pointer / Wheel / Keyboard<br/>(● Browser Events)"]
map["坐标映射和交互守卫<br/>(● ★ Coordinate And Guards)"]
rules{"工具规则 / Overlay Hit / Spatial Hit"}
controller["交互 Controller<br/>drag / draw / pen / transform / pan"]
state[("CanvasInteractionState")]
subgraph previews["Pointer Move 临时反馈<br/>(● Preview Phase)"]
viewport[("viewportInteraction")]
interaction[("EditorState.interactionPreview")]
guides[("canvasGuides")]
overlay[("draft / handles / marquee")]
end
renderer["Renderer Preview"]
inspector["Inspector Preview"]
finish["Pointer Up / Wheel Idle / Cancel"]
commands["Command Commit"]
history[("Store History<br/>Undo / Redo")]
store[("Zustand Store")]
journal[("Document Operation Journal / Draft Patches<br/>(○ ◇ Autosave Projection)")]
events --> map
map --> rules
rules --> controller
controller --> state
state --> viewport
state --> interaction
state --> guides
state --> overlay
viewport --> renderer
interaction --> renderer
interaction --> inspector
guides --> renderer
overlay --> renderer
state --> finish
finish --> commands
commands --> history
history --> store
store --> renderer
store --> inspector
commands --> journal
这就是架构图里反复强调的“预览不是业务真相”。它带来的体验差异很大:
• 拖动时足够跟手,因为画面可以先按 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 做的是同步,不是决策 它拿到 store snapshot(store 在某一刻的状态快照),再叠加 runtime evaluated 和 interaction preview,生成当前这一帧应该显示的状态。然后它决定哪些 NodeView 可以复用,哪些只要改 transform,哪些要重画几何,哪些要刷新纹理,哪些可以因为离开视口而释放。
• canvas background 负责编辑器工作区底色。 • viewportLayer 承载 pan / zoom。 • grid layer 负责网格。 • tile snapshot layer 在视口活动时作为临时快照代理。 • node layer 承载文档节点。 • overlay layer 画选框、手柄、参考线、钢笔点、裁剪框、mesh 控制点等编辑辅助。 图表将在进入视口后显示 flowchart TD
store[("Store Snapshot<br/>nodes / viewport / selection")]
runtime[("Runtime Evaluated<br/>nodeOverrides / visibilityMask<br/>(■)")]
previews[("Viewport / Interaction / Guides Preview")]
resources["Asset Resolver<br/>Blob / URL / Bundled Reference<br/>(◇)"]
sync["RendererSyncCoordinator<br/>差异和同步范围<br/>(● ★)"]
scheduler["RendererFrameScheduler<br/>预览帧 / 普通帧"]
culling{"Culling And Materialization<br/>可见节点与预算"}
nodeViews["NodeView<br/>Container / Graphics / Sprite / Mesh"]
typeRenderers["Node Renderers<br/>Shape / Vector / Image / Deformer"]
caches[("Texture / Vector / Skia Caches<br/>(● ◆)")]
overlay["Overlay Sync<br/>guide / draft / pen / marquee / selection / debug"]
subgraph stage["Pixi Stage<br/>(● ◆ Derived Scene Graph)"]
background["Canvas Background"]
viewportLayer["Viewport Layer<br/>position / scale"]
grid["Grid Layer"]
tiles["Tile Snapshot Layer"]
nodeLayer["Node Layer"]
overlayLayer["Overlay Layer"]
end
store --> sync
runtime --> sync
previews --> sync
resources --> sync
sync --> scheduler
scheduler --> culling
culling --> nodeViews
nodeViews --> typeRenderers
typeRenderers --> caches
caches --> nodeViews
previews --> overlay
sync --> overlay
scheduler --> background
previews --> viewportLayer
viewportLayer --> grid
viewportLayer --> tiles
viewportLayer --> nodeLayer
viewportLayer --> overlayLayer
nodeViews --> nodeLayer
culling --> tiles
overlay --> overlayLayer
这个分层的好处是,业务节点和编辑辅助不会混在一起。选框和参考线是帮助编辑的视觉,不应该成为文档 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 和命令边界决定“编辑器现在是什么”。
主线程 React / CanvasInput UI、输入解释、命令提交、store、history、preview Worker 私有业务副本 主线程 Pixi / Skia / CanvasKit Renderer 同步、NodeView、overlay、栅格化、WebGL 提交 文档或业务真相 Document Worker 解析、签名、序列化、完整 JSON 打开 直接驱动 Pixi scene graph DraftWorker operation patch、compact、IndexedDB 草稿写入和恢复上下文 撤销重做历史 Log Worker 脱敏后的日志文件 IO 完整敏感 payload 存储 浏览器 / GPU File / Blob、IndexedDB、WebGL 命令与合成 可序列化业务状态
Worker 结果必须先回到主线程的 ready、command、autosave 或 logging 边界,再由 store 或可重建缓存触发 UI / Renderer。主 Pixi scene graph 不迁入 Worker。
历史和草稿是两套东西 综合主图把撤销重做历史和草稿投影放在同一条命令链路里,这一点很有必要。
很多编辑器问题都来自把“用户要撤销什么”和“系统要自动保存什么”混在一起。Vectania Editor 里这两件事是分开的:
• Cmd+Z / redo 回退的是 store 里的 history.past 和 history.future。 • 自动草稿依赖 Operation Journal(DocumentEngine 的文档操作日志,用于草稿、签名和恢复辅助)、autosave bridge 和 DraftWorker。 用户拖动节点时,连续 pointermove 会被合并成一个 history transaction。取消时用事务开始快照恢复,提交时生成一条可撤销记录。这是面向用户理解的历史。
自动草稿关心的是如何把文档增量安全写到本地恢复投影里。它可以节流,可以合并,可以后台 compact,但不能替代 undo/redo,也不能直接决定画布状态。
这个分离让两套语义都更干净。撤销重做服务编辑体验,草稿服务崩溃恢复和持久化辅助。一个管“我刚才那步能不能撤”,一个管“电脑刚才如果突然休息,我还能不能回来”,不要互相抢戏。
撤销重做与草稿投影的关系已经包含在前面的“画布输入、交互 preview 与历史”主图中:命令同时进入 store history 与 operation journal,但二者服务不同语义。
性能策略不是一个开关 画布编辑器的性能问题很少能靠一个“优化一下”解决。
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。
图表将在进入视口后显示 flowchart TD
activity["Pan / Zoom / Trackpad 活动<br/>(● Viewport Activity)"]
gate{"viewportInteractionBudgetGate<br/>(● ★ Budget Gate)"}
policy{"RendererFrameScheduler<br/>(● ★ Render Policy)"}
subgraph active["活动窗口:优先跟手<br/>(● Active Window)"]
preview["Preview RAF<br/>轻量出帧"]
proxy{"Tile Snapshot 可用性"}
tile["显示 Tile Layer<br/>可覆盖时隐藏 Node Layer"]
live["Live Preview<br/>代理缺失时显式降级"]
end
subgraph deferred["延后重活<br/>(● ○ ◇ Deferred Work)"]
culling["Culling / Materialization"]
zoomViews["Zoom-dependent Vector / Model Clipping"]
prewarm["Texture / Vector / Skia Prewarm"]
autosave["Autosave / IndexedDB / Chunk Apply"]
end
idle["Interaction Idle + Cooldown"]
light["先刷新 Grid / Frame Chrome / Overlay Marks"]
drain["按预算排空 Deferred Queue"]
normal["Normal Render Catch-up"]
stats[("耗时 / proxy mode / allow reason / view count")]
activity --> gate
gate --> policy
policy --> preview
gate --> proxy
proxy -->|"ready / partial"| tile
proxy -->|"missing"| live
gate -->|"非可见正确性、非用户显式"| culling
gate --> zoomViews
gate --> prewarm
gate --> autosave
activity --> idle
idle --> light
light --> drain
culling --> drain
zoomViews --> drain
prewarm --> drain
autosave --> drain
drain --> normal
preview --> stats
tile --> stats
live --> stats
normal --> stats
交互活跃和 cooldown 期间,preview render 优先;普通 render、物化、缩放依赖绘制、预热、草稿与 IndexedDB 工作必须经过预算门。可见正确性和用户显式操作可以带 reason 放行,其它任务延后并在 idle 后分批补齐。
Tile Snapshot 只是 renderer-local 代理。覆盖足够时可临时承接 nodeLayer 的画面;部分覆盖或缺失时必须显示现有真实内容并记录降级原因,不能静默空白。性能优化不能改变 store、history、保存、签名和恢复语义。
这套架构真正保护的东西 把这些架构链路合起来看,我感觉它保护的不是某个具体模块,而是几个长期不变的判断。
第一,入口不能跳过 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 当前架构设计:它不是为了“层很多”而分层,而是为了让每个复杂问题都有一个不会互相抢方向盘的位置。
名词解释 表格将在进入视口后显示 2 columns / 34 rows 名词 简洁解释 Zustand store 由 Zustand 管理的编辑器内存状态仓库;当前会话的业务真相。 Pixi 浏览器里的 2D WebGL 渲染引擎;负责把显示对象画到画布上。 Pixi scene graph Pixi 显示对象树,由 Container、Graphics、Sprite、mesh、mask 等组成;不是文档真相。 CanvasInput 画布输入总调度器,把 pointer、wheel、keyboard 事件解释成 pan、draw、drag、transform、pen、crop、mesh、deformer 等交互。 Command Layer 命令层,一组正式写业务状态的函数入口;负责校验、写 store、接历史和 autosave。 Renderer / EditorRenderer Pixi 渲染同步器,读取 store、runtime 和 preview,创建、更新、释放 Pixi 显示对象。 NodeView 一个文档节点在 Pixi 里的派生视图,可包含容器、图形、图片、mask、label 或 overlay 关联对象。 interactionPreview 交互期临时预览快照;拖动、resize、路径、mesh、deformer 编辑时让画面和属性栏跟手。 viewportInteraction 视口 pan / zoom 的临时预览快照;交互结束或 idle 后才提交最终 viewport。 runtime evaluated 动画、State Machine 或模型求值后的当前帧显示覆盖;不直接改原始节点。 mesh 用顶点、UV 和三角形索引描述的可变形贴图网格,常用于图片网格变形和模型显示。 deformer 变形控制器,通过控制点、路径、旋转或骨骼影响图片、mesh 或绑定内容的显示。 overlay 编辑辅助覆盖层,画选框、手柄、参考线、钢笔点、裁剪框、mesh 控制点等,不保存为文档节点。 mask 渲染裁剪遮罩,用来限制图片、Frame 内容或效果显示范围。 .labpixi 项目主工程包格式,包含 manifest、文档、chunks、assets 和 preview 等。 Compatible JSON 兼容导入 / 导出格式;打开时必须在 loading 内完整 ready。 Launch Intent 打开意图,描述从 Home 触发的是打开最近文件、打开文件还是恢复自动草稿。 Ready Gate / DocumentOpenReadiness 打开就绪门,确认工程身份、文档进入 store、签名基线、草稿上下文和首屏条件都满足。 Project Identity 工程身份,用于把 recent、保存目标、草稿桶和签名基线关联到同一个项目。 Save Target 保存目标,描述保存时写回 .labpixi、JSON、download-only 还是另存路径。 Draft Bucket 草稿桶,自动草稿写入 IndexedDB 时使用的本地命名空间。 DocumentWorker 文档后台 Worker,负责解析、标准化、签名和序列化等可结构化克隆的数据任务。 DraftWorker 草稿后台 Worker,负责自动草稿 append、compact、读取和 IndexedDB 写入。 Workspace Asset Store 工作区资源缓存,用来保存图片等二进制资源,文档节点只保存资源引用。 Object URL 浏览器为 Blob 生成的临时可加载 URL,常用于把 workspace 图片交给 Pixi 纹理加载。 Merkle baseline 文档签名基线,用来判断相对保存点是否 dirty。 Patch Base 自动草稿增量补丁的起点,用来决定 operation patch 从哪个版本开始追加。 Operation Journal DocumentEngine 的文档操作日志,服务自动草稿、签名和恢复,不等同于 undo 栈。 History Transaction 连续交互历史事务,把拖动、resize、钢笔编辑等过程合并成一条可撤销记录。 Culling / Materialization 裁剪和物化,大文档下只为可见或受保护节点创建 Pixi 对象,离开范围后可释放。 Tile Snapshot 视口活动时复用的临时画面快照,用来降低 pan / zoom 中的重绘压力。 Budget Gate 预算门,判断视口活动期间纹理预热、矢量缓存、空间索引或草稿整理是否应该延后。 Skia / CanvasKit 用于复杂矢量离屏栅格化的图形后端,生成的是显示缓存,不改变路径真相。 LOD Level of Detail,按缩放和预算选择的图片清晰度层级。