From c4ec2c5c722963c95141ac2d2ddf94d952d2e47d Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 9 Jun 2026 14:22:08 +0800 Subject: [PATCH] =?UTF-8?q?perf(editor):=20=E4=BC=98=E5=8C=96=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E4=BF=A1=E6=81=AF=E6=9F=A5=E6=89=BE=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor/src/services/editor.ts | 25 +++++- .../editor/tests/unit/services/editor.spec.ts | 87 +++++++++++++++++++ packages/utils/src/index.ts | 26 +++--- packages/utils/tests/unit/extras.spec.ts | 21 +++++ packages/utils/tests/unit/index.spec.ts | 16 ++++ 5 files changed, 162 insertions(+), 13 deletions(-) diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index a761c69c..ee69ba99 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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); } /** diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index c98a3018..f90b80d0 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -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)); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e49337e5..980d440a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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 | 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 | 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 | 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; }; diff --git a/packages/utils/tests/unit/extras.spec.ts b/packages/utils/tests/unit/extras.spec.ts index 4f146788..a799a566 100644 --- a/packages/utils/tests/unit/extras.spec.ts +++ b/packages/utils/tests/unit/extras.spec.ts @@ -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', () => { diff --git a/packages/utils/tests/unit/index.spec.ts b/packages/utils/tests/unit/index.spec.ts index 0b2b4226..1600d036 100644 --- a/packages/utils/tests/unit/index.spec.ts +++ b/packages/utils/tests/unit/index.spec.ts @@ -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', () => {