perf(utils): 优化节点查找与批量替换

- getNodes 改用 Map 统计待查 id,命中后提前结束遍历,避免每个节点都做一次数组查找
- 新增 replaceChildNodes,一次遍历定位所有目标节点后批量替换(O(K*N) -> O(N+K)),
  并按层级由浅到深替换,避免父节点覆盖子节点更新
- compiledNode 读取依赖 keys 时兼容节点依赖缺失
This commit is contained in:
roymondchen 2026-10-30 16:46:35 +08:00
parent ccfc8c0733
commit 2f8a504163
2 changed files with 342 additions and 8 deletions

View File

@ -356,29 +356,71 @@ export const setValueByKeyPath = (keys: string | number, value: any, data: Recor
return objectSet(data, keys, value);
};
/**
* 按 id 获取节点(先序遍历顺序),找到的 id 会从传入的 ids 中移除。
* 内部使用 Map 查找,并在所有 id 都找到后提前结束遍历
*/
export const getNodes = (ids: Id[], data: MNode[] = []): MNode[] => {
const nodes: MNode[] = [];
const get = function (ids: Id[], data: MNode[]) {
if (!ids.length) {
return nodes;
}
// id -> 待查找的次数
const remain = new Map<string, number>();
for (const id of ids) {
const key = `${id}`;
remain.set(key, (remain.get(key) || 0) + 1);
}
let remainCount = ids.length;
const used = new Map<string, number>();
const get = function (data: MNode[]) {
if (!Array.isArray(data)) {
return;
}
for (const item of data) {
const index = ids.findIndex((id: Id) => `${id}` === `${item.id}`);
if (!remainCount) {
return;
}
if (index > -1) {
ids.splice(index, 1);
const key = `${item.id}`;
const count = remain.get(key);
if (count) {
remain.set(key, count - 1);
used.set(key, (used.get(key) || 0) + 1);
remainCount -= 1;
nodes.push(item);
}
if (item.items) {
get(ids, item.items);
get(item.items);
}
}
};
get(ids, data);
get(data);
if (nodes.length) {
// 保持原有行为:将已找到的 id 从 ids 中移除
const rest: Id[] = [];
for (const id of ids) {
const key = `${id}`;
const count = used.get(key);
if (count) {
used.set(key, count - 1);
} else {
rest.push(id);
}
}
ids.length = 0;
for (const id of rest) {
ids.push(id);
}
}
return nodes;
};
@ -430,6 +472,101 @@ export const replaceChildNode = (newNode: MNode, data?: MNode[], parentId?: Id):
parent.items.splice(index, 1, newNode);
};
/**
* 批量将新节点更新到data中对应id的节点位置,效果等同于依次调用 replaceChildNode,
* 但只需遍历一次树来定位所有节点,避免每个节点都做一次全树搜索(O(K*N) -> O(N+K))
* @param newNodes 新节点列表
* @param data 需要修改的数据
*/
export const replaceChildNodes = (newNodes: MNode[], data: MNode[] = []): void => {
if (!newNodes.length) {
return;
}
interface Location {
/** 节点所在的数组,undefined 表示节点在 data 顶层(没有父节点) */
container?: MNode[];
index: number;
node: MNode;
/** 节点在树中的层级,data 顶层为 0 */
depth: number;
}
const locate = (nodes: MNode[]) => {
const ids = new Set(nodes.map((node) => `${node.id}`));
const locations = new Map<string, Location>();
const walk = (list: MNode[], depth: number) => {
for (let i = 0, l = list.length; i < l; i++) {
if (locations.size === ids.size) {
return;
}
const item = list[i];
const key = `${item.id}`;
if (ids.has(key) && !locations.has(key)) {
locations.set(key, { container: depth === 0 ? undefined : list, index: i, node: item, depth });
}
if (Array.isArray(item.items)) {
walk(item.items, depth + 1);
}
}
};
if (Array.isArray(data)) {
walk(data, 0);
}
return locations;
};
let locations = locate(newNodes);
// 层级浅的先替换(父节点先于子节点),避免先替换了子节点后,父节点的新数据中仍是旧的子节点而把子节点的更新覆盖掉;
// 同一层级保持传入顺序,未找到的节点排在最后
const sortedNodes = newNodes
.map((node, order) => ({ node, order, depth: locations.get(`${node.id}`)?.depth ?? Infinity }))
.sort((a, b) => (a.depth === b.depth ? a.order - b.order : a.depth - b.depth))
.map(({ node }) => node);
for (let i = 0, l = sortedNodes.length; i < l; i++) {
const newNode = sortedNodes[i];
const location = locations.get(`${newNode.id}`);
if (!location) {
console.warn(`未找到目标节点(${newNode.id})`);
continue;
}
const { container, node } = location;
if (!container) {
console.warn(`未找到父节点(${newNode.id})`);
continue;
}
let { index } = location;
if (container[index]?.id !== node.id) {
index = container.findIndex((child: MNode) => child.id === node.id);
}
if (index === -1) {
console.warn(`未找到目标节点(${newNode.id})`);
continue;
}
const oldItems = node.items;
container.splice(index, 1, newNode);
// 新节点的 items 不是原来的数组时,后续节点的位置信息可能失效,需要重新定位
if (newNode.items !== oldItems && i < l - 1) {
locations = locate(sortedNodes.slice(i + 1));
}
}
};
export const DSL_NODE_KEY_COPY_PREFIX = '__tmagic__';
export const IS_DSL_NODE_KEY = '__tmagic__dslNode';
export const PAGE_FRAGMENT_CONTAINER_ID_KEY = 'tmagic-page-fragment-container-id';
@ -445,7 +582,7 @@ export const compiledNode = (
keys = getDepKeys(dataSourceDeps, node.id);
} else {
const dep = dataSourceDeps[sourceId];
keys = dep?.[node.id].keys || [];
keys = dep?.[node.id]?.keys || [];
}
keys.forEach((key) => {

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { assert, describe, expect, test } from 'vitest';
import { assert, describe, expect, test, vi } from 'vitest';
import type { DataSchema } from '@tmagic/schema';
@ -468,6 +468,188 @@ describe('getNodes', () => {
});
});
describe('getNodes - 补充', () => {
const createRoot = (): any[] => [
{ id: 1, items: [{ id: 11, items: [{ id: 111 }] }, { id: 12 }] },
{ id: 2, items: [{ id: 21 }] },
];
test('按先序遍历顺序返回,且 id 类型不敏感', () => {
const nodes = util.getNodes(['21', 111, 1], createRoot());
expect(nodes.map((n) => n.id)).toEqual([1, 111, 21]);
});
test('找到的 id 会从传入的 ids 中移除,未找到的保留', () => {
const ids: (string | number)[] = [11, 'none', 21];
const nodes = util.getNodes(ids, createRoot());
expect(nodes.map((n) => n.id)).toEqual([11, 21]);
expect(ids).toEqual(['none']);
});
test('ids 为空时直接返回空数组', () => {
expect(util.getNodes([], createRoot())).toEqual([]);
});
test('ids 中重复的 id 按次数匹配', () => {
const root = [{ id: 1 }, { id: 1 }, { id: 1 }];
const ids = [1, 1];
expect(util.getNodes(ids, root).length).toBe(2);
expect(ids).toEqual([]);
});
test('所有 id 找到后提前结束遍历', () => {
const root: any[] = createRoot();
const lateAccess = vi.fn();
Object.defineProperty(root[1], 'items', {
get() {
lateAccess();
return [];
},
});
const nodes = util.getNodes([11], root);
expect(nodes.length).toBe(1);
expect(lateAccess).not.toHaveBeenCalled();
});
test('data 默认值与非数组 items', () => {
expect(util.getNodes([1])).toEqual([]);
expect(util.getNodes([33], [{ id: 3, items: { id: 33 } } as any])).toEqual([]);
});
});
describe('replaceChildNodes', () => {
const createRoot = (): any[] => [
{
id: 'page',
items: [
{ id: 'a', text: '', items: [{ id: 'a1', text: '' }] },
{ id: 'b', text: '' },
{ id: 'c', text: '', items: [{ id: 'c1', text: '', items: [{ id: 'c11', text: '' }] }] },
],
},
];
test('批量替换多个节点', () => {
const root = createRoot();
util.replaceChildNodes(
[
{ id: 'a1', text: 'A1' },
{ id: 'b', text: 'B' },
{ id: 'c11', text: 'C11' },
],
root,
);
expect(root[0].items[0].items[0].text).toBe('A1');
expect(root[0].items[1].text).toBe('B');
expect(root[0].items[2].items[0].items[0].text).toBe('C11');
});
test('结果与依次调用 replaceChildNode 一致', () => {
const newNodes = [
{ id: 'c1', text: 'C1', items: [] as any[] },
{ id: 'a', text: 'A' },
];
const rootA = createRoot();
const rootB = createRoot();
newNodes.forEach((n) => util.replaceChildNode(n as any, rootA));
util.replaceChildNodes(newNodes as any, rootB);
expect(rootB).toEqual(rootA);
});
test('父节点与子节点同时替换,且新父节点的 items 是新数组时仍能替换到新父节点下', () => {
const root = createRoot();
const newChild = { id: 'c11', text: 'new' };
const newParent = { id: 'c1', text: 'p', items: [{ id: 'c11', text: 'old-copy' }] };
util.replaceChildNodes([newParent, newChild], root);
expect(root[0].items[2].items[0]).toBe(newParent);
expect(newParent.items[0]).toBe(newChild);
});
test('父节点与子节点同时替换,新父节点共用原 items 数组', () => {
const root = createRoot();
const oldItems = root[0].items[2].items;
const newParent = { id: 'c', text: 'p', items: oldItems };
const newChild = { id: 'c1', text: 'child' };
util.replaceChildNodes([newParent, newChild], root);
expect(root[0].items[2]).toBe(newParent);
expect(root[0].items[2].items[0]).toBe(newChild);
});
test('传入顺序为子节点在前、父节点在后时,也会先替换父节点,子节点的更新不会被覆盖', () => {
const root = createRoot();
const newChild = { id: 'c11', text: 'new-child' };
// 新父节点的 items 是新数组,里面还是旧的子节点副本
const newParent = { id: 'c1', text: 'p', items: [{ id: 'c11', text: 'old-copy' }] };
util.replaceChildNodes([newChild, newParent], root);
expect(root[0].items[2].items[0]).toBe(newParent);
expect(newParent.items[0]).toBe(newChild);
});
test('传入顺序为子节点在前、新父节点共用原 items 数组时同样正确', () => {
const root = createRoot();
const newParent = { id: 'c', text: 'p', items: root[0].items[2].items };
const newChild = { id: 'c1', text: 'child' };
util.replaceChildNodes([newChild, newParent], root);
expect(root[0].items[2]).toBe(newParent);
expect(root[0].items[2].items[0]).toBe(newChild);
});
test('同一层级保持传入顺序,不影响传入的数组', () => {
const root = createRoot();
const first = { id: 'b', text: '1' };
const second = { id: 'b', text: '2' };
const deep = { id: 'c11', text: 'deep' };
const input = [deep, first, second];
util.replaceChildNodes(input, root);
expect(root[0].items[1]).toBe(second);
expect(root[0].items[2].items[0].items[0]).toBe(deep);
expect(input).toEqual([deep, first, second]);
});
test('目标节点不存在、位于顶层、newNodes 为空时的处理', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const root = createRoot();
util.replaceChildNodes([], root);
expect(warn).not.toHaveBeenCalled();
util.replaceChildNodes([{ id: 'none' }], root);
expect(warn).toHaveBeenCalledWith('未找到目标节点(none)');
util.replaceChildNodes([{ id: 'page' }], root);
expect(warn).toHaveBeenCalledWith('未找到父节点(page)');
// data 使用默认值
expect(() => util.replaceChildNodes([{ id: 'x' }])).not.toThrow();
warn.mockRestore();
});
test('位置信息失效(数组被外部修改)时回退到 findIndex', () => {
const root = createRoot();
const first = { id: 'a', text: 'A', items: root[0].items[0].items };
const second = { id: 'b', text: 'B' };
// 第一次替换时通过 getter 在中途改变数组顺序
const container = root[0].items;
const origSplice = container.splice.bind(container);
container.splice = (...args: any[]) => {
const r = origSplice(...(args as [number, number, ...any[]]));
container.unshift({ id: 'inserted' });
return r;
};
util.replaceChildNodes([first, second], root);
container.splice = origSplice;
expect(container.find((n: any) => n.id === 'b')).toBe(second);
expect(container.find((n: any) => n.id === 'a')).toBe(first);
});
});
describe('getDepKeys', () => {
test('get keys', () => {
const keys = util.getDepKeys(
@ -599,6 +781,21 @@ describe('replaceChildNode', () => {
});
describe('compiledNode', () => {
test('传入 sourceId 但节点不在该数据源依赖中时不抛错,节点保持不变', () => {
const node: any = { id: 'x', type: 'text', text: 't' };
const compile = vi.fn((v: any) => v);
expect(() =>
util.compiledNode(compile, node, { ds_1: { other: { name: 'o', keys: ['text'] } } } as any, 'ds_1'),
).not.toThrow();
expect(util.compiledNode(compile, node, { ds_1: { other: { name: 'o', keys: ['text'] } } } as any, 'ds_1')).toBe(
node,
);
expect(util.compiledNode(compile, node, {}, 'ds_none')).toBe(node);
expect(compile).not.toHaveBeenCalled();
expect(node.text).toBe('t');
});
test('compiled', () => {
const node = util.compiledNode(
(_str: string) => '123',