03 · 双端运行与画布
本章讲两件事:① 同一个 reducer 如何在浏览器(乐观)和服务器(权威)各跑一遍而保持一致;② 一棵嵌套树如何被布局算法翻译成画布上 的方块和连线。
3.1 为什么要"跑两遍"
拖一个方块,UI 必须立刻响应(否则卡顿)。但真相必须由服务器校验并落库(否则前端可以乱改)。Activepieces 的解法是让同一份 flowOperations.apply 在两端各跑一次:
用户操作
│
├─▶ 前端 apply() → 立刻更新 UI(乐观,零延迟)
│
└─▶ 防抖入队 → HTTP → 后端 apply() → 校验 + 落库(权威)
│
└─▶ 返回真实 version,前端对齐 id/state
因为是同一份代码,正常情况下两端算出的树一致,前端无需"等服务器返回才显示"。
3.2 前端:乐观更新 + 防抖排队
核心在 packages/web/src/app/builder/state/flow-state.ts 的 applyOperation(flow-state.ts:159-260)。流程:
// 真实源码节选 flow-state.ts:159-260 —— 先本地算新树,再把网络更新入队
applyOperation: (operation, onSuccess) => set((state) => {
const newFlowVersion = flowOperations.apply(state.flowVersion, operation) // ① 本地乐观
state.operationListeners.forEach(l => l(state.flowVersion, operation)) // 通知监听者(如撤销栈)
set({ saving: true })
const updateRequest = async () => {
const { version } = await flowsApi.update(state.flow.id, operation, true) // ② 发后端
set({ flowVersion: { ...mergedSampleData, id: version.id, state: version.state },
saving: flowUpdatesQueue.size() !== 0 })
}
switch (operation.type) {
case UPDATE_TRIGGER: case UPDATE_ACTION:
debouncedAddToFlowUpdatesQueue(operation.request.name, updateRequest) // ③ 高频编辑防抖
break
default: flowUpdatesQueue.add(updateRequest) // 其余直接入队
}
return { flowVersion: newFlowVersion } // ④ 立刻渲染
})
两个关键机制:
- PromiseQueue 串行化(
flow-state.ts:87)。所有发往后端的更新进同一个队列按序执行,避免两个并发请求互相覆盖(后发的基于旧版本算)。出错时flowUpdatesQueue.halt()(flow-state.ts:214)停住队列,防 止在脏状态上继续叠加。 - 防抖合并高频编辑(
debounce(...)定义在flow-state.ts:88-93,1000ms)。在输入框里改 piece 设置会触发很多次UPDATE_ACTION;防抖把 1 秒内同一步骤的多次改并成一次网络请求。注意按步骤名 keying 发生在调用点(flow-state.ts:225-228,debouncedAddToFlowUpdatesQueue(operation.request.name, updateRequest)):底层debounce是按首个key参数分桶的(签名(key?, ...args),core/utils/utils.ts:47),所以不同步骤的编辑不会互相吞掉。
只读态的例外: 已发布版本只读,但便利贴(UPDATE_NOTE)仍允许本地 apply(flow-state.ts:161-170)——这是个刻意开的小口。
3.3 服务器:校验流水线
后端入口 flowVersionService.applyOperation(flow-version.service.ts:19-106)。它先把"宏级"操作展开(如 USE_AS_DRAFT 展成 IMPORT_FLOW + 可能的 sample data 操作,flow-version.service.ts:31-58),再对每个操作走 applySingleOperation(flow-version.service.ts:316-332):
// 真实源码节选 flow-version.service.ts:316-332 —— 后端三步:副作用、准备、apply
async function applySingleOperation(projectId, flowVersion, operation, platformId, log, userId) {
await flowVersionSideEffects(log).preApplyOperation({ projectId, flowVersion, operation }) // ① 副作用/校验
const preparedOperation = await flowVersionValidationUtil(log)
.prepareRequest({ platformId, request: operation, userId }) // ② 清洗/补全
const updatedFlowVersion = flowOperations.apply(flowVersion, preparedOperation) // ③ 同一 reducer
return updatedFlowVersion
}
三步各司其职:
| 步骤 | 干什么 | 例子 |
|---|---|---|
preApplyOperation(副作用) | 编辑前需要做的外部动作/校验 | 权限检查、删步骤时清理 sample data 文件 |
prepareRequest(准备) | 清洗、补全请求,使其安全可落库 | 把 piece 版本号补成精确版本、剥离敏感字段 |
flowOperations.apply | 和前端完全相同的 reducer | 算出新树 |
落库前还会重算派生字段并 sanitizeObjectForPostgresql(flow-version.service.ts:99-105):重置 updated/updatedBy、重抽 connectionIds/agentIds,再 flowVersionRepo.save。
对照看: AGENTS.md 里的规则"Side effects 分到
*-side-effects.ts,显式调用"(AGENTS.md:13,原文措辞是 "called explicitly after mutations")在这里落地了——只不过本仓库该钩子名为preApplyOperation(在apply之前跑副作用/校验)。无论时序在前在后,核心一致:把会碰外部世界的事关进显式副作用钩子,和纯 reducerapply干净分离。
3.4 画布:把树布局成图
树本身没有坐标。要画到画布上,得给每个步骤算一个 (x, y)。这由纯函数 flowCanvasUtils.computeStepPositions(trigger)(util/flow-canvas-util.ts:203)完成。
它分两趟:
第一趟 getFlowBoundingBox(step) —— 自底向上算每棵子树要占多宽多高(包围盒)
第二趟 buildPositions(step, x, y) —— 自顶向下根据包围盒把每个节点摆到具体坐标
为什么要先算包围盒? 因为分支并排时,左右分支各自可能很宽(里面还套循环/路由),必须先知道每个分支占多宽,才能决定它们之间留多大水平间距、整体往左偏移多少才居中。getFlowBoundingBox(flow-canvas-util.ts:17-65)对 loop / router / 失败分支分别递归算子树包围盒,再合并;mergeBranchedChildBoundingBoxes(flow-canvas-util.ts:120-140)负责把多个并排分支的包围盒拼起来并算居中偏移。
第二趟 buildPositions(flow-canvas-util.ts:67-102)拿着包围盒,把:
- 主干步骤竖直往下排(每步
y += STEP_HEIGHT + VSPACE); - loop 的循环体往右下方缩进(
flow-canvas-util.ts:77-90); - router 的各分支用
positionBranchedChildren(flow-canvas-util.ts:150-170)左右铺开。
布局常量(步宽 232、步高 60、垂直间距 60 等)都在 flow-canvas-util.ts:5-11,所以后端也能算出和前端一致的坐标(比如截图、AI 看流时用)。
3.5 横向画布的小聪明:反射
Activepieces 支持纵向和横向两种画布。它没有写两套布局算法——只写了纵向那套,横向是把纵向布局沿 y = x 轴反射(把 (x,y) 转成 (y,x))。前端 flow-canvas-utils.ts 顶部的大段注释(packages/web/src/app/builder/flow-canvas/utils/flow-canvas-utils.ts:36-44)写得很清楚:
"the finished graph is then reflected across the y = x axis — node positions by transposeGraphPositions, edge SVG paths by svgPathUtils.transposePath — turning the top-to-bottom layout into a left-to-right one."
节点坐标交换 x/y,连线的 SVG 路径也跟着转置。一套布局,两个方向。
3.6 巧妙之处
- 乐观更新零成本一致。 因为前后端是同一份纯 reducer,前端"先斩后奏"不会和服务器结果分叉;前端持有旧树,出错时回滚也只是"用旧树重渲染"。
- 副作用与纯计算分离。 服务器把"会碰外部世界的事"(权限、文件、版本号补全)关进
preApplyOperation/prepareRequest,核心apply保持纯净、可在前端复用。 - 布局是无副作用的纯函数。 给定一棵树,坐标是确定的,所以前端渲染、后端出图、AI 理解可以共用同一套坐标语义;两趟(包围盒 + 摆位)是经典的树布局套路。
- 横向 = 纵向的转置。 用一次坐标反射省掉一整套对称代码。
3.7 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 前端乐观更新 + 排队 | packages/web/src/app/builder/state/flow-state.ts | applyOperation |