EaselJS 原始碼分析系列--第四篇

2023-07-26 15:01:06

滑鼠互動事件

前幾篇關注的是如何渲染,那麼滑鼠互動如何實現呢?

Canvas context 本身沒有像瀏覽器 DOM 一樣的互動事件

EaselJS 如何在 canvas 內實現自己的滑鼠事件系統?

原理大致如下:

  1. Stage 類內的 canvas 監聽標準 DOM 滑鼠事件 (比如:mousedown), window 或 document 物件下監聽滑鼠事件 (比如: mouseup, mousemove)

  2. 一旦監聽的 DOM 滑鼠事件被觸發,碰撞檢測就是滑鼠當前位置與 Stage 顯示列表及其子列表遞迴判斷是否發生碰撞

  3. 如果發生碰撞,則用虛擬事件系統 EventDispatcher 派發對應的滑鼠事件給碰撞到的顯示物件

重點在於如何判斷點與顯示物件的碰撞!!

簡單範例偵錯

寫一個特別簡單的滑鼠互動例子用於 debugger 測試

再次提示 debugger 是要打在 /lib/easeljs-NEXT.js 這個 JS 內!!

我們自己實現一個測試滑鼠事件的簡單例子,如下:

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="utf-8">
	<title>EventSimple</title>

	<link href="../_assets/css/shared.css" rel="stylesheet" type="text/css"/>
	<link href="../_assets/css/examples.css" rel="stylesheet" type="text/css"/>
	<script src="../_assets/js/examples.js"></script>

	<script src="../lib/easeljs-NEXT.js"></script>

<script id="editable">
	var stage;

	function init() {
		stage = new createjs.Stage("testCanvas");
		stage.name = "stage";
		stage.enableMouseOver(10);
		var container = new createjs.Container();

		// 紅矩形
		var shape1 = new createjs.Shape();
		shape1.name = 'shape1 red';
		shape1.graphics.beginFill("#F00").drawRect(0, 0, 100, 100);
		shape1.x = 50;
		shape1.y = 50;

		// 綠矩形
		var shape2 = new createjs.Shape();
		shape2.name = 'shape2 green';
		shape2.graphics.beginFill("#0F0").drawRect(0, 0, 100, 100);
		shape2.x = 0;
		shape2.y = 0;

		container.addChild(shape1, shape2)
	
		stage.addChild(container)
		createjs.Ticker.addEventListener("tick", stage);

		shape1.on('mousedown', handleClick)
		shape2.on('mousedown', handleClick)
	}

	function handleClick(evt) {
		console.log(evt.target.name, 'clicked')
	}
</script>
</head>

<body onload="init();">
  <div>
    <canvas id="testCanvas" width="960" height="400"></canvas>
  </div>
</body>
</html>

把 html 檔案同樣放到 examples 資料夾 內執行

例子主要功能:

舞臺上新增了一紅 ('shape1 red'),一綠 ('shape2 green'); 兩個矩形, 有部分重疊在了一起

並都監聽了 mousedown 事件 handleClick 為事件回撥,點選生會輸出被點選物件的 name 名字

期望的滑鼠事件表現:

  • 點選 a 點,綠色矩形輸出資訊,b 點不輸出資訊

  • 點選 b 點,紅色矩形輸出資訊

  • 點選 c 點,不輸出資訊

從 stage 開始

在 Stage.js 類別建構函式內 原始碼 230 行 this.enableDOMEvents(true); 表示預設開啟

那麼當範例化一個 Stage 後,這個範例對應的 canvas 即開啟了 DOM 滑鼠事件互動: mouseup mousemove dblclick mousedown

// Stage.js 原始碼 546-569 行
p.enableDOMEvents = function(enable) {
  if (enable == null) { enable = true; }
  var n, o, ls = this._eventListeners;
  if (!enable && ls) {
    for (n in ls) {
      o = ls[n];
      o.t.removeEventListener(n, o.f, false);
    }
    this._eventListeners = null;
  } else if (enable && !ls && this.canvas) {
    var t = window.addEventListener ? window : document;
    var _this = this;
    ls = this._eventListeners = {};
    ls["mouseup"] = {t:t, f:function(e) { _this._handleMouseUp(e)} };
    ls["mousemove"] = {t:t, f:function(e) { _this._handleMouseMove(e)} };
    ls["dblclick"] = {t:this.canvas, f:function(e) { _this._handleDoubleClick(e)} };
    ls["mousedown"] = {t:this.canvas, f:function(e) { _this._handleMouseDown(e)} };

    for (n in ls) {
      o = ls[n];
      o.t.addEventListener(n, o.f, false);
    }
  }
};

主要功能

  1. 判斷如果禁用滑鼠事件 enable 引數為 false 且 有監聽列表,則清掉

  2. 如果啟用滑鼠事件,則在 DOM 最外層(頂層)監聽 mouseup、mousemove, 而在 canvas 上監聽 dblclick 和 mousedown 事件,這裡都是 DOM 原生事件

好了,現在只要 canvas 被點選,就會觸發 _handleMouseDown

// Stage.js 原始碼 746-748 行
p._handleMouseDown = function(e) {
  this._handlePointerDown(-1, e, e.pageX, e.pageY);
};

呼叫的是 _handlePointerDown 方法

// Stage.js 原始碼 759-771 行

p._handlePointerDown = function(id, e, pageX, pageY, owner) {
  if (this.preventSelection) { e.preventDefault(); }
  if (this._primaryPointerID == null || id === -1) { this._primaryPointerID = id; } // primaryPointer 滑鼠只有一個 pointer  id 直接為 -1 , primaryPointer 主要用於 touch 多點事件
  
  if (pageY != null) { this._updatePointerPosition(id, e, pageX, pageY); }
  var target = null, nextStage = this._nextStage, o = this._getPointerData(id);
  if (!owner) { target = o.target = this._getObjectsUnderPoint(o.x, o.y, null, true); }

  if (o.inBounds) { this._dispatchMouseEvent(this, "stagemousedown", false, id, o, e, target); o.down = true; }
  this._dispatchMouseEvent(target, "mousedown", true, id, o, e);
  
  nextStage&&nextStage._handlePointerDown(id, e, pageX, pageY, owner || target && this);
};

注意: Stage 類內的 _primaryPointerID、_getPointerData 等 pointer 指標相關屬性與方法都是用於抹平處理滑鼠事件與多點觸控事件

如果有點選的位置有物件,則向該物件派發 mousedown 事件

nextStage 作用是如果是多個 stage canvas 疊加(比如:作為單獨背景層用於優化效能,其中又有某個元素需要滑鼠互動),後層的 stage 需要傳遞滑鼠事件,否則就被前面層的 canvas 擋住了

_handlePointerDown 方法內最重要的一句是 target = o.target = this._getObjectsUnderPoint(o.x, o.y, null, true); 判斷點選位置是否存在顯示物件

_getObjectsUnderPoint 判斷獲取點選位置下面的所有物件

// Container.js 原始碼 608-649 行
p._getObjectsUnderPoint = function(x, y, arr, mouse, activeListener, currentDepth) {
  currentDepth = currentDepth || 0;
  if (!currentDepth && !this._testMask(this, x, y)) { return null; }
  var mtx, ctx = createjs.DisplayObject._hitTestContext;
  activeListener = activeListener || (mouse&&this._hasMouseEventListener());

  // 每次在專門的用於碰撞檢測的  canvas 上下檔案 _hitTestContext  上畫一個顯示物件,並判斷是否發生碰撞
  var children = this.children, l = children.length;
  for (var i=l-1; i>=0; i--) {
    var child = children[i];
    var hitArea = child.hitArea;
    if (!child.visible || (!hitArea && !child.isVisible()) || (mouse && !child.mouseEnabled)) { continue; }
    if (!hitArea && !this._testMask(child, x, y)) { continue; }
    
    // 如果有 hitArea 只需要判斷 hitArea 本身忽略其子顯示物件,hitArea 是人為為顯示物件指定的
    if (!hitArea && child instanceof Container) {
      var result = child._getObjectsUnderPoint(x, y, arr, mouse, activeListener, currentDepth+1);
      if (!arr && result) { return (mouse && !this.mouseChildren) ? this : result; }
    } else {
      if (mouse && !activeListener && !child._hasMouseEventListener()) { continue; }
      
      // TODO: can we pass displayProps forward, to avoid having to calculate this backwards every time? It's kind of a mixed bag. When we're only hunting for DOs with event listeners, it may not make sense.
      var props = child.getConcatenatedDisplayProps(child._props);
      mtx = props.matrix;
      
      if (hitArea) {
        mtx.appendMatrix(hitArea.getMatrix(hitArea._props.matrix));
        props.alpha = hitArea.alpha;
      }
      
      ctx.globalAlpha = props.alpha;
      ctx.setTransform(mtx.a,  mtx.b, mtx.c, mtx.d, mtx.tx-x, mtx.ty-y);
      (hitArea||child).draw(ctx);
      if (!this._testHit(ctx)) { continue; }
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, 2, 2);
      if (arr) { arr.push(child); }
      else { return (mouse && !this.mouseChildren) ? this : child; }
    }
  }
  return null;
};

主要步驟:

  1. 獲取在 DisplayObject 類上的靜態屬性 _hitTestContext,它儲存著專門用於碰撞檢測的 canvas 的上下文

  2. 迴圈顯示列表下的 child 顯示物件 ,如果 child 還是個 Container 類範例,則遞迴

  3. 獲取 child 最終顯示屬性矩陣 matrix,方式是呼叫 child.getConcatenatedDisplayProps 合併 child 和 child 遞迴的父級「顯示屬性」 matrix

  4. 將碰撞檢測的 ctx 即 canvas context 上下文矩陣變幻至 child 的顯示屬性矩陣

  5. 在碰撞檢測 ctx 上繪製 child 物件,注意,這是不可見的,可以理解為僅繪製在用於碰撞檢測的 canvas 上

  6. _testHit(ctx) 碰撞檢測,這個就是最重要的「畫素點判斷座標碰撞」

  7. 如果發生了碰撞檢測則 將 child 放入結果陣列 (如果不需要返回多個則直接返回第一個碰撞到的顯示物件)

碰撞檢測函數, 可以看到它只檢測一個畫素點,即座標點判斷

// DisplayObject.js 原始碼 1323-1332 行
p._testHit = function(ctx) {
  try {
    var hit = ctx.getImageData(0, 0, 1, 1).data[3] > 1;
  } catch (e) {
    if (!DisplayObject.suppressCrossDomainErrors) {
      throw "An error has occurred. This is most likely due to security restrictions on reading canvas pixel data with local or cross-domain images.";
    }
  }
  return hit;
};

通過 ctx.getImageData(0, 0, 1, 1).data[3] > 1; 判斷畫素點透明度是否大於 1 判斷有沒有碰撞

注意,_getObjectsUnderPoint 內針對 children 的 for 迴圈倒序的,這保證了首先檢測的是最後新增的 child ,而 child 的層級一定是從上至下檢測

這就保證瞭如果有疊在一起的兩個 child 被滑鼠點選時,只會觸發最上層 child 監聽的回撥, 巧妙!

再看一眼 mouseup 事件

// Stage.js 原始碼 721-739 行
p._handlePointerUp = function(id, e, clear, owner) {
  var nextStage = this._nextStage, o = this._getPointerData(id);
  if (this._prevStage && owner === undefined) { return; } // redundant listener.
  
  var target=null, oTarget = o.target;
  if (!owner && (oTarget || nextStage)) { target = this._getObjectsUnderPoint(o.x, o.y, null, true); }
  
  if (o.down) { this._dispatchMouseEvent(this, "stagemouseup", false, id, o, e, target); o.down = false; }
  
  if (target == oTarget) { this._dispatchMouseEvent(oTarget, "click", true, id, o, e); }
  this._dispatchMouseEvent(oTarget, "pressup", true, id, o, e);
  
  if (clear) {
    if (id==this._primaryPointerID) { this._primaryPointerID = null; }
    delete(this._pointerData[id]);
  } else { o.target = null; }
  
  nextStage&&nextStage._handlePointerUp(id, e, clear, owner || target && this);
};

_handlePointerDown 非常相似,只是 mouseup 它派發是 click、pressup 事件

還有一點值得注意,在 DisplayObject 原始碼中有這麼一句

// DisplayObject.js 原始碼 483 行
DisplayObject._MOUSE_EVENTS = ["click","dblclick","mousedown","mouseout","mouseover","pressmove","pressup","rollout","rollover"];

表明了,EaselJS 內部的虛擬事件系列不支援名為 'mouseup' 的事件

getObjectsUnderPoint

還有個關於滑鼠事件的方法需要提一下

DisplayObject 類的有個 getObjectsUnderPoint 實體方法,用於獲取對應座標位置下的顯示物件,

注意原始碼內的 arr 變數傳遞給 _getObjectsUnderPoint,它會忽略顯示物件重疊,只要是在對應座標發生碰撞的顯示物件都返回

// Container.js 原始碼 490-495 行
p.getObjectsUnderPoint = function(x, y, mode) {
  var arr = [];
  var pt = this.localToGlobal(x, y);
  this._getObjectsUnderPoint(pt.x, pt.y, arr, mode>0, mode==1);
  return arr;
};

Event 與 EventDispatcher

滑鼠事件實現最後當然還是要通過虛擬的事件派發實現

在 src/createjs/events/ 目錄下有兩個事件相關的類 Event 和 EventDispatcher

Event 類就是事件本身,而 EventDispatcher 就是事件派發類

顯示物件都繼承自 EventDispatcher 類 src/createjs/events/EventDispatcher.js

偵聽事件 -> 建立事件 -> 派發事件

本質上還就是實現了事件 「觀察者模式」

設計的 api 與 DOM 事件保持一至如下:

// EventDispatcher.js 原始碼 158 - 171 行
p.addEventListener = function(type, listener, useCapture) {
  var listeners;
  if (useCapture) {
    listeners = this._captureListeners = this._captureListeners||{};
  } else {
    listeners = this._listeners = this._listeners||{};
  }
  var arr = listeners[type];
  if (arr) { this.removeEventListener(type, listener, useCapture); }
  arr = listeners[type]; // remove may have deleted the array
  if (!arr) { listeners[type] = [listener];  }
  else { arr.push(listener); }
  return listener;
};

使用 _captureListeners 實現事件捕獲偵聽列表

使用 _listeners 實現普通冒泡事件偵聽列表

以下是 removeEventListener

// EventDispatcher.js 原始碼 235-247 行
p.removeEventListener = function(type, listener, useCapture) {
		var listeners = useCapture ? this._captureListeners : this._listeners;
		if (!listeners) { return; }
		var arr = listeners[type];
		if (!arr) { return; }
		for (var i=0,l=arr.length; i<l; i++) {
			if (arr[i] == listener) {
				if (l==1) { delete(listeners[type]); } // allows for faster checks.
				else { arr.splice(i,1); }
				break;
			}
		}
	};

由內部的 _dispatchEvent 真正派發或者說執行偵聽列表內的事件函數

// EventDispatcher.js 原始碼 386-405
p._dispatchEvent = function(eventObj, eventPhase) {
  var l, arr, listeners = (eventPhase <= 2) ? this._captureListeners : this._listeners;
  if (eventObj && listeners && (arr = listeners[eventObj.type]) && (l=arr.length)) {
    try { eventObj.currentTarget = this; } catch (e) {}
    try { eventObj.eventPhase = eventPhase|0; } catch (e) {}
    eventObj.removed = false;
    
    arr = arr.slice(); // to avoid issues with items being removed or added during the dispatch
    for (var i=0; i<l && !eventObj.immediatePropagationStopped; i++) {
      var o = arr[i];
      if (o.handleEvent) { o.handleEvent(eventObj); }
      else { o(eventObj); }
      if (eventObj.removed) {
        this.off(eventObj.type, o, eventPhase==1);
        eventObj.removed = false;
      }
    }
  }
  if (eventPhase === 2) { this._dispatchEvent(eventObj, 2.1); }
};

非常簡單 通過 eventObj.type 找到事件 hash 表 listeners 對應的事件列表,迴圈執行觸發事件

嗯,然後我發現有個事件物件中有個 handleEvent ,查了一下,原來在 DOM 中有這樣的用法:

const btn = document.querySelector('button');
// 定義一個帶 `handleEvent` 方法的物件
const myObject = {
  handleEvent: (event) => {
    alert(event.type);
  }
}
// 回撥處直接傳遞物件
btn.addEventListener('click', myObject);

說實話,我都忘了有這種應用,想不起來曾在哪裡用過,搜了一圈也沒找到實際的應用場景...

可以看看這裡 https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

看起來是用來解決 this 繫結問題 和 UI 分離問題,但對我來說還是沒辦法解決實際應用場景的問題。

小結

canvas 內實現虛擬的滑鼠事件沒想象的那麼複雜,最重要的是使用 getImageData 獲取畫素值判斷

知道原理後其實挺簡單

當然虛擬滑鼠事件的實現方式肯定不止這一種

在沒看原始碼之前,我以為 EaselJS 虛擬滑鼠實現會是:顯示物件隨機生成一種唯一的顏色為 id ,hash 對應顯示物件,並繪製在不可見的 canvas 上下文上,再通過 getImageData 獲取畫素點顏色反查 hash 反查

但看完原始碼後發現並沒有使用這種「顏色值 hash 反查」方法


部落格園: http://cnblogs.com/willian/
github: https://github.com/willian12345/