From dfc4f55390033b8ac7961c07c76b7ea2a8d96783 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 2 Nov 2026 14:39:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=20setPageFragm?= =?UTF-8?q?ent/deletePageFragment=20=E5=90=8C=E6=AD=A5=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=89=87=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App 新增 setPageFragment / deletePageFragment 方法,用于同步页面片实例。 容器关联、切换、取消关联页面片时同步 pageFragments,并触发 page-fragments-change。 - Page 重建时强制同步页面片内容(force) - useDsl 改用动态获取页面片实例,避免 setup 快照过期 - useEditorDsl 在关联变更、删除容器时同步页面片实例 - 补充 core 与 vue-runtime-help 单测,更新 runtime-api 文档 --- docs/runtime-api/core/app.md | 28 ++++ packages/core/src/App.ts | 64 +++++++- packages/core/src/Page.ts | 17 +-- packages/core/tests/App.spec.ts | 117 +++++++++++++++ runtime/vue-runtime-help/src/hooks/use-dsl.ts | 11 +- .../src/hooks/use-editor-dsl.ts | 4 + .../vue-runtime-help/tests/use-dsl.test.ts | 85 +++++++++++ .../tests/use-editor-dsl.test.ts | 140 ++++++++++++++++++ 8 files changed, 445 insertions(+), 21 deletions(-) create mode 100644 runtime/vue-runtime-help/tests/use-dsl.test.ts diff --git a/docs/runtime-api/core/app.md b/docs/runtime-api/core/app.md index c8351ffc..fd1bacb5 100644 --- a/docs/runtime-api/core/app.md +++ b/docs/runtime-api/core/app.md @@ -191,6 +191,33 @@ app.setPage('page_2'); 删除当前页面。 +### setPageFragment + +- **参数:** + - `{MComponent | MContainer} config` 页面片容器配置 + - `{boolean} [force]` 是否强制重建,默认 `false` + +- **返回:** + - `{void}` + +- **详情:** + + 按页面片容器的 `pageFragmentId` 同步 `pageFragments` 中的页面片实例。容器新增、关联/切换/取消关联页面片时都需要调用,否则容器拿不到页面片内容。 + + 默认在已关联同一个页面片时保持不变,避免容器每次更新都重建页面片;`force` 为 `true` 时即使关联的是同一个页面片也会重建,用于页面重建时拿到最新的页面片内容。 + +### deletePageFragment + +- **参数:** + - `{Id} id` 页面片容器 id + +- **返回:** + - `{void}` + +- **详情:** + + 删除页面片实例,页面片容器被删除时调用。 + ### getPage - **参数:** @@ -361,6 +388,7 @@ app.destroy(); |--------|----------|----------| | `dsl-change` | 调用 `setConfig` 时触发 | `({ dsl: MApp, curPage: Id })` | | `page-change` | 调用 `setPage` 切换页面时触发;切换到不存在的页面时也会触发,此时回调无参数。**注意**:当 `pageConfig === this.page.data`(重复 setPage 到当前页)时会直接 `return`,**不**触发该事件 | `(page?: Page)` | +| `page-fragments-change` | 调用 `setPageFragment` / `deletePageFragment` 导致页面片实例创建、替换或删除时触发 | `({ id: Id, pageFragmentId?: Id })` | ### 监听示例 diff --git a/packages/core/src/App.ts b/packages/core/src/App.ts index 50bfbbdf..1af26880 100644 --- a/packages/core/src/App.ts +++ b/packages/core/src/App.ts @@ -18,10 +18,10 @@ import { EventEmitter } from 'events'; -import { isEmpty } from 'lodash-es'; +import { cloneDeep, isEmpty } from 'lodash-es'; import { createDataSourceManager, DataSourceManager, type DataSourceManagerData } from '@tmagic/data-source'; -import type { CodeBlockDSL, Id, JsEngine, MApp, RequestFunction } from '@tmagic/schema'; +import type { CodeBlockDSL, Id, JsEngine, MApp, MComponent, MContainer, RequestFunction } from '@tmagic/schema'; import Env from './Env'; import EventHelper from './EventHelper'; @@ -208,6 +208,66 @@ class App extends EventEmitter { this.page = undefined; } + /** + * 按页面片容器的配置同步页面片实例 + * + * 容器新增、关联/切换/取消关联页面片时都要调用,否则 app.pageFragments 里没有对应实例,页面片内容不会渲染 + * @param config 页面片容器配置 + * @param force 为 true 时即使关联的还是同一个页面片也重建,用于页面重建时拿到最新的页面片内容 + */ + public setPageFragment(config: MComponent | MContainer, force = false) { + if (!(config.type && this.pageFragmentContainerType.has(config.type))) return; + + const { id, pageFragmentId } = config; + + if (!id) return; + + const pageFragmentConfig = pageFragmentId + ? this.dsl?.items?.find((page) => `${page.id}` === `${pageFragmentId}`) + : undefined; + + const existPageFragment = this.pageFragments.get(id); + + // 关联的还是同一个页面片时保持不变,避免容器每次更新都重建页面片,导致节点实例与数据源状态被重置 + if (!force && pageFragmentConfig && existPageFragment && `${existPageFragment.data.id}` === `${pageFragmentId}`) { + return; + } + + if (existPageFragment) { + existPageFragment.destroy(); + this.pageFragments.delete(id); + } + + if (pageFragmentConfig) { + this.pageFragments.set( + id, + new Page({ + config: cloneDeep(pageFragmentConfig), + app: this, + }), + ); + + this.eventHelper?.initEvents(); + } + + super.emit('page-fragments-change', { id, pageFragmentId }); + } + + /** + * 删除页面片实例,页面片容器被删除时调用 + * @param id 页面片容器 id + */ + public deletePageFragment(id: Id) { + const pageFragment = this.pageFragments.get(id); + + if (!pageFragment) return; + + pageFragment.destroy(); + this.pageFragments.delete(id); + + super.emit('page-fragments-change', { id }); + } + /** * 兼容id参数 * @param id 节点id diff --git a/packages/core/src/Page.ts b/packages/core/src/Page.ts index 9e9a8749..475cc8d1 100644 --- a/packages/core/src/Page.ts +++ b/packages/core/src/Page.ts @@ -16,8 +16,6 @@ * limitations under the License. */ -import { cloneDeep } from 'lodash-es'; - import type { Id, MComponent, MContainer, MPage, MPageFragment } from '@tmagic/schema'; import App from './App'; @@ -65,19 +63,8 @@ class Page extends Node { this.setNode(config.id, node); - if (config.type && this.app.pageFragmentContainerType.has(config.type) && config.pageFragmentId) { - const pageFragment = this.app.dsl?.items?.find((page) => page.id === config.pageFragmentId); - - if (pageFragment) { - this.app.pageFragments.set( - config.id, - new Page({ - config: cloneDeep(pageFragment), - app: this.app, - }), - ); - } - } + // 页面重建时强制同步,保证拿到的是页面片的最新内容 + this.app.setPageFragment(config, true); if (Array.isArray(config.items)) { config.items?.forEach((element: MComponent | MContainer) => { diff --git a/packages/core/tests/App.spec.ts b/packages/core/tests/App.spec.ts index e3a5d406..169fbc6e 100644 --- a/packages/core/tests/App.spec.ts +++ b/packages/core/tests/App.spec.ts @@ -72,6 +72,31 @@ const createAppDsl = (pageLength: number, nodeLength = 0) => { return dsl; }; +const createFragmentApp = () => + new App({ + config: { + type: NodeType.ROOT, + id: 'app', + items: [ + { + type: NodeType.PAGE, + id: 'p1', + items: [{ id: 'pfc', type: 'page-fragment-container' }], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'f1', + items: [{ id: 'text_in_f1', type: 'text' }], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'f2', + items: [{ id: 'text_in_f2', type: 'text' }], + }, + ], + }, + }); + describe('App', () => { test('instance', () => { const app = new App({}); @@ -497,6 +522,98 @@ describe('App 配置/方法/组件注册', () => { expect(cb).toHaveBeenCalledWith(node, 'arg1'); }); + test('setPageFragment: 关联页面片时创建实例', () => { + const app = new App({ + config: { + type: NodeType.ROOT, + id: 'app', + items: [ + { + type: NodeType.PAGE, + id: 'p1', + items: [{ id: 'pfc', type: 'page-fragment-container' }], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'f1', + items: [{ id: 'text_in_f1', type: 'text' }], + }, + ], + }, + }); + + expect(app.pageFragments.size).toBe(0); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + + expect(app.pageFragments.get('pfc')?.data.id).toBe('f1'); + expect(app.pageFragments.get('pfc')?.getNode('text_in_f1')?.data.id).toBe('text_in_f1'); + }); + + test('setPageFragment: 非页面片容器的配置被忽略', () => { + const app = new App({ + config: { + type: NodeType.ROOT, + id: 'app', + items: [{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }], + }, + }); + + app.setPageFragment({ id: 'btn', type: 'button', pageFragmentId: 'f1' }); + app.setPageFragment({ type: 'page-fragment-container', pageFragmentId: 'f1' } as any); + + expect(app.pageFragments.size).toBe(0); + }); + + test('setPageFragment: 已关联同一个页面片时默认不重建', () => { + const app = createFragmentApp(); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + const fragment = app.pageFragments.get('pfc'); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + + expect(app.pageFragments.get('pfc')).toBe(fragment); + }); + + test('setPageFragment: force 时重建页面片实例', () => { + const app = createFragmentApp(); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + const fragment = app.pageFragments.get('pfc'); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }, true); + + expect(app.pageFragments.get('pfc')).not.toBe(fragment); + expect(app.pageFragments.get('pfc')?.data.id).toBe('f1'); + }); + + test('setPageFragment: 关联不存在的页面片时清理旧实例', () => { + const app = createFragmentApp(); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'not-exist' }); + + expect(app.pageFragments.size).toBe(0); + }); + + test('setPageFragment / deletePageFragment 会触发 page-fragments-change', () => { + const app = createFragmentApp(); + const cb = vi.fn(); + app.on('page-fragments-change', cb); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + expect(cb).toHaveBeenCalledTimes(1); + + app.deletePageFragment('pfc'); + expect(cb).toHaveBeenCalledTimes(2); + expect(app.pageFragments.size).toBe(0); + + // 不存在的实例不触发 + app.deletePageFragment('pfc'); + expect(cb).toHaveBeenCalledTimes(2); + }); + test('destroy 清理所有资源', () => { const app = new App({ config: { diff --git a/runtime/vue-runtime-help/src/hooks/use-dsl.ts b/runtime/vue-runtime-help/src/hooks/use-dsl.ts index 22a8b2f5..44dc5f96 100644 --- a/runtime/vue-runtime-help/src/hooks/use-dsl.ts +++ b/runtime/vue-runtime-help/src/hooks/use-dsl.ts @@ -9,7 +9,8 @@ export const useDsl = (app = inject('app'), pageFragmentContainerId?: throw new Error('useDsl must be used after MagicApp is created'); } - const pageFragment = pageFragmentContainerId ? app.pageFragments.get(pageFragmentContainerId) : null; + // 页面片实例会在关联关系变化时被重建,不能只取 setup 时那一次快照 + const getPageFragment = () => (pageFragmentContainerId ? app.pageFragments.get(pageFragmentContainerId) : undefined); // 同一次 batch 的多个 update-data 携带的是同一份已编译节点,重复替换内容相同却要再付一次全树遍历, // 因此按节点对象去重:同一个节点对象只替换一次(非 batch 场景下每次编译都是新对象,不受影响) @@ -29,17 +30,19 @@ export const useDsl = (app = inject('app'), pageFragmentContainerId?: replaceChildNodes(pending, [config]); }; - const pageConfig = ref(pageFragmentContainerId ? pageFragment?.data : app.page?.data); + const pageConfig = ref(pageFragmentContainerId ? getPageFragment()?.data : app.page?.data); if (pageFragmentContainerId) { const setPageConfig = () => { appliedNodes = new WeakSet(); - pageConfig.value = pageFragment?.data; + pageConfig.value = getPageFragment()?.data; }; app.on('dsl-change', setPageConfig); + app.on('page-fragments-change', setPageConfig); onBeforeUnmount(() => { app.off('dsl-change', setPageConfig); + app.off('page-fragments-change', setPageConfig); }); } else { const setPageConfig = () => { @@ -56,7 +59,7 @@ export const useDsl = (app = inject('app'), pageFragmentContainerId?: const updateDataHandler = (nodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id) => { if ( !nodes.length || - (pageFragmentContainerId && pageFragment?.data.id !== pageId) || + (pageFragmentContainerId && getPageFragment()?.data.id !== pageId) || (!pageFragmentContainerId && app.page?.data.id !== pageId) ) { return; diff --git a/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts b/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts index 09bf22a2..e513e70f 100644 --- a/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts +++ b/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts @@ -139,6 +139,9 @@ export const useEditorDsl = (app = inject('app'), runtimeApi: Runtime nodeInstance.setData(newNode); } + // 关联/切换/取消关联页面片时同步页面片实例,否则容器拿不到页面片内容 + app.setPageFragment(newNode); + if (app.page?.data.id === config.id) { app.page.setData(newNode); } @@ -167,6 +170,7 @@ export const useEditorDsl = (app = inject('app'), runtimeApi: Runtime } } else { app?.page?.deleteNode(node.id); + app?.deletePageFragment(node.id); } const index = parent.items?.findIndex((child: MNode) => child.id === node.id); diff --git a/runtime/vue-runtime-help/tests/use-dsl.test.ts b/runtime/vue-runtime-help/tests/use-dsl.test.ts new file mode 100644 index 00000000..3d803e26 --- /dev/null +++ b/runtime/vue-runtime-help/tests/use-dsl.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import type { MApp } from '@tmagic/core'; +import Core, { NodeType } from '@tmagic/core'; + +import { useDsl } from '../src'; + +const createDsl = () => + ({ + type: NodeType.ROOT, + id: 'app', + items: [ + { + type: NodeType.PAGE, + id: 'p1', + items: [{ id: 'pfc', type: 'page-fragment-container' }], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'f1', + items: [{ id: 'text_in_f1', type: 'text' }], + }, + ], + }) as unknown as MApp; + +const createApp = () => new Core({ config: createDsl(), platform: 'editor' }); + +const createChild = (pageFragmentContainerId?: string) => + defineComponent({ + setup() { + const { pageConfig } = useDsl(undefined as any, pageFragmentContainerId); + + return () => + h( + 'div', + (pageConfig.value?.items || []).map((item) => h('span', { id: item.id })), + ); + }, + }); + +const render = (app: Core, pageFragmentContainerId?: string) => + mount(createChild(pageFragmentContainerId), { + global: { + provide: { app }, + }, + }); + +describe('useDsl 页面片', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('未关联页面片时渲染为空', () => { + const app = createApp(); + const wrapper = render(app, 'pfc'); + + expect(wrapper.find('span').exists()).toBe(false); + }); + + test('关联页面片后页面片内容会渲染出来', async () => { + const app = createApp(); + const wrapper = render(app, 'pfc'); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + await nextTick(); + + expect(wrapper.find('#text_in_f1').exists()).toBe(true); + }); + + test('取消关联后页面片内容消失', async () => { + const app = createApp(); + const wrapper = render(app, 'pfc'); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }); + await nextTick(); + expect(wrapper.find('#text_in_f1').exists()).toBe(true); + + app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: '' }); + await nextTick(); + + expect(wrapper.find('span').exists()).toBe(false); + }); +}); diff --git a/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts b/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts index cf907d5d..dd1bfda1 100644 --- a/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts +++ b/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts @@ -17,6 +17,47 @@ const createDsl = () => ], }) as unknown as MApp; +const createFragmentDsl = () => + ({ + type: NodeType.ROOT, + id: 'app', + items: [ + { + type: NodeType.PAGE, + id: 'p1', + items: [{ id: 'pfc', type: 'page-fragment-container' }], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'f1', + items: [{ id: 'text_in_f1', type: 'text' }], + }, + { + type: NodeType.PAGE_FRAGMENT, + id: 'f2', + items: [{ id: 'text_in_f2', type: 'text' }], + }, + ], + }) as unknown as MApp; + +const setupFragment = () => { + const app = new Core({}); + let runtime: Runtime = {}; + + (window as any).magic = { + onRuntimeReady: (rt: Runtime) => { + runtime = rt; + }, + }; + + useEditorDsl(app); + + const dsl = createFragmentDsl(); + runtime.updateRootConfig?.(dsl); + + return { app, runtime, dsl }; +}; + const setup = (curPageId?: string) => { const app = new Core({}); let runtime: Runtime = {}; @@ -96,6 +137,105 @@ describe('useEditorDsl add', () => { }); }); +describe('useEditorDsl 页面片容器关联', () => { + beforeEach(() => { + (window as any).magic = undefined; + }); + + test('新增未关联的容器不会创建页面片实例', () => { + const { app } = setupFragment(); + expect(app.pageFragments.size).toBe(0); + }); + + test('属性面板关联页面片后创建页面片实例', () => { + const { app, runtime, dsl } = setupFragment(); + + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' } as any, + parentId: 'p1', + root: dsl, + }); + + const fragment = app.pageFragments.get('pfc'); + expect(fragment?.data.id).toBe('f1'); + expect(fragment?.getNode('text_in_f1')?.data.id).toBe('text_in_f1'); + }); + + test('切换关联的页面片会替换实例', () => { + const { app, runtime, dsl } = setupFragment(); + + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' } as any, + parentId: 'p1', + root: dsl, + }); + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f2' } as any, + parentId: 'p1', + root: dsl, + }); + + expect(app.pageFragments.get('pfc')?.data.id).toBe('f2'); + expect(app.pageFragments.get('pfc')?.getNode('text_in_f2')?.data.id).toBe('text_in_f2'); + }); + + test('容器其它属性更新不会重建页面片实例', () => { + const { app, runtime, dsl } = setupFragment(); + + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' } as any, + parentId: 'p1', + root: dsl, + }); + const fragment = app.pageFragments.get('pfc'); + + runtime.update?.({ + config: { + id: 'pfc', + type: 'page-fragment-container', + pageFragmentId: 'f1', + style: { width: '100px' }, + } as any, + parentId: 'p1', + root: dsl, + }); + + expect(app.pageFragments.get('pfc')).toBe(fragment); + }); + + test('取消关联后移除页面片实例', () => { + const { app, runtime, dsl } = setupFragment(); + + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' } as any, + parentId: 'p1', + root: dsl, + }); + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: '' } as any, + parentId: 'p1', + root: dsl, + }); + + expect(app.pageFragments.size).toBe(0); + }); + + test('删除容器后移除页面片实例', () => { + const { app, runtime, dsl } = setupFragment(); + + runtime.update?.({ + config: { id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' } as any, + parentId: 'p1', + root: dsl, + }); + expect(app.pageFragments.size).toBe(1); + + runtime.remove?.({ id: 'pfc', parentId: 'p1', root: dsl }); + + expect(app.pageFragments.size).toBe(0); + }); +}); + describe('useEditorDsl remove', () => { beforeEach(() => { (window as any).magic = undefined;