02 · 操作引擎:一切编辑都是纯函数 reducer
本章是整个构建器的心脏。讲清楚:编辑流的唯一通道是什么、那个 reducer 怎么写、它靠哪个递归遍历器干活、复杂操作如何 被拆成简单操作。
2.1 它要解决的小问题
树是嵌套的,直接"在第 3 层第 2 个分支里插一个节点"很容易写出深一脚浅一脚、改坏别处的代码。Activepieces 的答案是:你不许直接改树。你只能提交一个描述意图的对象(FlowOperationRequest),由一个纯函数负责把意图变成一棵新树。
2.2 编辑的唯一词汇:FlowOperationRequest
所有可能的编辑被收敛成一个枚举 FlowOperationType(operations/index.ts:28-55),约 30 种。常见的几种:
| 操作 | 含义 |
|---|---|
ADD_ACTION | 在某父步骤的某位置插入一个 action |
UPDATE_ACTION / UPDATE_TRIGGER | 改某步骤/触发器的设置 |
DELETE_ACTION | 删一个或多个 action(并重新缝合链表) |
MOVE_ACTION | 把一个 action 搬到别处 |
DUPLICATE_ACTION | 复制一个 action 子树 |
ADD_BRANCH / DELETE_BRANCH / MOVE_BRANCH | router 分支增删移 |
IMPORT_FLOW | 用一整棵 trigger 树替换当前流 |
CHANGE_NAME / LOCK_FLOW / SET_SKIP_ACTION … | 元数据类 |
每个操作是 { type, request },request 的形状由对应的 zod schema 校验(整个联合在 operations/index.ts:217-322 的 FlowOperationRequest)。
"插在哪"由一个枚举精确表达 —— StepLocationRelativeToParent(operations/index.ts:106-112):
| 值 | 插到父步骤的哪里 |
|---|---|
AFTER | 紧跟在父步骤后面(主干) |
INSIDE_LOOP | 作为循环体的第一步(firstLoopAction) |
INSIDE_BRANCH | 作为某个 router 分支的第一步(配 branchIndex) |
INSIDE_ON_SUCCESS_BRANCH / INSIDE_ON_FAILURE_BRANCH | 作为失败分支子树的头 |
这个枚举 + parentStep(父步骤名) + 可选 branchIndex,就能唯一定位树里任何一个插入点。
2.3 reducer:flowOperations.apply
核心入口是 flowOperations.apply(flowVersion, operation)(operations/index.ts:331)。它的骨架:
// 真实源码节选 operations/index.ts:331-432 —— 重点看深拷贝 + switch + 末尾重算 valid
apply(flowVersion, operation) {
let clonedVersion = JSON.parse(JSON.stringify(flowVersion)) // ① 永不原地改
switch (operation.type) {
case ADD_ACTION: clonedVersion = _addAction(clonedVersion, operation.request); break
case DELETE_ACTION: clonedVersion = _deleteAction(clonedVersion, operation.request); break
case MOVE_ACTION: { // ② 宏:展开成多步
const ops = _moveAction(clonedVersion, operation.request)
ops.forEach(op => { clonedVersion = flowOperations.apply(clonedVersion, op) })
break
}
// … 其余 case
}
clonedVersion.valid = getAllSteps(clonedVersion.trigger) // ③ 每次都重算整流是否合法
.every(step => step.valid || isSkipped(step))
return clonedVersion
}
三个要点:
- 永不原地改。 第一行就深拷贝,所有
_xxx帮手在拷贝上操作,返回新树。这让乐观更新和回滚都简单(前端持有旧树即可回退)。 - 宏操作自递归。
MOVE_ACTION、DUPLICATE_ACTION、DUPLICATE_BRANCH、IMPORT_FLOW、USE_AS_DRAFT这些复杂操作,不自己手写树变换,而是生成一串基本操作,再forEach调apply依次落地(operations/index.ts:334-339等)。 - valid 是派生的,每次重算。 末尾
getAllSteps(...).every(...)(operations/index.ts:428-431)遍历全树,只要有一步不合法且没被 skip,整条流就valid: false。这驱动 UI 上的"发布"按钮能不能点。
心智模型:
apply就是 Redux 里的 reducer——(state, action) => newState,纯函数、不可变。只不过这里的 state 是一棵工作流树。
2.4 干活的递归遍历器:transferFlow / transferStep
几乎所有树变换都不手写递归,而是复用一个通用遍历器 transferStep(util/flow-structure-util.ts:79-132)。它接收"对单个步骤做什么"的函数,自己负责把这个函数递归地作用到树里每一个节点(包括 loop 体、router 各分支、失败分支、以及 nextAction)。
// 示意,非源码:transferStep 的本质
function transferStep(step, fn) {
step = fn(step) // 先变换当前节点
if (step.type === LOOP) step.firstLoopAction = transferStep(step.firstLoopAction, fn)
if (step.type === ROUTER) step.children = step.children.map(c => c && transferStep(c, fn))
// …失败分支 onSuccess/onFailure 同理…
if (step.nextAction) step.nextAction = transferStep(step.nextAction, fn)
return step
}
transferFlow(flow-structure-util.ts:135-145)是它在整条流上的封装:深拷贝后从 trigger 开始 transfer。
这个抽象的威力: 很多看似不同的功能,其实是"给 transfer 喂不同的函数":
| 功能 | 喂给 transfer 的函数干什么 |
|---|---|
getAllSteps(列出所有步骤) | 把每个步骤 push 进数组,原样返回(flow-structure-util.ts:147-154) |
_addAction(加步骤) | 命中父步骤时,在对应位置插新节点(add-action.ts:138-162) |
_deleteAction(删步骤) | 命中要删的节点的父节点时,把 nextAction 跳过它(delete-action.ts) |
| schema 迁移(改结构) | 把每个老结构节点改写成新结构(见 04 章 branch→router) |
一个遍历器,服务"读"(getAllSteps)和"写"(增删改迁移)两类需求。
2.5 一个具体操作:_addAction
看 _addAction(operations/add-action.ts:138-162)如何用 transferFlow 实现"插入":
// 真实源码节选 add-action.ts:138-162 —— 用 transferFlow 找到父步骤再按位置插
function _addAction(flowVersion, request) {
return flowStructureUtil.transferFlow(flowVersion, (parentStep) => {
if (parentStep.name !== request.parentStep) return parentStep // 不是目标,原样返回
// 命中父步骤:按 stepLocationRelativeToParent 决定插哪
switch (parentStep.type) {
case LOOP_ON_ITEMS: return handleLoopOnItems(parentStep, request) // INSIDE_LOOP / AFTER
case ROUTER: return handleRouter(parentStep, request) // INSIDE_BRANCH / AFTER
default: // 普通步骤:只能 AFTER
parentStep.nextAction = createAction(request.action, { nextAction: parentStep.nextAction })
return parentStep
}
})
}
注意"链表插入"的经典写法:新节点的 nextAction 接上父步骤原来的 nextAction,父步骤再指向新节点——一次性把新节点缝进链中(add-action.ts:155-157)。loop/router 的处理(handleLoopOnItems、handleRouter,add-action.ts:69-112)是同样的缝法,只是缝到 firstLoopAction 或 children[branchIndex]。
2.6 宏操作:move = delete + add + re-import
最能体现"宏"思想的是 _moveAction(operations/move-action.ts:7-38)。"移动一个步骤"被拆成:
// 真实源码节选 move-action.ts —— 移动 = 先删、再在新位置加、再重导其子树
return [
{ type: DELETE_ACTION, request: { names: [request.name] } }, // ① 从老位置摘掉
{ type: ADD_ACTION, request: { action: sourceStepWithoutNextAction, // ② 加到新父步骤
parentStep: request.newParentStep, ... } },
..._getImportOperations(sourceStepWithoutNextAction), // ③ 把它的子步骤逐个重新接回
]
为什么这么绕?因为被移动的步骤可能自己带着一棵子树(它是个 loop 或 router)。先把它"裸搬"过去(去掉 nextAction),再用 _getImportOperations 把子树里的每个子步骤生成 ADD_ACTION 逐个接回新位置。apply 收到 MOVE_ACTION 后,就是把这串操作 forEach 依次跑一遍(operations/index.ts:334-339)。
好处: 复杂操作复用已被充分测试的基本操作,无需为每种复杂操作单独写一套易错的树手术。
2.7 复制时的隐藏难点:重命名引用
复制一个步骤不能只复制结构——步骤之间靠 {{step_1.output}} 这种mustache 引用互相取数据。复制后必须把引用里的旧步骤名改成新名,否则复制出来的步骤还指着原件。这由 addActionUtils.clone(operations/add-action-util.ts:50-79)处理:遍历 settings.input 里的字符串,把 {{ }} 里的旧名替换成新名。
这里藏着一个已知 bug 的诚实注释(add-action-util.ts:32-36):正则 /{{(.*?)}}/g 是惰性匹配,遇到内容里本身含 }} 的 token(如字符串字面量)会截断,导致尾部步骤名没被改名。注释明说"Swap deferred — needs duplicate/paste re-testing"——这是真实代码里待修的坑,不是我编的。