NocoBase RunJS 事件取消订阅指南:ctx.off() 的原理、用法与最佳实践
【免费下载链接】nocobaseNocoBase is an open-source AI + no-code platform for building business systems fast. Instead of generating everything from scratch, AI works on top of production-proven infrastructure and a WYSIWYG no-code interface, so you get both speed and reliability.项目地址: https://gitcode.com/GitHub_Trending/no/nocobase
RunJS 是 NocoBase 中面向 JS 区块(JSBlock)、JS 字段(JSField / JSEditableField)、JS 操作(JS Action)等场景的 JavaScript 执行环境,代码运行在受限沙箱中,通过ctx上下文对象安全访问平台能力。本文聚焦其中承担“取消订阅”职责的ctx.off():它用于移除ctx.on()注册的事件监听,是防止内存泄漏、避免重复触发、保障页面长期稳定运行的关键 API。读完本文,你将掌握ctx.off()的类型签名、适用场景、与ctx.on()/ctx.resource的配对用法,以及 handler 引用一致性等底层原理。
一、ctx.off() 是什么
在 RunJS 中,ctx.on(eventName, handler)用于订阅上下文事件(如字段值变化、属性变化、资源刷新等),而ctx.off(eventName, handler)则用于移除这些监听,二者通常配对使用。官方文档对ctx.off()的定义是:
移除通过
ctx.on(eventName, handler)注册的事件监听。常与ctx.on()配合使用,在适当时机取消订阅,避免内存泄漏或重复触发。
从事件分发机制看,事件会根据类型映射到两条通道(见 ctx.on() 文档):
- 以
resource:为前缀的事件(如resource:refresh、resource:saved)走ctx.resource的内部事件总线; - 其余事件(如
js-field:value-change)通常映射为ctx.element(渲染容器)上的自定义 DOM 事件(CustomEvent)。
ctx.off()需要与这两条通道分别对应:DOM 事件用removeEventListener,资源事件总线用ctx.resource.off()。后者的底层实现在仓库中可以找到明确证据:FlowResource类维护了一个events: Record<string, Array<(...args) => void>>字典,on负责追加回调,off负责按引用过滤移除,emit负责触发(见 flowResource.ts)。
二、适用场景
| 场景 | 说明 |
|---|---|
| React useEffect 清理 | 在useEffect的 cleanup 中调用,组件卸载时移除监听 |
| JSField / JSEditableField | 字段双向绑定时,取消对js-field:value-change的订阅 |
| resource 相关 | 取消对ctx.resource.on注册的 refresh、saved 等监听 |
这三类场景分别对应事件源的三条典型链路:
- React 组件生命周期:RunJS 代码常写在
useEffect内,注册与清理天然配对; - 字段值双向绑定:JSField 渲染的自定义控件既要监听外部(表单联动、默认值更新)带来的值变化,也要在卸载时解除监听,否则组件重新渲染时会出现重复触发;
- 数据资源生命周期:监听
ctx.resource上的refresh(刷新完成)、saved(保存完成)等事件,在数据更新后执行联动逻辑,用完后必须取消。
三、类型定义
off(eventName: string, handler: (event?: any) => void): void;eventName:要取消的事件名,必须与ctx.on注册时完全一致;handler:注册时的同一函数引用(详见下文“注意事项”);- 返回
void,无返回值。
对应地,资源实例上的取消订阅签名(来自 runjs-context/contexts/base.ts 中ctx.resource的元数据定义)为:
off(event: string, callback: (...args) => void): void;四、事件映射规则与常见事件
在讨论示例之前,先明确ctx.on()的事件映射规则(源自 ctx.on() 文档):以resource:为前缀的事件走ctx.resource.on,其余通常走ctx.element上的 DOM 事件(若存在)。
| 事件名 | 说明 | 事件来源 |
|---|---|---|
js-field:value-change | 字段值被外部修改(如表单联动、默认值更新) | ctx.element上的 CustomEvent,ev.detail为新值 |
resource:refresh | 资源数据已刷新 | ctx.resource事件总线 |
resource:saved | 资源保存完成 | ctx.resource事件总线 |
资源事件的实际触发位置可在源码中验证:APIResource.refresh()请求成功后调用this.setData(data)并this.emit('refresh')(见 apiResource.ts);MultiRecordResource、SingleRecordResource在保存/刷新路径上会this.emit('saved', data)或this.emit('refresh')(见 multiRecordResource.ts 与 singleRecordResource.ts)。这意味着ctx.resource.on('refresh', handler)监听的是资源实例自身维护的事件总线,而非浏览器事件。
五、示例:如何在真实场景中配对使用
5.1 React useEffect 中配对使用
在 React 组件中,useEffect的返回函数(cleanup)是移除监听的推荐位置:
React.useEffect(() => { const handler = (ev) => setValue(ev?.detail ?? ''); ctx.on('js-field:value-change', handler); return () => ctx.off('js-field:value-change', handler); }, []);要点:
handler定义在useEffect内部,ctx.on与ctx.off引用同一个函数对象;- cleanup 在组件卸载或依赖变更重新执行前被调用,保证监听被及时移除;
ctx.off在部分上下文中可能不存在,官方文档建议使用可选链写法:ctx.off?.('js-field:value-change', handler)。
5.2 字段双向绑定与 js-field:value-change
js-field:value-change是 JSField / JSEditableField 场景中最常用的事件。仓库中的多区块联动示例(两个表单块同步值)给出了完整用法(见 cross-block-linkage.tsx)。其核心订阅代码为:
ctx.element.addEventListener('js-field:value-change', function(ev){ var next = ev.detail == null ? '' : String(ev.detail); if (selectEl) { var prev = selectEl.value; if (prev !== next) { selectEl.value = next; // 根据新值联动刷新子区块 notifyPeer(next); } } });这段示例直接使用原生ctx.element.addEventListener订阅(等价于ctx.on的 DOM 事件通道),并在变更监听中通过ev.detail读取新值。若要按ctx.on/off风格组织,则是:
React.useEffect(() => { const handler = (ev) => setValue(ev?.detail ?? ''); ctx.on?.('js-field:value-change', handler); return () => { ctx.off?.('js-field:value-change', handler); }; }, []);两种写法一一对应:DOM 原生监听用addEventListener/removeEventListener,ctx.on/ctx.off是其封装后的快捷通道(ctx.element的文档化属性可在 elementDoc.ts 中查看)。
5.3 资源事件取消订阅
ctx.resource是当前上下文中的 FlowResource 实例,提供on(event, callback)/off(event, callback)用于订阅/取消订阅资源事件(如refresh、saved)。取消订阅示例:
const handler = () => { /* ... */ }; ctx.resource?.on('refresh', handler); // 适当时机 ctx.resource?.off('refresh', handler);资源刷新后更新 UI 的完整模式:
ctx.resource?.on('refresh', () => { const data = ctx.resource?.getData?.(); // 根据 data 更新渲染 });注意:ctx.resource在多数区块(表单、表格、详情)和弹窗场景下由运行环境预先绑定;而在 JSBlock 等默认无 resource 的场景下为undefined,需要先调用ctx.initResource(type)初始化(详见 ctx.resource 文档)。因此取消订阅前应使用可选链ctx.resource?.off(...)做空值防御。
5.4 与 ctx.on 的原生 DOM 监听替代方案
当ctx.on未提供时,可直接使用ctx.element的原生 API:
// 当 ctx.on 未提供时,可直接使用 ctx.element const handler = (ev) => { if (selectEl) selectEl.value = String(ev?.detail ?? ''); }; ctx.element?.addEventListener('js-field:value-change', handler); // 清理时:ctx.element?.removeEventListener('js-field:value-change', handler);六、与 ctx.on / ctx.resource 的配合要点
- 使用
ctx.on注册的监听,应在适当时机通过ctx.off移除,避免内存泄漏或重复触发; - 在 React 中,通常在
useEffect的 cleanup 函数中调用ctx.off; ctx.off可能不存在,使用时建议加可选链:ctx.off?.('eventName', handler);ctx.resource的on/off是资源事件总线上的对称 API(底层见 flowResource.ts),除on/off外还提供once(一次性监听,触发后自动off)与emit(触发事件)。
七、注意事项
handler 引用一致:
ctx.off时传入的handler必须与ctx.on时是同一引用,否则无法正确移除。这由底层实现决定:FlowResource.off通过(this.events[event] || []).filter((fn) => fn !== callback)按引用过滤(见 flowResource.ts),匿名函数每次创建都是新引用,无法命中过滤条件。因此切勿写成:ctx.on('refresh', () => { ... }); ctx.off('refresh', () => { ... }); // 无效:两个匿名函数引用不同及时清理:在组件卸载或 context 销毁前调用
ctx.off,避免内存泄漏。FlowContext的实例通过_props、_methods、委托链(delegate/addDelegate)组织属性与方法(见 flowContext.ts),RunJS 每次执行都会创建上下文,若不清理监听,事件总线上的回调会持续累积。事件可用性:不同 context 类型支持的事件不同,具体以各组件文档为准。例如
js-field:value-change仅在 JSField / JSEditableField 等字段上下文中被派发,而resource:refresh/resource:saved取决于 resource 类型(APIResource、MultiRecordResource、SingleRecordResource、SQLResource 的触发点各有差异)。配对取消:每次
ctx.on(eventName, handler)都应有对应的ctx.off(eventName, handler),且传入的handler引用必须一致;若需要“只触发一次”,优先使用ctx.resource.once()而非手动配对。重复触发风险:若在未清理旧监听的情况下重复注册(例如
useEffect依赖变化后重新执行),同一 handler 会被多次追加到事件列表,emit时会被多次调用。off只会移除与传入引用匹配的那一个,因此依赖数组中请保持 handler 稳定或通过 cleanup 先行清理。
八、源码验证:refresh 事件的订阅与取消
在 apiResource.test.ts 中有针对refresh事件订阅的完整测试,可作为理解on/off/emit行为的最小样例:
it('should fetch data successfully and emit refresh event', async () => { const r = createAPIResource(); const api = { request: vi.fn().mockResolvedValue({ data: { ok: 1 } }), }; const onRefresh = vi.fn(); r.on('refresh', onRefresh); r.setAPIClient(api as any); r.setURL('/foo'); r.setRequestMethod('get'); r.addRequestHeader('Accept', 'application/json'); r.setRequestParameters({ a: 1 }); r.setRequestBody(null); await r.refresh(); // 数据已写入 expect(r.getData()).toEqual({ ok: 1 }); // 无错误 expect(r.getError()).toBeNull(); // refresh 事件被触发一次 expect(onRefresh).toHaveBeenCalledTimes(1); });测试同时验证了失败路径:请求失败时数据保持不变、错误被记录为ResourceError、且refresh事件不会被触发(onRefresh未被调用)。这说明事件仅在refresh()成功路径上由this.emit('refresh')派发,监听者无需自行处理失败分支。
九、相关文档
- ctx.on() - 订阅事件
- ctx.resource - 资源实例及其
on/off - ctx.element - 渲染容器与 DOM 事件
- ctx.setValue() - 设置字段值(会触发
js-field:value-change) - RunJS 概述 - RunJS 执行环境能力总览
【免费下载链接】nocobaseNocoBase is an open-source AI + no-code platform for building business systems fast. Instead of generating everything from scratch, AI works on top of production-proven infrastructure and a WYSIWYG no-code interface, so you get both speed and reliability.项目地址: https://gitcode.com/GitHub_Trending/no/nocobase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考