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:
roymondchen 2026-10-30 16:47:56 +08:00
parent 522a0068f5
commit dfad691493
7 changed files with 683 additions and 15 deletions

View File

@ -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)` |
### 事件监听示例

View File

@ -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) => {

View File

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

View File

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

View File

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

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

View File

@ -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',