fix(runtime): 修复 useDsl 换页后更新失效与重复替换

- 事件处理改为读取最新页面配置(ref),换页后不再更新到旧页面
- react 版事件监听移入 useEffect 并依赖 app,避免监听泄漏
- 按节点对象去重并批量 replaceChildNodes,同一次批量更新的多份相同节点只替换一次
- 忽略非当前页面的 update-data
This commit is contained in:
roymondchen 2026-10-30 16:49:34 +08:00
parent 35ed2cc0d0
commit 0c1d103cd0
4 changed files with 604 additions and 31 deletions

View File

@ -1,45 +1,95 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { cloneDeep } from 'lodash-es';
import type TMagicApp from '@tmagic/core';
import type { ChangeEvent, MNode } from '@tmagic/core';
import { isPage, replaceChildNode } from '@tmagic/core';
import type { ChangeEvent, Id, MNode } from '@tmagic/core';
import { isPage, replaceChildNodes } from '@tmagic/core';
export const useDsl = (app: TMagicApp | undefined) => {
const [pageConfig, setPageConfig] = useState(app?.page?.data);
app?.on('page-change', () => {
setPageConfig(app.page?.data);
});
// 始终指向最新的页面配置,事件处理函数只注册一次,不能直接使用闭包里的 pageConfig(换页后会是旧页面)
const pageConfigRef = useRef(pageConfig);
pageConfigRef.current = pageConfig;
const updateDataHandler = (nodes: MNode[], sourceId: string, event: ChangeEvent) => {
let config = pageConfig;
nodes.forEach((node) => {
if (isPage(node)) {
config = node;
} else {
config && replaceChildNode(node, [config]);
}
});
setPageConfig(cloneDeep(config));
setTimeout(() => {
app?.emit('replaced-node', {
...event,
nodes,
sourceId,
});
}, 0);
};
// 同一次 batch 的多个 update-data 携带的是同一份已编译节点,重复替换内容相同却要再付一次全树遍历和整页 cloneDeep,
// 因此按节点对象去重:同一个节点对象只替换一次(非 batch 场景下每次编译都是新对象,不受影响)
const appliedNodesRef = useRef<WeakSet<MNode> | null>(null);
if (!appliedNodesRef.current) {
appliedNodesRef.current = new WeakSet();
}
useEffect(() => {
app?.dataSourceManager?.on('update-data', updateDataHandler);
if (!app) {
return;
}
const pageChangeHandler = () => {
appliedNodesRef.current = new WeakSet();
pageConfigRef.current = app.page?.data;
setPageConfig(app.page?.data);
};
app.on('page-change', pageChangeHandler);
return () => {
app?.dataSourceManager?.off('update-data', updateDataHandler);
app.off('page-change', pageChangeHandler);
};
}, []);
}, [app]);
useEffect(() => {
const dataSourceManager = app?.dataSourceManager;
if (!app || !dataSourceManager) {
return;
}
const updateDataHandler = (nodes: MNode[], sourceId: string, event: ChangeEvent, pageId?: Id) => {
// 不是当前页面的更新(如页面片、其他页面)不处理
if (!nodes.length || (pageId !== undefined && app.page?.data.id !== pageId)) {
return;
}
const appliedNodes = appliedNodesRef.current!;
let config = pageConfigRef.current;
let pending: MNode[] = [];
nodes.forEach((node) => {
if (isPage(node)) {
config && replaceChildNodes(pending, [config]);
pending = [];
config = node;
appliedNodesRef.current = new WeakSet();
} else if (!appliedNodes.has(node)) {
pending.push(node);
}
});
if (pending.length) {
for (const node of pending) {
appliedNodes.add(node);
}
config && replaceChildNodes(pending, [config]);
const newConfig = cloneDeep(config);
pageConfigRef.current = newConfig;
setPageConfig(newConfig);
}
setTimeout(() => {
app.emit('replaced-node', {
...event,
nodes,
sourceId,
});
}, 0);
};
dataSourceManager.on('update-data', updateDataHandler);
return () => {
dataSourceManager.off('update-data', updateDataHandler);
};
}, [app]);
return { pageConfig };
};

View File

@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import TMagicApp, { NodeType } from '@tmagic/core';
import { useDsl } from '../src/hooks/use-dsl';
/**
* 最小化的 react hooks 运行时(react-dom 没有安装在该包中):
* 支持 useState / useRef / useEffect(含 deps 与 cleanup),渲染由测试手动触发
*/
const runtime = vi.hoisted(() => {
interface Instance {
hooks: any[];
index: number;
effects: { deps?: any[]; cleanup?: () => void; fn: () => void | (() => void) }[];
pending: (() => void)[];
}
const state: { current: Instance | null } = { current: null };
return { state };
});
vi.mock('react', () => ({
useState: (init: any) => {
const inst = runtime.state.current!;
const i = inst.index;
inst.index += 1;
if (!(i in inst.hooks)) inst.hooks[i] = init;
return [inst.hooks[i], (v: any) => (inst.hooks[i] = v)];
},
useRef: (init: any) => {
const inst = runtime.state.current!;
const i = inst.index;
inst.index += 1;
if (!(i in inst.hooks)) inst.hooks[i] = { current: init };
return inst.hooks[i];
},
useEffect: (fn: () => void | (() => void), deps?: any[]) => {
const inst = runtime.state.current!;
const i = inst.index;
inst.index += 1;
const prev = inst.effects[i];
const changed = !prev || !deps || !prev.deps || deps.some((d, k) => d !== prev.deps![k]);
if (changed) {
inst.pending.push(() => {
prev?.cleanup?.();
const cleanup = fn();
inst.effects[i] = { deps, fn, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
});
} else {
inst.effects[i] = prev;
}
},
}));
const mountHook = (app: TMagicApp | undefined) => {
const inst = { hooks: [] as any[], index: 0, effects: [] as any[], pending: [] as (() => void)[] };
let result: ReturnType<typeof useDsl>;
const render = () => {
inst.index = 0;
runtime.state.current = inst;
result = useDsl(app);
runtime.state.current = null;
inst.pending.splice(0).forEach((run) => run());
return result;
};
const unmount = () => inst.effects.forEach((e) => e?.cleanup?.());
return {
render,
unmount,
get result() {
return result;
},
};
};
const createApp = () =>
new TMagicApp({
platform: 'mobile',
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'n1', type: 'text', text: 'p1 ${ds.name}' }] },
{ type: NodeType.PAGE, id: 'p2', items: [{ id: 'n2', type: 'text', text: 'p2 ${ds.name}' }] },
],
dataSourceDeps: { ds: { n1: { name: 'n1', keys: ['text'] }, n2: { name: 'n2', keys: ['text'] } } },
dataSources: [{ id: 'ds', type: 'base', fields: [{ name: 'name', defaultValue: 'x' }], methods: [], events: [] }],
} as any,
});
describe('react useDsl', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('多次重渲染只保留一个 page-change / update-data 监听,卸载后清理', () => {
const app = createApp();
const hook = mountHook(app);
for (let i = 0; i < 5; i++) hook.render();
expect(app.listenerCount('page-change')).toBe(1);
expect(app.dataSourceManager!.listenerCount('update-data')).toBe(1);
hook.unmount();
expect(app.listenerCount('page-change')).toBe(0);
expect(app.dataSourceManager!.listenerCount('update-data')).toBe(0);
});
test('换页后数据源更新,处理的是当前页面而不是首次渲染时的页面', () => {
const app = createApp();
const hook = mountHook(app);
hook.render();
expect(hook.result.pageConfig?.id).toBe('p1');
app.setPage('p2');
expect(hook.render().pageConfig?.id).toBe('p2');
app.dataSourceManager!.get('ds')!.setData('NEW', 'name');
const { pageConfig } = hook.render();
expect(pageConfig?.id).toBe('p2');
expect((pageConfig as any).items[0].text).toBe('p2 NEW');
});
test('连续多次更新都基于最新配置', () => {
const app = createApp();
const hook = mountHook(app);
hook.render();
const ds = app.dataSourceManager!.get('ds')!;
ds.setData('A', 'name');
ds.setData('B', 'name');
expect((hook.render().pageConfig as any).items[0].text).toBe('p1 B');
});
test('非当前页面(页面片等)的 update-data 被忽略', () => {
const app = createApp();
const hook = mountHook(app);
const before = hook.render().pageConfig;
const replaced = vi.fn();
app.on('replaced-node', replaced);
app.dataSourceManager!.emit(
'update-data',
[{ id: 'n1', type: 'text', text: 'OTHER' }],
'ds',
{ updateData: 1 },
'p2',
);
vi.runAllTimers();
expect(hook.render().pageConfig).toBe(before);
expect(replaced).not.toHaveBeenCalled();
});
test('更新后异步发出 replaced-node', () => {
const app = createApp();
const hook = mountHook(app);
hook.render();
const replaced = vi.fn();
app.on('replaced-node', replaced);
app.dataSourceManager!.get('ds')!.setData('Z', 'name');
expect(replaced).not.toHaveBeenCalled();
vi.runAllTimers();
expect(replaced).toHaveBeenCalledTimes(1);
expect(replaced.mock.calls[0][0].sourceId).toBe('ds');
expect(replaced.mock.calls[0][0].nodes[0].id).toBe('n1');
});
test('同一份已编译节点的多次 update-data 只替换一次,但 replaced-node 每次都发', () => {
const app = createApp();
const hook = mountHook(app);
hook.render();
const replaced = vi.fn();
app.on('replaced-node', replaced);
const node: any = { id: 'n1', type: 'text', text: 'NEW' };
app.dataSourceManager!.emit('update-data', [node], 'ds', { updateData: 'a' }, 'p1');
const afterFirst = hook.render().pageConfig;
app.dataSourceManager!.emit('update-data', [node], 'ds', { updateData: 'b' }, 'p1');
const afterSecond = hook.render().pageConfig;
// 第二个事件没有新节点需要替换,不再 cloneDeep / setPageConfig
expect(afterSecond).toBe(afterFirst);
expect((afterSecond as any).items[0].text).toBe('NEW');
vi.runAllTimers();
expect(replaced).toHaveBeenCalledTimes(2);
});
test('app 或 dataSourceManager 不存在时不报错', () => {
expect(() => mountHook(undefined).render()).not.toThrow();
const app = new TMagicApp({});
const hook = mountHook(app);
expect(() => hook.render()).not.toThrow();
expect(app.listenerCount('page-change')).toBe(1);
hook.unmount();
});
});

View File

@ -2,7 +2,7 @@ import { inject, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
import type TMagicApp from '@tmagic/core';
import type { ChangeEvent, Id, MNode } from '@tmagic/core';
import { isPage, isPageFragment, replaceChildNode } from '@tmagic/core';
import { isPage, isPageFragment, replaceChildNodes } from '@tmagic/core';
export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?: Id) => {
if (!app) {
@ -11,10 +11,29 @@ export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?:
const pageFragment = pageFragmentContainerId ? app.pageFragments.get(pageFragmentContainerId) : null;
// 同一次 batch 的多个 update-data 携带的是同一份已编译节点,重复替换内容相同却要再付一次全树遍历,
// 因此按节点对象去重:同一个节点对象只替换一次(非 batch 场景下每次编译都是新对象,不受影响)
let appliedNodes: WeakSet<MNode> = new WeakSet();
const applyNodes = (nodes: MNode[], config: MNode) => {
const pending = nodes.filter((node) => !appliedNodes.has(node));
if (!pending.length) {
return;
}
for (const node of pending) {
appliedNodes.add(node);
}
replaceChildNodes(pending, [config]);
};
const pageConfig = ref<MNode | undefined>(pageFragmentContainerId ? pageFragment?.data : app.page?.data);
if (pageFragmentContainerId) {
const setPageConfig = () => {
appliedNodes = new WeakSet();
pageConfig.value = pageFragment?.data;
};
app.on('dsl-change', setPageConfig);
@ -24,6 +43,7 @@ export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?:
});
} else {
const setPageConfig = () => {
appliedNodes = new WeakSet();
pageConfig.value = app.page?.data;
};
app.on('page-change', setPageConfig);
@ -42,16 +62,25 @@ export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?:
return;
}
// 批量替换,避免每个节点都做一次全树搜索
let pending: MNode[] = [];
for (const node of nodes) {
if (
(isPage(node) && !pageFragmentContainerId && node.id === pageId) ||
(isPageFragment(node) && pageFragmentContainerId)
) {
applyNodes(pending, pageConfig.value as MNode);
pending = [];
pageConfig.value = node;
appliedNodes = new WeakSet();
} else {
replaceChildNode(reactive(node), [pageConfig.value as MNode]);
const newNode = reactive(node) as MNode;
if (!appliedNodes.has(newNode)) {
pending.push(newNode);
}
}
}
applyNodes(pending, pageConfig.value as MNode);
nextTick(() => {
app.emit('replaced-node', { nodes, sourceId, ...changeEvent });

View File

@ -0,0 +1,286 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick, provide } from 'vue';
import { mount } from '@vue/test-utils';
import TMagicApp, { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, NodeType } from '@tmagic/core';
import IteratorContainer from '../../../vue-components/iterator-container/src/IteratorContainer.vue';
import { useDsl } from '../src';
/** 叶子组件:接收绑定到数据源的“兄弟字段” matrix 作为 prop,用于验证子孙组件的更新 */
const cellRenders = { count: 0 };
const cellComp = defineComponent({
props: { matrix: { type: Array, default: () => [] } },
setup(props) {
return () => {
cellRenders.count += 1;
return h('i', { class: 'cell' }, String((props.matrix[0] as any)?.v));
};
},
});
const textComp = defineComponent({
props: { config: { type: Object, required: true } },
setup(props) {
return () =>
h('span', { class: 'txt' }, [
(props.config as any).text,
(props.config as any).cell ? h(cellComp, { matrix: (props.config as any).cell.matrix }) : null,
]);
},
});
const createApp = () => {
const app = new TMagicApp({
platform: 'mobile',
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'page',
items: [
{
type: 'iterator-container',
id: 'iter',
dsField: ['ds', 'list'],
iteratorData: [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds`, 'list'],
itemConfig: { style: {} },
items: [
{
type: 'text',
id: 'txt',
text: '${ds.list.label}',
cell: { isBindDataSource: true, dataSourceId: 'ds' },
events: [{ name: 'click', actions: [] }],
},
],
},
{ type: 'text', id: 'plain', text: 'total=${ds.total}' },
],
},
],
dataSourceDeps: {
ds: { iter: { name: 'iter', keys: ['iteratorData'] }, plain: { name: 'plain', keys: ['text'] } },
},
dataSources: [
{
id: 'ds',
type: 'base',
fields: [
{
name: 'list',
type: 'array',
fields: [{ name: 'label' }],
defaultValue: [{ label: 'A' }, { label: 'B' }],
},
{ name: 'matrix', type: 'array', fields: [{ name: 'v' }], defaultValue: [{ v: 1 }] },
{ name: 'total', defaultValue: 2 },
],
methods: [],
events: [],
},
],
} as any,
});
app.registerComponent('iterator-container', IteratorContainer);
app.registerComponent('text', textComp);
const renderNodes = (items: any[]) =>
(items || []).map((item) => h(app.resolveComponent(item.type), { key: item.id, config: item }));
app.registerComponent(
'container',
defineComponent({
props: { config: { type: Object, required: true } },
setup(props) {
return () => h('div', renderNodes((props.config as any).items));
},
}),
);
app.registerComponent(
'page',
defineComponent({
setup() {
const { pageConfig } = useDsl(app);
// 与真实运行时一致:页面组件创建后会给 page 设置 instance
app.page!.emit('created', { config: app.page!.data });
return () => h('div', { class: 'page' }, renderNodes(pageConfig.value?.items as any[]));
},
}),
);
return app;
};
const mountApp = (app: TMagicApp) => {
const root = defineComponent({
setup() {
provide('app', app);
return () => h(app.resolveComponent('page'));
},
});
return mount(root, { global: { provide: { app } } });
};
const texts = (wrapper: ReturnType<typeof mountApp>) =>
wrapper.findAll('.txt').map((el) => el.element.firstChild?.textContent);
describe('真实 IteratorContainer + useDsl + createDataSourceManager', () => {
afterEach(() => {
cellRenders.count = 0;
});
test('初始渲染', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
expect(texts(wrapper)).toEqual(['A', 'B', 'total=2']);
wrapper.unmount();
});
test('整体替换列表(含追加一项)后视图更新', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
app.dataSourceManager!.get('ds')!.setData([{ label: 'A' }, { label: 'B' }, { label: 'C' }], 'list');
await nextTick();
await nextTick();
expect(texts(wrapper)).toEqual(['A', 'B', 'C', 'total=2']);
wrapper.unmount();
});
test('按 path 原地修改 matrix.0.v:迭代项里的子孙组件会更新', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
expect(wrapper.findAll('.cell').map((c) => c.text())).toEqual(['1', '1']);
// 触发迭代容器重新编译(列表整体替换),同时 matrix 原地修改
app.dataSourceManager!.get('ds')!.setData(9, 'matrix.0.v');
app.dataSourceManager!.get('ds')!.setData([{ label: 'A' }, { label: 'B' }], 'list');
await nextTick();
await nextTick();
expect(wrapper.findAll('.cell').map((c) => c.text())).toEqual(['9', '9']);
wrapper.unmount();
});
test('batch:多次 setData 合并成一次 update-data,视图为最终值', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
const update = vi.fn();
app.dataSourceManager!.on('update-data', update);
app.dataSourceManager!.batch(
() => {
const ds = app.dataSourceManager!.get('ds')!;
ds.setData(3, 'total');
ds.setData(4, 'total');
ds.setData([{ label: 'X' }], 'list');
},
{ mergeChangeEvents: true },
);
await nextTick();
await nextTick();
expect(update).toHaveBeenCalledTimes(1);
expect(texts(wrapper)).toEqual(['X', 'total=4']);
wrapper.unmount();
});
test('batch 会让 replaced-node 只触发一次并只携带最后一次变更', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
const replaced = vi.fn();
app.on('replaced-node', replaced);
const ds = app.dataSourceManager!.get('ds')!;
ds.setData(1, 'total');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(replaced).toHaveBeenCalledTimes(1);
replaced.mockClear();
app.dataSourceManager!.batch(
() => {
ds.setData(2, 'total');
ds.setData([{ label: 'Y' }], 'list');
},
{ mergeChangeEvents: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
// 合并后只发一次,changeEvent 取该数据源最后一次变更
expect(replaced).toHaveBeenCalledTimes(1);
expect(replaced.mock.calls[0][0].path).toBe('list');
expect(texts(wrapper)).toEqual(['Y', 'total=2']);
wrapper.unmount();
});
test('默认(不传选项)replaced-node 每次变化各触发一次,不丢中间变更', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
const replaced = vi.fn();
app.on('replaced-node', replaced);
const ds = app.dataSourceManager!.get('ds')!;
app.dataSourceManager!.batch(() => {
ds.setData(2, 'total');
ds.setData([{ label: 'Z' }], 'list');
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(replaced).toHaveBeenCalledTimes(2);
expect(replaced.mock.calls.map((call) => call[0].path)).toEqual(['total', 'list']);
expect(texts(wrapper)).toEqual(['Z', 'total=2']);
wrapper.unmount();
});
test('mergeChangeEvents: false 时 replaced-node 每次变化各触发一次,不丢中间变更', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
const replaced = vi.fn();
app.on('replaced-node', replaced);
const ds = app.dataSourceManager!.get('ds')!;
app.dataSourceManager!.batch(
() => {
ds.setData(2, 'total');
ds.setData([{ label: 'Z' }], 'list');
},
{ mergeChangeEvents: false },
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(replaced).toHaveBeenCalledTimes(2);
expect(replaced.mock.calls.map((call) => call[0].path)).toEqual(['total', 'list']);
expect(replaced.mock.calls.map((call) => call[0].updateData)).toEqual([2, [{ label: 'Z' }]]);
expect(texts(wrapper)).toEqual(['Z', 'total=2']);
wrapper.unmount();
});
test('反复更新列表后,EventHelper 上的节点事件监听数量不增长', async () => {
const app = createApp();
const wrapper = mountApp(app);
await nextTick();
const helper: any = app.eventHelper;
const ds = app.dataSourceManager!.get('ds')!;
ds.setData([{ label: 'A' }, { label: 'B' }], 'list');
await nextTick();
await nextTick();
const baseline = helper.nodeEventList.size;
for (let i = 0; i < 20; i++) {
ds.setData([{ label: `A${i}` }, { label: `B${i}` }], 'list');
await nextTick();
await nextTick();
}
console.log(`[e2e-leak] 基线 nodeEventList=${baseline}, 更新20次后=${helper.nodeEventList.size}`);
expect(helper.nodeEventList.size).toBe(baseline);
wrapper.unmount();
});
});