系统设计 / 任务运行时 · 从一次 run() 到可观测的调度层 / Vue 集成:composables 与卸载 待审核 10 / 10
Vue 适配层

Vue 集成:composables 与卸载

一个组件里的异步请求最终要回答两个问题:这次请求现在处在什么状态,以及组件被卸下时它怎么办。前一个问题各家数据请求库都答过,答案是把 pending / data / error 三件事变成响应式变量;后一个问题在 Vue 里没有统一答案,惯例是在 onUnmounted 里手写一句 controller.abort(),漏写了也不会报错。@vega/job/vue 这层适配把两个问题一起接下:一侧把 Task 的状态投影成 ref,另一侧把 scope 与 flow 的生命期钉在组件实例上。本页拆开这层适配的七个入口,最后回到卸载这件事。

1 · 状态的投影面

Task 句柄本身就是可订阅的:getSnapshot() 取一次原子快照,subscribe(listener) 收每次状态变化(见任务与状态机)。适配层做的事只是把这对方法翻译成 Vue 的读写协议——一个 shallowRef 装当前 TaskState,七个派生量挂在它上面。

派生量在所有 composable 上一致:status 是状态名,data 只在 success 下有值,error 只在 error 下有值,isInFlight 覆盖 parked / queued / running / cancelling 四类在途状态,isSuccessisError 是两个终态的谓词。区别只在 state 由谁填。

useTask(task) 填得最直白:接一个已有句柄,setup 时立即订阅,onUnmounted 时退订。它适用于句柄产生在组件之外的场合,比如一个 store 里跑着的任务被多个组件同时观察。useTaskState(task, selector) 是它的窄化版本,只把 selector 这个取片函数选出的那一片存进 ref,底层用 Object.is 比较,状态变了但选出的片没变时不触发更新。

useRunTask(runtime, taskFn, options) 反过来:组件里并没有句柄,有的是一个还没跑的 taskFn 与一个触发时机。它返回 run()reset(),加上同一组派生量。run() 每次调用先退订上一个任务,再 run(runtime, taskFn, options),把新句柄接上。

createJobPlugin(runtime)useRuntime() 是把 runtime 送进组件树的一对。插件走 app.provide,注入的静态类型是 Runtime 接口,Runtime 只是它的一个实现,所以测试里换一个假实现不必改任何组件。

options 里多出一个引擎不认识的字段 debounceMs。它由 hook 自己的 setTimeout 实现:窗口内的连续 run() 只有最后一次真正落到 run(),前面几次连队都没排上。它与时间窗口 shaper里的 debounce shaper 同名而不同层,后者是运行时的 shaper,会在事件流里留下痕迹,前者在运行时之外,引擎完全不知道有过这些调用。

代价写在状态上。等待窗口期间,hook 直接把 state 写成 parked:coordinator。该状态名在引擎里属于协调器(见同 key 协调),此处并没有协调器参与,也没有任何一条事件与它对应,inspect(runtime) 与 devtools 面板都看不到这个「任务」。这样写只为让 isInFlight 在窗口期返回 true,UI 上的 spinner 得以连续。

警示 · hook 这一层没有 key,也不会自动合成一个。UseRunTaskOptions 就是 RunOptions & { debounceMs? },而 RunOptions 里根本没有 key 字段 —— 调用方硬塞一个进去,它会原样流进 run(runtime) 然后无人认领(lab/facts.test.ts §1)。协调器的 key 绑在 withCoordinator(taskFn, coordinator, { key }) 的第三个参数上,绑定发生在把 taskFn 交给 hook 之前:两个组件共用同一个绑好的 taskFn 才会互相协调,各绑各的 key 则互不干扰。debounceMs 与此无关,它是 hook 自己的定时器,在调用抵达 run(runtime) 之前就把连发合并掉了。

图 1-1 · 由 useRunTask 驱动的防抖搜索框,读数给出键入次数、实际发出的请求数与被合并掉的次数。可连续键入观察 350ms 窗口内的多次输入合并成一次请求。

2 · 重复触发的默认语义

run() 在上一次调用还在飞时被再次调用,不会取消前一个任务。两个任务并行跑完,各自的 hooks、重试与 effects 照常发生,变化的只有 hook 的观察对象:state 与它派生的六个量此后只跟随最新那一次。前一次的结局对组件不可见,除非调用方留住了 run() 的返回值,自己去 task.subscribe()await task.promise

这个默认与 TanStack Query 的 useMutation、SWR 与 RTK Query 一致,理由写在源码注释里:mutation 是用户意图的写操作,静默取消它没法回滚已经发到服务端的副作用。读操作要的语义相反,选择权被留在 taskFn 那一侧:withCoordinator 绑上 restartCoordinator 得到后发取消先发,绑上 dedupeCoordinator 得到同 key 共享,绑上 serialize 得到排队(见同 key 协调)。

reset() 同样不取消。它退订、清掉可能挂着的 debounce 定时器,把 state 打回 idle,在飞的任务继续跑到自己的终态。要连任务一起停,得先在 run() 返回的句柄上 cancel()

注 · run() 的返回值在 debounce 窗口期是 undefined:那一刻还没有任务,只有一个定时器。想拿到句柄的调用方要么不设 debounceMs,要么改用 debounce shaper,后者返回的是 Promise,同样给不出句柄。

图 2-1 · 一次表单提交的四种状态,提交约有三分之一的概率失败。可在提交进行中再次触发 run(),观察两次提交并行跑完而面板只反映最后一次。

3 · 卸载时在飞的任务

组件卸载时,useRunTask 只做退订与清定时器两件事,任务本身继续跑到自己的终态(lab/facts.test.ts §3)。这对写操作是对的,对绑着视图的读操作是浪费:面板已经不在了,它请求的数据没有人看。要让任务随组件一起消失,得换一个绑定层次——绑到 scope 上。

useScope(runtime, label) 建一个 TaskScope,并在 onUnmountedscope.cancel()。scope 内部是一个 sentinel 任务,scope.run() 起的每个任务都以它为 parent,于是取消 scope 等于取消一整棵子树(见作用域与 effects)。三个在飞任务上跑这一步,三个的终态都是 cancelledcancelReason 都是 parentcore/scenarios.test.ts §1)。组件卸载走的是同一条路,本页的 lab/facts.test.ts 把组件那一侧也钉住了:一个用 useScope 起了三个任务的组件被卸载之后,三个任务的原因同样是 parent(§3)。

reason 的这个取值有实际用处。调用方在 catch 里拿到 CancelledError 时可以据它分流:abort 是用户主动取消,值得提示;parent 是组件没了,任何提示都会打在一个已经不存在的界面上,直接吞掉即可。

建议 · 一个组件同时有读与写两类任务时,读走 scope.run()、写走 useRunTask,卸载语义就自动分开了:读随组件消失,写跑到终态。把两类混进同一个 scope,等于承诺卸载会中断提交。

图 3-1 · 一个用 useScope 起任务的子组件被挂载与卸载,右侧日志订阅同一台 runtime。可在请求在飞时卸载子组件,观察日志里的 cancelled 行给出 reason=parent。

4 · 流程句柄的绑定

useFlow(runtime, ...steps)flow() 加一句 onUnmounted(handle.cancel),把上一节那套绑定换到 FlowHandle 上。步骤写法与编排与依赖图一页相同,parallel 作为一个步骤并行若干支。

useFlow 另有一条来自 Vue 的硬约束:onUnmounted 只在 setup 执行期间有活动组件实例可挂,写在点击回调里的注册会落空,Vue 只在控制台留一句警告。这条约束被 useFlow 继承下来,它必须在 setup 里调用——也就是说流程的启动时机被钉死在组件的挂载时机上。旧 demo example/vue/UseJob.vue 的第四张卡片正是在点击回调里调 useFlow,那里的 onUnmounted 一次都没有注册成功,卡片上「组件卸载时自动 cancel()」的说明与实际行为对不上(lab/facts.test.ts §4)。可行的写法是把流程放进一个子组件,让子组件的挂载与卸载充当启动与终止。

还有一处容易漏:flow(runtime, ...) 的步骤挂在 runtime 根下,外层 scope 取消不会级联到它们;要让流程也随 scope 一起倒,得用 scope.flow(...)useFlow 走的是前者(lab/facts.test.ts §4)。

图 4-1 · validate 到 compress 与 upload 并行再到 notify 的三段流程,跑在一个由 useFlow 驱动的子组件里。可在流程进行中卸载该子组件,观察它与显式 cancel 得到同一个终态。

至此这台运行时的九个切面看完了:任务的状态机与协作式取消、队列与优先级、依赖与编排、时间窗口、同 key 协调、重试与超时、作用域与 effects、事件流与观测,以及建在其上的响应式依赖图。适配层没有引入任何新机制,它做的全部工作是把这些机制的两个端口接到 Vue 上:读的一端是 subscribe,写的一端是 onUnmounted