Vectania Editor 架构设计简析

架构|架构|字数 7,067|阅读时长 ≈ 18 分钟

这篇算是一篇补记,也算是把前面两张架构图揉成一篇更容易入口的说明。

之前只看 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 里,但它们大部分都不应该进入保存文件。

这些约束最后收束成两条主链路:打开链路和画布链路。

Codemermaid
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 显示对象树)只是一棵由 ContainerGraphicsSprite、mesh 和 mask 组成的显示树。它很重要,但它不是文档户口本。

第一站:Home 不是传送门

最近文件与 Open Project 加载架构图 里最值得记住的点是:Home 页面不是直接把用户扔进编辑器,而是把点击动作转成 launch intent(打开意图,描述“打开最近文件、打开本地文件、恢复自动草稿”等来源和目标)。

这一步看起来像礼貌寒暄,其实很关键。因为最近文件可能没有权限了,工程包句柄可能失效了,JSON 可能需要重新选择,自动草稿也可能和当前工程身份不匹配。Home 如果直接进入画板,就等于还没验票就上车,短程可能没事,长途迟早补票补到手忙脚乱。

Codemermaid
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(打开就绪门,确认文档、身份、保存目标、草稿和首屏条件都满足)有没有放行。

Codemermaid
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 显示对象。

Codemermaid
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 对象,那能不能直接改 ContainerGraphicsSprite,然后从它们反推文档?

短期看很顺手,长期会很痛。就像把便签贴在冰箱上,然后宣布冰箱是知识库,气势很足,检索很难。

比如一个图片节点,在文档里保存的是资源引用、裁剪参数、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(连续交互合并成一条可撤销记录的历史事务)。

Codemermaid
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 控制点等编辑辅助。
Codemermaid
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.pasthistory.future
  • 自动草稿依赖 Operation Journal(DocumentEngine 的文档操作日志,用于草稿、签名和恢复辅助)、autosave bridge 和 DraftWorker。

用户拖动节点时,连续 pointermove 会被合并成一个 history transaction。取消时用事务开始快照恢复,提交时生成一条可撤销记录。这是面向用户理解的历史。

自动草稿关心的是如何把文档增量安全写到本地恢复投影里。它可以节流,可以合并,可以后台 compact,但不能替代 undo/redo,也不能直接决定画布状态。

这个分离让两套语义都更干净。撤销重做服务编辑体验,草稿服务崩溃恢复和持久化辅助。一个管“我刚才那步能不能撤”,一个管“电脑刚才如果突然休息,我还能不能回来”,不要互相抢戏。

Codemermaid
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 当前架构设计:它不是为了“层很多”而分层,而是为了让每个复杂问题都有一个不会互相抢方向盘的位置。

名词解释

表格将在进入视口后显示2 columns / 34 rows
0