perf(data-source): 迭代器容器数据上下文改为惰性深拷贝

createIteratorContentData 原先整体深拷贝数据源数据,迭代项很多时为 O(N^2)。
改为沿 fields 路径逐层构造新对象,路径之外的对象型字段在首次读取时才拷贝并缓存,
既避免修改原始数据,也保证引用变化可被渲染层感知
This commit is contained in:
roymondchen 2026-10-30 16:49:02 +08:00
parent 6f2ffc3ca1
commit 35ed2cc0d0
3 changed files with 262 additions and 8 deletions

View File

@ -75,6 +75,45 @@ export const updateNode = (node: MNode, dsl: MApp) => {
}
};
/**
* 将 source 的字段复制到 target,对象型字段使用惰性深拷贝(首次读取时拷贝并缓存)
* @param target 目标对象
* @param source 源对象
* @param skipKey 需要跳过的字段
*/
const assignLazyClone = (target: Record<string, any>, source: Record<string, any> | 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<string, any> | undefined = dsData[dsId];
let obj: Record<string, any> = 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<string, any> = {};
obj[key] = nextObj;
rawData = rawData[key];
obj = obj[key];
rawData = Array.isArray(next) ? undefined : next;
obj = nextObj;
});
return data;

View File

@ -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<any>({ 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();
});
});

View File

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