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 内容