diff --git a/packages/data-source/src/utils.ts b/packages/data-source/src/utils.ts index 38b41c5e..81887e92 100644 --- a/packages/data-source/src/utils.ts +++ b/packages/data-source/src/utils.ts @@ -75,6 +75,45 @@ export const updateNode = (node: MNode, dsl: MApp) => { } }; +/** + * 将 source 的字段复制到 target,对象型字段使用惰性深拷贝(首次读取时拷贝并缓存) + * @param target 目标对象 + * @param source 源对象 + * @param skipKey 需要跳过的字段 + */ +const assignLazyClone = (target: Record, source: Record | undefined, skipKey: string) => { + if (!source) return; + + for (const key of Object.keys(source)) { + if (key === skipKey) continue; + + const value = source[key]; + + if (typeof value !== 'object' || value === null) { + target[key] = value; + continue; + } + + let cloned: any; + let isCloned = false; + Object.defineProperty(target, key, { + enumerable: true, + configurable: true, + get() { + if (!isCloned) { + cloned = cloneDeep(value); + isCloned = true; + } + return cloned; + }, + set(newValue) { + cloned = newValue; + isCloned = true; + }, + }); + } +}; + /** * 创建迭代器容器编译的数据上下文 * @param itemData 迭代数据 @@ -93,24 +132,26 @@ export const createIteratorContentData = ( [dsId]: {}, }; - let rawData = cloneDeep(dsData[dsId]); + // 沿 fields 路径逐层构造新对象,不修改原始数据。 + // 路径之外的对象型字段不会预先整体深拷贝(迭代项很多时会导致 O(N²)),而是在首次被读取时才拷贝并缓存, + // 这样依然是与数据源隔离的快照(引用每次编译都是新的,保证 vue 组件的 props 能感知到按 path 原地修改的数据)。 + let rawData: Record | undefined = dsData[dsId]; let obj: Record = data[dsId]; fields.forEach((key, index) => { - Object.assign(obj, rawData); + assignLazyClone(obj, rawData, key); if (index === fields.length - 1) { obj[key] = itemData; return; } - if (Array.isArray(rawData[key])) { - rawData[key] = {}; - obj[key] = {}; - } + const next = rawData?.[key]; + const nextObj: Record = {}; + obj[key] = nextObj; - rawData = rawData[key]; - obj = obj[key]; + rawData = Array.isArray(next) ? undefined : next; + obj = nextObj; }); return data; diff --git a/packages/data-source/tests/iterator-reactivity.spec.ts b/packages/data-source/tests/iterator-reactivity.spec.ts new file mode 100644 index 00000000..b60cf8ae --- /dev/null +++ b/packages/data-source/tests/iterator-reactivity.spec.ts @@ -0,0 +1,156 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { computed, createApp, defineComponent, h, nextTick, ref } from 'vue'; + +import TMagicApp, { NodeType } from '@tmagic/core'; + +import { DataSourceManager } from '@data-source/index'; + +/** + * 模拟 IteratorContainer.vue: + * configs 是依赖 props.config 的 computed,内部调用 dataSourceManager.compliedIteratorItems, + * 每项渲染一个 Item,Item 把绑定到的“兄弟字段对象”作为 prop 继续传给 Cell。 + */ +const setup = () => { + const dsId = 'ds_iter_reactivity'; + const app = new TMagicApp({ + config: { + type: NodeType.ROOT, + id: 'app_iter_reactivity', + items: [], + dataSources: [ + { + type: 'base', + id: dsId, + fields: [ + { + name: 'list', + type: 'array', + fields: [{ name: 'label' }], + defaultValue: [{ label: 'A' }, { label: 'B' }], + }, + { name: 'matrix', type: 'array', fields: [{ name: 'v' }], defaultValue: [{ v: 1 }] }, + ], + methods: [], + events: [], + }, + ], + }, + }); + + const dsm = new DataSourceManager({ app }); + const ds = dsm.get(dsId)!; + + const rawMatrix = dsm.data[dsId].matrix; + + const templateNodes = [ + { + id: 'iter_reactivity_node', + type: 'text', + text: '${ds_iter_reactivity.list.label}', + // 绑定整个数据源,编译结果是迭代上下文(matrix 是迭代路径之外的兄弟字段) + cell: { isBindDataSource: true, dataSourceId: dsId }, + }, + ] as any[]; + + const renderCount = { cell: 0, item: 0 }; + + const cellComp = defineComponent({ + props: { matrix: { type: Array, required: true } }, + setup(props) { + return () => { + renderCount.cell += 1; + return h('i', { class: 'cell' }, String((props.matrix[0] as any).v)); + }; + }, + }); + + const itemComp = defineComponent({ + props: { config: { type: Object, required: true } }, + setup(props) { + return () => { + renderCount.item += 1; + const node = (props.config as any).items[0]; + return h('div', { class: 'item' }, [h('b', node.text), h(cellComp, { matrix: node.cell.matrix })]); + }; + }, + }); + + // 与 IteratorContainer.vue 一样:数据源变化 -> 节点被替换(config 变成新对象) -> computed 重算 + const containerConfig = ref({ iteratorData: ds.data.list, items: templateNodes }); + + const containerComp = defineComponent({ + setup() { + const configs = computed(() => + (containerConfig.value.iteratorData as any[]).map((itemData) => ({ + items: dsm.compliedIteratorItems(itemData, containerConfig.value.items, [dsId, 'list']), + })), + ); + return () => + h( + 'div', + configs.value.map((config, index) => h(itemComp, { key: index, config })), + ); + }, + }); + + const root = document.createElement('div'); + const vueApp = createApp(containerComp); + vueApp.mount(root); + + // 模拟 createDataSourceManager 的 change 处理:触发容器节点被整体替换 + const replaceContainerNode = () => { + containerConfig.value = { iteratorData: ds.data.list, items: templateNodes }; + }; + + return { ds, dsm, root, renderCount, rawMatrix, replaceContainerNode, unmount: () => vueApp.unmount() }; +}; + +describe('迭代容器编译 + Vue 响应式', () => { + afterAll(() => { + DataSourceManager.clearDataSourceClass(); + }); + + test('路径之外的兄弟字段是与数据源隔离的快照,不修改数据源原始数据', async () => { + const { dsm, root, rawMatrix, unmount } = setup(); + await nextTick(); + + expect(root.querySelectorAll('.item').length).toBe(2); + expect(root.querySelector('.cell')?.textContent).toBe('1'); + expect(rawMatrix).toEqual([{ v: 1 }]); + expect(dsm.data.ds_iter_reactivity.matrix).toBe(rawMatrix); + + unmount(); + }); + + test('整体替换数据源数据(setData 无 path)后,视图和子孙组件都会更新', async () => { + const { ds, root, replaceContainerNode, unmount } = setup(); + await nextTick(); + + ds.setData([{ v: 2 }], 'matrix'); + replaceContainerNode(); + await nextTick(); + + expect(root.querySelector('.cell')?.textContent).toBe('2'); + + unmount(); + }); + + test('按 path 原地修改兄弟字段对象内部值后,子孙组件也会更新', async () => { + const { ds, root, renderCount, replaceContainerNode, unmount } = setup(); + await nextTick(); + const cellRenderBefore = renderCount.cell; + + // SimpleObservedData 按路径更新为原地修改,matrix[0] 的引用不变 + ds.setData(9, 'matrix.0.v'); + replaceContainerNode(); + await nextTick(); + + // Item 一定会因 config 换新对象而重新渲染 + expect(renderCount.item).toBeGreaterThan(2); + // 兄弟字段是新的快照,子孙组件的 props 会变化并拿到最新值 + expect(renderCount.cell).toBeGreaterThan(cellRenderBefore); + expect(root.querySelector('.cell')?.textContent).toBe('9'); + + unmount(); + }); +}); diff --git a/packages/data-source/tests/utils.spec.ts b/packages/data-source/tests/utils.spec.ts index 07961474..fe61822d 100644 --- a/packages/data-source/tests/utils.spec.ts +++ b/packages/data-source/tests/utils.spec.ts @@ -193,6 +193,63 @@ describe('createIteratorContentData', () => { }); expect(ctxData.ds.a.c.a).toBe(1); }); + + test('不修改原始数据源数据', () => { + const dsData: any = { + ds: { + a: [{ b: 0, c: [{ a: 1 }] }], + obj: { x: { y: 1 }, z: 2 }, + b: 2, + }, + }; + const snapshot = JSON.parse(JSON.stringify(dsData)); + + const ctxData: any = createIteratorContentData({ a: 1 }, 'ds', ['a', 'c'], dsData); + expect(ctxData.ds.a.c).toEqual({ a: 1 }); + + const objCtx: any = createIteratorContentData({ n: 1 }, 'ds', ['obj', 'x'], dsData); + expect(objCtx.ds.obj.x).toEqual({ n: 1 }); + expect(objCtx.ds.obj.z).toBe(2); + expect(objCtx.ds.b).toBe(2); + + expect(dsData).toEqual(snapshot); + expect(ctxData.ds).not.toBe(dsData.ds); + expect(objCtx.ds.obj).not.toBe(dsData.ds.obj); + }); + + test('路径之外的对象型字段惰性深拷贝:与原数据隔离、多次读取引用稳定、可被覆盖', () => { + const dsData: any = { ds: { a: [{ b: 1 }], matrix: [{ v: 1 }], n: 1 } }; + + const ctxData: any = createIteratorContentData({ b: 1 }, 'ds', ['a'], dsData); + + expect(ctxData.ds.n).toBe(1); + const { matrix } = ctxData.ds; + expect(matrix).toEqual([{ v: 1 }]); + expect(matrix).not.toBe(dsData.ds.matrix); + expect(matrix[0]).not.toBe(dsData.ds.matrix[0]); + expect(ctxData.ds.matrix).toBe(matrix); + + // 修改拷贝不影响原数据,原地修改原数据也不影响已读取过的快照 + matrix[0].v = 2; + expect(dsData.ds.matrix[0].v).toBe(1); + dsData.ds.matrix[0].v = 3; + expect(ctxData.ds.matrix[0].v).toBe(2); + + // 每次创建的上下文拿到的都是新的引用 + expect(createIteratorContentData({ b: 1 }, 'ds', ['a'], dsData).ds).not.toBe(ctxData.ds); + expect((createIteratorContentData({ b: 1 }, 'ds', ['a'], dsData) as any).ds.matrix).not.toBe(dsData.ds.matrix); + + // 支持覆盖,对象展开可枚举 + ctxData.ds.matrix = 'x'; + expect(ctxData.ds.matrix).toBe('x'); + expect(Object.keys(ctxData.ds)).toEqual(expect.arrayContaining(['a', 'matrix', 'n'])); + }); + + test('数据源数据或路径中间层缺失时不抛错', () => { + expect((createIteratorContentData({ a: 1 }, 'ds', ['a', 'c'], {}) as any).ds.a.c).toEqual({ a: 1 }); + expect((createIteratorContentData({ a: 1 }, 'ds', ['a', 'c'], { ds: {} }) as any).ds.a.c).toEqual({ a: 1 }); + expect((createIteratorContentData({ a: 1 }, 'ds', [], { ds: { b: 1 } }) as any).ds).toEqual({}); + }); }); describe('compliedConditions', () => {