feat(data-source): 新增数据源 mounted 生命周期

页面渲染完成后由 runtime 触发 mounted 事件,统一管理数据源 mounted 及 timing=mounted 的方法执行;支持异步注册数据源在 init 后补充 mounted。
This commit is contained in:
roymondchen 2026-09-03 20:27:18 +08:00
parent 959c0322b6
commit 092bc2914d
10 changed files with 369 additions and 7 deletions

View File

@ -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<void>}`
- **详情:**
页面渲染完成后执行,执行后 `isMounted``true`。由 `DataSourceManager` 监听到 `mounted` 事件后统一调用,自定义数据源可以重写该方法实现「页面渲染后」的逻辑。
- **示例:**
```typescript
class CustomDataSource extends DataSource {
public async mounted() {
// 页面渲染后再拉取数据,避免阻塞首屏
this.setData(await fetchSomething());
await super.mounted();
}
}
```
### destroy
- **返回:**

View File

@ -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<void>}`
- **详情:**
页面渲染完成后执行单个数据源的 `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)` |

View File

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

View File

@ -31,6 +31,7 @@ import type { ChangeEvent, DataSourceOptions } from '@data-source/types';
*/
export default class DataSource<T extends DataSourceSchema = DataSourceSchema> extends EventEmitter {
public isInit = false;
public isMounted = false;
/** @tmagic/core 实例 */
public app: TMagicApp;
@ -152,6 +153,13 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
this.isInit = true;
}
/**
* DataSourceManager mounted
*/
public async mounted() {
this.isMounted = true;
}
public destroy() {
this.#fields = [];
this.removeAllListeners();

View File

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

View File

@ -302,6 +302,227 @@ describe('DataSourceManager - init 生命周期', () => {
});
});
describe('DataSourceManager - mounted 生命周期', () => {
afterEach(() => {
DataSourceManager.clearDataSourceClass();
});
// 等待 mounted 事件触发的异步流程执行完
const flush = () => new Promise<void>((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();

View File

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

View File

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

View File

@ -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 <MagicUiPage config={pageConfig}></MagicUiPage>;

View File

@ -3,7 +3,7 @@
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
import { onMounted, reactive } from 'vue';
import type { Id, MPage } from '@tmagic/core';
import { cloneDeep, DevtoolApi, getNodeInfo, replaceChildNode, setValueByKeyPath } from '@tmagic/core';
@ -12,6 +12,10 @@ import { useComponent, useDsl } from '@tmagic/vue-runtime-help';
const { pageConfig, app } = useDsl();
const pageComponent = useComponent('page');
onMounted(() => {
app.dataSourceManager?.emit('mounted');
});
if (import.meta.env.DEV) {
app.devtools = new (class extends DevtoolApi {
public updateDsl(nodeId: Id, data: any, path: string) {