首页 > 解决方案 > OffScreenCanvas 和触摸事件

问题描述

我正在尝试为 OffScreenCanvas 使用新的 API。这个想法是移动所有关于为玩家绘制和更新数据的逻辑,但在主线程中保留一些其他逻辑,比如触摸事件(因为工作者无法访问窗口对象)。

所以我有课Player.js

export class Player {
  constructor() {
    this.width = 100;
    this.height = 100;
    this.posX = 0;
    this.posY = 0;
  }

  draw = (ctx) => {
    ctx.fillRect(this.posX, this.posY, this.width, this.height);
  }

  updatePos = (x, y) => {
    this.posX = x;
    this.posY = y;
  }
}

我在另一个名为的模块中生成播放器实例playerObject.js

import { Player } from "./Player.js";

export const player = new Player();

OffScreenCanvas 是这样创建的

const canvas = document.querySelector('#myGame');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('offscreencanvas.js', { type: "module" });
worker.postMessage({ canvas: offscreen }, [offscreen]);

现在我将 playerObject 导入 OffScreenCanvas 工作者

import {player} from "./playerObject.js";

addEventListener('message', (evt) => {
  const canvas = evt.data.canvas;
  const ctx = canvas.getContext("2d");

  const render = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    player.draw(ctx);

    requestAnimationFrame(render);
  }

  render();
});

以及包含触摸事件的类(模块),这些事件正在修改玩家位置:

import {player} from "./playerObject.js";

export class Game {
  constructor() {
    this.touch();
  }

  touch = () => {
    window.addEventListener('touchstart', (e) => {
      player.updatePos(e.touches[0].clientX, e.touches[0].clientY);

    }, {passive: true});
  }
}

问题是 OffScreenCanvas 看不到 Game 类所做的更改。触摸本身正在工作(console.log 显示事件并且还修改了播放器对象)但在 OffScreenCanvas 播放器中仍然具有初始坐标。

我仍然不确定那里发生了什么。工人是否正在创建一个新的类实例,这就是为什么它看不到触摸事件的变化?

有没有办法做到这一点?

标签: javascriptcanvashtml5-canvasoffscreen-canvas

解决方案


您当前有两个不同的 Player 实例,它们不会与另一个进行通信。

草案中有一个提议允许将一些事件从主线程传递到工作线程,但它实际上仍然只是一个草案,我完全不确定它什么时候会出现,也不知道具体是什么形式。

目前,我们唯一的解决方案是在主线程和 Worker 线程之间搭建一座桥梁,让主线程在事件发生时将所有事件发送给 Worker。
这有很多缺点,其中

  • 明显的延迟,因为我们必须等待主线程接收到事件,然后再调度一个新的 MessageEvent 任务,
  • 对主线程的依赖:它必须可以自由地处理事件,这意味着即使主线程被锁定,OffscreenCanvas 也可以顺利运行的承诺在这里被打破。
  • 难以维护(?)由于我们没有明确的 API 来访问我们正在获取的目标,因此我们必须在主线程和工作线程中使用难看的硬编码值

但是,我们仍然可以取得一些成就。

我只是有一些时间写了一个粗略的玩具,大致基于我链接到的当前提案,有一些更改,没有测试,所以用它作为编写你自己的基础,但不要指望它在各种情况下无缝工作。

基本逻辑是

  • 我们从主线程增强了 Worker 接口,使其与 Worker 线程(通过 MessageChannel 对象)启动一个私有通信通道。
  • 我们还addEventTarget( target, uuid )向该接口添加和方法。
  • 当我们从主线程脚本接收到消息时,我们从 Worker 线程初始化一个接收器。从那里,我们持有 MessageChannel 并等待它说何时从主线程声明了 新的delegatedTargets 。
  • 发生这种情况时,我们会触发一个新事件eventtargetadd,该事件在 Worker 中运行的用户脚本可以侦听,从而公开一个EventDelegate实例,该实例将在 Worker 和主线程之间创建一个新的私有通信通道。
  • 通过这个 EventDelegate 的通道,主线程中的每个事件对象都将在被清理后被克隆。

但是足够多的单词和难以理解的解释,这里有一个 plnkr,它可能更清楚它是如何工作的。

这是 StackSnippet 的实时版本,可能有点难以阅读:

// StackSnippet only: build up internal path to our Worker scripts
const event_delegate_worker_script = document.getElementById( 'event-delegate-worker' ).textContent;
const event_delegate_worker_url = generateScriptURL( event_delegate_worker_script );

const user_script_worker_script = document.getElementById( 'user-script-worker' ).textContent
  .replace( "event-delegate-worker.js", event_delegate_worker_url );
const user_script_worker_url = generateScriptURL( user_script_worker_script );

function generateScriptURL( content ) {
  // Chrome refuses to importScripts blob:// URI...
  return 'data:text/javascript,' + encodeURIComponent( content );
}

// end StackSnippets only

onload = evt => {
  const worker = new EventDelegatingWorker( user_script_worker_url );
  const canvas = document.getElementById( 'canvas' );
  worker.addEventTarget( canvas, "canvas" );

  try {
    const off_canvas = canvas.transferControlToOffscreen();
    worker.postMessage( off_canvas, [ off_canvas ] );    
  }
  catch (e) {
    // no support for OffscreenCanvas, we'll just log evt
    worker.onmessage = (evt) => { console.log( "from worker", evt.data ); }
  }
};
canvas { border: 1px solid; }
<canvas id="canvas"width="500" height="500"></canvas>

<script id="user-script-worker" type="worker-script">
importScripts( "event-delegate-worker.js" );

self.addEventListener( "eventtargetadded", ({ delegatedTarget }) => {
  if( delegatedTarget.context === "canvas" ) {
    delegatedTarget.addEventListener( "mousemove", handleMouseMove );
  }
} );

let ctx;
function handleMouseMove( evt ) {
  if( ctx ) {
    draw( evt.offsetX, evt.offsetY );
  }
  else {
    // so we can log for browsers without OffscreenCanvas
    postMessage( evt );
  }
}

function draw( x, y ) {

  const rad = 30;
  ctx.clearRect( 0, 0, ctx.canvas.width, ctx.canvas.height );
  ctx.beginPath();
  ctx.arc( x, y, rad, 0, Math.PI*2 );
  ctx.fill();

}

onmessage = (evt) => {
  const canvas = evt.data;
  ctx = canvas.getContext("2d");
};
</script>

<!-- below are the two scripts required to bridge the events -->
<script id="event-delegate-main">
(()=> { "use strict";

  const default_event_options_dict = {
    capture: false,
    passive: true
  };
  const event_keys_to_remove = new Set( [
    "view",
    "target",
    "currentTarget"
  ] );
  class EventDelegatingWorker extends Worker {
    constructor( url, options ) {
      super( url, options );
      // this channel will be used to notify the Worker of added targets
      const channel = new MessageChannel();
      this._mainPort = channel.port2;
      this.postMessage( "init-event-delegation", [ channel.port1 ] );
    }
    addEventTarget( event_target, context ) {
      // this channel will be used to notify us when the Worker adds or removes listeners
      // and to notify the worker of new events fired on the target
      const channel = new MessageChannel();
      channel.port1.onmessage = (evt) => {
        const { type, action } = evt.data;
        if( action === "add" ) {
          event_target.addEventListener( type, handleDOMEvent, default_event_options_dict );        
        }
        else if( action === "remove" ) {
          event_target.removeEventListener( type, handleDOMEvent, default_event_options_dict );        
        }
      };
      // let the Worker side know they have a new target they can listen on
      this._mainPort.postMessage( context, [ channel.port2 ] );
      
      function handleDOMEvent( domEvent ) {
        channel.port1.postMessage( sanitizeEvent( domEvent ) );
      }
    }
  }
  window.EventDelegatingWorker = EventDelegatingWorker;

  // Events can not be cloned as is, so we need to stripe out all non cloneable properties
  function sanitizeEvent( evt ) {
    
    const copy = {};
    // Most events only have .isTrusted as own property, so we use a for in loop to get all
    // otherwise JSON.stringify() would just ignore them
    for( let key in evt ) {
      if( event_keys_to_remove.has( key ) ) {
        continue;
      }
      copy[ key ] = evt[ key ];      
    }
    
    const as_string = tryToStringify( copy );
    return JSON.parse( as_string );

    // over complicated recursive function to handle cross-origin access
    function tryToStringify() {
      const referenced_objects = new Set; // for cyclic
      // for cross-origin objects (e.g window.parent in a cross-origin iframe)
      // we save the previous key value so we can delete it if throwing
      let lastKey;  
      let nextVal = copy;
      let lastVal = copy;
      try {
        return JSON.stringify( copy, removeDOMRefsFunctionsAndCyclics );
      }
      catch( e ) {   
        delete lastVal[ lastKey ];
        return tryToStringify();
      }
      
      function removeDOMRefsFunctionsAndCyclics( key, value ) {
        lastVal = nextVal;
        lastKey = key;
        
        if( typeof value === "function" ) {
          return;
        }
        if( typeof value === "string" || typeof value === "number") {
          return value;
        }
        if( value && typeof value === "object" ) {
          if( value instanceof Node ) {
            return;
          }
          if( referenced_objects.has( value ) ) {
            return "[cyclic]";
          }
          referenced_objects.add( value );
          nextVal = value;
          return value;
        }
        return value;
      }
    }
  }

})();
</script>
<script id="event-delegate-worker" type="worker-script">
(()=> { "use strict";

// This script should be imported at the top of user's worker-script
function initDelegatedEventReceiver( evt ) {

  // currently the only option is "once"
  const defaultOptionsDict = {
    once: false,
  };
  // in case it's not our message (which would be quite odd...)
  if( evt.data !== "init-event-delegation" ) {
    return;
  }

  // let's not let user-script know it happend
  evt.stopImmediatePropagation();
  removeEventListener( 'message', initDelegatedEventReceiver, true );

  // this is where the main thread will let us know when a new target is available
  const main_port = evt.ports[ 0 ];

  class EventDelegate {
    constructor( port, context ) {
      this.port = port; // the port to communicate with main
      this.context = context; // can help identify our target
      this.callbacks = {}; // we'll store the added callbacks here
      // this will fire when main thread fired an event on our target
      port.onmessage = (evt) => {
        const evt_object = evt.data;
        const slot = this.callbacks[ evt_object.type ];
        if( slot ) {
          const to_remove = [];
          slot.forEach( ({ callback, options }, index) => {
            try {
              callback( evt_object );
            }
            catch( e ) {
              // we don't want to block our execution,
              // but still, we should notify the exception
              setTimeout( () => { throw e; } );
            }
            if( options.once ) {
              to_remove.push( index );
            }
          } );
          // remove 'once' events
          to_remove.reverse().forEach( index => slot.splice( index, 1 ) );
        }
      };
    }
    addEventListener( type, callback, options = defaultOptionsDict ) {

      const callbacks = this.callbacks;
      let slot = callbacks[ type ];
      if( !slot ) {
        slot = callbacks[ type ] = [];
        // make the main thread attach only a single event,
        // we'll handle the multiple callbacks
        // and since we force { passive: true, capture: false }
        // they'll all get attached the same way there
        this.port.postMessage( { type, action: "add" } );
      }
      // to store internally, and avoid duplicates (like EventTarget.addEventListener does)
      const new_item = {
          callback,
          options,
          options_as_string: stringifyOptions( options )
        };
      if( !getStoredItem( slot, new_item ) ) {
        slot.push( new_item );
      }

    }
    removeEventListener( type, callback, options = defaultOptionsDict ) {

      const callbacks = this.callbacks;
      const slot = callbacks[ type ];
      const options_as_string = stringifyOptions( options );

      const item = getStoredItem( slot, { callback, options, options_as_string } );
      const index = item && slot.indexOf( item );

      if( item ) {
        slot.splice( index, 1 );
      }
      if( slot && !slot.length ) {
        delete callbacks[ type ];
        // we tell the main thread to remove the event handler
        // only when there is no callbacks of this type anymore
        this.port.postMessage( { type, action: "remove" } );
      }

    }
  }
  // EventInitOptions need to be serialized in a deterministic way
  // so we can detect duplicates 
  function stringifyOptions( options ) {
    if( typeof options === "boolean" ) {
      options = { once: options };
    }
    try {
      return JSON.stringify(
        Object.fromEntries(
          Object.entries(
            options
          ).sort( byKeyAlpha )
        )
      );
    } 
    catch( e ) {
      return JSON.stringify( defaultOptionsDict );
    }
  }
  function byKeyAlpha( entry_a, entry_b ) {
    return entry_a[ 0 ].localeCompare( entry_b[ 0 ] );
  }
  
  // retrieves an event item in a slot based on its callback and its stringified options
  function getStoredItem( slot, { callback, options_as_string } ) {
    return Array.isArray( slot ) && slot.find( (obj) => {
      return obj.callback === callback &&
        obj.options_as_string === options_as_string;
    } );
  }

  // a new EventTarget has been declared by main thread
  main_port.onmessage = evt => {
    const target_added_evt = new Event( 'eventtargetadded' );
    target_added_evt.delegatedTarget = new EventDelegate( evt.ports[ 0 ], evt.data );
    dispatchEvent( target_added_evt );
  };
  
}
addEventListener( 'message', initDelegatedEventReceiver );

})();
</script>


Ps:既然这个答案已经发布,我确实开始实现一个EventPort 接口,松散地基于这个 PR,它可能更容易使用,并且更接近最​​终规范。
不过,在 Stackoverflow 中发布答案有点长。
您可以在此处查看实时示例


推荐阅读