feat(editor,dep): collectIdle 使用常驻 Web Worker 做依赖收集

将节点树遍历与 target 匹配下沉到与 collectByWorker 共用的常驻 worker,
避免大页面时主线程卡死;通过可序列化 descriptor 重建内置 target,
失败时回退主线程空闲队列。
This commit is contained in:
roymondchen 2026-07-30 17:03:48 +08:00
parent aa42144738
commit 4bbda354a9
10 changed files with 1206 additions and 73 deletions

View File

@ -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 {
* truefalse时需要传入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;
}

View File

@ -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<string, any>) => boolean;
/** 创建代码块 target 只需要名称Record 用于兼容直接传入完整代码块 */
export type CodeBlockName = Pick<CodeBlockContent, 'name'> & Record<string, any>;
/**
* 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<DataSourceSchema, 'id' | 'fields'> }
| { type: DepTargetType.DATA_SOURCE_COND; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
| {
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 {

View File

@ -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<DataSourceSchema, 'id' | 'fields
type: DepTargetType.DATA_SOURCE,
id: ds.id,
initialDeps,
// isTarget 需要完整 fields 结构(含 type / 嵌套 fields但不必带上 methods 等无关字段
descriptor: { type: DepTargetType.DATA_SOURCE, ds: { id: ds.id, fields: ds.fields || [] } },
isTarget: (key: string | number, value: any) => isDataSourceTarget(ds, key, value),
});
@ -246,6 +248,8 @@ export const createDataSourceCondTarget = (ds: Pick<DataSourceSchema, 'id' | 'fi
type: DepTargetType.DATA_SOURCE_COND,
id: ds.id,
initialDeps,
// 与 DATA_SOURCE 相同,只序列化 isTarget 所需的 id + fields
descriptor: { type: DepTargetType.DATA_SOURCE_COND, ds: { id: ds.id, fields: ds.fields || [] } },
isTarget: (key: string | number, value: any) => 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<DataSourceSchema, 'id' | 'methods' | 'fields'>,
initialDeps,
);
default:
throw new Error(`unknown target descriptor: ${JSON.stringify(descriptor)}`);
}
};
export const traverseTarget = (
targetsList: TargetList,
cb: (target: Target) => void,

View File

@ -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' });

View File

@ -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<void>();
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<Record<string, Record<string, DepData>>>((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<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
@ -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<Name extends keyof DepEvents, Param extends DepEvents[Name]>(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);
}

View File

@ -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<number, (response: CollectWorkerResponse | null) => void>();
public get isSupported() {
return typeof Worker !== 'undefined';
}
/**
* worker DSL target worker DSL
* @returns null worker
*/
public collectDsl(dsl: MApp): Promise<DepsData | null> {
return this.request((id) => ({ id, dsl: serialize(dsl) })).then((response) => response?.deps || null);
}
/**
* worker target
* @returns null worker 退线
*/
public collect(payload: CollectWorkerPayload): Promise<CollectWorkerResult | null> {
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<CollectWorkerResponse | null> {
const worker = this.getWorker();
if (!worker) {
return Promise.resolve(null);
}
this.seed += 1;
const id = this.seed;
return new Promise<CollectWorkerResponse | null>((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<CollectWorkerResponse>) => {
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);
}
}
}

View File

@ -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<string, Record<Id, DepData>>;
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;
/** 增量收集时本次覆盖到的节点 iddeep=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<string, Record<Id, DepData>> = {
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<CollectWorkerRequest>) => {
if ('dsl' in e.data) {
collectDsl(e.data);
return;
}
collectNodes(e.data);
};

View File

@ -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<string, any> = {};
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 共用常驻 CollectWorkerClienthappy-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();

View File

@ -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);
});
});

View File

@ -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);
});
});