diff --git a/common/changes/@visactor/vchart/fix-event-listener-remake-duplication_2026-07-28-02-02.json b/common/changes/@visactor/vchart/fix-event-listener-remake-duplication_2026-07-28-02-02.json new file mode 100644 index 0000000000..a4def3fa58 --- /dev/null +++ b/common/changes/@visactor/vchart/fix-event-listener-remake-duplication_2026-07-28-02-02.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: avoid duplicate event handlers after chart remake", + "type": "patch", + "packageName": "@visactor/vchart" + } + ], + "packageName": "@visactor/vchart", + "email": "lixuef1313@163.com" +} diff --git a/packages/vchart/__tests__/runtime/browser/event-update-spec.html b/packages/vchart/__tests__/runtime/browser/event-update-spec.html new file mode 100644 index 0000000000..fd3a21d8ff --- /dev/null +++ b/packages/vchart/__tests__/runtime/browser/event-update-spec.html @@ -0,0 +1,82 @@ + + + + + + VChart Event Update Spec Case + + + +
+

pointerdown 中 updateSpec 的事件注册用例

+

连续点击任意柱子。每次点击都会更新被点击系列的 zIndex;回调次数应当每次只增加 1。

+
+ pointerdown 回调次数:0 + 最近命中系列:- +
+
+
+ + 返回默认测试页 +
+
+ + + diff --git a/packages/vchart/__tests__/runtime/browser/index.html b/packages/vchart/__tests__/runtime/browser/index.html index e5c76efe01..60095d05ac 100644 --- a/packages/vchart/__tests__/runtime/browser/index.html +++ b/packages/vchart/__tests__/runtime/browser/index.html @@ -49,6 +49,7 @@
+ 打开事件更新用例
diff --git a/packages/vchart/__tests__/runtime/browser/test-page/event-update-spec.ts b/packages/vchart/__tests__/runtime/browser/test-page/event-update-spec.ts new file mode 100644 index 0000000000..128242b2da --- /dev/null +++ b/packages/vchart/__tests__/runtime/browser/test-page/event-update-spec.ts @@ -0,0 +1,102 @@ +import { default as VChart } from '../../../../src'; + +const CONTAINER_ID = 'chart'; +const eventCountElement = document.getElementById('eventCount') as HTMLElement; +const targetIndexElement = document.getElementById('targetIndex') as HTMLElement; + +const spec = { + type: 'common' as const, + data: [ + { + id: 'A', + values: [ + { x: 'Q1', y: 45 }, + { x: 'Q2', y: 35 }, + { x: 'Q3', y: 20 } + ] + }, + { + id: 'B', + values: [ + { x: 'Q1', y: 35 }, + { x: 'Q2', y: 40 }, + { x: 'Q3', y: 45 } + ] + }, + { + id: 'C', + values: [ + { x: 'Q1', y: 20 }, + { x: 'Q2', y: 25 }, + { x: 'Q3', y: 35 } + ] + } + ], + series: [ + { + type: 'bar' as const, + dataId: 'A', + xField: 'x', + yField: 'y', + stack: true, + bar: { style: { fill: '#22c55e' } } + }, + { + type: 'bar' as const, + dataId: 'B', + xField: 'x', + yField: 'y', + stack: true, + zIndex: 2, + bar: { + style: { + fill: '#eab308', + outerBorder: { stroke: '#3370ff', lineWidth: 3, distance: 2 } + } + } + }, + { + type: 'bar' as const, + dataId: 'C', + xField: 'x', + yField: 'y', + stack: true, + bar: { style: { fill: '#ef4444' } } + } + ], + axes: [{ orient: 'left' as const }, { orient: 'bottom' as const, type: 'band' as const }] +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID, animation: false }); +let eventCount = 0; + +vchart.renderSync(); + +vchart.on('pointerdown', { markName: 'bar' }, event => { + const targetIndex = event.model.getSpecIndex(); + + spec.series.forEach((series, index) => { + if (index === targetIndex) { + series.zIndex = 2; + series.bar.style.outerBorder = { stroke: '#3370ff', lineWidth: 3, distance: 2 }; + } else { + series.zIndex = 1; + series.bar.style.outerBorder = { + stroke: false + }; + } + }); + + eventCount += 1; + eventCountElement.textContent = `${eventCount}`; + targetIndexElement.textContent = `${targetIndex}`; + console.log('pointerdown target index:', targetIndex); + vchart.updateSpec(spec); +}); + +document.getElementById('reset')?.addEventListener('click', () => { + window.location.reload(); +}); + +// 仅用于在浏览器控制台检查实例,不是公共 API 使用示例。 +window['vchart'] = vchart; diff --git a/packages/vchart/__tests__/unit/chart/bar.test.ts b/packages/vchart/__tests__/unit/chart/bar.test.ts index 697dbb5400..f3d0043a94 100644 --- a/packages/vchart/__tests__/unit/chart/bar.test.ts +++ b/packages/vchart/__tests__/unit/chart/bar.test.ts @@ -103,7 +103,7 @@ describe('Bar chart test', () => { { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - eventDispatcher: new EventDispatcher({} as never, { addEventListener: () => {} } as never), + eventDispatcher: new EventDispatcher({} as never, getTestCompiler()), globalInstance: { isAnimationEnable: () => true, getContainer: () => ({}), @@ -137,7 +137,7 @@ describe('Bar chart test', () => { { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - eventDispatcher: new EventDispatcher({} as never, { addEventListener: () => {} } as never), + eventDispatcher: new EventDispatcher({} as never, getTestCompiler()), globalInstance: { isAnimationEnable: () => true, getContainer: () => ({}), diff --git a/packages/vchart/__tests__/unit/core/vchart-event.test.ts b/packages/vchart/__tests__/unit/core/vchart-event.test.ts index c0566edb0e..ff055194e1 100644 --- a/packages/vchart/__tests__/unit/core/vchart-event.test.ts +++ b/packages/vchart/__tests__/unit/core/vchart-event.test.ts @@ -243,6 +243,137 @@ describe('vchart event test', () => { expect(pointDowmSpy).toBeCalledTimes(2); }); + it('should not duplicate a user event when its handler remakes the chart', async () => { + const eventContainer = createDiv(); + const eventDom = createDiv(eventContainer); + const spec: ICommonChartSpec = { + type: 'common', + data: [ + { + id: 'barData', + values: [ + { x: 'A', y: 10 }, + { x: 'B', y: 20 } + ] + } + ], + series: [ + { + type: 'bar', + dataId: 'barData', + xField: 'x', + yField: 'y' + } + ], + axes: [{ orient: 'left' }, { orient: 'bottom', type: 'band' }] + }; + const chart = new VChart(spec, { dom: eventDom, animation: false }); + const updatePromises: Promise[] = []; + const pointerdownSpy = jest.fn(() => { + spec.series[0].zIndex = 1; + updatePromises.push(chart.updateSpec(spec)); + }); + const stage = chart.getStage(); + const emitPointerdown = () => { + const listeners = (stage as unknown as { _events?: { pointerdown?: StageEventListener | StageEventListener[] } }) + ._events?.pointerdown; + const event = { + type: 'pointerdown', + target: stage, + defaultPrevented: false, + stopPropagation: jest.fn(), + preventDefault: jest.fn() + }; + (listeners ? ('fn' in listeners ? [listeners] : listeners) : []).forEach((listener: StageEventListener) => { + listener.fn.call(listener.context, event); + }); + }; + + try { + chart.renderSync(); + chart.on('pointerdown', pointerdownSpy); + + emitPointerdown(); + await Promise.all(updatePromises.splice(0)); + emitPointerdown(); + await Promise.all(updatePromises.splice(0)); + + expect(pointerdownSpy).toBeCalledTimes(2); + } finally { + chart.release(); + removeDom(eventContainer); + } + }); + + it('should release chart-owned interaction event handlers before remake', () => { + const eventContainer = createDiv(); + const eventDom = createDiv(eventContainer); + const spec: ICommonChartSpec = { + type: 'common', + data: [ + { + id: 'barData', + values: [ + { x: 'A', y: 10 }, + { x: 'B', y: 20 } + ] + } + ], + series: [ + { + type: 'bar', + dataId: 'barData', + xField: 'x', + yField: 'y', + hover: false, + select: false, + bar: { + state: { + active: { + fillOpacity: 0.5 + } + } + }, + interactions: [ + { + type: 'element-active', + trigger: 'pointerover', + triggerOff: 'none' + } + ] + } + ], + axes: [{ orient: 'left' }, { orient: 'bottom', type: 'band' }] + }; + const chart = new VChart(spec, { dom: eventDom, animation: false }); + const getPointeroverHandlerCount = () => { + const eventDispatcher = chart as unknown as { + _eventDispatcher: { _viewBubbles: Map number }> }; + }; + + return eventDispatcher._eventDispatcher._viewBubbles.get('pointerover')?.getCount(); + }; + + try { + chart.renderSync(); + expect(getPointeroverHandlerCount()).toBe(1); + + spec.series[0].zIndex = 1; + chart.updateSpecSync(spec); + + expect(getPointeroverHandlerCount()).toBe(1); + const barSeries = (chart.getChart() as IChart).getAllSeries()[0]; + const barMark = barSeries.getMarks().find(mark => mark.name === 'bar') as IMark; + const barGraphic = barMark.getGraphics()[0] as IMarkGraphic; + + (chart.getChart() as IChart).getEvent().emit('pointerover', { item: barGraphic } as unknown as BaseEventParams); + expect(barGraphic.hasState('active')).toBe(true); + } finally { + chart.release(); + removeDom(eventContainer); + } + }); + it('should keep tooltip and crosshair triggerable after line mark and marker update without remake', () => { const lineContainer = createDiv(); const lineDom = createDiv(lineContainer); diff --git a/packages/vchart/__tests__/util/factory/compiler.ts b/packages/vchart/__tests__/util/factory/compiler.ts index b0318bfa9a..9f547b13d1 100644 --- a/packages/vchart/__tests__/util/factory/compiler.ts +++ b/packages/vchart/__tests__/util/factory/compiler.ts @@ -9,6 +9,8 @@ export const getTestCompiler = () => updateLayoutTag: () => {}, getStage: getTestStage, addRootMark: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, renderNextTick: () => {}, addGrammarItem: () => {} } as any); diff --git a/packages/vchart/src/chart/base/base-chart.ts b/packages/vchart/src/chart/base/base-chart.ts index 8259092d13..862951b6cb 100644 --- a/packages/vchart/src/chart/base/base-chart.ts +++ b/packages/vchart/src/chart/base/base-chart.ts @@ -1571,6 +1571,7 @@ export class BaseChart extends CompilableBase implements I } /* 开始 release */ + this._event.release(); super.release(); // clear event , temporary function of chart items this.clear(); diff --git a/packages/vchart/src/core/vchart.ts b/packages/vchart/src/core/vchart.ts index 8adb7c4cea..b36e48fea7 100644 --- a/packages/vchart/src/core/vchart.ts +++ b/packages/vchart/src/core/vchart.ts @@ -693,8 +693,6 @@ export class VChart implements IVChart { } protected _reCompile(updateResult: IUpdateSpecResult, morphConfig?: IMorphConfig) { - const shouldRestoreUserEvents = updateResult.reMake && !!this._chart; - if (updateResult.reMake) { this._releaseData(); this._initDataSet(); @@ -719,12 +717,6 @@ export class VChart implements IVChart { const cacheGrammarForMorph = this.isAnimationEnable() && morphConfig?.morph !== false; // morph 需要保留上一轮 simple mark product,后续 compiler.compile 会统一 diff 并释放未使用的旧 product。 this._compiler?.releaseGrammar(!cacheGrammarForMorph); - // chart 内部事件 模块自己必须删除 - // 内部模块删除事件时,调用了event Dispatcher.release() 导致用户事件被一起删除 - // 外部事件现在需要重新添加 - if (shouldRestoreUserEvents) { - this._userEvents.forEach(e => this._event?.on(e.eType as any, e.query as any, e.handler as any)); - } } else if (updateResult.reCompile) { // recompile // 清除之前的所有 compile 内容