mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-01 07:20:39 +00:00
feat(data-source): 支持批量更新数据源
新增 dataSourceManager.batch(fn, options),fn 中的多次 setData 在结束后 统一更新一次页面:受影响节点只编译、替换一次,并按数据源分别触发 update-data。 - mergeChangeEvents 默认 false,每次变化各触发一次 update-data 并携带自己的 changeEvent,不丢失中间变化;传 true 则每个数据源只触发一次 - 支持嵌套,仅支持同步函数;isBatching 暴露当前是否处于批量更新中 - 页面/页面片节点的替换改为批量 replaceChildNodes,避免每个节点全树搜索 - 数据源字段变更事件在 batch 内仍按每次 setData 触发
This commit is contained in:
parent
522a0068f5
commit
dfad691493
@ -330,6 +330,53 @@ dataSourceManager.onDataChange('ds_1', 'user.name', (newVal) => {
|
||||
|
||||
取消监听数据源数据变化。
|
||||
|
||||
### batch
|
||||
|
||||
- **参数:**
|
||||
- `{() => T} fn` 同步执行的函数
|
||||
- `{BatchOptions} [options]` 批量更新选项,嵌套时以最外层为准
|
||||
- `{boolean} [mergeChangeEvents=false]` 是否把同一数据源的多次变化合并成一次 `update-data`
|
||||
|
||||
- **返回:**
|
||||
- `{T}` fn 的返回值
|
||||
|
||||
- **详情:**
|
||||
|
||||
批量更新。`fn` 中产生的所有数据变化,会在 `fn` 执行结束后(同步)统一更新一次页面:受影响的节点只编译、替换一次,并按数据源分别触发 `update-data`。
|
||||
|
||||
- 数据本身(`data`、`onDataChange`、`change` 事件)仍然实时生效,只是页面节点的更新被合并
|
||||
- 支持嵌套,最外层 `batch` 结束时才统一更新
|
||||
- 仅支持同步函数,`fn` 返回 Promise 时不会等待
|
||||
|
||||
`mergeChangeEvents` 默认为 `false`:每次 `setData` 各触发一次 `update-data`,各自携带自己的 `changeEvent`(`path`、`updateData`),与不使用 `batch` 时一致,监听 `update-data` / `replaced-node` 的业务不会漏掉中间变化。页面节点只编译一次,且渲染层会按节点对象去重(同一份已编译节点只替换一次),因此事件变多不会带来重复的替换开销。
|
||||
|
||||
传 `true` 时每个数据源只触发一次 `update-data`,`changeEvent` 是该数据源最后一次变化,中间变化会丢失;适合不关心每次变化、只想少收事件的场景。
|
||||
|
||||
```typescript
|
||||
dataSourceManager.batch(() => {
|
||||
ds1.setData(list, 'list');
|
||||
ds1.setData(total, 'total');
|
||||
ds2.setData(title, 'title');
|
||||
});
|
||||
|
||||
// 每个数据源只触发一次 update-data,只带最后一次 changeEvent
|
||||
dataSourceManager.batch(
|
||||
() => {
|
||||
ds1.setData(list, 'list');
|
||||
ds1.setData(total, 'total');
|
||||
},
|
||||
{ mergeChangeEvents: true },
|
||||
);
|
||||
```
|
||||
|
||||
### isBatching
|
||||
|
||||
- **类型:** `{boolean}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
当前是否处于 `batch` 中(只读属性)。
|
||||
|
||||
### destroy
|
||||
|
||||
- **返回:**
|
||||
@ -349,6 +396,7 @@ DataSourceManager 继承自 EventEmitter,支持以下事件:
|
||||
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
|
||||
| `mounted` | 由 runtime 在顶层组件渲染完成后触发,manager 收到后统一执行各数据源的 `mounted` | 无 |
|
||||
| `registered-all` | 所有数据源注册完成 | 无 |
|
||||
| `batch-change` | `batch` 结束时发出,携带期间所有的数据变化,`createDataSourceManager` 据此统一更新页面 | `(changes: { sourceId: string, changeEvent: ChangeEvent }[])` |
|
||||
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |
|
||||
|
||||
### 事件监听示例
|
||||
|
||||
@ -34,6 +34,8 @@ import { SimpleObservedData } from './observed-data/SimpleObservedData';
|
||||
import { DataSource, HttpDataSource } from './data-sources';
|
||||
import { getDeps } from './depsCache';
|
||||
import type {
|
||||
BatchChange,
|
||||
BatchOptions,
|
||||
ChangeEvent,
|
||||
DataSourceManagerData,
|
||||
DataSourceManagerOptions,
|
||||
@ -87,6 +89,10 @@ class DataSourceManager extends EventEmitter {
|
||||
/** 页面是否已经渲染完成 */
|
||||
public isMounted = false;
|
||||
|
||||
private batchDepth = 0;
|
||||
private batchChanges: BatchChange[] = [];
|
||||
private batchOptions: BatchOptions = {};
|
||||
|
||||
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
|
||||
super();
|
||||
|
||||
@ -223,9 +229,51 @@ class DataSourceManager extends EventEmitter {
|
||||
|
||||
public setData(ds: DataSource, changeEvent: ChangeEvent) {
|
||||
this.data[ds.id] = ds.data;
|
||||
|
||||
if (this.batchDepth > 0) {
|
||||
this.batchChanges.push({ sourceId: ds.id, changeEvent });
|
||||
}
|
||||
|
||||
this.emit('change', ds.id, changeEvent);
|
||||
}
|
||||
|
||||
/** 是否处于 batch 中 */
|
||||
public get isBatching() {
|
||||
return this.batchDepth > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新:fn 中产生的所有数据源变化只会在 fn 执行结束后统一更新一次页面(同步),
|
||||
* 页面节点只编译、替换一次,并按数据源分别触发 update-data。
|
||||
* - 数据源的数据(ds.data / manager.data)仍然实时更新,onDataChange 监听也仍然实时触发,manager 的 change 事件也照常触发
|
||||
* - 支持嵌套,最外层 batch 结束时才统一更新
|
||||
* - 仅支持同步函数,fn 返回 Promise 时不会等待
|
||||
* @param fn 同步执行的函数
|
||||
* @param options 批量更新选项,嵌套时以最外层为准
|
||||
* @returns fn 的返回值
|
||||
*/
|
||||
public batch<T>(fn: () => T, options?: BatchOptions): T {
|
||||
const isOutermost = this.batchDepth === 0;
|
||||
if (isOutermost) {
|
||||
this.batchOptions = options ?? {};
|
||||
}
|
||||
|
||||
this.batchDepth += 1;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
this.batchDepth -= 1;
|
||||
|
||||
if (this.batchDepth === 0 && this.batchChanges.length) {
|
||||
const changes = this.batchChanges;
|
||||
const { batchOptions } = this;
|
||||
this.batchChanges = [];
|
||||
this.batchOptions = {};
|
||||
this.emit('batch-change', changes, batchOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeDataSource(id: string) {
|
||||
this.get(id)?.destroy();
|
||||
delete this.data[id];
|
||||
@ -371,6 +419,9 @@ class DataSourceManager extends EventEmitter {
|
||||
|
||||
public destroy() {
|
||||
this.removeAllListeners();
|
||||
this.batchDepth = 0;
|
||||
this.batchChanges = [];
|
||||
this.batchOptions = {};
|
||||
this.data = {};
|
||||
this.initialData = {};
|
||||
this.dataSourceMap.forEach((ds) => {
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
*/
|
||||
import { cloneDeep, union } from 'lodash-es';
|
||||
|
||||
import type { default as TMagicApp } from '@tmagic/core';
|
||||
import { getDepNodeIds, getNodes, isPage, isPageFragment, replaceChildNode } from '@tmagic/core';
|
||||
import type { default as TMagicApp, MNode, Page } from '@tmagic/core';
|
||||
import { getDepNodeIds, getNodes, isPage, isPageFragment, replaceChildNodes } from '@tmagic/core';
|
||||
|
||||
import DataSourceManager from './DataSourceManager';
|
||||
import type { ChangeEvent, DataSourceManagerData } from './types';
|
||||
import type { BatchChange, BatchOptions, ChangeEvent, DataSourceManagerData } from './types';
|
||||
import { updateNode } from './utils';
|
||||
|
||||
/**
|
||||
@ -56,11 +56,25 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
|
||||
return dataSourceManager;
|
||||
}
|
||||
|
||||
dataSourceManager.on('change', (sourceId: string, changeEvent: ChangeEvent) => {
|
||||
const dep = dsl.dataSourceDeps?.[sourceId] || {};
|
||||
const condDep = dsl.dataSourceCondDeps?.[sourceId] || {};
|
||||
const updateByChanges = (changes: BatchChange[], options: BatchOptions = {}) => {
|
||||
// 按数据源归组,同一个数据源的多次变化按顺序保留
|
||||
const sources = new Map<string, { nodeIds: Set<string>; changeEvents: ChangeEvent[] }>();
|
||||
for (const { sourceId, changeEvent } of changes) {
|
||||
const dep = dsl.dataSourceDeps?.[sourceId] || {};
|
||||
const condDep = dsl.dataSourceCondDeps?.[sourceId] || {};
|
||||
const ids = [...Object.keys(condDep), ...Object.keys(dep)];
|
||||
|
||||
const nodeIds = union([...Object.keys(condDep), ...Object.keys(dep)]);
|
||||
const source = sources.get(sourceId);
|
||||
if (source) {
|
||||
ids.forEach((id) => source.nodeIds.add(id));
|
||||
source.changeEvents.push(changeEvent);
|
||||
} else {
|
||||
sources.set(sourceId, { nodeIds: new Set(ids), changeEvents: [changeEvent] });
|
||||
}
|
||||
}
|
||||
|
||||
// 所有数据源涉及的节点只编译一次
|
||||
const nodeIds = union(...Array.from(sources.values()).map(({ nodeIds }) => Array.from(nodeIds)));
|
||||
|
||||
for (const page of dsl.items) {
|
||||
if (app.platform === 'editor' || (isPage(page) && page.id === app.page?.data.id) || isPageFragment(page)) {
|
||||
@ -69,16 +83,22 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
|
||||
node.condResult = dataSourceManager.compliedConds(node);
|
||||
}
|
||||
|
||||
const newNode = dataSourceManager.compiledNode(node);
|
||||
return dataSourceManager.compiledNode(node);
|
||||
});
|
||||
|
||||
if (typeof app.page?.setData === 'function') {
|
||||
if (typeof app.page?.setData === 'function') {
|
||||
// 需要替换到页面数据、页面片数据中的节点,收集后批量替换,避免每个节点都做一次全树搜索
|
||||
const replaceInPage: MNode[] = [];
|
||||
const replaceInFragments = new Map<Page, MNode[]>();
|
||||
|
||||
for (const newNode of newNodes) {
|
||||
if (isPage(newNode)) {
|
||||
app.page.setData(newNode);
|
||||
} else if (page.id === app.page.data.id && !app.page.instance) {
|
||||
replaceChildNode(newNode, [app.page.data]);
|
||||
replaceInPage.push(newNode);
|
||||
}
|
||||
|
||||
app.getNode(node.id, { strict: true })?.setData(newNode);
|
||||
app.getNode(newNode.id, { strict: true })?.setData(newNode);
|
||||
|
||||
for (const [, pageFragment] of app.pageFragments) {
|
||||
if (pageFragment.data.id === newNode.id) {
|
||||
@ -86,21 +106,57 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
|
||||
} else if (pageFragment.data.id === page.id) {
|
||||
pageFragment.getNode(newNode.id, { strict: true })?.setData(cloneDeep(newNode));
|
||||
if (!pageFragment.instance) {
|
||||
replaceChildNode(cloneDeep(newNode), [pageFragment.data]);
|
||||
const list = replaceInFragments.get(pageFragment);
|
||||
if (list) {
|
||||
list.push(cloneDeep(newNode));
|
||||
} else {
|
||||
replaceInFragments.set(pageFragment, [cloneDeep(newNode)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newNode;
|
||||
});
|
||||
replaceChildNodes(replaceInPage, [app.page.data]);
|
||||
replaceInFragments.forEach((list, pageFragment) => {
|
||||
replaceChildNodes(list, [pageFragment.data]);
|
||||
});
|
||||
}
|
||||
|
||||
if (newNodes.length) {
|
||||
dataSourceManager.emit('update-data', newNodes, sourceId, changeEvent, page.id);
|
||||
sources.forEach(({ nodeIds: sourceNodeIds, changeEvents }, sourceId) => {
|
||||
const nodes =
|
||||
sources.size === 1 ? newNodes : newNodes.filter((newNode) => sourceNodeIds.has(`${newNode.id}`));
|
||||
|
||||
if (!nodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.mergeChangeEvents === true) {
|
||||
// 合并成一次,changeEvent 为该数据源最后一次变化,中间变化(path / updateData)会丢失
|
||||
dataSourceManager.emit('update-data', nodes, sourceId, changeEvents[changeEvents.length - 1], page.id);
|
||||
} else {
|
||||
// 默认:每次变化各触发一次,各自携带自己的 changeEvent,中间变化不会丢失
|
||||
for (const changeEvent of changeEvents) {
|
||||
dataSourceManager.emit('update-data', nodes, sourceId, changeEvent, page.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dataSourceManager.on('change', (sourceId: string, changeEvent: ChangeEvent) => {
|
||||
// batch 期间的变化在 batch 结束后统一更新
|
||||
if (dataSourceManager.isBatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateByChanges([{ sourceId, changeEvent }]);
|
||||
});
|
||||
|
||||
dataSourceManager.on('batch-change', updateByChanges);
|
||||
|
||||
return dataSourceManager;
|
||||
};
|
||||
|
||||
@ -61,6 +61,22 @@ export interface ChangeEvent {
|
||||
updateData: any;
|
||||
}
|
||||
|
||||
/** DataSourceManager.batch 期间收集到的数据变化 */
|
||||
export interface BatchChange {
|
||||
sourceId: string;
|
||||
changeEvent: ChangeEvent;
|
||||
}
|
||||
|
||||
export interface BatchOptions {
|
||||
/**
|
||||
* 是否把同一数据源在 batch 中的多次变化合并成一次 update-data,默认 true。
|
||||
* - true:每个数据源只触发一次 update-data,changeEvent 为该数据源最后一次变化(中间变化会丢失)
|
||||
* - false:每次变化各触发一次 update-data,各自携带自己的 changeEvent(如 path / updateData),
|
||||
* 页面节点仍然只编译、替换一次
|
||||
*/
|
||||
mergeChangeEvents?: boolean;
|
||||
}
|
||||
|
||||
export type AsyncDataSourceResolveResult<T = typeof DataSource> = {
|
||||
default: T;
|
||||
};
|
||||
|
||||
@ -841,3 +841,314 @@ describe('createDataSourceManager - 自定义数据源类型尚未注册', () =>
|
||||
expect(manager?.get('ds_lazy')).toBeInstanceOf(LazyDataSource);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDataSourceManager - batch 批量更新', () => {
|
||||
let app: TMagicApp;
|
||||
let manager: DataSourceManager;
|
||||
|
||||
beforeEach(() => {
|
||||
const dsl: MApp = {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_batch',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{ type: 'text', id: 'text_a', text: 'a ${ds_1.name}' } as any,
|
||||
{ type: 'text', id: 'text_b', text: 'b ${ds_2.title}' } as any,
|
||||
{ type: 'text', id: 'text_ab', text: '${ds_1.name}-${ds_2.title}' } as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
dataSourceDeps: {
|
||||
ds_1: {
|
||||
text_a: { name: 'a', keys: ['text'] },
|
||||
text_ab: { name: 'ab', keys: ['text'] },
|
||||
},
|
||||
ds_2: {
|
||||
text_b: { name: 'b', keys: ['text'] },
|
||||
text_ab: { name: 'ab', keys: ['text'] },
|
||||
},
|
||||
},
|
||||
dataSources: [
|
||||
{ id: 'ds_1', type: 'base', fields: [{ name: 'name', defaultValue: 'n1' }], methods: [], events: [] },
|
||||
{ id: 'ds_2', type: 'base', fields: [{ name: 'title', defaultValue: 't1' }], methods: [], events: [] },
|
||||
],
|
||||
};
|
||||
app = new TMagicApp({ config: dsl, platform: 'mobile' });
|
||||
manager = createDataSourceManager(app)!;
|
||||
});
|
||||
|
||||
test('batch 内多次 setData 只在结束后统一更新一次,数据本身实时更新', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
const change = vi.fn();
|
||||
manager.on('change', change);
|
||||
const dataChange = vi.fn();
|
||||
manager.onDataChange('ds_1', 'name', dataChange);
|
||||
|
||||
const compiledSpy = vi.spyOn(manager, 'compiledNode');
|
||||
|
||||
const ds1 = manager.get('ds_1')!;
|
||||
const result = manager.batch(
|
||||
() => {
|
||||
expect(manager.isBatching).toBe(true);
|
||||
ds1.setData('x', 'name');
|
||||
// 数据、change 事件、onDataChange 仍然实时生效
|
||||
expect(manager.data.ds_1.name).toBe('x');
|
||||
expect(change).toHaveBeenCalledTimes(1);
|
||||
expect(dataChange).toHaveBeenCalledTimes(1);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
ds1.setData('y', 'name');
|
||||
return 'ret';
|
||||
},
|
||||
{ mergeChangeEvents: true },
|
||||
);
|
||||
|
||||
expect(result).toBe('ret');
|
||||
expect(manager.isBatching).toBe(false);
|
||||
expect(change).toHaveBeenCalledTimes(2);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(compiledSpy).toHaveBeenCalledTimes(2); // text_a, text_ab 各编译一次
|
||||
|
||||
const [nodes, sourceId, changeEvent, pageId] = update.mock.calls[0];
|
||||
expect(sourceId).toBe('ds_1');
|
||||
expect(pageId).toBe('page_1');
|
||||
// 同一数据源多次变化,只保留最后一次 changeEvent
|
||||
expect(changeEvent.updateData).toBe('y');
|
||||
expect(nodes.map((n: any) => n.id)).toEqual(['text_a', 'text_ab']);
|
||||
expect(app.getNode('text_a')?.data.text).toBe('a y');
|
||||
});
|
||||
|
||||
test('多个数据源的变化:节点只编译一次,按数据源分别发出 update-data', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
const compiledSpy = vi.spyOn(manager, 'compiledNode');
|
||||
|
||||
manager.batch(() => {
|
||||
manager.get('ds_1')!.setData('N', 'name');
|
||||
manager.get('ds_2')!.setData('T', 'title');
|
||||
});
|
||||
|
||||
// text_a、text_b、text_ab 各编译一次
|
||||
expect(compiledSpy).toHaveBeenCalledTimes(3);
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
|
||||
const bySource: Record<string, string[]> = {};
|
||||
update.mock.calls.forEach(([nodes, sourceId]) => {
|
||||
bySource[sourceId] = nodes.map((n: any) => n.id);
|
||||
});
|
||||
expect(bySource.ds_1).toEqual(['text_a', 'text_ab']);
|
||||
expect(bySource.ds_2).toEqual(['text_b', 'text_ab']);
|
||||
|
||||
expect(app.getNode('text_ab')?.data.text).toBe('N-T');
|
||||
expect(app.getNode('text_b')?.data.text).toBe('b T');
|
||||
});
|
||||
|
||||
test('嵌套 batch 只在最外层结束时更新', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.batch(() => {
|
||||
manager.batch(() => {
|
||||
manager.get('ds_1')!.setData('inner', 'name');
|
||||
});
|
||||
expect(manager.isBatching).toBe(true);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('batch 内没有数据变化时不触发更新;fn 抛错时也会更新并恢复状态', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.batch(() => undefined);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
expect(() =>
|
||||
manager.batch(() => {
|
||||
manager.get('ds_1')!.setData('err', 'name');
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).toThrow('boom');
|
||||
|
||||
expect(manager.isBatching).toBe(false);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(app.getNode('text_a')?.data.text).toBe('a err');
|
||||
|
||||
// 恢复后普通 setData 仍然同步更新
|
||||
manager.get('ds_1')!.setData('after', 'name');
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('mergeChangeEvents: false 时每次变化各触发一次 update-data,节点仍只编译一次', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
const compiledSpy = vi.spyOn(manager, 'compiledNode');
|
||||
|
||||
manager.batch(
|
||||
() => {
|
||||
manager.get('ds_1')!.setData('x', 'name');
|
||||
manager.get('ds_1')!.setData('y', 'name');
|
||||
},
|
||||
{ mergeChangeEvents: false },
|
||||
);
|
||||
|
||||
// 每次变化各一次,各自携带自己的 changeEvent
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
expect(update.mock.calls.map((call) => call[2].updateData)).toEqual(['x', 'y']);
|
||||
expect(update.mock.calls.map((call) => call[1])).toEqual(['ds_1', 'ds_1']);
|
||||
|
||||
// 页面节点仍然只编译一次
|
||||
expect(compiledSpy).toHaveBeenCalledTimes(2);
|
||||
expect(app.getNode('text_a')?.data.text).toBe('a y');
|
||||
expect(app.getNode('text_ab')?.data.text).toBe('y-t1');
|
||||
});
|
||||
|
||||
test('mergeChangeEvents: false 时多个数据源各自触发,且只带自己依赖的节点', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.batch(
|
||||
() => {
|
||||
manager.get('ds_1')!.setData('N', 'name');
|
||||
manager.get('ds_2')!.setData('T', 'title');
|
||||
},
|
||||
{ mergeChangeEvents: false },
|
||||
);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
const bySource: Record<string, string[]> = {};
|
||||
update.mock.calls.forEach(([nodes, sourceId]) => {
|
||||
bySource[sourceId] = nodes.map((n: any) => n.id);
|
||||
});
|
||||
expect(bySource.ds_1).toEqual(['text_a', 'text_ab']);
|
||||
expect(bySource.ds_2).toEqual(['text_b', 'text_ab']);
|
||||
});
|
||||
|
||||
test('嵌套 batch 的选项以最外层为准', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.batch(
|
||||
() => {
|
||||
manager.batch(
|
||||
() => {
|
||||
manager.get('ds_1')!.setData('x', 'name');
|
||||
manager.get('ds_1')!.setData('y', 'name');
|
||||
},
|
||||
{ mergeChangeEvents: false },
|
||||
);
|
||||
},
|
||||
{ mergeChangeEvents: true },
|
||||
);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update.mock.calls[0][2].updateData).toBe('y');
|
||||
});
|
||||
|
||||
test('默认(不传选项)每次变化各触发一次,各自携带自己的 changeEvent', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.batch(() => {
|
||||
manager.get('ds_1')!.setData('x', 'name');
|
||||
manager.get('ds_1')!.setData('y', 'name');
|
||||
});
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
expect(update.mock.calls.map((call) => call[2].updateData)).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
test('destroy 会清空 batch 状态', () => {
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.batch(() => {
|
||||
manager.get('ds_1')!.setData('x', 'name');
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
expect(manager.isBatching).toBe(false);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDataSourceManager - 父子节点同时依赖同一数据源', () => {
|
||||
const create = () => {
|
||||
const dsl: MApp = {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app_nested',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [
|
||||
{
|
||||
type: 'container',
|
||||
id: 'node_b',
|
||||
title: 'b ${ds_1.name}',
|
||||
items: [{ type: 'text', id: 'node_a', text: 'a ${ds_1.name}' }],
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
dataSourceDeps: {
|
||||
ds_1: {
|
||||
node_b: { name: 'b', keys: ['title'] },
|
||||
node_a: { name: 'a', keys: ['text'] },
|
||||
},
|
||||
},
|
||||
dataSources: [
|
||||
{ id: 'ds_1', type: 'base', fields: [{ name: 'name', defaultValue: 'n' }], methods: [], events: [] },
|
||||
],
|
||||
};
|
||||
const app = new TMagicApp({ config: dsl, platform: 'mobile' });
|
||||
const manager = createDataSourceManager(app)!;
|
||||
return { app, manager };
|
||||
};
|
||||
|
||||
test('页面数据中父、子节点都是最新编译结果,子节点不会被覆盖', () => {
|
||||
const { app, manager } = create();
|
||||
manager.get('ds_1')!.setData('v1', 'name');
|
||||
|
||||
const pageData: any = app.page!.data;
|
||||
expect(pageData.items[0].title).toBe('b v1');
|
||||
expect(pageData.items[0].items[0].text).toBe('a v1');
|
||||
|
||||
manager.get('ds_1')!.setData('v2', 'name');
|
||||
expect(pageData.items[0].title).toBe('b v2');
|
||||
expect(pageData.items[0].items[0].text).toBe('a v2');
|
||||
});
|
||||
|
||||
test('update-data 的节点顺序为父节点在前,运行时按顺序替换不会丢失子节点更新', () => {
|
||||
const { app, manager } = create();
|
||||
const update = vi.fn();
|
||||
manager.on('update-data', update);
|
||||
|
||||
manager.get('ds_1')!.setData('v1', 'name');
|
||||
|
||||
const nodes = update.mock.calls[0][0] as any[];
|
||||
expect(nodes.map((n) => n.id)).toEqual(['node_b', 'node_a']);
|
||||
|
||||
// 新父节点与原父节点共用同一个 items 数组
|
||||
const container: any = app.page!.data.items![0];
|
||||
expect(nodes[0].items).toBe(container.items);
|
||||
});
|
||||
|
||||
test('batch 中的父子节点同样不会互相覆盖', () => {
|
||||
const { app, manager } = create();
|
||||
manager.batch(() => {
|
||||
manager.get('ds_1')!.setData('x', 'name');
|
||||
manager.get('ds_1')!.setData('y', 'name');
|
||||
});
|
||||
|
||||
const pageData: any = app.page!.data;
|
||||
expect(pageData.items[0].title).toBe('b y');
|
||||
expect(pageData.items[0].items[0].text).toBe('a y');
|
||||
});
|
||||
});
|
||||
|
||||
116
packages/data-source/tests/dataSourceFieldEvent.spec.ts
Normal file
116
packages/data-source/tests/dataSourceFieldEvent.spec.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import TMagicApp, { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, IteratorContainer, NodeType } from '@tmagic/core';
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const createApp = (beforeEventHandler?: (e: any) => void) =>
|
||||
new TMagicApp({
|
||||
platform: 'mobile',
|
||||
beforeEventHandler,
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page_1',
|
||||
items: [{ type: 'text', id: 'n1', text: '${ds_1.foo}' }],
|
||||
},
|
||||
],
|
||||
dataSourceDeps: { ds_1: { n1: { name: 'n1', keys: ['text'] } } },
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_1',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'foo', type: 'string', defaultValue: 'f0' },
|
||||
{ name: 'bar', type: 'string', defaultValue: 'b0' },
|
||||
],
|
||||
methods: [],
|
||||
events: [{ name: `${DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX}.foo`, actions: [] }],
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
} as any);
|
||||
|
||||
describe('数据源字段更新的事件监听', () => {
|
||||
test('onDataChange 在 batch 内同步触发,且每次 setData 都会触发', () => {
|
||||
const app = createApp();
|
||||
const manager = app.dataSourceManager!;
|
||||
const ds = manager.get('ds_1')!;
|
||||
|
||||
const callback = vi.fn();
|
||||
manager.onDataChange('ds_1', 'foo', callback);
|
||||
|
||||
const order: string[] = [];
|
||||
callback.mockImplementation(() => order.push('callback'));
|
||||
|
||||
manager.batch(() => {
|
||||
ds.setData('a', 'foo');
|
||||
order.push('after-first');
|
||||
ds.setData('b', 'foo');
|
||||
order.push('after-second');
|
||||
});
|
||||
order.push('batch-end');
|
||||
|
||||
// 监听在 setData 时同步执行,不会被 batch 合并
|
||||
expect(order).toEqual(['callback', 'after-first', 'callback', 'after-second', 'batch-end']);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback.mock.calls[1][0].updateData).toBe('b');
|
||||
});
|
||||
|
||||
test('数据源字段变更事件(联动动作)在 batch 内仍按每次 setData 触发', async () => {
|
||||
const events: any[] = [];
|
||||
const app = createApp((e) => events.push(e));
|
||||
const manager = app.dataSourceManager!;
|
||||
app.eventHelper!.bindDataSourceEvents();
|
||||
const ds = manager.get('ds_1')!;
|
||||
await flush();
|
||||
|
||||
events.length = 0;
|
||||
ds.setData('x', 'foo');
|
||||
await flush();
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
events.length = 0;
|
||||
manager.batch(() => {
|
||||
ds.setData('y', 'foo');
|
||||
ds.setData('z', 'bar');
|
||||
});
|
||||
await flush();
|
||||
|
||||
// foo 的监听触发 1 次(bar 没有配置事件),batch 不会合并字段变更事件
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].args[0].updateData).toBe('y');
|
||||
});
|
||||
|
||||
test('反复重建迭代节点不会移除、也不会重复注册数据源字段变更监听', async () => {
|
||||
const events: any[] = [];
|
||||
const app = createApp((e) => events.push(e));
|
||||
const manager = app.dataSourceManager!;
|
||||
app.eventHelper!.bindDataSourceEvents();
|
||||
const helper: any = app.eventHelper;
|
||||
const ds = manager.get('ds_1')!;
|
||||
await flush();
|
||||
|
||||
const before = helper.dataSourceEventList.get('ds_1')?.size;
|
||||
const offDataChange = vi.spyOn(ds, 'offDataChange');
|
||||
|
||||
const container = new IteratorContainer({ config: { id: 'iter', type: 'iterator-container' } as any, app });
|
||||
for (let round = 0; round < 20; round++) {
|
||||
container.resetNodes();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
container.setNodes([{ id: `btn_${i}`, type: 'button', events: [{ name: 'click', actions: [] }] } as any], i);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offDataChange).not.toHaveBeenCalled();
|
||||
expect(helper.dataSourceEventList.get('ds_1')?.size).toBe(before);
|
||||
|
||||
events.length = 0;
|
||||
ds.setData('after', 'foo');
|
||||
await flush();
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -64,6 +64,39 @@ const dsl: MApp = {
|
||||
},
|
||||
params: [],
|
||||
},
|
||||
code_batch_update: {
|
||||
name: 'batchUpdate',
|
||||
desc: '演示 dataSourceManager.batch 批量更新',
|
||||
timing: '',
|
||||
params: [],
|
||||
content: ({ app }) => {
|
||||
const manager = app?.dataSourceManager;
|
||||
const ds = manager?.get('ds_b64c92b5');
|
||||
|
||||
if (!manager || !ds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 一次业务操作里连续多次 setData,用 batch 包起来,页面节点只编译、替换一次
|
||||
manager.batch(() => {
|
||||
ds.setData('批量更新', 'text');
|
||||
ds.setData([{ a: 1 }, { a: 2 }, { a: 3 }], 'array');
|
||||
ds.setData({ a: 3 }, 'object');
|
||||
});
|
||||
|
||||
// 默认(mergeChangeEvents 为 false):每次 setData 各触发一次 update-data / replaced-node,
|
||||
// 各自携带自己的 path 与 updateData,监听方不会漏掉中间变化;页面节点只编译一次,
|
||||
// 渲染层按节点对象去重,因此不会重复替换
|
||||
// 传 { mergeChangeEvents: true } 则每个数据源只触发一次,只带最后一次变化
|
||||
manager.batch(
|
||||
() => {
|
||||
ds.setData('逐次更新', 'text');
|
||||
ds.setData([{ a: 4 }], 'array');
|
||||
},
|
||||
{ mergeChangeEvents: false },
|
||||
);
|
||||
},
|
||||
},
|
||||
code_6778: {
|
||||
name: 'toOtherPage',
|
||||
desc: '',
|
||||
@ -296,6 +329,35 @@ const dsl: MApp = {
|
||||
],
|
||||
displayConds: [],
|
||||
},
|
||||
{
|
||||
id: 'button_batch_update',
|
||||
text: 'batch 更新数据源',
|
||||
multiple: true,
|
||||
style: {
|
||||
width: '270',
|
||||
height: '37.5',
|
||||
border: 0,
|
||||
backgroundColor: '#fb6f00',
|
||||
position: 'absolute',
|
||||
left: 56,
|
||||
top: 306,
|
||||
},
|
||||
type: 'button',
|
||||
name: '按钮',
|
||||
events: [
|
||||
{
|
||||
name: 'magic:common:events:click',
|
||||
actions: [
|
||||
{
|
||||
actionType: ActionType.CODE,
|
||||
codeId: 'code_batch_update',
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayConds: [],
|
||||
},
|
||||
{
|
||||
type: 'overlay',
|
||||
id: 'overlay_2159',
|
||||
@ -470,6 +532,14 @@ const dsl: MApp = {
|
||||
layout: 'absolute',
|
||||
},
|
||||
],
|
||||
dataSourceDeps: {
|
||||
ds_b64c92b5: {
|
||||
button_430: {
|
||||
name: 'button_430',
|
||||
keys: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds_b64c92b5',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user