You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Vue3+Pixi.js:类属性/重写父类的DisplayObject事件监听失效

Vue3 + Pixi.js + TypeScript OOP开发中的两类问题

我在Vue3中基于Pixi.js开发项目,因为多数图形模式相似但行为、属性不同,采用TypeScript面向对象(OOP)方式避免代码重复,却遇到以下两个核心问题:

  • 当DisplayObject作为类属性创建或从父类重写时,事件监听器无法正常工作,触发错误cannot find propagation path to disconnected target;
  • 修改DisplayObject的属性后,画面不会触发重渲染。

相关代码实现

主组件(Vue)

<script setup lang="ts">
const main = ref<HTMLElement | null>(null)
  
let visualisationEngine: VisualisationEngine

onMounted(() => {
  if (main.value) {
    visualisationEngine = new VisualisationEngine(main.value!!, window)
    visualisationEngine.init()
  }
})
</script>

VisualisationEngine.ts

import { Application, Graphics } from 'pixi.js'
import '@pixi/unsafe-eval' // Electron环境下使用

import { onEscKeyDown, onPointerDown, onPointerMove, onPointerUp } from '@/visualisation_engine/eventHandlers'
import { Viewport } from 'pixi-viewport'

interface GlobalThis {
  __PIXI_APP__: Application<HTMLCanvasElement>
}

export class VisualisationEngine {
  app: Application<HTMLCanvasElement>
  elem: HTMLElement
  window: Window
  viewport: Viewport

  constructor(elem: HTMLElement, window: Window) {
    this.window = window

    this.app = new Application<HTMLCanvasElement>({
      antialias: true,
      autoDensity: true,
      resizeTo: elem,
      hello: true
    })

    this.viewport = new Viewport({
      // 省略配置
    })

    this.elem = elem

    ;(globalThis as any as GlobalThis).__PIXI_APP__ = this.app // 用于Pixi调试工具
  }

  init() {
    this.render()
    const background = this.drawBackground() // 临时用局部变量,类属性绑定事件会失效
    this.startEventHandlers(background)
  }

  render() {
    this.elem.appendChild(this.app.view)
    this.app.stage.addChild(this.viewport)
  }

  drawBackground() {
    const background = new Graphics()
    background.beginFill(0x000000)
    background.drawRect(0, 0, this.elem.clientWidth, this.elem.clientHeight)
    background.endFill()
    background.eventMode = 'static'

    this.viewport.addChild(background)
    return background
  }

  startEventHandlers(graphic: Graphics) {
    document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape') {
        onEscKeyDown()
      }
    })

    graphic
      .on('pointerdown', (event) => {
        onPointerDown(event, (elem) => this.viewport.addChild(elem))
      })
      .on('pointermove', (event) => {
        onPointerMove(event, (elem) => this.viewport.addChild(elem))
      })
      .on('pointerup', (event) => onPointerUp(event))
  }
}

eventHandlers.ts

export const onPointerDown = (event: FederatedPointerEvent, callback: (elem: Graphics) => void) => {
  const mainStore = useMainStore()

  if (mainStore.elementToAdd !== null) {
    mainStore.elementToAdd.position.set(Math.round(event.globalX), Math.round(event.globalY))

    const elem = mainStore.elementToAdd.draw() // 临时返回Graphics,直接返回Container会触发事件错误
    if (callback) {
      callback(elem)
    }
    mainStore.onScreenElements.push(mainStore.elementToAdd)
    mainStore.elementToAdd = null

    return
  }

  // 省略其他逻辑
  mainStore.currentlySelectedElement = null
}

export const onPointerUp = (event: FederatedPointerEvent) => {
  const mainStore = useMainStore()
  if (useMainStore().draggingElement !== null) {
    useMainStore().draggingElement = null
  }
}

export const onPointerMove = (event: FederatedPointerEvent, callback: (elem: Graphics) => void) => {
  if (useMainStore().draggingElement !== null) {
    useMainStore().draggingElement!!.move(event.globalX, event.globalY) // 位置已变更,但画面无更新
    console.log(useMainStore().draggingElement!!.position) // 控制台显示坐标正确
    // 调用element.parent会返回null,对象已脱离渲染树
  }
}

BaseElement.ts

import { defineStore } from 'pinia'
import { Container, Graphics } from 'pixi.js'
import { useMainStore } from '@/store/main'
import { ref } from 'vue'

export abstract class BaseElement extends Container {
  useStore
  nodes: number[]
  abstract readonly type: string

  id: number = Math.floor(Math.random() * 1000000)
  readonly store = defineStore(`${this.constructor.name}-${this.id}`, () => {
    const name = ref(this.name)
    const value = ref<{ [key: string]: { [key: string]: string } }>({})

    return { name, value }
  })

  protected constructor(x: number = 0, y: number = 0, name: string, nodes: number[]) {
    super()
    this.nodes = nodes
    this.x = x
    this.y = y
    this.useStore = this.store()

    this.useStore.$patch({
      name: name
    })
  }

  abstract draw(): Graphics

  initializeEventListeners(graphic: Graphics) {
    graphic.hitArea = graphic.getBounds()
    graphic.eventMode = 'static'

    graphic.on(
      'pointerdown',
      () => {
        if (useMainStore().currentAction === 'move') {
          useMainStore().draggingElement = this
        } else {
          useMainStore().currentlySelectedElement = this
        }
      },
      this
    )

    graphic.on('pointerup', () => {
      console.log('pointer up')
    })
  }

  move(x: number, y: number) {
    this.x = x
    this.y = y
  }
}

Resistor.ts(示例元素)

import { BaseElement } from './BaseElement'
import { Graphics, Point } from 'pixi.js'

export class Resistor extends BaseElement {
  override type = 'Resistor'

  constructor(x: number, y: number, nodes: [number, number], name: string = 'R', resistance: number) {
    super(x, y, name, nodes)
  }

  override draw() {
    // 原本计划将图形作为父类属性,但会导致事件完全失效
    const resistorGraphic = new Graphics()
    const body = new Graphics()

    resistorGraphic
      .lineStyle(DIMENSIONS.CONDUCTOR_WIDTH, COLORS.CONDUCTOR)
      .moveTo(this.x, this.y)
      // 省略绘制逻辑

    body.addChild(resistorGraphic)
    this.addConductor().forEach((conductor) => {
      body.addChild(conductor)
    })

    this.initializeEventListeners(resistorGraphic)
    return body
  }
}

元素添加逻辑(Pinia)

// 用户选择元素后执行
mainStore.setElementToAdd(new Resistor(...))

// Store中的方法
setElementToAdd(element: BaseElement) {
  // 省略状态更新逻辑
  state.elementToAdd = element
}

问题修复方案

问题1:事件监听器失效与传播路径错误

核心原因

  • DisplayObject未正确加入PIXI渲染树,事件系统无法找到传播路径;
  • BaseElement作为Container,未将draw()创建的Graphics添加到自身,导致图形与容器脱节;
  • 事件绑定时机过早,对象未挂载到viewport就绑定事件。

修复步骤

  1. 让BaseElement容器包含自身图形
    修改BaseElement.ts,将图形作为类属性存储并添加到容器:

    export abstract class BaseElement extends Container {
      protected graphic: Graphics | null = null;
      // 省略其他属性
    
      abstract draw(): void; // 改为无返回值,直接将图形添加到自身容器
    
      initializeEventListeners() {
        if (this.graphic) {
          this.graphic.eventMode = 'static';
          // 延迟设置hitArea,确保图形已渲染
          requestAnimationFrame(() => {
            this.graphic!.hitArea = this.graphic!.getBounds();
          });
          this.graphic.on('pointerdown', () => {
            const mainStore = useMainStore();
            if (mainStore.currentAction === 'move') {
              mainStore.draggingElement = this;
            } else {
              mainStore.currentlySelectedElement = this;
            }
          }, this);
        }
        // 给容器本身设置事件模式,确保事件能正确传播
        this.eventMode = 'static';
      }
    }
    

    修改Resistor.ts的draw()方法:

    override draw() {
      const resistorGraphic = new Graphics();
      const body = new Graphics();
      // 省略绘制逻辑
    
      body.addChild(resistorGraphic);
      this.addConductor().forEach(conductor => body.addChild(conductor));
      
      this.graphic = body;
      this.addChild(body); // 将图形添加到当前Container
      this.initializeEventListeners();
    }
    
  2. 确保事件绑定前对象已加入渲染树
    修改VisualisationEngine.ts,将background改为类属性,先添加到viewport再绑定事件:

    export class VisualisationEngine {
      background: Graphics | null = null;
      // 省略其他属性
    
      init() {
        this.render();
        this.drawBackground(); // 先创建并添加到viewport
        this.startEventHandlers(); // 再绑定事件
      }
    
      drawBackground() {
        this.background = new Graphics();
        this.background.beginFill(0x000000);
        this.background.drawRect(0, 0, this.elem.clientWidth, this.elem.clientHeight);
        this.background.endFill();
        this.background.eventMode = 'static';
        this.viewport.addChild(this.background);
      }
    
      startEventHandlers() {
        document.addEventListener('keydown', (event) => {
          if (event.key === 'Escape') {
            onEscKeyDown();
          }
        });
        
        if (this.background) {
          this.background
            .on('pointerdown', (event) => {
              onPointerDown(event, (elem) => this.viewport.addChild(elem));
            })
            .on('pointermove', (event) => {
              onPointerMove(event, (elem) => this.viewport.addChild(elem));
            })
            .on('pointerup', (event) => onPointerUp(event));
        }
      }
    }
    

问题2:修改属性后画面不重渲染

核心原因

  • BaseElement实例未被添加到viewport,仅添加了draw()返回的Graphics,导致容器与渲染图形脱节,修改容器坐标无法影响画面;
  • hitArea设置过早,图形未渲染就获取边界,导致事件触发区域错误。

修复步骤

  1. 将BaseElement实例添加到viewport
    修改eventHandlers.ts的onPointerDown方法,直接添加BaseElement容器:

    export const onPointerDown = (event: FederatedPointerEvent, callback: (elem: Container) => void) => {
      const mainStore = useMainStore();
      if (mainStore.elementToAdd !== null) {
        mainStore.elementToAdd.position.set(Math.round(event.globalX), Math.round(event.globalY));
        mainStore.elementToAdd.draw(); // 先执行draw,将图形添加到容器
        callback(mainStore.elementToAdd); // 添加容器到viewport
        mainStore.onScreenElements.push(mainStore.elementToAdd);
        mainStore.elementToAdd = null;
        return;
      }
      // 省略其他逻辑
    }
    

    同步修改VisualisationEngine.ts中回调的参数类型:

    graphic.on('pointerdown', (event) => {
      onPointerDown(event, (elem) => this.viewport.addChild(elem)); // elem为Container类型
    })
    
  2. 确保渲染更新的正确性
    只要BaseElement实例在PIXI渲染树中,修改x/y属性会自动触发重绘。如果仍无更新,可以在move()方法中手动标记对象为需要更新:

    move(x: number, y: number) {
      this.x = x;
      this.y = y;
      this.dirty = true; // 标记为脏,强制PIXI更新
    }
    

内容的提问来源于stack exchange,提问作者ayitinya

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 05:10:54