From 4bbda354a987b0a409a01184e8511822a0384a13 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 30 Jul 2026 17:03:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor,dep):=20collectIdle=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=B8=B8=E9=A9=BB=20Web=20Worker=20=E5=81=9A=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=94=B6=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将节点树遍历与 target 匹配下沉到与 collectByWorker 共用的常驻 worker, 避免大页面时主线程卡死;通过可序列化 descriptor 重建内置 target, 失败时回退主线程空闲队列。 --- packages/dep/src/Target.ts | 7 +- packages/dep/src/types.ts | 28 +- packages/dep/src/utils.ts | 44 ++- packages/dep/tests/utils.spec.ts | 59 ++++ packages/editor/src/services/dep.ts | 191 +++++++++++-- .../src/utils/dep/collect-worker-client.ts | 149 ++++++++++ packages/editor/src/utils/dep/worker.ts | 122 +++++++- .../editor/tests/unit/services/dep.spec.ts | 269 +++++++++++++++++- .../utils/dep/collect-worker-client.spec.ts | 257 +++++++++++++++++ .../tests/unit/utils/dep/worker.spec.ts | 153 ++++++++-- 10 files changed, 1206 insertions(+), 73 deletions(-) create mode 100644 packages/editor/src/utils/dep/collect-worker-client.ts create mode 100644 packages/editor/tests/unit/utils/dep/collect-worker-client.spec.ts diff --git a/packages/dep/src/Target.ts b/packages/dep/src/Target.ts index a7e56556..ac8d3203 100644 --- a/packages/dep/src/Target.ts +++ b/packages/dep/src/Target.ts @@ -1,6 +1,6 @@ import type { DepData } from '@tmagic/schema'; -import { DepTargetType, type IsTarget, type TargetOptions } from './types'; +import { DepTargetType, type IsTarget, type TargetDescriptor, type TargetOptions } from './types'; export interface DepUpdateOptions { id: string | number; @@ -40,12 +40,17 @@ export default class Target { * 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集 */ public isCollectByDefault?: boolean; + /** + * 可序列化描述,有该描述的 target 可以在 worker 中重建,从而把依赖收集放到子线程执行 + */ + public descriptor?: TargetDescriptor; constructor(options: TargetOptions) { this.isTarget = options.isTarget; this.id = options.id; this.name = options.name; this.isCollectByDefault = options.isCollectByDefault ?? true; + this.descriptor = options.descriptor; if (options.type) { this.type = options.type; } diff --git a/packages/dep/src/types.ts b/packages/dep/src/types.ts index f42b3acc..6cdda1e3 100644 --- a/packages/dep/src/types.ts +++ b/packages/dep/src/types.ts @@ -1,4 +1,4 @@ -import type { DepData } from '@tmagic/schema'; +import type { CodeBlockContent, DataSourceSchema, DepData, Id } from '@tmagic/schema'; import type Target from './Target'; @@ -17,6 +17,30 @@ export enum DepTargetType { export type IsTarget = (key: string | number, value: any, data?: Record) => boolean; +/** 创建代码块 target 只需要名称,Record 用于兼容直接传入完整代码块 */ +export type CodeBlockName = Pick & Record; + +/** + * target 的可序列化描述 + * + * isTarget 是带闭包的函数,无法跨线程传递,只能在另一端用同样的入参重新创建。 + * 内置的代码块/数据源 target 由工厂函数写入该描述,因此可以放到 worker 中收集; + * 业务自定义的 target 没有该描述,只能在主线程收集。 + * + * 各类型只带 isTarget 真正用到的字段: + * - CODE_BLOCK: id + name + * - DATA_SOURCE / DATA_SOURCE_COND: id + 完整 fields(含 type / 嵌套,用于判断数组模板) + * - DATA_SOURCE_METHOD: id + 方法名/字段名(不序列化 content) + */ +export type TargetDescriptor = + | { type: DepTargetType.CODE_BLOCK; id: Id; codeBlock: CodeBlockName } + | { type: DepTargetType.DATA_SOURCE; ds: Pick } + | { type: DepTargetType.DATA_SOURCE_COND; ds: Pick } + | { + type: DepTargetType.DATA_SOURCE_METHOD; + ds: { id: Id; methods: Array<{ name?: string }>; fields: Array<{ name?: string }> }; + }; + export interface TargetOptions { isTarget: IsTarget; id: string | number; @@ -26,6 +50,8 @@ export interface TargetOptions { initialDeps?: DepData; /** 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集 */ isCollectByDefault?: boolean; + /** 可序列化描述,用于在 worker 中重建该 target */ + descriptor?: TargetDescriptor; } export interface TargetList { diff --git a/packages/dep/src/utils.ts b/packages/dep/src/utils.ts index 78f487f1..31e7bdd2 100644 --- a/packages/dep/src/utils.ts +++ b/packages/dep/src/utils.ts @@ -1,5 +1,4 @@ import { - type CodeBlockContent, type DataSchema, type DataSourceSchema, type DepData, @@ -16,16 +15,17 @@ import { } from '@tmagic/utils'; import Target from './Target'; -import { DepTargetType, type TargetList } from './types'; +import { type CodeBlockName, DepTargetType, type TargetDescriptor, type TargetList } from './types'; const INTEGER_REGEXP = /^\d+$/; -export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) => +export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockName, initialDeps: DepData = {}) => new Target({ type: DepTargetType.CODE_BLOCK, id, initialDeps, name: codeBlock.name, + descriptor: { type: DepTargetType.CODE_BLOCK, id, codeBlock: { name: codeBlock.name } }, isTarget: (_key: string | number, value: any) => { if (id === value) { return true; @@ -238,6 +238,8 @@ export const createDataSourceTarget = (ds: Pick isDataSourceTarget(ds, key, value), }); @@ -246,6 +248,8 @@ export const createDataSourceCondTarget = (ds: Pick isDataSourceCondTarget(ds, key, value), }); @@ -257,6 +261,15 @@ export const createDataSourceMethodTarget = ( type: DepTargetType.DATA_SOURCE_METHOD, id: ds.id, initialDeps, + // isTarget 只用方法名/字段名,不序列化 content 等函数,降低 idle 收集通信成本 + descriptor: { + type: DepTargetType.DATA_SOURCE_METHOD, + ds: { + id: ds.id, + methods: (ds.methods || []).map((method) => ({ name: method.name })), + fields: (ds.fields || []).map((field) => ({ name: field.name })), + }, + }, isTarget: (_key: string | number, value: any) => { // 使用data-source-method-select 可以配置出来 if (!Array.isArray(value)) { @@ -282,6 +295,31 @@ export const createDataSourceMethodTarget = ( }, }); +/** + * 用可序列化描述重建 target,使依赖收集可以在 worker 等无法传递函数的环境中进行 + * @param descriptor 由工厂函数写入 target 的描述 + * @param initialDeps 初始依赖 + * @returns Target + */ +export const createTargetByDescriptor = (descriptor: TargetDescriptor, initialDeps: DepData = {}): Target => { + switch (descriptor.type) { + case DepTargetType.CODE_BLOCK: + return createCodeBlockTarget(descriptor.id, descriptor.codeBlock, initialDeps); + case DepTargetType.DATA_SOURCE: + return createDataSourceTarget(descriptor.ds, initialDeps); + case DepTargetType.DATA_SOURCE_COND: + return createDataSourceCondTarget(descriptor.ds, initialDeps); + case DepTargetType.DATA_SOURCE_METHOD: + // descriptor 只保留名称,对 isTarget 已足够;重建时按同样结构传入 + return createDataSourceMethodTarget( + descriptor.ds as Pick, + initialDeps, + ); + default: + throw new Error(`unknown target descriptor: ${JSON.stringify(descriptor)}`); + } +}; + export const traverseTarget = ( targetsList: TargetList, cb: (target: Target) => void, diff --git a/packages/dep/tests/utils.spec.ts b/packages/dep/tests/utils.spec.ts index cbe97670..b407c387 100644 --- a/packages/dep/tests/utils.spec.ts +++ b/packages/dep/tests/utils.spec.ts @@ -266,6 +266,65 @@ describe('utils', () => { expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true); }); + test('内置 target 带可序列化描述,可在 worker 中重建', () => { + const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] }; + + expect(utils.createCodeBlockTarget('code_1', { name: 'fn', content: () => false, params: [] }).descriptor).toEqual({ + type: DepTargetType.CODE_BLOCK, + id: 'code_1', + codeBlock: { name: 'fn' }, + }); + const dsWithMethods = { + ...ds, + methods: [{ name: 'load', content: () => undefined, params: [] } as any], + }; + // DATA_SOURCE / COND 只带 id + fields,不透传完整数据源对象 + expect(utils.createDataSourceTarget(dsWithMethods).descriptor).toEqual({ + type: DepTargetType.DATA_SOURCE, + ds: { id: 'ds_1', fields: ds.fields }, + }); + expect(utils.createDataSourceCondTarget(dsWithMethods).descriptor).toEqual({ + type: DepTargetType.DATA_SOURCE_COND, + ds: { id: 'ds_1', fields: ds.fields }, + }); + // METHOD 描述只保留名称,不带 content 等函数 + expect(utils.createDataSourceMethodTarget(dsWithMethods).descriptor).toEqual({ + type: DepTargetType.DATA_SOURCE_METHOD, + ds: { id: 'ds_1', methods: [{ name: 'load' }], fields: [{ name: 'name' }] }, + }); + // 自定义 target 无法序列化 isTarget,因此没有描述 + expect(new Target({ id: 'custom', isTarget: () => true }).descriptor).toBeUndefined(); + }); + + test('createTargetByDescriptor 用描述重建出等价的 target', () => { + const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] }; + + const codeBlockTarget = utils.createTargetByDescriptor({ + type: DepTargetType.CODE_BLOCK, + id: 'code_1', + codeBlock: { name: 'fn' }, + }); + expect(codeBlockTarget.type).toBe(DepTargetType.CODE_BLOCK); + expect(codeBlockTarget.name).toBe('fn'); + expect(codeBlockTarget.isTarget('created', 'code_1')).toBe(true); + + const dsTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE, ds }); + expect(dsTarget.type).toBe(DepTargetType.DATA_SOURCE); + expect(dsTarget.isTarget('text', '${ds_1.name}')).toBe(true); + + const condTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE_COND, ds }); + expect(condTarget.isTarget(`${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true); + + const methodTarget = utils.createTargetByDescriptor( + { type: DepTargetType.DATA_SOURCE_METHOD, ds: { ...ds, methods: [] } }, + { n1: { name: 'n1', keys: ['k'] } }, + ); + expect(methodTarget.isTarget('k', ['ds_1', 'load'])).toBe(true); + expect(methodTarget.deps.n1.keys).toEqual(['k']); + + expect(() => utils.createTargetByDescriptor({ type: 'unknown' } as any)).toThrow(); + }); + test('traverseTarget 遍历所有 / 指定 type', () => { const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' }); const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' }); diff --git a/packages/editor/src/services/dep.ts b/packages/editor/src/services/dep.ts index 74c3b313..4ce4c72d 100644 --- a/packages/editor/src/services/dep.ts +++ b/packages/editor/src/services/dep.ts @@ -17,14 +17,14 @@ */ import { reactive, shallowReactive } from 'vue'; import { throttle } from 'lodash-es'; -import serialize from 'serialize-javascript'; -import type { DepData, DepExtendedData, Id, MApp, MNode, Target, TargetNode } from '@tmagic/core'; +import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core'; import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core'; import { isPage } from '@tmagic/utils'; +import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client'; import { IdleTask } from '@editor/utils/dep/idle-task'; -import Work from '@editor/utils/dep/worker.ts?worker&inline'; +import type { DepsData } from '@editor/utils/dep/worker'; import BaseService from './BaseService'; @@ -54,6 +54,7 @@ interface CollectBatch { dsPending: number; collectedEmitted: boolean; dsSettled: boolean; + aborted: boolean; resolve: (completed: boolean) => void; } @@ -63,7 +64,9 @@ class Dep extends BaseService { taskLength: 0, }); - private idleTask = new IdleTask<{ node: TargetNode; deep: boolean; target: Target }>(); + private idleTask = new IdleTask(); + + private collectWorker = new CollectWorkerClient(); private watcher = new Watcher({ initialTargets: reactive({}) }); @@ -153,6 +156,14 @@ class Dep extends BaseService { this.emit('ds-collected', nodes, deep); } + /** + * 空闲收集依赖 + * + * 遍历匹配是整个收集过程中最耗时的部分(要深度遍历节点树并对每个属性做 target 匹配), + * 节点/数据源多时在主线程执行会长时间卡死页面,因此优先交给 worker 执行,主线程只负责把结果写回 target。 + * 自定义 target 的 isTarget 是无法跨线程传递的闭包函数(没有 descriptor),仍走主线程空闲队列; + * worker 不可用(如 SSR)或执行失败时也会回退到主线程空闲队列。 + */ public async collectIdle(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) { if (this.waitingWorker) { await this.waitingWorker; @@ -165,12 +176,23 @@ class Dep extends BaseService { dsPending: 0, collectedEmitted: false, dsSettled: false, + aborted: false, resolve: () => {}, }; - this.watcher.collectByCallback(nodes, type, ({ node, target }) => { - this.enqueueTask(node, target, depExtendedData, deep, batch); - }); + const workerTargets: Target[] = []; + + for (const target of this.watcher.getCollectableTargets(type)) { + if (target.descriptor && this.collectWorker.isSupported) { + workerTargets.push(target); + } else { + this.enqueueTasks(nodes, target, depExtendedData, deep, batch); + } + } + + if (workerTargets.length && nodes.length) { + this.collectByCollectWorker(nodes, workerTargets, depExtendedData, deep, batch); + } // 没有命中任何 target,无需收集,直接完成 if (batch.pending === 0) { @@ -197,18 +219,9 @@ class Dep extends BaseService { this.waitingWorker = promise; this.resolveWaitingWorker = waitingResolve; - return new Promise>>((resolve) => { - const worker = new Work(); - worker.postMessage({ - dsl: serialize(dsl), - }); - worker.onmessage = (e) => { - resolve(e.data); - }; - worker.onerror = () => { - resolve({}); - }; - }).then((depsData) => { + return this.collectWorker.collectDsl(dsl).then((deps) => { + const depsData: DepsData = deps || {}; + if (generation !== this.workerGeneration) { waitingResolve(); return depsData; @@ -245,8 +258,12 @@ class Dep extends BaseService { // 先删除原有依赖,重新收集 if (isPage(node)) { this.removePageDep(target, depExtendedData); - } else { + } else if (deep) { + // deep 收集会覆盖整棵子树,删除也要同步清掉子孙旧依赖 this.watcher.removeTargetDep(target, node); + } else { + // 非 deep 只动当前节点,避免误清子节点上其他尚未重收的依赖 + target.removeDep(node.id); } this.watcher.collectItem(node, target, depExtendedData, deep); @@ -271,6 +288,8 @@ class Dep extends BaseService { public clearIdleTasks() { this.abortActiveBatches(); this.idleTask.clearTasks(); + // 丢掉 worker 在途任务,避免 abort 后的长任务继续占着常驻 worker + this.collectWorker.abort(); } public on( @@ -294,6 +313,7 @@ class Dep extends BaseService { this.resolveWaitingWorker = undefined; this.waitingWorker = undefined; this.idleTask.clearTasks(); + this.collectWorker.abort(); for (const type of Object.keys(this.watcher.getTargetsList())) { this.removeTargets(type); @@ -309,6 +329,7 @@ class Dep extends BaseService { this.reset(); this.removeAllPlugins(); this.idleTask.removeAllListeners(); + this.collectWorker.terminate(); } public emit(eventName: Name, ...args: Param) { @@ -328,6 +349,110 @@ class Dep extends BaseService { } } + /** + * 把遍历匹配交给 worker,结果回来后按 target 分片写回主线程 + */ + private collectByCollectWorker( + nodes: MNode[], + targets: Target[], + depExtendedData: DepExtendedData, + deep: boolean, + batch: CollectBatch, + ) { + const hasDataSource = targets.some((target) => target.type === DepTargetType.DATA_SOURCE); + + // 先占位,避免 worker 返回前批次被误判为已完成(提前 resolve、collecting 提前复位) + this.beginBatchTask(batch, hasDataSource); + + this.collectWorker + .collect({ + nodes, + targets: targets.map((target) => target.descriptor!), + depExtendedData, + deep, + }) + .then((result) => { + if (batch.aborted) return; + + for (const target of targets) { + if (result) { + // 写回是纯字典操作,但数据量大时同样会占用主线程,因此按 target 拆成空闲任务 + this.enqueueApplyTask(target, result.deps[target.type]?.[target.id] || {}, result.nodeIds, batch, { + nodes, + depExtendedData, + deep, + }); + } else { + this.enqueueTasks(nodes, target, depExtendedData, deep, batch); + } + } + }) + .finally(() => { + // 释放占位:必须在新任务入队之后,否则批次会在中途被判定为完成 + this.onBatchTaskDone(batch, hasDataSource); + }); + } + + /** + * 用 worker 的收集结果替换 target 上这批节点的依赖 + */ + private enqueueApplyTask( + target: Target, + deps: DepData, + nodeIds: Id[], + batch: CollectBatch, + { nodes, depExtendedData, deep }: { nodes: MNode[]; depExtendedData: DepExtendedData; deep: boolean }, + ) { + const isDataSource = target.type === DepTargetType.DATA_SOURCE; + + this.beginBatchTask(batch, isDataSource); + + this.idleTask.enqueueTask( + () => { + try { + // target 可能在 worker 收集期间被移除或重建(如修改数据源字段) + const current = this.watcher.getTarget(target.id, target.type); + if (current !== target) { + // 已被同 id 的新 target 替换时,用当前 target 在主线程补收,避免依赖空窗 + if (current) { + this.enqueueTasks(nodes, current, depExtendedData, deep, batch); + } + return; + } + + // 页面按 pageId 匹配删除,可清掉页面内已被删除节点的残留依赖 + if (nodes.some((node) => isPage(node))) { + this.removePageDep(target, depExtendedData); + } + + for (const id of nodeIds) { + target.removeDep(id); + } + + for (const [id, dep] of Object.entries(deps)) { + target.deps[id] = dep; + } + } finally { + this.onBatchTaskDone(batch, isDataSource); + } + }, + undefined, + isDataSource, + ); + } + + private enqueueTasks( + nodes: MNode[], + target: Target, + depExtendedData: DepExtendedData, + deep: boolean, + batch: CollectBatch, + ) { + for (const node of nodes) { + this.enqueueTask(node, target, depExtendedData, deep, batch); + } + } + private enqueueTask( node: MNode, target: Target, @@ -337,24 +462,17 @@ class Dep extends BaseService { ) { const isDataSource = target.type === DepTargetType.DATA_SOURCE; - batch.pending += 1; - if (isDataSource) { - batch.dsPending += 1; - } + this.beginBatchTask(batch, isDataSource); this.idleTask.enqueueTask( - ({ node, deep, target }) => { + () => { try { - this.collectNode(node, target, depExtendedData, deep); + this.collectNode(node, target, depExtendedData); } finally { this.onBatchTaskDone(batch, isDataSource); } }, - { - node, - deep: false, - target, - }, + undefined, isDataSource, ); @@ -365,7 +483,16 @@ class Dep extends BaseService { } } + private beginBatchTask(batch: CollectBatch, isDataSource: boolean) { + batch.pending += 1; + if (isDataSource) { + batch.dsPending += 1; + } + } + private onBatchTaskDone(batch: CollectBatch, isDataSource: boolean) { + if (batch.aborted) return; + if (isDataSource) { batch.dsPending -= 1; // 数据源依赖收集完先 resolve,让 stage 尽快更新,其余依赖继续在后台收集 @@ -412,6 +539,8 @@ class Dep extends BaseService { this.activeBatches.clear(); for (const batch of batches) { + // 标记中断:worker 结果可能在之后才返回,此时不能再写回依赖,也不能再结算批次 + batch.aborted = true; batch.resolve(false); } diff --git a/packages/editor/src/utils/dep/collect-worker-client.ts b/packages/editor/src/utils/dep/collect-worker-client.ts new file mode 100644 index 00000000..464de3cd --- /dev/null +++ b/packages/editor/src/utils/dep/collect-worker-client.ts @@ -0,0 +1,149 @@ +import serialize from 'serialize-javascript'; + +import type { Id, MApp } from '@tmagic/core'; + +import { error } from '../logger'; + +import type { CollectWorkerPayload, CollectWorkerRequest, CollectWorkerResponse, DepsData } from './worker'; +import CollectWorker from './worker.ts?worker&inline'; + +export interface CollectWorkerResult { + deps: DepsData; + nodeIds: Id[]; +} + +/** + * 依赖收集 worker 的主线程客户端 + * + * 依赖收集需要深度遍历整棵节点树并对每个属性做匹配,节点/数据源多时会长时间占用主线程导致页面卡死, + * 这里把遍历匹配放到 worker 中执行,主线程只负责把结果写回 target。 + * + * 全量收集与增量收集共用一个常驻 worker:增量收集调用频繁(每次节点更新都会触发), + * 按次创建 worker 的启动开销无法接受,也容易漏掉销毁导致线程泄漏。 + */ +export class CollectWorkerClient { + private worker: Worker | null = null; + + private seed = 0; + + private pending = new Map void>(); + + public get isSupported() { + return typeof Worker !== 'undefined'; + } + + /** + * 在 worker 中全量收集整个 DSL 的依赖,target 由 worker 根据 DSL 重建 + * @returns 收集结果,返回 null 表示 worker 不可用或执行失败 + */ + public collectDsl(dsl: MApp): Promise { + return this.request((id) => ({ id, dsl: serialize(dsl) })).then((response) => response?.deps || null); + } + + /** + * 在 worker 中增量收集指定节点的依赖,target 以可序列化描述的形式传入 + * @returns 收集结果,返回 null 表示 worker 不可用或执行失败,调用方需要回退到主线程收集 + */ + public collect(payload: CollectWorkerPayload): Promise { + return this.request((id) => ({ id, payload: serialize(payload) })).then((response) => + response ? { deps: response.deps || {}, nodeIds: response.nodeIds || [] } : null, + ); + } + + public terminate() { + this.worker?.terminate(); + this.worker = null; + this.settleAllPending(); + } + + /** + * 丢弃在途请求并重建 worker + * clearIdleTasks / reset 中断收集时调用,避免已 abort 的长任务继续占着常驻 worker 拖慢后续收集 + */ + public abort() { + this.terminate(); + } + + /** + * worker 按收到的顺序逐个处理请求,因此请求间不会互相打断,用 id 把结果分发回各自的调用方 + */ + private request(createRequest: (id: number) => CollectWorkerRequest): Promise { + const worker = this.getWorker(); + + if (!worker) { + return Promise.resolve(null); + } + + this.seed += 1; + const id = this.seed; + + return new Promise((resolve) => { + this.pending.set(id, resolve); + + try { + // 节点配置中可能存在函数等无法结构化克隆的值,需要先序列化再传递 + worker.postMessage(createRequest(id)); + } catch (e) { + error('magic editor: 依赖收集 worker 通信失败', e); + this.pending.delete(id); + resolve(null); + } + }); + } + + private getWorker() { + if (!this.isSupported) { + return null; + } + + if (this.worker) { + return this.worker; + } + + try { + const worker: Worker = new CollectWorker(); + worker.onmessage = (e: MessageEvent) => { + this.handleResponse(e.data); + }; + // worker 整体异常时无法定位到具体请求,只能结算全部在途请求并重建 worker + worker.onerror = () => { + this.handleFatalError(); + }; + worker.onmessageerror = () => { + this.handleFatalError(); + }; + this.worker = worker; + } catch (e) { + error('magic editor: 依赖收集 worker 创建失败', e); + return null; + } + + return this.worker; + } + + private handleResponse(response: CollectWorkerResponse) { + const resolve = this.pending.get(response?.id); + + if (!resolve) { + return; + } + + this.pending.delete(response.id); + resolve(response.failed ? null : response); + } + + private handleFatalError() { + this.worker?.terminate(); + this.worker = null; + this.settleAllPending(); + } + + private settleAllPending() { + const resolvers = [...this.pending.values()]; + this.pending.clear(); + + for (const resolve of resolvers) { + resolve(null); + } + } +} diff --git a/packages/editor/src/utils/dep/worker.ts b/packages/editor/src/utils/dep/worker.ts index e5710e22..fd270499 100644 --- a/packages/editor/src/utils/dep/worker.ts +++ b/packages/editor/src/utils/dep/worker.ts @@ -3,30 +3,85 @@ import { createDataSourceCondTarget, createDataSourceMethodTarget, createDataSourceTarget, + createTargetByDescriptor, type DepData, + type DepExtendedData, DepTargetType, type Id, type MApp, + type TargetDescriptor, + type TargetNode, traverseTarget, Watcher, } from '@tmagic/core'; import { error } from '../logger'; -onmessage = (e) => { - const watcher = new Watcher({ initialTargets: {} }); +export type DepsData = Record>; - const { dsl } = e.data; +/** 全量收集请求:worker 中根据 DSL 重建代码块/数据源 target */ +export interface DslCollectRequest { + /** 请求 id,用于把结果与调用方对应起来 */ + id: number; + /** serialize-javascript 序列化后的 MApp */ + dsl: string; +} +export interface CollectWorkerPayload { + /** 待收集的节点 */ + nodes: TargetNode[]; + /** 参与本次收集的 target 描述,worker 中据此重建 target */ + targets: TargetDescriptor[]; + depExtendedData: DepExtendedData; + deep: boolean; +} + +/** 增量收集请求:target 由主线程以可序列化描述的形式传入 */ +export interface NodesCollectRequest { + id: number; + /** serialize-javascript 序列化后的 CollectWorkerPayload */ + payload: string; +} + +export type CollectWorkerRequest = DslCollectRequest | NodesCollectRequest; + +export interface CollectWorkerResponse { + id: number; + /** 收集结果,结构为 { [target.type]: { [target.id]: deps } } */ + deps: DepsData; + /** 增量收集时本次覆盖到的节点 id(deep=true 时含子孙),主线程据此删除旧依赖 */ + nodeIds?: Id[]; + /** 收集失败,调用方需要回退到主线程收集 */ + failed?: boolean; +} + +/** + * 收集本次会覆盖到的节点 id + * 必须与 deep 一致:deep=false 时不能带上子孙 id,否则写回阶段 removeDep 会清掉未重收的子节点依赖 + */ +const getNodeIds = (nodes: TargetNode[], deep: boolean, ids: Id[] = []) => { + for (const node of nodes) { + ids.push(node.id); + + if (deep && Array.isArray(node.items) && node.items.length) { + getNodeIds(node.items, deep, ids); + } + } + + return ids; +}; + +const collectDsl = ({ id, dsl }: DslCollectRequest) => { try { // eslint-disable-next-line no-eval const mApp: MApp = eval(`(${dsl})`); if (!mApp) { - postMessage({}); + postMessage({ id, deps: {} }); + return; } - watcher.clearTargets(); + const watcher = new Watcher({ initialTargets: {} }); if (mApp.codeBlocks) { for (const [id, code] of Object.entries(mApp.codeBlocks)) { @@ -48,7 +103,7 @@ onmessage = (e) => { watcher.collectItems(page, targets, { pageId: page.id }, true); } - const data: Record> = { + const deps: DepsData = { [DepTargetType.DATA_SOURCE]: {}, [DepTargetType.DATA_SOURCE_METHOD]: {}, [DepTargetType.DATA_SOURCE_COND]: {}, @@ -56,12 +111,61 @@ onmessage = (e) => { }; traverseTarget(watcher.getTargetsList(), (target) => { - data[target.type][target.id] = target.deps; + deps[target.type][target.id] = target.deps; }); - postMessage(data); + const response: CollectWorkerResponse = { id, deps }; + postMessage(response); } catch (e: any) { error(e); - postMessage({}); + const response: CollectWorkerResponse = { id, deps: {}, failed: true }; + postMessage(response); } }; + +const collectNodes = ({ id, payload }: NodesCollectRequest) => { + try { + // eslint-disable-next-line no-eval + const { nodes, targets: descriptors, depExtendedData, deep }: CollectWorkerPayload = eval(`(${payload})`); + + const watcher = new Watcher({ initialTargets: {} }); + // worker 中 target 均为新建(deps 为空),无需删除阶段,直接批量收集 + const targets = descriptors.map((descriptor) => createTargetByDescriptor(descriptor)); + + for (const node of nodes) { + watcher.collectItems(node, targets, depExtendedData, deep); + } + + const deps: DepsData = {}; + for (const target of targets) { + if (!deps[target.type]) { + deps[target.type] = {}; + } + deps[target.type][target.id] = target.deps; + } + + const response: CollectWorkerResponse = { id, deps, nodeIds: getNodeIds(nodes, deep) }; + postMessage(response); + } catch (e: any) { + error(e); + const response: CollectWorkerResponse = { id, deps: {}, nodeIds: [], failed: true }; + postMessage(response); + } +}; + +/** + * 依赖收集 worker + * + * 依赖收集需要深度遍历整棵节点树并对每个属性做 target 匹配,节点/数据源多时会长时间占用主线程导致页面卡死, + * 因此把遍历匹配放到 worker 中执行。 + * 全量与增量收集共用一个常驻 worker:收集逻辑只打进一份 inline worker 产物, + * 也不会因为频繁收集而反复创建线程,每个请求用 id 与调用方对应。 + */ +onmessage = (e: MessageEvent) => { + if ('dsl' in e.data) { + collectDsl(e.data); + return; + } + + collectNodes(e.data); +}; diff --git a/packages/editor/tests/unit/services/dep.spec.ts b/packages/editor/tests/unit/services/dep.spec.ts index 09744f9c..3e4ded54 100644 --- a/packages/editor/tests/unit/services/dep.spec.ts +++ b/packages/editor/tests/unit/services/dep.spec.ts @@ -4,27 +4,57 @@ * Copyright (C) 2025 Tencent. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { reactive } from 'vue'; -import { DepTargetType, Target } from '@tmagic/core'; +import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core'; import depService from '@editor/services/dep'; +// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({ default: class FakeWorker { + public static instances: FakeWorker[] = []; + /** 全量收集返回的 deps */ public static nextData: Record = {}; public static nextError = false; public static nextDelay = 0; + /** 收到的全部请求(全量 + 增量),用于断言测例真正走到了 worker */ + public static requests: any[] = []; + /** 增量收集返回的数据 */ + public static response: any = { deps: {}, nodeIds: [] }; + public static failed = false; + public static delay = 0; + public onmessage: ((e: any) => void) | null = null; public onerror: (() => void) | null = null; - public postMessage() { + public onmessageerror: (() => void) | null = null; + + constructor() { + FakeWorker.instances.push(this); + } + + public postMessage(request: any) { + FakeWorker.requests.push(request); + + if (!('dsl' in request)) { + setTimeout(() => { + this.onmessage?.({ + data: { id: request.id, failed: FakeWorker.failed, ...FakeWorker.response }, + }); + }, FakeWorker.delay); + return; + } + setTimeout(() => { if (FakeWorker.nextError) { this.onerror?.(); return; } - this.onmessage?.({ data: FakeWorker.nextData }); + this.onmessage?.({ data: { id: request.id, deps: FakeWorker.nextData } }); }, FakeWorker.nextDelay); } + + public terminate() {} }, })); @@ -35,11 +65,31 @@ const makeTarget = (id = 't1', type: string = DepTargetType.DEFAULT) => isTarget: () => false, }); -beforeEach(() => { +const getFakeWorker = async () => (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + +/** 环境不支持 Worker 时会走主线程 / 返回空结果,这里显式打开 worker 路径并重置 mock 状态 */ +const enableCollectWorker = async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.instances = []; + fakeCollectWorker.requests = []; + fakeCollectWorker.response = { deps: {}, nodeIds: [] }; + fakeCollectWorker.failed = false; + fakeCollectWorker.delay = 0; + fakeCollectWorker.nextData = {}; + fakeCollectWorker.nextError = false; + fakeCollectWorker.nextDelay = 0; + (globalThis as any).Worker = class {}; + return fakeCollectWorker; +}; + +beforeEach(async () => { depService.reset(); + // collectByWorker / collectIdle 共用常驻 CollectWorkerClient,happy-dom 默认没有 Worker + await enableCollectWorker(); }); afterEach(() => { + delete (globalThis as any).Worker; vi.clearAllMocks(); }); @@ -128,11 +178,14 @@ describe('Dep service', () => { }); test('collectByWorker 完成后触发 collected 与 ds-collected', async () => { + const fakeWorker = await enableCollectWorker(); const fn = vi.fn(); const dsFn = vi.fn(); depService.on('collected', fn); depService.on('ds-collected', dsFn); await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any); + // 必须真正 postMessage 到 worker,避免 isSupported=false 时假通过 + expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true); expect(fn).toHaveBeenCalled(); expect(dsFn).toHaveBeenCalled(); depService.off('collected', fn); @@ -183,20 +236,19 @@ describe('Dep service', () => { }); test('collectByWorker worker 报错时返回空对象并完成 collected', async () => { - const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + const fakeWorker = await enableCollectWorker(); fakeWorker.nextError = true; - fakeWorker.nextData = {}; const collected = vi.fn(); depService.on('collected', collected); const result = await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any); + expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true); expect(result).toEqual({}); expect(collected).toHaveBeenCalled(); - fakeWorker.nextError = false; depService.off('collected', collected); }); test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => { - const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + const fakeWorker = await enableCollectWorker(); depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE)); depService.addTarget(makeTarget('cond1', DepTargetType.DATA_SOURCE_COND)); depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD)); @@ -214,10 +266,10 @@ describe('Dep service', () => { dataSourceMethodDeps: {}, }; await depService.collectByWorker(dsl); + expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true); expect(dsl.dataSourceDeps.ds1).toBeDefined(); expect(dsl.dataSourceCondDeps.cond1).toBeDefined(); expect(dsl.dataSourceMethodDeps.method1).toBeDefined(); - fakeWorker.nextData = {}; }); test('collectIdle 命中 target 时最终 resolve 并按批次 emit collected/ds-collected', async () => { @@ -320,6 +372,205 @@ describe('Dep service', () => { errorSpy.mockRestore(); }); + test('collectIdle 优先用 worker 收集,主线程只把结果写回 target', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.response = { + deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } }, + nodeIds: ['n1'], + }; + + const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})); + depService.addTarget(target); + // 旧依赖应被本次收集结果覆盖,而不是与之合并 + target.deps.n1 = { name: 'n1', keys: ['stale'] }; + + const collected = vi.fn(); + const dsCollected = vi.fn(); + depService.on('collected', collected); + depService.on('ds-collected', dsCollected); + + const nodes = [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any; + await expect(depService.collectIdle(nodes, {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true); + + expect(fakeCollectWorker.requests).toHaveLength(1); + // isTarget 无法跨线程传递,worker 中用 descriptor 重建 target + expect(fakeCollectWorker.requests[0].payload).toContain('ds_1'); + expect(target.deps.n1.keys).toEqual(['text']); + expect(dsCollected).toHaveBeenCalledWith(nodes, true); + expect(collected).toHaveBeenCalledWith(nodes, true); + expect(depService.get('collecting')).toBe(false); + + depService.off('collected', collected); + depService.off('ds-collected', dsCollected); + }); + + test('collectIdle 会删除 worker 收集范围内节点的旧依赖', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.response = { deps: {}, nodeIds: ['n1', 'n1_1'] }; + + const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})); + depService.addTarget(target); + target.deps.n1 = { name: 'n1', keys: ['text'] }; + target.deps.n1_1 = { name: 'n1_1', keys: ['text'] }; + target.deps.other = { name: 'other', keys: ['text'] }; + + await depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE); + + expect(target.deps.n1).toBeUndefined(); + expect(target.deps.n1_1).toBeUndefined(); + // 不在本次收集范围内的节点依赖不受影响 + expect(target.deps.other).toBeDefined(); + }); + + test('collectIdle 对 page 节点按 pageId 清理旧依赖', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.response = { deps: {}, nodeIds: ['p1'] }; + + const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})); + depService.addTarget(target); + // 已被删除的节点残留依赖,只能按 pageId 匹配清理 + target.deps.removed = { name: 'removed', keys: ['text'], data: { pageId: 'p1' } }; + target.deps.otherPage = { name: 'otherPage', keys: ['text'], data: { pageId: 'p2' } }; + + await depService.collectIdle( + [{ id: 'p1', type: 'page', items: [] }] as any, + { pageId: 'p1' }, + true, + DepTargetType.DATA_SOURCE, + ); + + expect(target.deps.removed).toBeUndefined(); + expect(target.deps.otherPage).toBeDefined(); + }); + + test('worker 收集失败时回退到主线程收集,结果一致', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.failed = true; + + const target = createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({})); + depService.addTarget(target); + + const nodes = [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any; + await expect(depService.collectIdle(nodes, {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true); + + expect(target.deps.n1.keys).toEqual(['text']); + }); + + test('没有 descriptor 的自定义 target 仍在主线程收集', async () => { + const fakeCollectWorker = await enableCollectWorker(); + + const target = new Target({ + id: 'custom', + type: DepTargetType.DEFAULT, + isTarget: (key) => key === 'text', + }); + depService.addTarget(target); + + await depService.collectIdle([{ id: 'n1', type: 'text', text: 'abc' }] as any); + + expect(fakeCollectWorker.requests).toHaveLength(0); + expect(target.deps.n1.keys).toEqual(['text']); + }); + + test('批次被中断后不再写回 worker 结果', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.delay = 20; + fakeCollectWorker.response = { + deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } }, + nodeIds: ['n1'], + }; + + const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})); + depService.addTarget(target); + + const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE); + depService.clearIdleTasks(); + + await expect(promise).resolves.toBe(false); + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(target.deps.n1).toBeUndefined(); + expect(depService.get('collecting')).toBe(false); + }); + + test('collectIdle deep=false 不会清掉未参与重收的子孙依赖', async () => { + const fakeCollectWorker = await enableCollectWorker(); + // worker 按 deep=false 只返回容器自身 id + fakeCollectWorker.response = { deps: {}, nodeIds: ['container'] }; + + const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})); + depService.addTarget(target); + target.deps.container = { name: 'container', keys: ['text'] }; + target.deps.child = { name: 'child', keys: ['text'] }; + + await depService.collectIdle( + [{ id: 'container', type: 'container', items: [{ id: 'child', type: 'text', text: '${ds_1.name}' }] }] as any, + {}, + false, + DepTargetType.DATA_SOURCE, + ); + + expect(target.deps.container).toBeUndefined(); + // 子孙未重收,旧依赖必须保留 + expect(target.deps.child).toBeDefined(); + }); + + test('target 在 worker 收集期间被重建时丢弃过期结果并主线程补收', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.response = { + deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['stale'] } } } }, + nodeIds: ['n1'], + }; + + depService.addTarget( + createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({})), + ); + + const promise = depService.collectIdle( + [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any, + {}, + true, + DepTargetType.DATA_SOURCE, + ); + + // 模拟修改数据源字段:target 被移除并重建 + depService.removeTarget('ds_1', DepTargetType.DATA_SOURCE); + const newTarget = createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({})); + depService.addTarget(newTarget); + + await promise; + + // 过期的 stale 结果未写入;对新 target 主线程补收得到正确依赖 + expect(newTarget.deps.n1.keys).toEqual(['text']); + expect(depService.get('collecting')).toBe(false); + }); + + test('clearIdleTasks 会 abort 常驻 worker,避免在途任务继续占用', async () => { + const fakeCollectWorker = await enableCollectWorker(); + fakeCollectWorker.delay = 50; + depService.addTarget(createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}))); + + const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE); + expect(fakeCollectWorker.instances).toHaveLength(1); + + depService.clearIdleTasks(); + await expect(promise).resolves.toBe(false); + + // abort 后再次收集会新建 worker,而不是排队在已 abort 的旧任务后面 + fakeCollectWorker.delay = 0; + await depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE); + expect(fakeCollectWorker.instances.length).toBeGreaterThan(1); + }); + + test('collectIdle 节点为空时不会启动 worker', async () => { + const fakeCollectWorker = await enableCollectWorker(); + depService.addTarget(createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}))); + + await expect(depService.collectIdle([], {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true); + + expect(fakeCollectWorker.requests).toHaveLength(0); + }); + test('destroy 会 reset 并移除监听', () => { depService.addTarget(makeTarget('destroy-me')); expect(() => depService.destroy()).not.toThrow(); diff --git a/packages/editor/tests/unit/utils/dep/collect-worker-client.spec.ts b/packages/editor/tests/unit/utils/dep/collect-worker-client.spec.ts new file mode 100644 index 00000000..b3bc2aba --- /dev/null +++ b/packages/editor/tests/unit/utils/dep/collect-worker-client.spec.ts @@ -0,0 +1,257 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { DepTargetType } from '@tmagic/core'; + +import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client'; + +vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({ + default: class FakeCollectWorker { + public static instances: FakeCollectWorker[] = []; + /** 收到的请求 */ + public static requests: any[] = []; + public static response: any = { deps: {}, nodeIds: [] }; + /** worker 内部执行失败 */ + public static failed = false; + /** worker 整体异常 */ + public static fatal = false; + /** 构造时抛错 */ + public static throwOnCreate = false; + /** postMessage 抛错,如结构化克隆失败 */ + public static throwOnPost = false; + + public onmessage: ((e: any) => void) | null = null; + public onerror: (() => void) | null = null; + public onmessageerror: (() => void) | null = null; + public terminated = false; + + constructor() { + if (FakeCollectWorker.throwOnCreate) { + throw new Error('create failed'); + } + FakeCollectWorker.instances.push(this); + } + + public postMessage(request: any) { + if (FakeCollectWorker.throwOnPost) { + throw new Error('post failed'); + } + + FakeCollectWorker.requests.push(request); + + setTimeout(() => { + if (FakeCollectWorker.fatal) { + this.onerror?.(); + return; + } + + this.onmessage?.({ + data: { id: request.id, failed: FakeCollectWorker.failed, ...FakeCollectWorker.response }, + }); + }); + } + + public terminate() { + this.terminated = true; + } + }, +})); + +const getFakeWorker = async () => (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + +const payload = { + nodes: [{ id: 'text_1', type: 'text' }] as any, + targets: [{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [] } }] as any, + depExtendedData: {}, + deep: false, +}; + +const dsl = { id: 'app', type: 'app', items: [{ id: 'text_1', type: 'text' }] } as any; + +beforeEach(async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.instances = []; + fakeCollectWorker.requests = []; + fakeCollectWorker.response = { deps: {}, nodeIds: [] }; + fakeCollectWorker.failed = false; + fakeCollectWorker.fatal = false; + fakeCollectWorker.throwOnCreate = false; + fakeCollectWorker.throwOnPost = false; + + (globalThis as any).Worker = class {}; +}); + +afterAll(() => { + delete (globalThis as any).Worker; +}); + +describe('dep/collect-worker-client', () => { + test('环境不支持 Worker 时返回 null,由调用方回退主线程收集', async () => { + delete (globalThis as any).Worker; + const client = new CollectWorkerClient(); + + expect(client.isSupported).toBe(false); + await expect(client.collect(payload)).resolves.toBeNull(); + }); + + test('收集结果按请求 id 返回', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.response = { + deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { text_1: { name: 'text', keys: ['text'] } } } }, + nodeIds: ['text_1'], + }; + + const client = new CollectWorkerClient(); + const result = await client.collect(payload); + + expect(result?.nodeIds).toEqual(['text_1']); + expect(result?.deps[DepTargetType.DATA_SOURCE].ds_1).toEqual({ text_1: { name: 'text', keys: ['text'] } }); + // 节点配置可能存在无法结构化克隆的值,需要先序列化 + expect(typeof fakeCollectWorker.requests[0].payload).toBe('string'); + }); + + test('collectDsl 全量收集返回 deps', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.response = { + deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { text_1: { name: 'text', keys: ['text'] } } } }, + }; + + const client = new CollectWorkerClient(); + const deps = await client.collectDsl(dsl); + + expect(deps?.[DepTargetType.DATA_SOURCE].ds_1).toEqual({ text_1: { name: 'text', keys: ['text'] } }); + expect(typeof fakeCollectWorker.requests[0].dsl).toBe('string'); + }); + + test('collectDsl 执行失败时返回 null', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.failed = true; + + const client = new CollectWorkerClient(); + + await expect(client.collectDsl(dsl)).resolves.toBeNull(); + }); + + test('全量与增量收集共用一个常驻 worker,各请求按 id 对应结果', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + await Promise.all([client.collectDsl(dsl), client.collect(payload), client.collect(payload)]); + + expect(fakeCollectWorker.instances).toHaveLength(1); + expect(fakeCollectWorker.requests.map((request: any) => request.id)).toEqual([1, 2, 3]); + expect(fakeCollectWorker.requests.map((request: any) => 'dsl' in request)).toEqual([true, false, false]); + }); + + test('worker 执行失败时返回 null', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.failed = true; + + const client = new CollectWorkerClient(); + + await expect(client.collect(payload)).resolves.toBeNull(); + }); + + test('worker 整体异常时结算所有在途请求并重建 worker', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.fatal = true; + + const client = new CollectWorkerClient(); + const results = await Promise.all([client.collect(payload), client.collect(payload)]); + + expect(results).toEqual([null, null]); + expect(fakeCollectWorker.instances[0].terminated).toBe(true); + + // 异常后重建 worker,后续收集仍可用 + fakeCollectWorker.fatal = false; + await expect(client.collect(payload)).resolves.not.toBeNull(); + expect(fakeCollectWorker.instances).toHaveLength(2); + }); + + test('onmessageerror 同样结算在途请求', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + const promise = client.collect(payload); + fakeCollectWorker.instances[0].onmessageerror?.(); + + await expect(promise).resolves.toBeNull(); + }); + + test('worker 创建失败时返回 null', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.throwOnCreate = true; + + const client = new CollectWorkerClient(); + + await expect(client.collect(payload)).resolves.toBeNull(); + }); + + test('postMessage 抛错时返回 null', async () => { + const fakeCollectWorker = await getFakeWorker(); + fakeCollectWorker.throwOnPost = true; + + const client = new CollectWorkerClient(); + + await expect(client.collect(payload)).resolves.toBeNull(); + }); + + test('响应缺少字段时按空结果处理', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + const promise = client.collect(payload); + fakeCollectWorker.instances[0].onmessage?.({ data: { id: 1 } }); + + await expect(promise).resolves.toEqual({ deps: {}, nodeIds: [] }); + }); + + test('非法响应被忽略', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + const promise = client.collect(payload); + expect(() => fakeCollectWorker.instances[0].onmessage?.({ data: undefined })).not.toThrow(); + + await expect(promise).resolves.not.toBeNull(); + }); + + test('未知请求 id 的响应被忽略', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + const promise = client.collect(payload); + fakeCollectWorker.instances[0].onmessage?.({ data: { id: 999, deps: {}, nodeIds: [] } }); + + await expect(promise).resolves.not.toBeNull(); + }); + + test('terminate 结算在途请求并销毁 worker', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + const promise = client.collect(payload); + client.terminate(); + + await expect(promise).resolves.toBeNull(); + expect(fakeCollectWorker.instances[0].terminated).toBe(true); + }); + + test('abort 与 terminate 一样丢弃在途请求,后续收集会重建 worker', async () => { + const fakeCollectWorker = await getFakeWorker(); + const client = new CollectWorkerClient(); + + const promise = client.collect(payload); + client.abort(); + + await expect(promise).resolves.toBeNull(); + expect(fakeCollectWorker.instances[0].terminated).toBe(true); + + await expect(client.collect(payload)).resolves.not.toBeNull(); + expect(fakeCollectWorker.instances).toHaveLength(2); + }); +}); diff --git a/packages/editor/tests/unit/utils/dep/worker.spec.ts b/packages/editor/tests/unit/utils/dep/worker.spec.ts index 43148b91..4ffb246c 100644 --- a/packages/editor/tests/unit/utils/dep/worker.spec.ts +++ b/packages/editor/tests/unit/utils/dep/worker.spec.ts @@ -4,6 +4,7 @@ * Copyright (C) 2025 Tencent. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import serialize from 'serialize-javascript'; import { DepTargetType } from '@tmagic/core'; @@ -26,6 +27,18 @@ afterEach(() => { const loadWorker = () => import('@editor/utils/dep/worker'); +/** 全量收集:worker 中根据 dsl 重建 target */ +const postDslRequest = (dsl: string, id = 1) => { + (globalThis as any).onmessage({ data: { id, dsl } }); + return postedMessages[0]; +}; + +/** 增量收集:target 由主线程以可序列化描述传入 */ +const postCollectRequest = (payload: any, id = 1) => { + (globalThis as any).onmessage({ data: { id, payload: typeof payload === 'string' ? payload : serialize(payload) } }); + return postedMessages[0]; +}; + describe('dep/worker', () => { test('注册 onmessage 处理器', async () => { await loadWorker(); @@ -34,36 +47,138 @@ describe('dep/worker', () => { test('正常 dsl - 收集 codeBlocks/dataSources/items 并 postMessage', async () => { await loadWorker(); - const dsl = JSON.stringify({ - id: 'app', - type: 'app', - codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } }, - dataSources: [{ id: 'ds_1', type: 'base', fields: [] }], - items: [{ id: 'page_1', type: 'page', items: [] }], - }); - (globalThis as any).onmessage({ data: { dsl } }); + const response = postDslRequest( + JSON.stringify({ + id: 'app', + type: 'app', + codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } }, + dataSources: [{ id: 'ds_1', type: 'base', fields: [] }], + items: [{ id: 'page_1', type: 'page', items: [] }], + }), + ); expect(postedMessages).toHaveLength(1); - const data = postedMessages[0]; - expect(data).toHaveProperty(DepTargetType.DATA_SOURCE); - expect(data).toHaveProperty(DepTargetType.CODE_BLOCK); + // 全量与增量收集共用一个 worker,响应必须带上请求 id + expect(response.id).toBe(1); + expect(response.deps).toHaveProperty(DepTargetType.DATA_SOURCE); + expect(response.deps).toHaveProperty(DepTargetType.CODE_BLOCK); }); - test('eval dsl 抛错时 postMessage({})', async () => { + test('eval dsl 抛错时返回 failed', async () => { await loadWorker(); - (globalThis as any).onmessage({ data: { dsl: '!@#invalid' } }); - expect(postedMessages[0]).toEqual({}); + expect(postDslRequest('!@#invalid')).toEqual({ id: 1, deps: {}, failed: true }); }); - test('mApp 为空时也会调用 postMessage', async () => { + test('mApp 为空时只返回一次空结果', async () => { await loadWorker(); - (globalThis as any).onmessage({ data: { dsl: 'null' } }); - expect(postedMessages.length).toBeGreaterThanOrEqual(1); + expect(postDslRequest('null')).toEqual({ id: 1, deps: {} }); + expect(postedMessages).toHaveLength(1); }); test('mApp 没有 codeBlocks/dataSources 时也能完成', async () => { await loadWorker(); - const dsl = JSON.stringify({ id: 'app', type: 'app', items: [] }); - (globalThis as any).onmessage({ data: { dsl } }); + expect(postDslRequest(JSON.stringify({ id: 'app', type: 'app', items: [] })).deps).toBeDefined(); expect(postedMessages).toHaveLength(1); }); + + test('增量收集 - 收集数据源依赖并返回覆盖到的节点 id', async () => { + await loadWorker(); + + const response = postCollectRequest({ + nodes: [ + { + id: 'page_1', + type: 'page', + items: [{ id: 'text_1', type: 'text', text: '${ds_1.name}' }], + }, + ], + targets: [{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } }], + depExtendedData: { pageId: 'page_1' }, + deep: true, + }); + + expect(response.id).toBe(1); + expect(response.failed).toBeUndefined(); + expect(response.deps[DepTargetType.DATA_SOURCE].ds_1.text_1.keys).toEqual(['text']); + expect(response.deps[DepTargetType.DATA_SOURCE].ds_1.text_1.data).toEqual({ pageId: 'page_1' }); + // 主线程据此删除旧依赖,必须包含子孙节点 + expect(response.nodeIds).toEqual(['page_1', 'text_1']); + }); + + test('增量收集 - deep 为 false 时不收集子节点依赖,也不返回子节点 id', async () => { + await loadWorker(); + + const response = postCollectRequest({ + nodes: [ + { + id: 'page_1', + type: 'page', + items: [{ id: 'text_1', type: 'text', text: '${ds_1.name}' }], + }, + ], + targets: [{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } }], + depExtendedData: {}, + deep: false, + }); + + expect(response.deps[DepTargetType.DATA_SOURCE].ds_1).toEqual({}); + // deep=false 时 nodeIds 不能含子孙,否则写回会误清子节点依赖 + expect(response.nodeIds).toEqual(['page_1']); + }); + + test('增量收集 - 支持代码块 target', async () => { + await loadWorker(); + + const response = postCollectRequest({ + nodes: [{ id: 'text_1', type: 'text', created: 'code_1' }], + targets: [{ type: DepTargetType.CODE_BLOCK, id: 'code_1', codeBlock: { name: 'fn' } }], + depExtendedData: {}, + deep: false, + }); + + expect(response.deps[DepTargetType.CODE_BLOCK].code_1.text_1.keys).toEqual(['created']); + }); + + test('增量收集 - 多个 target 一次收集,结果按 type/id 分组', async () => { + await loadWorker(); + + const response = postCollectRequest({ + nodes: [{ id: 'text_1', type: 'text', created: 'code_1', text: '${ds_1.name}' }], + targets: [ + { type: DepTargetType.CODE_BLOCK, id: 'code_1', codeBlock: { name: 'fn' } }, + { type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } }, + { type: DepTargetType.DATA_SOURCE_COND, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } }, + { type: DepTargetType.DATA_SOURCE_METHOD, ds: { id: 'ds_1', fields: [], methods: [] } }, + ], + depExtendedData: {}, + deep: false, + }); + + expect(Object.keys(response.deps).sort()).toEqual( + [ + DepTargetType.CODE_BLOCK, + DepTargetType.DATA_SOURCE, + DepTargetType.DATA_SOURCE_COND, + DepTargetType.DATA_SOURCE_METHOD, + ].sort(), + ); + }); + + test('增量收集 - payload 非法时返回 failed,调用方据此回退主线程收集', async () => { + await loadWorker(); + + expect(postCollectRequest('!@#invalid')).toEqual({ id: 1, deps: {}, nodeIds: [], failed: true }); + }); + + test('增量收集 - target 描述非法时返回 failed', async () => { + await loadWorker(); + + const response = postCollectRequest({ + nodes: [{ id: 'text_1', type: 'text' }], + targets: [{ type: 'unknown-type' }], + depExtendedData: {}, + deep: false, + }); + + expect(response.failed).toBe(true); + }); });