mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-01 15:40:43 +00:00
perf(utils): 优化节点查找与批量替换
- getNodes 改用 Map 统计待查 id,命中后提前结束遍历,避免每个节点都做一次数组查找 - 新增 replaceChildNodes,一次遍历定位所有目标节点后批量替换(O(K*N) -> O(N+K)), 并按层级由浅到深替换,避免父节点覆盖子节点更新 - compiledNode 读取依赖 keys 时兼容节点依赖缺失
This commit is contained in:
parent
ccfc8c0733
commit
2f8a504163
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user