Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

canvas 进阶 —— 如何实现 canvas 的事件系统 #8

Open
JS-Hao opened this issue Oct 28, 2020 · 0 comments
Open

canvas 进阶 —— 如何实现 canvas 的事件系统 #8

JS-Hao opened this issue Oct 28, 2020 · 0 comments
Labels

Comments

@JS-Hao
Copy link
Owner

JS-Hao commented Oct 28, 2020

众所周知,canvas 是前端进军可视化领域的一大利器,借助 canvas 画布我们不仅可以实现许多 dom 和 css 难以实现的、各种绚丽多彩的视觉效果,而且在渲染数量繁多、内容复杂的场景下,其性能表现及优化空间也占据一定优势。

然而 canvas 却存在一个缺陷:由于 canvas 是作为一个整体画布存在,所有的内容只不过是其内部渲染的结果,我们不能像在 dom 元素上监听事件一样,在 canvas 所渲染的图形内绑定各种事件,因此基于 canvas 画布开发出一套交互式应用是件复杂的事情。虽然 gayhub 上很多 canvas 框架自带了事件系统,但如果想深入学习 canvas,笔者认为还是有必要了解其实现原理,因此本篇文章将实现一个简易版的 canvas 事件系统。

正文开始前,先贴下仓库地址,各位按需取用 canvas-event-system

环境搭建

要在 canvas 上实现事件系统,我们必须先做些准备工作 —— 首先我们得往 canvas 上填充些“内容”,没有内容,谈何事件监听,下文我们将这些可绑定事件的内容称之为元素。同时,为简明扼要,笔者这里仅实现了形状(Shape) 这一类元素;当我们有了一个个元素后,我们还需要一个容器去管理它们,这个容器则是 —— 舞台(Stage),舞台如同上帝一般,负责元素们的渲染、事件管理及事件触发,接下来我们先初始化这两大类

API 设计

在实现细节前,笔者是这样设想事件系统的:我们可以通过 new 操作符生成一个个的 Shape 实例,并可在实例上监听各类事件,然后再将它们addStage即可,就像这样:

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基类,然后让诸如RectCircle等形状继承此类:

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 在高清屏上的模糊问题,由于不是本文讨论范围,这里略过;
  • 对画布监听了三个事件mousedownmouseupmousemove,之所以监听是因为后面其他事件的模拟判断均和它们有关,此外,由于三者接下来的处理逻辑相似度非常高,为代码复用,故使用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,两个转换函数 idToRgbargbaToId

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 方法也进行了调整,需要多传入一个影子画布的上下文

对于继承自 BaseRectCircle,也要进行相应的改造,这里就以 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 事件系统就搭建完成啦,一起看看运行时的效果吧~

看起来效果不错,我们可以在 RectCircle 等形状通过 Rect.on('xxx', func) 的形式实现事件监听,满足了基本需求。

然而,由于篇幅关系,本文做了许多内容的缩减,要想真正实现一个能用于生产环境的事件系统,还需要很多工作,比如 元素嵌套关系所带来的额外处理:其实元素之间不仅存在层级关系,还有嵌套关系,如果元素存在嵌套,那必然要处理事件捕获、冒泡相关问题,比如如何取消冒泡;此外本文并未模拟 mouseovermouseout等与嵌套关系相关的事件等......当你看到这,不妨一起思考下要如何解决以上的场景:)(PS:如果有机会的话,也许会单独写篇文章讨论这些问题)

以上便是本文的所有内容,欢迎交流讨论~😊

@JS-Hao JS-Hao added the canvas label Oct 28, 2020
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant