You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
内容框架搭建好了,接下来就到了构建事件系统的环节,要实现事件绑定及触发,首要解决的问题是:如何判断当前鼠标选择的是哪个元素?这个问题放在 dom 上并不复杂,每个 dom 元素占据的空间均是矩形,我们完全可以通过鼠标的坐标x, y,加上每个矩形的xywidthheight 四个值,简单判断它处于哪个矩形内部:
if(mouse.x>rect.x&&mouse.x<rect.x+rect.width&&mouse.y>rect.y&&mouse.y<rect.y+rect.height){// 在 rect 内部}else{// 不在 rect 内部}
我们先对 canvas 画布内的每个元素添加唯一的 id,并设计一种 id 与 rgba 互相转换的算法,然后再建立一个与当前画布等大的“影子画布”(不必显示在页面上),我们将用户能看见的画布称为 A,影子画布为 B,每当在 A 上渲染一个元素的时候,同步在 B 上的相同位置渲染一个等大的元素,并以其 id 所转换的 rgba 值填充。这样,当鼠标处于 A 上时,可通过当前坐标和 getImageData 可找到 B 上对应点的 rgba 值,将 rgba 反转为 id,即可知晓被选中的元素
exportclassStage{privatecanvas: HTMLCanvasElement;privateosCanvas: OffscreenCanvas;privatectx: CanvasRenderingContext2D;privateosCtx: OffscreenCanvasRenderingContext2D;privatedpr: number;privateshapes: Set<string>;constructor(canvas: HTMLCanvasElement){// other codes...this.osCanvas=newOffscreenCanvas(canvas.width,canvas.height);this.osCtx=this.osCanvas.getContext('2d');this.osCtx.scale(dpr,dpr);this.dpr=dpr;this.shapes=newSet();// 通过一个 Set 保存所有 add 进来的形状元素}add(shape: Shape){constid=shape.getId();this.shapes.add(id);shape.draw(this.ctx,this.osCtx);}privatehandleCreator=(type: ActionType)=>(evt: MouseEvent)=>{constx=evt.offsetX;consty=evt.offsetY;// 根据 x, y 拿到当前被选中的 idconstid=this.hitJudge(x,y);};/** * Determine whether the current position is inside a certain shape, if it is, then return its id * @param x * @param y */privatehitJudge(x: number,y: number): string{constrgba=Array.from(this.osCtx.getImageData(x*this.dpr,y*this.dpr,1,1).data);constid=rgbaToId(rgbaas[number,number,number,number]);returnthis.shapes.has(id) ? id : undefined;}}
import{rgbaToId}from'./helpers';import{Shape}from'./shapes/types';importEventSimulator,{ActionType}from'./EventSimulator';export*from'./shapes';exportclassStage{privatecanvas: HTMLCanvasElement;privateosCanvas: OffscreenCanvas;privatectx: CanvasRenderingContext2D;privateosCtx: OffscreenCanvasRenderingContext2D;privatedpr: number;privateshapes: Set<string>;privateeventSimulator: EventSimulator;constructor(canvas: HTMLCanvasElement){constdpr=window.devicePixelRatio;canvas.width=parseInt(canvas.style.width)*dpr;canvas.height=parseInt(canvas.style.height)*dpr;this.canvas=canvas;this.osCanvas=newOffscreenCanvas(canvas.width,canvas.height);this.ctx=this.canvas.getContext('2d');this.osCtx=this.osCanvas.getContext('2d');this.ctx.scale(dpr,dpr);this.osCtx.scale(dpr,dpr);this.dpr=dpr;this.canvas.addEventListener('mousedown',this.handleCreator(ActionType.Down));this.canvas.addEventListener('mouseup',this.handleCreator(ActionType.Up));this.canvas.addEventListener('mousemove',this.handleCreator(ActionType.Move));this.shapes=newSet();this.eventSimulator=newEventSimulator();}add(shape: Shape){constid=shape.getId();this.eventSimulator.addListeners(id,shape.getListeners());this.shapes.add(id);shape.draw(this.ctx,this.osCtx);}privatehandleCreator=(type: ActionType)=>(evt: MouseEvent)=>{constx=evt.offsetX;consty=evt.offsetY;constid=this.hitJudge(x,y);this.eventSimulator.addAction({ type, id },evt);};/** * Determine whether the current position is inside a certain shape, if it is, then return its id * @param x * @param y */privatehitJudge(x: number,y: number): string{constrgba=Array.from(this.osCtx.getImageData(x*this.dpr,y*this.dpr,1,1).data);constid=rgbaToId(rgbaas[number,number,number,number]);returnthis.shapes.has(id) ? id : undefined;}}
众所周知,canvas 是前端进军可视化领域的一大利器,借助 canvas 画布我们不仅可以实现许多 dom 和 css 难以实现的、各种绚丽多彩的视觉效果,而且在渲染数量繁多、内容复杂的场景下,其性能表现及优化空间也占据一定优势。
然而 canvas 却存在一个缺陷:由于 canvas 是作为一个整体画布存在,所有的内容只不过是其内部渲染的结果,我们不能像在 dom 元素上监听事件一样,在 canvas 所渲染的图形内绑定各种事件,因此基于 canvas 画布开发出一套交互式应用是件复杂的事情。虽然 gayhub 上很多 canvas 框架自带了事件系统,但如果想深入学习 canvas,笔者认为还是有必要了解其实现原理,因此本篇文章将实现一个简易版的 canvas 事件系统。
正文开始前,先贴下仓库地址,各位按需取用 canvas-event-system
环境搭建
要在 canvas 上实现事件系统,我们必须先做些准备工作 —— 首先我们得往 canvas 上填充些“内容”,没有内容,谈何事件监听,下文我们将这些可绑定事件的内容称之为元素。同时,为简明扼要,笔者这里仅实现了形状(
Shape
) 这一类元素;当我们有了一个个元素后,我们还需要一个容器去管理它们,这个容器则是 —— 舞台(Stage
),舞台如同上帝一般,负责元素们的渲染、事件管理及事件触发,接下来我们先初始化这两大类API 设计
在实现细节前,笔者是这样设想事件系统的:我们可以通过
new
操作符生成一个个的Shape
实例,并可在实例上监听各类事件,然后再将它们add
进Stage
即可,就像这样:构建 Shape
由于不同形状间有许多相似的逻辑,因此我们先实现一个
Base
基类,然后让诸如Rect
、Circle
等形状继承此类:Base
有三个对外暴露的 api:draw
用于绘制内容,需要将 canvas 上下文CanvasRenderingContext2D
传入on
用于事件监听,收集到的事件回调会以事件名eventName
为 key,回调函数数组为 value 的形式存放在一个对象当中,此外我们还用了枚举类型定义了所有事件getListeners
获取此形状上所有的监听事件有了
Base
基类,我们就可以轻松定义其他具体的形状:比如
Rect
:又比如原型
Circle
构建 Stage
Stage
的代码如下在
Stage
的实现中,我们做了三件事情:mousedown
、mouseup
和mousemove
,之所以监听是因为后面其他事件的模拟判断均和它们有关,此外,由于三者接下来的处理逻辑相似度非常高,为代码复用,故使用handleCreator
统一逻辑处理,并使用ActionType
加以区分不同类型add
方法,在案例中我们通过调用stage.add(rect)
实现内容绘制,本质上add
会在内部调用形状的draw
方法,并把绘制上下文传入,由具体形状控制内部的内容展示当我们实现到这,一个基本的 canvas 绘图系统就完成了,起码页面上能初现雏形,demo 代码如下:
鼠标的命中问题
内容框架搭建好了,接下来就到了构建事件系统的环节,要实现事件绑定及触发,首要解决的问题是:如何判断当前鼠标选择的是哪个元素?这个问题放在 dom 上并不复杂,每个 dom 元素占据的空间均是矩形,我们完全可以通过鼠标的坐标
x, y
,加上每个矩形的x
y
width
height
四个值,简单判断它处于哪个矩形内部:但 canvas 内的形状各种各样,不仅有圆形、椭圆形、多边形、不规则多边形形状,甚至还存在由曲线构成的不规则形状。比如像下面这种类似肥皂的形状:

虽然从数学意义上,我们可以通过诸如 射线法 等算法判断,但由于内容千变万化,在非常复杂的图形上,难以依靠数学计算得以解决,因此这里我们将利用 canvas 本身的特性,使用一种取巧的方式,解决鼠标的命中问题,思路如下:
我们先对 canvas 画布内的每个元素添加唯一的 id,并设计一种 id 与 rgba 互相转换的算法,然后再建立一个与当前画布等大的“影子画布”(不必显示在页面上),我们将用户能看见的画布称为 A,影子画布为 B,每当在 A 上渲染一个元素的时候,同步在 B 上的相同位置渲染一个等大的元素,并以其 id 所转换的 rgba 值填充。这样,当鼠标处于 A 上时,可通过当前坐标和
getImageData
可找到 B 上对应点的 rgba 值,将 rgba 反转为 id,即可知晓被选中的元素为此,首先我们需要一个函数
createId
生成 id,两个转换函数idToRgba
和rgbaToId
原理很简单,rgba 的 r、g、b 的范围是 0
255,我们生成 3 个 0255 的随机数即可。对于透明度 a 值则必须为 1(不透明),否则当两个形状重叠时,重叠部分的 rgba 将被混合,会影响命中的判断,这里为方便转换,a 默认给了 255接着需要对
Base
进行调整:每当创建一个实例时,实例内部均会默认添加一个
id
,可通过getId
获取,此外,draw
方法也进行了调整,需要多传入一个影子画布的上下文对于继承自
Base
的Rect
和Circle
,也要进行相应的改造,这里就以Rect
为例,更多内容可详见源码:看,为保持两个画布的同步,每当在
ctx
绘制一个矩形时,也要在osCtx
同步绘制。接下来则需要对
Stage
进行调整:它需要根据传入的 canvas 复刻一个 OffscreenCanvas,同时还需要在根据当前鼠标的位置判断元素的命中:事件模拟
由于
handleCreator(actionType)
同时处理了三个鼠标事件,因此只要鼠标在 canvas 上,它的一举一动、经过了哪些元素都会被捕获到,当然,要实现事件的触发,我们必须通过一些操作“组合”,去判断当前的事件类型,由于篇幅关系,笔者主要模拟了以下几种事件:于是我们创建一个
EventSimulator
类,它将根据传入的当前鼠标动作类型,预判此时应该发生的事件:接着我们继续完善下
Stage
,将实现好的EventSimulator
放入进去,完整代码如下结尾
到此为止,到此为止,整个 canvas 事件系统就搭建完成啦,一起看看运行时的效果吧~

看起来效果不错,我们可以在
Rect
、Circle
等形状通过Rect.on('xxx', func)
的形式实现事件监听,满足了基本需求。然而,由于篇幅关系,本文做了许多内容的缩减,要想真正实现一个能用于生产环境的事件系统,还需要很多工作,比如 元素嵌套关系所带来的额外处理:其实元素之间不仅存在层级关系,还有嵌套关系,如果元素存在嵌套,那必然要处理事件捕获、冒泡相关问题,比如如何取消冒泡;此外本文并未模拟
mouseover
、mouseout
等与嵌套关系相关的事件等......当你看到这,不妨一起思考下要如何解决以上的场景:)(PS:如果有机会的话,也许会单独写篇文章讨论这些问题)以上便是本文的所有内容,欢迎交流讨论~😊
The text was updated successfully, but these errors were encountered: