fix(editor): 修复 eventsService resetState 响应式失效并在编辑器重置时清空

resetState 改为原地清空 reactive 对象以保留代理身份,避免 computed 不更新。
编辑器 reset 时同步调用 eventsService.resetState,并补充文档与单测。
This commit is contained in:
roymondchen 2026-09-03 20:02:03 +08:00
parent b0d43dc277
commit 959c0322b6
6 changed files with 383 additions and 12 deletions

View File

@ -16,7 +16,11 @@
- **详情:**
批量设置多个组件类型的事件列表
批量设置多个组件类型的事件列表。增量合并语义:只写入入参中出现的类型,已有的其他类型保持不变。
::: tip 切换 runtime 版本
不同版本的 runtime 事件表不同,直接调用 `setEvents` 会让上一个版本独有的类型残留。是否需要清掉上一个版本的数据由业务自行决定,需要清掉时先调用 [`resetState`](#resetstate) 再写入新表。
:::
- **示例:**
@ -47,7 +51,7 @@ eventsService.setEvents({
- **详情:**
设置指定组件类型的事件列表
设置指定组件类型的事件列表,只影响该类型
- **示例:**
@ -106,7 +110,7 @@ eventsService.usePlugin({
- **详情:**
批量设置多个组件类型的方法列表
批量设置多个组件类型的方法列表。语义与 `setEvents` 一致:增量合并,入参中不存在的类型保持不变。
- **示例:**
@ -138,7 +142,7 @@ eventsService.setMethods({
- **详情:**
设置指定组件类型的方法列表
设置指定组件类型的方法列表,只影响该类型
- **示例:**
@ -209,14 +213,17 @@ eventsService.usePlugin({
- **详情:**
重置事件服务状态,清空所有事件和方法配置
重置事件服务状态,清空所有事件和方法配置`setEvents` / `setMethods` 是增量合并,切换 runtime 版本这类需要丢弃上一份事件表的场景,由业务在写入新表前自行调用本方法。
- **示例:**
```js
import { eventsService } from '@tmagic/editor';
// 切到另一个版本的 runtime先清空再写入该版本的事件表
eventsService.resetState();
eventsService.setEvents(nextVersionEvents);
eventsService.setMethods(nextVersionMethods);
```
## destroy

View File

@ -630,6 +630,10 @@ const propsValues = {
该属性最终会设置到[eventsService](./eventsServiceMethods.md)中,所以也可直接调用[eventsService.setEvents()](./eventsServiceMethods.md#setEvents)与[eventsService.setMethods()](./eventsServiceMethods.md#setMethods)方法来配置
:::
:::warning
该属性每次变更都是增量合并,不会清掉上一次写入的类型。切换 runtime 版本时若需要丢弃旧版本的事件表,请自行调用 [eventsService.resetState()](./eventsServiceMethods.md#resetstate)
:::
- **默认值:** `{}`
- **类型:** `Record<string, { events: EventOption[]; methods: EventOption[] }>`

View File

@ -254,6 +254,7 @@ export const initServiceState = (
uiService.resetState();
componentListService.resetState();
codeBlockService.resetState();
eventsService.resetState();
keybindingService.reset();
depService.reset();
});

View File

@ -35,8 +35,8 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
let eventMap: Record<string, EventOption[]> = reactive({});
let methodMap: Record<string, EventOption[]> = reactive({});
const eventMap: Record<string, EventOption[]> = reactive({});
const methodMap: Record<string, EventOption[]> = reactive({});
class Events extends BaseService {
constructor() {
@ -75,8 +75,9 @@ class Events extends BaseService {
}
public resetState() {
eventMap = reactive({});
methodMap = reactive({});
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
}
public destroy() {

View File

@ -8,6 +8,7 @@ import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { DepTargetType } from '@tmagic/core';
import { getNodes } from '@tmagic/utils';
import { initServiceEvents, initServiceState } from '@editor/initService';
import * as logger from '@editor/utils/logger';
@ -60,6 +61,7 @@ const mkServices = () => {
...mkSvc('events'),
setEvents: vi.fn(),
setMethods: vi.fn(),
resetState: vi.fn(),
};
const uiService: any = {
...mkSvc('ui'),
@ -210,8 +212,8 @@ describe('initServiceState', () => {
eventMethodList: { typeA: { events: [{ name: 'click' }], methods: [{ name: 'm' }] } },
} as any;
mount(Wrap(props, services));
expect(services.eventsService.setEvents).toHaveBeenCalled();
expect(services.eventsService.setMethods).toHaveBeenCalled();
expect(services.eventsService.setEvents).toHaveBeenCalledWith({ typeA: [{ name: 'click' }] });
expect(services.eventsService.setMethods).toHaveBeenCalledWith({ typeA: [{ name: 'm' }] });
});
test('datasourceConfigs 设置 form config', () => {
@ -284,11 +286,44 @@ describe('initServiceState', () => {
expect(services.uiService.resetState).toHaveBeenCalled();
expect(services.componentListService.resetState).toHaveBeenCalled();
expect(services.codeBlockService.resetState).toHaveBeenCalled();
expect(services.eventsService.resetState).toHaveBeenCalled();
expect(services.keybindingService.reset).toHaveBeenCalled();
expect(services.depService.reset).toHaveBeenCalled();
});
});
const mkDataSourceManager = () => ({
addDataSource: vi.fn(),
removeDataSource: vi.fn(),
get: vi.fn(() => ({
setFields: vi.fn(),
setData: vi.fn(),
getDefaultData: vi.fn(() => ({})),
})),
init: vi.fn(),
compiledNode: vi.fn((node: any) => node),
});
/** renderer.runtime 直接就绪getTMagicApp 走同步分支 */
const mkReadyStage = (app: any) => {
const runtime = {
getApp: vi.fn(() => app),
updateRootConfig: vi.fn(),
updatePageId: vi.fn(),
};
return {
renderer: {
runtime,
getRuntime: vi.fn(async () => runtime),
once: vi.fn(),
},
select: vi.fn(),
reloadIframe: vi.fn(),
update: vi.fn(),
runtime,
};
};
describe('initServiceEvents', () => {
let services: ReturnType<typeof mkServices>;
let emit: any;
@ -499,4 +534,217 @@ describe('initServiceEvents', () => {
});
// 因 services 中 editor.state 不是 reactivestage watch 不会触发,跳过该测试场景
test('getTMagicApp 在 runtime 未就绪时等待 runtime-ready并复用同一个 promise', async () => {
const dataSourceManager = mkDataSourceManager();
const app = { dsl: {}, dataSourceManager };
let runtimeReady: any;
const renderer: any = {
runtime: null,
getRuntime: vi.fn(),
once: vi.fn((_event: string, cb: any) => {
runtimeReady = cb;
}),
};
services.editorService.state.stage = { renderer, select: vi.fn() };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('add', { id: 'd1', type: 'base' });
services.dataSourceService.emit('add', { id: 'd2', type: 'base' });
await nextTick();
// 两次调用只应注册一次 runtime-ready 监听
expect(renderer.once).toHaveBeenCalledTimes(1);
renderer.runtime = { getApp: () => app };
runtimeReady();
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.addDataSource).toHaveBeenCalledTimes(2);
expect(dataSourceManager.init).toHaveBeenCalled();
});
test('getTMagicApp 在 stage 没有 renderer 时返回 undefined', async () => {
services.editorService.state.stage = { select: vi.fn() };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('add', { id: 'd1', type: 'base' });
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.addTarget).toHaveBeenCalled();
});
test('ds-collected 事件把 dataSourceDeps 同步给 runtime app', async () => {
const app: any = { dsl: { dataSourceDeps: null }, dataSourceManager: mkDataSourceManager() };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', dataSourceDeps: { d1: {} } };
mount(WrapEvents({} as any, emit, services));
services.depService.emit('ds-collected');
await new Promise((r) => setTimeout(r, 0));
expect(app.dsl.dataSourceDeps).toEqual({ d1: {} });
});
test('rootChange items 是数组时更新画布 dsl', async () => {
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
const stage = mkReadyStage(app);
services.editorService.state.stage = stage;
services.editorService.state.page = { id: 'p1' };
services.editorService.state.node = { id: 'n1' };
mount(WrapEvents({} as any, emit, services));
services.editorService.emit('root-change', {
id: 'r',
items: [{ id: 'n1', type: 'text' }],
dataSources: [],
dataSourceDeps: { d1: {} },
codeBlocks: {},
});
await new Promise((r) => setTimeout(r, 10));
expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1');
expect(stage.runtime.updateRootConfig).toHaveBeenCalled();
expect(stage.select).toHaveBeenCalledWith('n1');
});
test('rootChange items 不是数组时清空依赖', async () => {
services.editorService.state.root = { id: 'r' };
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } };
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.clear).toHaveBeenCalled();
expect(value.dataSourceDeps).toBeUndefined();
expect(value.dataSourceCondDeps).toBeUndefined();
});
test('rootChange 没有可选中节点时回落到 root 自身', async () => {
services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', items: [] };
services.editorService.emit('root-change', value, { id: 'prev' });
await new Promise((r) => setTimeout(r, 0));
expect(services.editorService.set).toHaveBeenCalledWith('nodes', [value]);
expect(services.editorService.set).toHaveBeenCalledWith('parent', null);
expect(services.editorService.set).toHaveBeenCalledWith('page', null);
expect(emit).toHaveBeenCalledWith('update:modelValue', value);
});
test('rootChange 有 items 时选中第一个节点', async () => {
services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services));
services.editorService.emit('root-change', { id: 'r', items: [{ id: 'first', type: 'page' }] });
await new Promise((r) => setTimeout(r, 0));
expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' });
});
test('update 事件ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => {
services.editorService.state.root = { id: 'r', items: [] };
services.depService.getTargets.mockReturnValue({
t1: { deps: { n3: { keys: ['props.text'] } } },
});
mount(WrapEvents({} as any, emit, services));
services.editorService.emit('update', [
{ newNode: { id: 'r', type: 'root' }, oldNode: { id: 'r', type: 'root' } },
{
newNode: { id: 'n2', type: 'text' },
oldNode: { id: 'n2', type: 'text' },
changeRecords: [{ value: 'x' }],
},
{
newNode: { id: 'n3', type: 'text' },
oldNode: { id: 'n3', type: 'text' },
changeRecords: [{ propPath: 'props.text', value: 'x' }],
},
]);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.collectIdle).toHaveBeenCalled();
});
test('dataSource update 修改 mocks / methods 分别只重建对应 target', async () => {
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', items: [{ id: 'a', type: 'text' }] };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit(
'update',
{ id: 'd1', type: 'base', fields: [], mocks: [{ useInEditor: true, data: { a: 1 } }] },
{ changeRecords: [{ propPath: 'mocks' }] },
);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE);
services.depService.removeTarget.mockClear();
services.dataSourceService.emit(
'update',
{ id: 'd1', type: 'base', fields: [], methods: [] },
{ changeRecords: [{ propPath: 'methods.0.name' }] },
);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_METHOD);
});
test('dataSource update 无依赖变更时只刷新数据', async () => {
const dataSourceManager = mkDataSourceManager();
const app: any = { dsl: {}, dataSourceManager };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', items: [], dataSources: [{ id: 'd1' }] };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit(
'update',
{ id: 'd1', type: 'base', fields: [] },
{ changeRecords: [{ propPath: 'title' }, {}] },
);
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.get).toHaveBeenCalledWith('d1');
expect(services.depService.clearIdleTasks).not.toHaveBeenCalled();
});
test('dataSource remove 重新收集依赖并从 app 中移除数据源', async () => {
const dataSourceManager = mkDataSourceManager();
const app: any = { dsl: {}, dataSourceManager };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = {
id: 'r',
items: [],
dataSources: [{ id: 'd1' }],
dataSourceDeps: { d1: { n1: {} } },
};
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('remove', 'd1');
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.removeDataSource).toHaveBeenCalledWith('d1');
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE);
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_COND);
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_METHOD);
expect(app.dsl.dataSources).toEqual([{ id: 'd1' }]);
});
test('dataSource remove 收集未完成时不移除数据源', async () => {
const dataSourceManager = mkDataSourceManager();
const app: any = { dsl: {}, dataSourceManager };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', items: [] };
vi.mocked(getNodes).mockReturnValueOnce([{ id: 'n1', type: 'text' }] as any);
services.depService.collectIdle.mockResolvedValue(false);
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('remove', 'd1');
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.removeDataSource).not.toHaveBeenCalled();
});
});

View File

@ -16,11 +16,16 @@
* limitations under the License.
*/
import { describe, expect, test } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { computed } from 'vue';
import events from '@editor/services/events';
describe('events', () => {
beforeEach(() => {
events.resetState();
});
test('setEvent', () => {
const event = [{ label: '点击', value: 'magic:common:events:click' }];
events.setEvent('button', event);
@ -42,6 +47,37 @@ describe('events', () => {
expect(events.getEvent('text')).toHaveLength(1);
});
test('setEvents 增量合并,不动入参中不存在的类型', () => {
events.setEvents({
Image: [{ label: 'click', value: 'click' }],
Text: [{ label: 'init', value: 'init' }],
} as any);
events.setEvents({
Image: [{ label: 'hover', value: 'hover' }],
} as any);
expect(events.getEvent('image')).toEqual([{ label: 'hover', value: 'hover' }]);
expect(events.getEvent('text')).toEqual([{ label: 'init', value: 'init' }]);
});
test('setEvents 空对象是空操作', () => {
events.setEvent('button', [{ label: 'click', value: 'click' }]);
events.setEvents({});
expect(events.getEvent('button')).toEqual([{ label: 'click', value: 'click' }]);
});
test('setEvents 入参类型名按 toLine 归一后写入', () => {
events.setEvent('foo-bar', [{ label: 'a', value: 'a' }]);
events.setEvents({
fooBar: [{ label: 'b', value: 'b' }],
} as any);
expect(events.getEvent('foo-bar')).toEqual([{ label: 'b', value: 'b' }]);
});
test('setEvents 缺省列表按空数组写入', () => {
events.setEvents({ button: undefined } as any);
expect(events.getEvent('button')).toEqual([]);
});
test('setMethods 批量设置', () => {
events.setMethods({
Image: [{ label: 'show', value: 'show' }],
@ -49,6 +85,29 @@ describe('events', () => {
expect(events.getMethod('image', {})).toHaveLength(1);
});
test('setMethods 增量合并,不动入参中不存在的类型', () => {
events.setMethods({
Image: [{ label: 'show', value: 'show' }],
Video: [{ label: 'play', value: 'play' }],
} as any);
events.setMethods({
Video: [{ label: 'pause', value: 'pause' }],
} as any);
expect(events.getMethod('video', {})).toEqual([{ label: 'pause', value: 'pause' }]);
expect(events.getMethod('image', {})).toEqual([{ label: 'show', value: 'show' }]);
});
test('setMethods 缺省列表按空数组写入', () => {
events.setMethods({ video: undefined } as any);
expect(events.getMethod('video')).toEqual([]);
});
test('setMethods 空对象是空操作', () => {
events.setMethod('button', [{ label: 'open', value: 'open' }]);
events.setMethods({});
expect(events.getMethod('button')).toEqual([{ label: 'open', value: 'open' }]);
});
test('getEvent / getMethod 支持节点上下文参数', () => {
const node = { id: 'n1', type: 'button' } as any;
events.setEvent('button', [{ label: '点击', value: 'click' }]);
@ -64,7 +123,58 @@ describe('events', () => {
test('resetState 清空所有事件 / 方法', () => {
events.setEvent('foo', [{ label: 'l', value: 'v' }]);
events.setMethod('foo', [{ label: 'm', value: 'm' }]);
events.resetState();
expect(events.getEvent('foo')).toEqual([]);
expect(events.getMethod('foo')).toEqual([]);
});
test('resetState 原地清空后仍可继续写入', () => {
events.setEvent('foo', [{ label: 'l', value: 'v' }]);
events.resetState();
events.setEvent('bar', [{ label: 'click', value: 'click' }]);
events.setMethod('bar', [{ label: 'open', value: 'open' }]);
expect(events.getEvent('bar')).toEqual([{ label: 'click', value: 'click' }]);
expect(events.getMethod('bar')).toEqual([{ label: 'open', value: 'open' }]);
});
test('resetState 原地清空会使已有 computed 失效', () => {
events.setEvent('foo', [{ label: 'l', value: 'v' }]);
events.setMethod('foo', [{ label: 'm', value: 'm' }]);
const eventList = computed(() => events.getEvent('foo'));
const methodList = computed(() => events.getMethod('foo'));
expect(eventList.value).toHaveLength(1);
expect(methodList.value).toHaveLength(1);
events.resetState();
expect(eventList.value).toEqual([]);
expect(methodList.value).toEqual([]);
});
test('切换 runtime 版本:宿主先 resetState 再写入新版本事件表', () => {
events.setEvents({
button: [{ label: 'click', value: 'click' }],
video: [{ label: 'play', value: 'play' }],
} as any);
// 是否清掉上一个版本的数据由业务决定,需要清掉时自行调用 resetState
events.resetState();
events.setEvents({ button: [{ label: 'click', value: 'click' }] } as any);
expect(events.getEvent('video')).toEqual([]);
expect(events.getEvent('button')).toHaveLength(1);
});
test('usePlugin 钩子生效destroy 后重置状态并移除插件', () => {
const afterGetEvent = vi.fn((result: any[]) => [...result, { label: 'extra', value: 'extra' }]);
events.usePlugin({ afterGetEvent } as any);
events.setEvent('button', [{ label: 'click', value: 'click' }]);
expect(events.getEvent('button')).toHaveLength(2);
events.destroy();
expect(events.getEvent('button')).toEqual([]);
events.setEvent('button', [{ label: 'click', value: 'click' }]);
expect(events.getEvent('button')).toHaveLength(1);
expect(afterGetEvent).toHaveBeenCalled();
});
});