From 092bc2914d444c9527140a204ab8bc2776d31a1b Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 3 Sep 2026 20:27:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(data-source):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=20mounted=20=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面渲染完成后由 runtime 触发 mounted 事件,统一管理数据源 mounted 及 timing=mounted 的方法执行;支持异步注册数据源在 init 后补充 mounted。 --- docs/runtime-api/data-source/dataSource.md | 23 ++ .../data-source/dataSourceManager.md | 25 +- packages/data-source/src/DataSourceManager.ts | 57 ++++- packages/data-source/src/data-sources/Base.ts | 8 + packages/data-source/tests/DataSource.spec.ts | 19 ++ .../tests/DataSourceMenager.spec.ts | 221 ++++++++++++++++++ packages/editor/src/utils/code-block.ts | 1 + .../unit/components/CodeBlockEditor.spec.ts | 10 +- runtime/react/page/App.tsx | 6 +- runtime/vue/page/App.vue | 6 +- 10 files changed, 369 insertions(+), 7 deletions(-) diff --git a/docs/runtime-api/data-source/dataSource.md b/docs/runtime-api/data-source/dataSource.md index d574d4b6..467455b9 100644 --- a/docs/runtime-api/data-source/dataSource.md +++ b/docs/runtime-api/data-source/dataSource.md @@ -43,6 +43,7 @@ new DataSource(options: DataSourceOptions) | `methods` | `CodeBlockContent[]` | 自定义方法配置 | | `data` | `any` | 当前数据 | | `isInit` | `boolean` | 是否已初始化 | +| `isMounted` | `boolean` | 页面渲染后的 `mounted` 是否已执行 | ## 实例方法 @@ -172,6 +173,28 @@ ds.onDataChange('user.name', (payload) => { 初始化数据源。 +### mounted + +- **返回:** + - `{Promise}` + +- **详情:** + + 页面渲染完成后执行,执行后 `isMounted` 为 `true`。由 `DataSourceManager` 监听到 `mounted` 事件后统一调用,自定义数据源可以重写该方法实现「页面渲染后」的逻辑。 + +- **示例:** + +```typescript +class CustomDataSource extends DataSource { + public async mounted() { + // 页面渲染后再拉取数据,避免阻塞首屏 + this.setData(await fetchSomething()); + + await super.mounted(); + } +} +``` + ### destroy - **返回:** diff --git a/docs/runtime-api/data-source/dataSourceManager.md b/docs/runtime-api/data-source/dataSourceManager.md index 5a223fb8..5cef584e 100644 --- a/docs/runtime-api/data-source/dataSourceManager.md +++ b/docs/runtime-api/data-source/dataSourceManager.md @@ -25,6 +25,7 @@ new DataSourceManager(options: DataSourceManagerOptions) | `data` | `DataSourceManagerData` | 所有数据源的数据 | | `initialData` | `DataSourceManagerData` | 初始化数据 | | `useMock` | `boolean` | 是否使用 Mock 数据 | +| `isMounted` | `boolean` | 页面是否已渲染完成(收到过 `mounted` 事件) | ## 静态方法 @@ -109,6 +110,27 @@ if (ds) { } ``` +### mounted + +- **参数:** + - `{DataSource} ds` 单个数据源实例(必填) + +- **返回:** + - `{Promise}` + +- **详情:** + + 页面渲染完成后执行单个数据源的 `mounted`:先调用 `ds.mounted()`,再依次执行 `methods` 中 `timing === 'mounted'` 的方法。当 `ds.isMounted` 为 `true`,或当前 `app.jsEngine` 命中 `ds.schema.disabledInitInJsEngine` 时直接跳过。 + + 一般不需要手动调用,runtime 在顶层组件渲染完成后触发 `mounted` 事件,由 manager 统一对所有数据源调用该方法。 + +- **示例:** + +```typescript +// runtime 顶层组件渲染完成后 +app.dataSourceManager?.emit('mounted'); +``` + ### get - **参数:** @@ -184,7 +206,7 @@ const ds = dataSourceManager.addDataSource({ - **详情:** - 同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`)。一般在编辑器中修改配置后调用。 + 同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`);若此时 `isMounted` 已为 `true`,`init` 完成后还会补充执行 `mounted`。一般在编辑器中修改配置后调用。 ### compiledNode @@ -325,6 +347,7 @@ DataSourceManager 继承自 EventEmitter,支持以下事件: |--------|------|----------| | `change` | 单个数据源数据变化 | `(dsId: string, changeEvent: ChangeEvent)` | | `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` | +| `mounted` | 由 runtime 在顶层组件渲染完成后触发,manager 收到后统一执行各数据源的 `mounted` | 无 | | `registered-all` | 所有数据源注册完成 | 无 | | `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` | diff --git a/packages/data-source/src/DataSourceManager.ts b/packages/data-source/src/DataSourceManager.ts index 23dd4cab..a5666499 100644 --- a/packages/data-source/src/DataSourceManager.ts +++ b/packages/data-source/src/DataSourceManager.ts @@ -57,7 +57,7 @@ class DataSourceManager extends EventEmitter { for (let config = list.shift(); config; config = list.shift()) { const ds = app.addDataSource(config); if (ds) { - app.init(ds); + app.initAndMount(ds); } } }); @@ -84,6 +84,8 @@ class DataSourceManager extends EventEmitter { public data: DataSourceManagerData = {}; public initialData: DataSourceManagerData = {}; public useMock?: boolean = false; + /** 页面是否已经渲染完成 */ + public isMounted = false; constructor({ app, useMock, initialData }: DataSourceManagerOptions) { super(); @@ -109,6 +111,12 @@ class DataSourceManager extends EventEmitter { this.callDsInit(); }); } + + // 由runtime在顶层组件渲染完成后触发 + this.on('mounted', () => { + this.isMounted = true; + this.callDsMounted(); + }); } public async init(ds: DataSource) { @@ -137,6 +145,29 @@ class DataSourceManager extends EventEmitter { } } + /** + * 页面渲染完成后执行单个数据源的mounted + * @param {DataSource} ds 数据源实例 + */ + public async mounted(ds: DataSource) { + if (ds.isMounted) { + return; + } + + if (this.app.jsEngine && ds.schema.disabledInitInJsEngine?.includes(this.app.jsEngine)) { + return; + } + + await ds.mounted?.(); + + for (const method of ds.methods) { + if (typeof method.content !== 'function') return; + if (method.timing === 'mounted') { + await method.content({ params: {}, dataSource: ds, app: this.app }); + } + } + } + public get(id: string) { return this.dataSourceMap.get(id); } @@ -219,7 +250,7 @@ class DataSourceManager extends EventEmitter { this.addDataSource(cloneDeep(schema)); const newDs = this.get(schema.id); if (newDs) { - this.init(newDs); + this.initAndMount(newDs); } } } @@ -390,6 +421,28 @@ class DataSourceManager extends EventEmitter { }); } } + + private callDsMounted() { + const promises = Array.from(this.dataSourceMap).map(([, ds]) => this.mounted(ds)); + + if (typeof Promise.allSettled === 'function') { + return Promise.allSettled(promises); + } + + return Promise.all(promises.map((promise) => promise.catch(() => undefined))); + } + + /** + * 初始化数据源,如果页面已经渲染完成(如异步注册的数据源类型),则在初始化后补充执行mounted + * @param {DataSource} ds 数据源实例 + */ + private async initAndMount(ds: DataSource) { + await this.init(ds); + + if (this.isMounted) { + await this.mounted(ds); + } + } } export default DataSourceManager; diff --git a/packages/data-source/src/data-sources/Base.ts b/packages/data-source/src/data-sources/Base.ts index 90965a4e..123e7199 100644 --- a/packages/data-source/src/data-sources/Base.ts +++ b/packages/data-source/src/data-sources/Base.ts @@ -31,6 +31,7 @@ import type { ChangeEvent, DataSourceOptions } from '@data-source/types'; */ export default class DataSource extends EventEmitter { public isInit = false; + public isMounted = false; /** @tmagic/core 实例 */ public app: TMagicApp; @@ -152,6 +153,13 @@ export default class DataSource e this.isInit = true; } + /** + * 页面渲染后执行,由 DataSourceManager 监听到 mounted 事件后统一调用 + */ + public async mounted() { + this.isMounted = true; + } + public destroy() { this.#fields = []; this.removeAllListeners(); diff --git a/packages/data-source/tests/DataSource.spec.ts b/packages/data-source/tests/DataSource.spec.ts index 2666248d..8578afa4 100644 --- a/packages/data-source/tests/DataSource.spec.ts +++ b/packages/data-source/tests/DataSource.spec.ts @@ -38,6 +38,25 @@ describe('DataSource', () => { expect(ds.isInit).toBeTruthy(); }); + + test('mounted', async () => { + const ds = new DataSource({ + schema: { + type: 'base', + id: '1', + fields: [{ name: 'name' }], + methods: [], + events: [], + }, + app: new App({}), + }); + + expect(ds.isMounted).toBe(false); + + await ds.mounted(); + + expect(ds.isMounted).toBe(true); + }); }); describe('DataSource setData', () => { diff --git a/packages/data-source/tests/DataSourceMenager.spec.ts b/packages/data-source/tests/DataSourceMenager.spec.ts index 0c05293b..8e7d47bb 100644 --- a/packages/data-source/tests/DataSourceMenager.spec.ts +++ b/packages/data-source/tests/DataSourceMenager.spec.ts @@ -302,6 +302,227 @@ describe('DataSourceManager - init 生命周期', () => { }); }); +describe('DataSourceManager - mounted 生命周期', () => { + afterEach(() => { + DataSourceManager.clearDataSourceClass(); + }); + + // 等待 mounted 事件触发的异步流程执行完 + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + const createApp = (id: string, dataSources: any[] = [], jsEngine?: any) => + new TMagicApp({ + ...(jsEngine ? { jsEngine } : {}), + config: { + type: NodeType.ROOT, + id, + items: [], + dataSources, + }, + } as any); + + test('mounted 事件会统一执行所有数据源的 mounted', async () => { + const app = createApp('app_mounted', [ + { type: 'base', id: 'ds_m1', fields: [], methods: [], events: [] }, + { type: 'base', id: 'ds_m2', fields: [], methods: [], events: [] }, + ]); + const dsm = new DataSourceManager({ app }); + + expect(dsm.isMounted).toBe(false); + expect(dsm.get('ds_m1')?.isMounted).toBe(false); + + dsm.emit('mounted'); + await flush(); + + expect(dsm.isMounted).toBe(true); + expect(dsm.get('ds_m1')?.isMounted).toBe(true); + expect(dsm.get('ds_m2')?.isMounted).toBe(true); + }); + + test('methods 中 timing=mounted 的 content 会在 ds.mounted 之后调用', async () => { + const app = createApp('app_mounted_method'); + const dsm = new DataSourceManager({ app }); + const order: string[] = []; + const mountedContent = vi.fn(() => { + order.push('method'); + }); + const ds = new DataSource({ + app, + schema: { + type: 'base', + id: 'ds_mounted_method', + fields: [], + events: [], + methods: [{ name: 'onMounted', content: mountedContent, timing: 'mounted', params: [] }], + } as any, + }); + const origMounted = ds.mounted.bind(ds); + ds.mounted = async () => { + order.push('mounted'); + await origMounted(); + }; + + await dsm.mounted(ds); + + expect(mountedContent).toHaveBeenCalledTimes(1); + const arg = mountedContent.mock.calls[0][0] as any; + expect(arg.dataSource).toBe(ds); + expect(arg.app).toBe(app); + expect(order).toEqual(['mounted', 'method']); + }); + + test('ds.isMounted 为 true 时直接跳过', async () => { + const app = createApp('app_mounted_skip'); + const dsm = new DataSourceManager({ app }); + const content = vi.fn(); + const ds = new DataSource({ + app, + schema: { + type: 'base', + id: 'ds_mounted_skip', + fields: [], + events: [], + methods: [{ name: 'onMounted', content, timing: 'mounted', params: [] }], + } as any, + }); + + await dsm.mounted(ds); + await dsm.mounted(ds); + + expect(content).toHaveBeenCalledTimes(1); + }); + + test('jsEngine 命中 disabledInitInJsEngine 时跳过 mounted', async () => { + const app = createApp('app_mounted_disabled', [], 'nodejs'); + const dsm = new DataSourceManager({ app }); + const ds = new DataSource({ + app, + schema: { + type: 'base', + id: 'ds_mounted_disabled', + fields: [], + methods: [], + events: [], + disabledInitInJsEngine: ['nodejs'], + } as any, + }); + + await dsm.mounted(ds); + + expect(ds.isMounted).toBe(false); + }); + + test('method.content 非函数时提前返回', async () => { + const app = createApp('app_mounted_bad'); + const dsm = new DataSourceManager({ app }); + const content = vi.fn(); + const ds = new DataSource({ + app, + schema: { + type: 'base', + id: 'ds_mounted_bad', + fields: [], + events: [], + methods: [ + { name: 'bad', content: 'not-a-function', timing: 'mounted', params: [] } as any, + { name: 'onMounted', content, timing: 'mounted', params: [] } as any, + ], + } as any, + }); + + await dsm.mounted(ds); + + expect(ds.isMounted).toBe(true); + expect(content).not.toHaveBeenCalled(); + }); + + test('mounted 抛错时不会产生未处理的异常', async () => { + const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValue(new Error('mounted-boom')); + + try { + const app = createApp('app_mounted_err', [{ type: 'base', id: 'ds_me', fields: [], methods: [], events: [] }]); + const dsm = new DataSourceManager({ app }); + + dsm.emit('mounted'); + await flush(); + + expect(dsm.isMounted).toBe(true); + } finally { + mountedSpy.mockRestore(); + } + }); + + test('Promise.allSettled 不可用时走 Promise.all 兼容分支', async () => { + const original = Promise.allSettled; + (Promise as any).allSettled = undefined; + const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValueOnce(new Error('compat-boom')); + + try { + const app = createApp('app_mounted_compat', [ + { type: 'base', id: 'ds_mc1', fields: [], methods: [], events: [] }, + { type: 'base', id: 'ds_mc2', fields: [], methods: [], events: [] }, + ]); + const dsm = new DataSourceManager({ app }); + + dsm.emit('mounted'); + await flush(); + + expect(mountedSpy).toHaveBeenCalledTimes(2); + expect(dsm.get('ds_mc2')?.isMounted).toBe(true); + } finally { + (Promise as any).allSettled = original; + mountedSpy.mockRestore(); + } + }); + + test('页面渲染后注册的数据源类型会在 init 之后补充执行 mounted', async () => { + const app = createApp('app_mounted_register', [ + { type: 'mounted-late', id: 'ds_late', fields: [], methods: [], events: [] }, + ]); + const dsm = new DataSourceManager({ app }); + + expect(dsm.get('ds_late')).toBeUndefined(); + + dsm.emit('mounted'); + await flush(); + + class LateDataSource extends DataSource {} + DataSourceManager.register('mounted-late', LateDataSource as any); + await flush(); + + expect(dsm.get('ds_late')?.isInit).toBe(true); + expect(dsm.get('ds_late')?.isMounted).toBe(true); + }); + + test('页面渲染后 updateSchema 重建的数据源会执行 mounted', async () => { + const app = createApp('app_mounted_update', [ + { type: 'base', id: 'ds_up', fields: [{ name: 'a' }], methods: [], events: [] }, + ]); + const dsm = new DataSourceManager({ app }); + + dsm.emit('mounted'); + await flush(); + + dsm.updateSchema([{ type: 'base', id: 'ds_up', fields: [{ name: 'b' }], methods: [], events: [] }]); + await flush(); + + expect(dsm.get('ds_up')?.isMounted).toBe(true); + }); + + test('页面未渲染时 updateSchema 不会执行 mounted', async () => { + const app = createApp('app_update_not_mounted', [ + { type: 'base', id: 'ds_nm', fields: [{ name: 'a' }], methods: [], events: [] }, + ]); + const dsm = new DataSourceManager({ app }); + + dsm.updateSchema([{ type: 'base', id: 'ds_nm', fields: [{ name: 'b' }], methods: [], events: [] }]); + await flush(); + + expect(dsm.get('ds_nm')?.isInit).toBe(true); + expect(dsm.get('ds_nm')?.isMounted).toBe(false); + }); +}); + describe('DataSourceManager - addDataSource 边界', () => { afterEach(() => { DataSourceManager.clearDataSourceClass(); diff --git a/packages/editor/src/utils/code-block.ts b/packages/editor/src/utils/code-block.ts index efdb5557..5b20ff2a 100644 --- a/packages/editor/src/utils/code-block.ts +++ b/packages/editor/src/utils/code-block.ts @@ -103,6 +103,7 @@ export const getCodeBlockFormConfig = (options: GetCodeBlockFormConfigOptions = const list = [ { text: '初始化前', value: 'beforeInit' }, { text: '初始化后', value: 'afterInit' }, + { text: '页面渲染后', value: 'mounted' }, ]; if (dataSourceType?.() !== 'base') { list.push({ text: '请求前', value: 'beforeRequest' }); diff --git a/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts b/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts index 4ec02d46..61f36a64 100644 --- a/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts +++ b/packages/editor/tests/unit/components/CodeBlockEditor.spec.ts @@ -222,7 +222,13 @@ describe('CodeBlockEditor', () => { }); const timingItem = capturedConfig.find((c: any) => c.name === 'timing'); const opts = timingItem.options(); - expect(opts.length).toBe(4); + expect(opts.map((opt: any) => opt.value)).toEqual([ + 'beforeInit', + 'afterInit', + 'mounted', + 'beforeRequest', + 'afterRequest', + ]); }); test('timing options - base 类型', () => { @@ -231,7 +237,7 @@ describe('CodeBlockEditor', () => { }); const timingItem = capturedConfig.find((c: any) => c.name === 'timing'); const opts = timingItem.options(); - expect(opts.length).toBe(2); + expect(opts.map((opt: any) => opt.value)).toEqual(['beforeInit', 'afterInit', 'mounted']); }); test('changeHandler 触发 changedValue', async () => { diff --git a/runtime/react/page/App.tsx b/runtime/react/page/App.tsx index b6a6e66c..837d67bd 100644 --- a/runtime/react/page/App.tsx +++ b/runtime/react/page/App.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import type TMagicApp from '@tmagic/core'; import { AppContent, useDsl } from '@tmagic/react-runtime-help'; @@ -26,6 +26,10 @@ function App() { const { pageConfig } = useDsl(app); + useEffect(() => { + app?.dataSourceManager?.emit('mounted'); + }, [app]); + const MagicUiPage = app?.resolveComponent('page'); return ; diff --git a/runtime/vue/page/App.vue b/runtime/vue/page/App.vue index 687193dd..d546c9f9 100644 --- a/runtime/vue/page/App.vue +++ b/runtime/vue/page/App.vue @@ -3,7 +3,7 @@