perf(editor): 优化节点信息查找性能

This commit is contained in:
roymondchen 2026-06-09 14:22:08 +08:00
parent 48519b0155
commit c4ec2c5c72
5 changed files with 162 additions and 13 deletions

View File

@ -217,7 +217,30 @@ class Editor extends BaseService {
root = toRaw(root);
}
return getNodeInfo(id, root);
if (!root) {
return { node: null, parent: null, page: null };
}
if (id === root.id) {
return { node: root, parent: null, page: null };
}
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
// 注意:不能直接使用 state.page它可能与当前 root 不同步(指向已脱离的旧页面对象),
// 因此仅借用其 id再从当前 root 中取回真正的页面对象(页面均为 root 的直接子节点,数量很少)。
const pageIdStr = `${this.get('page')?.id || ''}`;
const currentPageNode = root.items?.find((item) => `${item.id}` === pageIdStr);
if (currentPageNode && `${id}` !== pageIdStr) {
// util 仅读取 root.id 与 root.items按容器结构传入当前页面是安全的
const info = getNodeInfo(id, currentPageNode);
if (info.node) {
return info;
}
}
// 回退:在完整 root 上查找;当前页面已搜索过,用 skip 跳过其子树避免重复遍历,
// 同时保留真实的 parent / page 引用id 命中当前页面节点本身时会在跳过子树前先匹配到)
return getNodeInfo(id, root, currentPageNode);
}
/**

View File

@ -190,6 +190,93 @@ describe('getParentById', () => {
});
});
describe('getNodeInfo 当前页面优先 / 跨页面回退', () => {
// 两个页面page2 内含一个容器及其子节点,用于覆盖「优先当前页面、回退跳过当前页面」逻辑
const PAGE2_ID = 20;
const NODE_IN_PAGE2 = 21;
const CONTAINER_IN_PAGE2 = 22;
const CHILD_IN_PAGE2 = 23;
const multiPageRoot: MApp = {
id: NodeId.ROOT_ID,
type: NodeType.ROOT,
items: [
cloneDeep(root.items[0]),
{
id: PAGE2_ID,
type: NodeType.PAGE,
layout: 'absolute',
style: { width: 375 },
items: [
{ id: NODE_IN_PAGE2, type: 'text', style: {} },
{
id: CONTAINER_IN_PAGE2,
type: 'container',
style: {},
items: [{ id: CHILD_IN_PAGE2, type: 'text', style: {} }],
},
],
},
],
};
beforeAll(async () => {
editorService.set('root', cloneDeep(multiPageRoot));
// 当前停留在 page1
await editorService.select(NodeId.PAGE_ID);
});
test('id 为 root.id 时返回 root 自身parent / page 为 null', () => {
const info = editorService.getNodeInfo(NodeId.ROOT_ID);
expect(info.node?.id).toBe(NodeId.ROOT_ID);
expect(info.parent).toBeNull();
expect(info.page).toBeNull();
});
test('当前页面节点本身node 为页面、parent 为 root、page 为页面自身', () => {
const info = editorService.getNodeInfo(NodeId.PAGE_ID);
expect(info.node?.id).toBe(NodeId.PAGE_ID);
expect(info.parent?.id).toBe(NodeId.ROOT_ID);
expect(info.page?.id).toBe(NodeId.PAGE_ID);
});
test('命中当前页面内的节点(快速路径)', () => {
const info = editorService.getNodeInfo(NodeId.NODE_ID);
expect(info.node?.id).toBe(NodeId.NODE_ID);
expect(info.parent?.id).toBe(NodeId.PAGE_ID);
expect(info.page?.id).toBe(NodeId.PAGE_ID);
});
test('命中非当前页面内的深层节点回退跳过当前页面parent / page 正确', () => {
const info = editorService.getNodeInfo(CHILD_IN_PAGE2);
expect(info.node?.id).toBe(CHILD_IN_PAGE2);
expect(info.parent?.id).toBe(CONTAINER_IN_PAGE2);
expect(info.page?.id).toBe(PAGE2_ID);
});
test('非当前页面的页面节点parent 为真实 root同一引用可安全 mutate', () => {
const info = editorService.getNodeInfo(PAGE2_ID, false);
expect(info.node?.id).toBe(PAGE2_ID);
expect(info.page?.id).toBe(PAGE2_ID);
// parent 必须是真实 root 引用,而非临时副本,否则对页面增删/排序会改不到真实树
expect(info.parent).toBe(editorService.get('root'));
});
test('不存在的节点返回空 info', () => {
const info = editorService.getNodeInfo(NodeId.ERROR_NODE_ID);
expect(info.node).toBeNull();
expect(info.page).toBeNull();
});
test('未选中任何页面时仍能跨页面查找到节点', () => {
editorService.set('root', cloneDeep(multiPageRoot));
editorService.set('page', null);
const info = editorService.getNodeInfo(CHILD_IN_PAGE2);
expect(info.node?.id).toBe(CHILD_IN_PAGE2);
expect(info.parent?.id).toBe(CONTAINER_IN_PAGE2);
expect(info.page?.id).toBe(PAGE2_ID);
});
});
describe('isOnDifferentPage', () => {
test('当前未选中任何页面时返回 false', () => {
editorService.set('root', cloneDeep(root));

View File

@ -23,7 +23,6 @@ import type {
DataSchema,
DataSourceDeps,
Id,
MApp,
MComponent,
MContainer,
MNode,
@ -80,10 +79,12 @@ export const emptyFn = (): any => undefined;
* @param {Array} data
* @return {Array} data中的子孙路径
*/
export const getNodePath = (id: Id, data: MNode[] = []): MNode[] => {
export const getNodePath = (id: Id, data: MNode[] = [], skip?: MNode | null): MNode[] => {
const path: MNode[] = [];
// 目标 id 字符串只计算一次,避免在递归遍历中对每个节点重复拼接
const targetId = `${id}`;
const get = function (id: number | string, data: MNode[]): MNode | null {
const get = function (data: MNode[]): MNode | null {
if (!Array.isArray(data)) {
return null;
}
@ -92,12 +93,13 @@ export const getNodePath = (id: Id, data: MNode[] = []): MNode[] => {
const item = data[i];
path.push(item);
if (`${item.id}` === `${id}`) {
if (`${item.id}` === targetId) {
return item;
}
if (item.items) {
const node = get(id, item.items);
// skip 用于跳过已经搜索过的子树(如已优先搜索过的当前页面),避免重复遍历
if (item.items && item !== skip) {
const node = get(item.items);
if (node) {
return node;
}
@ -109,12 +111,12 @@ export const getNodePath = (id: Id, data: MNode[] = []): MNode[] => {
return null;
};
get(id, data);
get(data);
return path;
};
export const getNodeInfo = (id: Id, root: Pick<MApp, 'id' | 'items'> | null) => {
export const getNodeInfo = (id: Id, root: { id: Id; items?: MNode[] } | null, skip?: MNode | null) => {
const info: EditorNodeInfo = {
node: null,
parent: null,
@ -128,7 +130,7 @@ export const getNodeInfo = (id: Id, root: Pick<MApp, 'id' | 'items'> | null) =>
return info;
}
const path = getNodePath(id, root.items);
const path = getNodePath(id, root.items, skip);
if (!path.length) return info;
@ -137,12 +139,12 @@ export const getNodeInfo = (id: Id, root: Pick<MApp, 'id' | 'items'> | null) =>
info.node = path[path.length - 1] as MComponent;
info.parent = path[path.length - 2] as MContainer;
path.forEach((item) => {
for (const item of path) {
if (isPage(item) || isPageFragment(item)) {
info.page = item as MPage | MPageFragment;
return;
break;
}
});
}
return info;
};

View File

@ -248,6 +248,27 @@ describe('getNodeInfo', () => {
const info = getNodeInfo('foo', null);
expect(info.node).toBeNull();
});
test('skip 跳过指定页面子树后查找不到其内部节点', () => {
const multiRoot = {
id: 'app',
items: [
{ id: 'page_1', type: NodeType.PAGE, items: [{ id: 'btn_1', type: 'button' }] },
{ id: 'page_2', type: NodeType.PAGE, items: [{ id: 'btn_2', type: 'button' }] },
],
} as any;
// 跳过 page_1 后page_1 内的节点查不到
const skipped = getNodeInfo('btn_1', multiRoot, multiRoot.items[0]);
expect(skipped.node).toBeNull();
// 其它页面节点不受影响parent / page 仍为真实引用
const info = getNodeInfo('btn_2', multiRoot, multiRoot.items[0]);
expect(info.node?.id).toBe('btn_2');
expect(info.parent?.id).toBe('page_2');
expect(info.page?.id).toBe('page_2');
expect(info.parent).toBe(multiRoot.items[1]);
});
});
describe('setValueByKeyPath / getKeys', () => {

View File

@ -201,6 +201,22 @@ describe('getNodePath', () => {
expect(path).toHaveLength(0);
expect(path2).toHaveLength(0);
});
test('skip 跳过指定子树后查找不到其内部节点', () => {
// 跳过 id 为 2 的容器其子树22 / 222不再被遍历
const skipped = util.getNodePath(222, root, root[1]);
expect(skipped).toHaveLength(0);
// 其它子树不受影响
const path = util.getNodePath(111, root, root[1]);
expect(path).toHaveLength(3);
});
test('skip 不影响对被跳过节点自身的匹配', () => {
// skip 仅阻止递归进入其 items节点自身仍可被匹配到
const path = util.getNodePath(2, root, root[1]);
expect(path).toHaveLength(1);
expect(path[0].id).toBe(2);
});
});
describe('filterXSS', () => {