tmagic-editor/packages/data-source/src/DataSourceManager.ts
roymondchen dfad691493 feat(data-source): 支持批量更新数据源
新增 dataSourceManager.batch(fn, options),fn 中的多次 setData 在结束后
统一更新一次页面:受影响节点只编译、替换一次,并按数据源分别触发 update-data。

- mergeChangeEvents 默认 false,每次变化各触发一次 update-data 并携带自己的
  changeEvent,不丢失中间变化;传 true 则每个数据源只触发一次
- 支持嵌套,仅支持同步函数;isBatching 暴露当前是否处于批量更新中
- 页面/页面片节点的替换改为批量 replaceChildNodes,避免每个节点全树搜索
- 数据源字段变更事件在 batch 内仍按每次 setData 触发
2026-10-30 16:47:56 +08:00

500 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable @typescript-eslint/naming-convention */
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import EventEmitter from 'events';
import { cloneDeep } from 'lodash-es';
import type { DataSourceSchema, default as TMagicApp, DisplayCond, Id, MNode } from '@tmagic/core';
import {
compiledNode,
getDefaultValueFromFields,
NODE_CONDS_KEY,
NODE_CONDS_RESULT_KEY,
NODE_DISABLE_DATA_SOURCE_KEY,
} from '@tmagic/core';
import { SimpleObservedData } from './observed-data/SimpleObservedData';
import { DataSource, HttpDataSource } from './data-sources';
import { getDeps } from './depsCache';
import type {
BatchChange,
BatchOptions,
ChangeEvent,
DataSourceManagerData,
DataSourceManagerOptions,
ObservedDataClass,
SchemaListMap,
} from './types';
import { compiledNodeField, compliedConditions, compliedIteratorItem, createIteratorContentData } from './utils';
class DataSourceManager extends EventEmitter {
private static dataSourceClassMap = new Map<string, any>([
['base', DataSource],
['http', HttpDataSource],
]);
private static ObservedDataClass: ObservedDataClass = SimpleObservedData;
private static waitInitSchemaList = new Map<DataSourceManager, SchemaListMap>();
public static register<T extends typeof DataSource = typeof DataSource>(type: string, dataSource: T) {
DataSourceManager.dataSourceClassMap.set(type, dataSource);
DataSourceManager.waitInitSchemaList?.forEach((listMap, app) => {
const list = listMap[type] || [];
for (let config = list.shift(); config; config = list.shift()) {
const ds = app.addDataSource(config);
if (ds) {
app.initAndMount(ds);
}
}
});
}
public static getDataSourceClass(type: string) {
return DataSourceManager.dataSourceClassMap.get(type);
}
public static clearDataSourceClass() {
DataSourceManager.dataSourceClassMap.clear();
DataSourceManager.dataSourceClassMap.set('base', DataSource);
DataSourceManager.dataSourceClassMap.set('http', HttpDataSource);
}
public static registerObservedData(ObservedDataClass: ObservedDataClass) {
DataSourceManager.ObservedDataClass = ObservedDataClass;
}
public app: TMagicApp;
public dataSourceMap = new Map<string, DataSource & Record<string, any>>();
public data: DataSourceManagerData = {};
public initialData: DataSourceManagerData = {};
public useMock?: boolean = false;
/** 页面是否已经渲染完成 */
public isMounted = false;
private batchDepth = 0;
private batchChanges: BatchChange[] = [];
private batchOptions: BatchOptions = {};
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
super();
DataSourceManager.waitInitSchemaList.set(this, {});
this.app = app;
this.useMock = useMock;
if (initialData) {
this.initialData = initialData;
this.data = { ...initialData };
}
app.dsl?.dataSources?.forEach((config) => {
this.addDataSource(config);
});
if (this.isAllDataSourceRegistered()) {
this.callDsInit();
} else {
this.on('registered-all', () => {
this.callDsInit();
});
}
// 由runtime在顶层组件渲染完成后触发
this.on('mounted', () => {
this.isMounted = true;
this.callDsMounted();
});
}
public async init(ds: DataSource) {
if (ds.isInit) {
return;
}
if (this.app.jsEngine && ds.schema.disabledInitInJsEngine?.includes(this.app.jsEngine)) {
return;
}
for (const method of ds.methods) {
if (typeof method.content !== 'function') return;
if (method.timing === 'beforeInit') {
await method.content({ params: {}, dataSource: ds, app: this.app });
}
}
await ds.init();
for (const method of ds.methods) {
if (typeof method.content !== 'function') return;
if (method.timing === 'afterInit') {
await method.content({ params: {}, dataSource: ds, app: this.app });
}
}
}
/**
* 页面渲染完成后执行单个数据源的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);
}
public addDataSource(config?: DataSourceSchema) {
if (!config) return;
const DataSourceClass = DataSourceManager.dataSourceClassMap.get(config.type);
if (!DataSourceClass) {
let listMap = DataSourceManager.waitInitSchemaList.get(this);
if (!listMap) {
listMap = {};
DataSourceManager.waitInitSchemaList.set(this, listMap);
}
if (listMap[config.type]) {
listMap[config.type].push(config);
} else {
listMap[config.type] = [config];
}
// 保证初始化时的第一次编译有值
this.data[config.id] = this.initialData[config.id] ?? getDefaultValueFromFields(config.fields);
return;
}
const ds = new DataSourceClass({
app: this.app,
schema: config,
request: this.app.request,
useMock: this.useMock,
initialData: this.initialData[config.id],
ObservedDataClass: DataSourceManager.ObservedDataClass,
});
this.dataSourceMap.set(config.id, ds);
this.data[ds.id] = ds.data;
ds.on('change', (changeEvent: ChangeEvent) => {
this.setData(ds, changeEvent);
});
if (this.isAllDataSourceRegistered()) {
this.emit('registered-all');
}
return ds;
}
public setData(ds: DataSource, changeEvent: ChangeEvent) {
this.data[ds.id] = ds.data;
if (this.batchDepth > 0) {
this.batchChanges.push({ sourceId: ds.id, changeEvent });
}
this.emit('change', ds.id, changeEvent);
}
/** 是否处于 batch 中 */
public get isBatching() {
return this.batchDepth > 0;
}
/**
* 批量更新:fn 中产生的所有数据源变化只会在 fn 执行结束后统一更新一次页面(同步),
* 页面节点只编译、替换一次,并按数据源分别触发 update-data。
* - 数据源的数据(ds.data / manager.data)仍然实时更新,onDataChange 监听也仍然实时触发,manager 的 change 事件也照常触发
* - 支持嵌套,最外层 batch 结束时才统一更新
* - 仅支持同步函数,fn 返回 Promise 时不会等待
* @param fn 同步执行的函数
* @param options 批量更新选项,嵌套时以最外层为准
* @returns fn 的返回值
*/
public batch<T>(fn: () => T, options?: BatchOptions): T {
const isOutermost = this.batchDepth === 0;
if (isOutermost) {
this.batchOptions = options ?? {};
}
this.batchDepth += 1;
try {
return fn();
} finally {
this.batchDepth -= 1;
if (this.batchDepth === 0 && this.batchChanges.length) {
const changes = this.batchChanges;
const { batchOptions } = this;
this.batchChanges = [];
this.batchOptions = {};
this.emit('batch-change', changes, batchOptions);
}
}
}
public removeDataSource(id: string) {
this.get(id)?.destroy();
delete this.data[id];
this.dataSourceMap.delete(id);
}
/**
* 更新数据源dsl,在编辑器中修改配置后需要更新,一般在其他环境下不需要更新dsl
* @param {DataSourceSchema[]} schemas 所有数据源配置
*/
public updateSchema(schemas: DataSourceSchema[]) {
for (const schema of schemas) {
const ds = this.get(schema.id);
if (!ds) {
return;
}
this.removeDataSource(schema.id);
}
for (const schema of schemas) {
this.addDataSource(cloneDeep(schema));
const newDs = this.get(schema.id);
if (newDs) {
this.initAndMount(newDs);
}
}
}
/**
* 将组件dsl中所有key中数据源相关的配置编译成对应的值
* @param {MNode} node 组件dsl
* @param {string | number} sourceId 数据源ID
* @param {boolean} deep 是否编译子项(items),默认为false
* @returns {MNode} 编译后的组件dsl
*/
public compiledNode(n: MNode, sourceId?: Id, deep = false) {
if (n[NODE_DISABLE_DATA_SOURCE_KEY]) {
return n;
}
const { items, ...node } = n;
const newNode = cloneDeep(node);
if (items) {
newNode.items =
Array.isArray(items) && deep ? items.map((item) => this.compiledNode(item, sourceId, deep)) : items;
}
if (node.condResult === false || (typeof node.condResult === 'undefined' && node[NODE_CONDS_RESULT_KEY])) {
return newNode;
}
// 编译函数这里作为参数,方便后续支持自定义编译
return compiledNode(
(value: any) => compiledNodeField(value, this.data),
newNode,
this.app.dsl?.dataSourceDeps || {},
sourceId,
);
}
/**
* 编译组件条件组配置(用于配置组件显示时机)
* @param {{ [NODE_CONDS_KEY]?: DisplayCond[] }} node 显示条件组配置
* @returns {boolean} 是否显示
*/
public compliedConds(
node: {
[NODE_CONDS_KEY]?: DisplayCond[];
[NODE_CONDS_RESULT_KEY]?: boolean;
[NODE_DISABLE_DATA_SOURCE_KEY]?: boolean;
},
data = this.data,
) {
if (node[NODE_DISABLE_DATA_SOURCE_KEY]) {
return true;
}
const result = compliedConditions(node, data);
if (!node[NODE_CONDS_RESULT_KEY]) {
return result;
}
return !result;
}
/**
* 编译迭代器容器的迭代项的显示条件
* @param {any[]} itemData 迭代数据
* @param {{ [NODE_CONDS_KEY]?: DisplayCond[] }} node 显示条件组配置
* @param {string[]} dataSourceField 迭代数据在数据源中的字段,格式如['dsId', 'key1', 'key2']
* @returns {boolean}是否显示
*/
public compliedIteratorItemConds(
itemData: any,
node: {
[NODE_CONDS_KEY]?: DisplayCond[];
[NODE_CONDS_RESULT_KEY]?: boolean;
[NODE_DISABLE_DATA_SOURCE_KEY]?: boolean;
},
dataSourceField: string[] = [],
) {
const [dsId, ...keys] = dataSourceField;
const ds = this.get(dsId);
if (!ds) return true;
const ctxData = createIteratorContentData(itemData, ds.id, keys, this.data);
return this.compliedConds(node, ctxData);
}
public compliedIteratorItems(itemData: any, nodes: MNode[], dataSourceField: string[] = []): MNode[] {
const [dsId, ...keys] = dataSourceField;
const ds = this.get(dsId);
if (!ds) return nodes;
const inEditor = this.app.platform === 'editor';
const ctxData = createIteratorContentData(itemData, ds.id, keys, this.data);
const { deps = {}, condDeps = {} } = getDeps(ds.schema, nodes, inEditor);
if (!Object.keys(deps).length && !Object.keys(condDeps).length) {
return nodes;
}
return nodes.map((item) =>
compliedIteratorItem({
compile: (value: any) => compiledNodeField(value, ctxData),
dsId: ds.id,
item,
deps,
condDeps,
inEditor,
ctxData,
}),
);
}
public isAllDataSourceRegistered() {
return !this.app.dsl?.dataSources?.length || this.dataSourceMap.size === this.app.dsl.dataSources.length;
}
public destroy() {
this.removeAllListeners();
this.batchDepth = 0;
this.batchChanges = [];
this.batchOptions = {};
this.data = {};
this.initialData = {};
this.dataSourceMap.forEach((ds) => {
ds.destroy();
});
this.dataSourceMap.clear();
DataSourceManager.waitInitSchemaList.delete(this);
}
public onDataChange(id: string, path: string, callback: (newVal: any) => void, options?: { immediate?: boolean }) {
return this.get(id)?.onDataChange(path, callback, options);
}
public offDataChange(id: string, path: string, callback: (newVal: any) => void) {
return this.get(id)?.offDataChange(path, callback);
}
private callDsInit() {
const dataSourceList = Array.from(this.dataSourceMap);
if (typeof Promise.allSettled === 'function') {
Promise.allSettled<Record<string, any>>(dataSourceList.map(([, ds]) => this.init(ds))).then((values) => {
const data: DataSourceManagerData = {};
const errors: Record<string, Error> = {};
values.forEach((value, index) => {
const dsId = dataSourceList[index][0];
if (value.status === 'fulfilled') {
if (this.data[dsId]) {
data[dsId] = this.data[dsId];
} else {
delete data[dsId];
}
} else if (value.status === 'rejected') {
delete data[dsId];
errors[dsId] = value.reason;
}
});
this.emit('init', data, errors);
});
} else {
Promise.all<Record<string, any>>(dataSourceList.map(([, ds]) => this.init(ds)))
.then(() => {
this.emit('init', this.data);
})
.catch(() => {
this.emit('init', this.data);
});
}
}
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;