feat(core): 新增 setPageFragment/deletePageFragment 同步页面片实例

App 新增 setPageFragment / deletePageFragment 方法,用于同步页面片实例。
容器关联、切换、取消关联页面片时同步 pageFragments,并触发 page-fragments-change。

- Page 重建时强制同步页面片内容(force)
- useDsl 改用动态获取页面片实例,避免 setup 快照过期
- useEditorDsl 在关联变更、删除容器时同步页面片实例
- 补充 core 与 vue-runtime-help 单测,更新 runtime-api 文档
This commit is contained in:
roymondchen 2026-11-02 14:39:56 +08:00
parent 0c1d103cd0
commit dfc4f55390
8 changed files with 445 additions and 21 deletions

View File

@ -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 })` |
### 监听示例

View File

@ -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

View File

@ -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) => {

View File

@ -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: {

View File

@ -9,7 +9,8 @@ export const useDsl = (app = inject<TMagicApp>('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<TMagicApp>('app'), pageFragmentContainerId?:
replaceChildNodes(pending, [config]);
};
const pageConfig = ref<MNode | undefined>(pageFragmentContainerId ? pageFragment?.data : app.page?.data);
const pageConfig = ref<MNode | undefined>(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<TMagicApp>('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;

View File

@ -139,6 +139,9 @@ export const useEditorDsl = (app = inject<TMagicApp>('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<TMagicApp>('app'), runtimeApi: Runtime
}
} else {
app?.page?.deleteNode(node.id);
app?.deletePageFragment(node.id);
}
const index = parent.items?.findIndex((child: MNode) => child.id === node.id);

View File

@ -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);
});
});

View File

@ -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;