From dfad6914931c369ef9b3d7e3477b454a151cc3a6 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Fri, 30 Oct 2026 16:47:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(data-source):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 dataSourceManager.batch(fn, options),fn 中的多次 setData 在结束后 统一更新一次页面:受影响节点只编译、替换一次,并按数据源分别触发 update-data。 - mergeChangeEvents 默认 false,每次变化各触发一次 update-data 并携带自己的 changeEvent,不丢失中间变化;传 true 则每个数据源只触发一次 - 支持嵌套,仅支持同步函数;isBatching 暴露当前是否处于批量更新中 - 页面/页面片节点的替换改为批量 replaceChildNodes,避免每个节点全树搜索 - 数据源字段变更事件在 batch 内仍按每次 setData 触发 --- .../data-source/dataSourceManager.md | 48 +++ packages/data-source/src/DataSourceManager.ts | 51 +++ .../src/createDataSourceManager.ts | 86 ++++- packages/data-source/src/types.ts | 16 + .../tests/createDataSourceManager.spec.ts | 311 ++++++++++++++++++ .../tests/dataSourceFieldEvent.spec.ts | 116 +++++++ playground/src/configs/dsl.ts | 70 ++++ 7 files changed, 683 insertions(+), 15 deletions(-) create mode 100644 packages/data-source/tests/dataSourceFieldEvent.spec.ts diff --git a/docs/runtime-api/data-source/dataSourceManager.md b/docs/runtime-api/data-source/dataSourceManager.md index 5cef584e..9d9a253d 100644 --- a/docs/runtime-api/data-source/dataSourceManager.md +++ b/docs/runtime-api/data-source/dataSourceManager.md @@ -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)` | ### 事件监听示例 diff --git a/packages/data-source/src/DataSourceManager.ts b/packages/data-source/src/DataSourceManager.ts index a5666499..d4e5f2b4 100644 --- a/packages/data-source/src/DataSourceManager.ts +++ b/packages/data-source/src/DataSourceManager.ts @@ -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(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) => { diff --git a/packages/data-source/src/createDataSourceManager.ts b/packages/data-source/src/createDataSourceManager.ts index 2cc06622..8a7b7907 100644 --- a/packages/data-source/src/createDataSourceManager.ts +++ b/packages/data-source/src/createDataSourceManager.ts @@ -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; 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(); + + 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; }; diff --git a/packages/data-source/src/types.ts b/packages/data-source/src/types.ts index 7803404f..b5465973 100644 --- a/packages/data-source/src/types.ts +++ b/packages/data-source/src/types.ts @@ -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 = { default: T; }; diff --git a/packages/data-source/tests/createDataSourceManager.spec.ts b/packages/data-source/tests/createDataSourceManager.spec.ts index 516b7050..e1d57c4a 100644 --- a/packages/data-source/tests/createDataSourceManager.spec.ts +++ b/packages/data-source/tests/createDataSourceManager.spec.ts @@ -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 = {}; + 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 = {}; + 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'); + }); +}); diff --git a/packages/data-source/tests/dataSourceFieldEvent.spec.ts b/packages/data-source/tests/dataSourceFieldEvent.spec.ts new file mode 100644 index 00000000..20afc80c --- /dev/null +++ b/packages/data-source/tests/dataSourceFieldEvent.spec.ts @@ -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); + }); +}); diff --git a/playground/src/configs/dsl.ts b/playground/src/configs/dsl.ts index 5d5d9ec1..a107334e 100644 --- a/playground/src/configs/dsl.ts +++ b/playground/src/configs/dsl.ts @@ -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',