众所周知,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
即可,就像这样:
const stage = new Stage(myCanvas);
// 生成形状
const rect = new Rect(props); // 矩形
const circle = new Rect(props); // 圆形
// 监听点击事件
rect.on('click', () => console.log('click rect!'));
circle.on('click', () => console.log('click circle!'));
// 将形状添加至舞台,即可渲染到画布上
stage.add(rect);
stage.add(circle);
构建 Shape
由于不同形状间有许多相似的逻辑,因此我们先实现一个Base
基类,然后让诸如Rect
、Circle
等形状继承此类:
import { Listener, EventName, Shape } from './types';
export default class Base implements Shape {
private listeners: { [eventName: string]: Listener[] };
constructor() {
this.listeners = {};
}
draw(ctx: CanvasRenderingContext2D): void {
throw new Error('Method not implemented.');
}
on(eventName: EventNames, listener: Listener): void {
if (this.listeners[eventName]) {
this.listeners[eventName].push(listener);
} else {
this.listeners[eventName] = [listener];
}
}
getListeners(): { [name: string]: Listener[] } {
return this.listeners;
}
}
Base
有三个对外暴露的 api:
-
draw
用于绘制内容,需要将 canvas 上下文CanvasRenderingContext2D
传入 -
on
用于事件监听,收集到的事件回调会以事件名eventName
为 key,回调函数数组为 value 的形式存放在一个对象当中,此外我们还用了枚举类型定义了所有事件export enum EventNames { click = 'click', mousedown = 'mousedown', mousemove = 'mousemove', mouseup = 'mouseup', mouseenter = 'mouseenter', mouseleave = 'mouseleave', }
-
getListeners
获取此形状上所有的监听事件
有了Base
基类,我们就可以轻松定义其他具体的形状:
比如Rect
:
import Base from './Base';
interface RectProps {
x: number;
y: number;
width: number;
height: number;
strokeWidth?: number;
strokeColor?: string;
fillColor?: string;
}
export default class Rect extends Base {
constructor(private props: RectProps) {
super();
this.props.fillColor = this.props.fillColor || '#fff';
this.props.strokeColor = this.props.strokeColor || '#000';
this.props.strokeWidth = this.props.strokeWidth || 1;
}
draw(ctx: CanvasRenderingContext2D) {
const { x, y, width, height, strokeColor, strokeWidth, fillColor } = this.props;
ctx.save();
ctx.beginPath();
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.fillStyle = fillColor;
ctx.rect(x, y, width, height);
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
又比如原型 Circle
import Base from './Base';
interface RectProps {
x: number;
y: number;
radius: number;
strokeWidth?: number;
strokeColor?: string;
fillColor?: string;
}
export default class Circle extends Base {
constructor(private props: RectProps) {
super();
this.props.fillColor = this.props.fillColor || '#fff';
this.props.strokeColor = this.props.strokeColor || '#000';
this.props.strokeWidth = this.props.strokeWidth || 1;
}
draw(ctx: CanvasRenderingContext2D) {
const { x, y, radius, strokeColor, strokeWidth, fillColor } = this.props;
ctx.save();
ctx.beginPath();
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
构建 Stage
Stage
的代码如下
import { Shape } from './shapes/types';
export class Stage {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private dpr: number;
private shapes: Set<string>;
constructor(canvas: HTMLCanvasElement) {
// 解决 canvas 在高清屏上的模糊问题
const dpr = window.devicePixelRatio;
canvas.width = parseInt(canvas.style.width) * dpr;
canvas.height = parseInt(canvas.style.height) * dpr;
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d');
this.ctx.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 = new Set();
}
add(shape: Shape) {
shape.draw(this.ctx);
}
private handleCreator = (type: ActionType) => (evt: MouseEvent) => {};
}
在 Stage
的实现中,我们做了三件事情:
- 解决了 canvas 在高清屏上的模糊问题,由于不是本文讨论范围,这里略过;
- 对画布监听了三个事件
mousedown
、mouseup
和mousemove
,之所以监听是因为后面其他事件的模拟判断均和它们有关,此外,由于三者接下来的处理逻辑相似度非常高,为代码复用,故使用handleCreator
统一逻辑处理,并使用ActionType
加以区分不同类型 - 实现了
add
方法,在案例中我们通过调用stage.add(rect)
实现内容绘制,本质上add
会在内部调用形状的draw
方法,并把绘制上下文传入,由具体形状控制内部的内容展示
当我们实现到这,一个基本的 canvas 绘图系统就完成了,起码页面上能初现雏形,demo 代码如下:
import { Stage, Rect, Circle, EventNames } from './canvas-event-system';
const canvas = document.querySelector('#canvas') as HTMLCanvasElement;
const stage = new Stage(canvas);
const rect = new Rect({
x: 50,
y: 50,
width: 250,
height: 175,
fillColor: 'green',
});
const circle = new Circle({
x: 200,
y: 200,
radius: 100,
fillColor: 'red',
});
rect.on(EventNames.mousedown, () => console.log('rect mousedown'));
rect.on(EventNames.mouseup, () => console.log('rect mouseup'));
rect.on(EventNames.mouseenter, () => console.log('rect mouseenter'));
circle.on(EventNames.click, () => console.log('circle click!!'));
circle.on(EventNames.mouseleave, () => console.log('circle mouseleave!'));
stage.add(rect);
stage.add(circle);
鼠标的命中问题
内容框架搭建好了,接下来就到了构建事件系统的环节,要实现事件绑定及触发,首要解决的问题是:如何判断当前鼠标选择的是哪个元素?这个问题放在 dom 上并不复杂,每个 dom 元素占据的空间均是矩形,我们完全可以通过鼠标的坐标x, y
,加上每个矩形的x
y
width
height
四个值,简单判断它处于哪个矩形内部:
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 内的形状各种各样,不仅有圆形、椭圆形、多边形、不规则多边形形状,甚至还存在由曲线构成的不规则形状。比如像下面这种类似肥皂的形状:
虽然从数学意义上,我们可以通过诸如 射线法 等算法判断,但由于内容千变万化,在非常复杂的图形上,难以依靠数学计算得以解决,因此这里我们将利用 canvas 本身的特性,使用一种取巧的方式,解决鼠标的命中问题,思路如下:
我们先对 canvas 画布内的每个元素添加唯一的 id,并设计一种 id 与 rgba 互相转换的算法,然后再建立一个与当前画布等大的“影子画布”(不必显示在页面上),我们将用户能看见的画布称为 A,影子画布为 B,每当在 A 上渲染一个元素的时候,同步在 B 上的相同位置渲染一个等大的元素,并以其 id 所转换的 rgba 值填充。这样,当鼠标处于 A 上时,可通过当前坐标和 getImageData
可找到 B 上对应点的 rgba 值,将 rgba 反转为 id,即可知晓被选中的元素
为此,首先我们需要一个函数 createId
生成 id,两个转换函数 idToRgba
和 rgbaToId
const idPool = {}; // 避免 id 重复
export function createId(): string {
let id = createOnceId();
while (idPool[id]) {
id = createOnceId();
}
return id;
}
function createOnceId(): string {
return Array(3)
.fill(0)
.map(() => Math.ceil(Math.random() * 255))
.concat(255)
.join('-');
}
export function idToRgba(id: string) {
return id.split('-');
}
export function rgbaToId(rgba: [number, number, number, number]) {
return rgba.join('-');
}
原理很简单,rgba 的 r、g、b 的范围是 0255,我们生成 3 个 0255 的随机数即可。对于透明度 a 值则必须为 1(不透明),否则当两个形状重叠时,重叠部分的 rgba 将被混合,会影响命中的判断,这里为方便转换,a 默认给了 255
接着需要对 Base
进行调整:
export default class Base implements Shape {
public id: string;
constructor() {
// ...other code
this.id = createId();
}
// ...other code
draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D): void {
throw new Error('Method not implemented.');
}
getId(): string {
return this.id;
}
}
每当创建一个实例时,实例内部均会默认添加一个 id
,可通过 getId
获取,此外,draw
方法也进行了调整,需要多传入一个影子画布的上下文
对于继承自 Base
的 Rect
和 Circle
,也要进行相应的改造,这里就以 Rect
为例,更多内容可详见源码:
import { idToRgba } from '../helpers';
import Base from './Base';
export default class Rect extends Base {
// other code...
draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D) {
const { x, y, width, height, strokeColor, strokeWidth, fillColor } = this.props;
ctx.save();
ctx.beginPath();
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.fillStyle = fillColor;
ctx.rect(x, y, width, height);
ctx.fill();
ctx.stroke();
ctx.restore();
const [r, g, b, a] = idToRgba(this.id);
osCtx.save();
osCtx.beginPath();
osCtx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
osCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
osCtx.rect(x, y, width, height);
osCtx.fill();
osCtx.stroke();
osCtx.restore();
}
}
看,为保持两个画布的同步,每当在 ctx
绘制一个矩形时,也要在 osCtx
同步绘制。
接下来则需要对 Stage
进行调整:它需要根据传入的 canvas 复刻一个 OffscreenCanvas,同时还需要在根据当前鼠标的位置判断元素的命中:
export class Stage {
private canvas: HTMLCanvasElement;
private osCanvas: OffscreenCanvas;
private ctx: CanvasRenderingContext2D;
private osCtx: OffscreenCanvasRenderingContext2D;
private dpr: number;
private shapes: Set<string>;
constructor(canvas: HTMLCanvasElement) {
// other codes...
this.osCanvas = new OffscreenCanvas(canvas.width, canvas.height);
this.osCtx = this.osCanvas.getContext('2d');
this.osCtx.scale(dpr, dpr);
this.dpr = dpr;
this.shapes = new Set(); // 通过一个 Set 保存所有 add 进来的形状元素
}
add(shape: Shape) {
const id = shape.getId();
this.shapes.add(id);
shape.draw(this.ctx, this.osCtx);
}
private handleCreator = (type: ActionType) => (evt: MouseEvent) => {
const x = evt.offsetX;
const y = evt.offsetY;
// 根据 x, y 拿到当前被选中的 id
const id = 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
*/
private hitJudge(x: number, y: number): string {
const rgba = Array.from(this.osCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data);
const id = rgbaToId(rgba as [number, number, number, number]);
return this.shapes.has(id) ? id : undefined;
}
}
事件模拟
由于 handleCreator(actionType)
同时处理了三个鼠标事件,因此只要鼠标在 canvas 上,它的一举一动、经过了哪些元素都会被捕获到,当然,要实现事件的触发,我们必须通过一些操作“组合”,去判断当前的事件类型,由于篇幅关系,笔者主要模拟了以下几种事件:
- mousedown = mousedown
- mouesmove = mousemove
- mouseup = mouseup
- click = mousedown + mouseup
- mouseenter(id1) = mousemove(id2) + mousemove(id1)
- mouseleave(id2) = mousemove(id2) + mousemove(id1)
于是我们创建一个 EventSimulator
类,它将根据传入的当前鼠标动作类型,预判此时应该发生的事件:
import { Listener, EventNames } from './shapes';
export interface Action {
type: ActionType;
id: string;
}
export enum ActionType {
Down = 'DOWN',
Up = 'Up',
Move = 'MOVE',
}
export default class EventSimulator {
private listenersMap: {
[id: string]: {
[eventName: string]: Listener[];
};
} = {};
private lastDownId: string;
private lastMoveId: string;
addAction(action: Action, evt: MouseEvent) {
const { type, id } = action;
// mousemove
if (type === ActionType.Move) {
this.fire(id, EventNames.mousemove, evt);
}
// mouseover
// mouseenter
if (type === ActionType.Move && (!this.lastMoveId || this.lastMoveId !== id)) {
this.fire(id, EventNames.mouseenter, evt);
this.fire(this.lastMoveId, EventNames.mouseleave, evt);
}
// mousedown
if (type === ActionType.Down) {
this.fire(id, EventNames.mousedown, evt);
}
// mouseup
if (type === ActionType.Up) {
this.fire(id, EventNames.mouseup, evt);
}
// click
if (type === ActionType.Up && this.lastDownId === id) {
this.fire(id, EventNames.click, evt);
}
if (type === ActionType.Move) {
this.lastMoveId = action.id;
} else if (type === ActionType.Down) {
this.lastDownId = action.id;
}
}
addListeners(
id: string,
listeners: {
[eventName: string]: Listener[];
},
) {
this.listenersMap[id] = listeners;
}
fire(id: string, eventName: EventNames, evt: MouseEvent) {
if (this.listenersMap[id] && this.listenersMap[id][eventName]) {
this.listenersMap[id][eventName].forEach((listener) => listener(evt));
}
}
}
接着我们继续完善下 Stage
,将实现好的 EventSimulator
放入进去,完整代码如下
import { rgbaToId } from './helpers';
import { Shape } from './shapes/types';
import EventSimulator, { ActionType } from './EventSimulator';
export * from './shapes';
export class Stage {
private canvas: HTMLCanvasElement;
private osCanvas: OffscreenCanvas;
private ctx: CanvasRenderingContext2D;
private osCtx: OffscreenCanvasRenderingContext2D;
private dpr: number;
private shapes: Set<string>;
private eventSimulator: EventSimulator;
constructor(canvas: HTMLCanvasElement) {
const dpr = window.devicePixelRatio;
canvas.width = parseInt(canvas.style.width) * dpr;
canvas.height = parseInt(canvas.style.height) * dpr;
this.canvas = canvas;
this.osCanvas = new OffscreenCanvas(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 = new Set();
this.eventSimulator = new EventSimulator();
}
add(shape: Shape) {
const id = shape.getId();
this.eventSimulator.addListeners(id, shape.getListeners());
this.shapes.add(id);
shape.draw(this.ctx, this.osCtx);
}
private handleCreator = (type: ActionType) => (evt: MouseEvent) => {
const x = evt.offsetX;
const y = evt.offsetY;
const id = 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
*/
private hitJudge(x: number, y: number): string {
const rgba = Array.from(this.osCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data);
const id = rgbaToId(rgba as [number, number, number, number]);
return this.shapes.has(id) ? id : undefined;
}
}
结尾
到此为止,到此为止,整个 canvas 事件系统就搭建完成啦,一起看看运行时的效果吧~
看起来效果不错,我们可以在
Rect
、Circle
等形状通过 Rect.on('xxx', func)
的形式实现事件监听,满足了基本需求。
然而,由于篇幅关系,本文做了许多内容的缩减,要想真正实现一个能用于生产环境的事件系统,还需要很多工作,比如 元素嵌套关系所带来的额外处理:其实元素之间不仅存在层级关系,还有嵌套关系,如果元素存在嵌套,那必然要处理事件捕获、冒泡相关问题,比如如何取消冒泡;此外本文并未模拟 mouseover
、mouseout
等与嵌套关系相关的事件等......当你看到这,不妨一起思考下要如何解决以上的场景:)(PS:如果有机会的话,也许会单独写篇文章讨论这些问题)
以上便是本文的所有内容,欢迎交流讨论~😊