mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-12 15:09:43 +00:00
feat(data-source): 新增数据源 mounted 生命周期
页面渲染完成后由 runtime 触发 mounted 事件,统一管理数据源 mounted 及 timing=mounted 的方法执行;支持异步注册数据源在 init 后补充 mounted。
This commit is contained in:
parent
959c0322b6
commit
092bc2914d
@ -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
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -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)` |
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user