mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-31 18:35:47 +00:00
feat(editor,dep): collectIdle 使用常驻 Web Worker 做依赖收集
将节点树遍历与 target 匹配下沉到与 collectByWorker 共用的常驻 worker, 避免大页面时主线程卡死;通过可序列化 descriptor 重建内置 target, 失败时回退主线程空闲队列。
This commit is contained in:
parent
aa42144738
commit
4bbda354a9
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
149
packages/editor/src/utils/dep/collect-worker-client.ts
Normal file
149
packages/editor/src/utils/dep/collect-worker-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
/** 增量收集时本次覆盖到的节点 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<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);
|
||||
};
|
||||
|
||||
@ -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 共用常驻 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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user