100行代码写个canvas库

2,509 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。 点击查看活动详情

写在前面

最近看了好几篇万字长文的canvas教程,除了介绍了canvas是干嘛的,无一例外都是调了一下canvasapi画了几个矩形、圆形、线段什么的,看完之后能用canvas做点啥还是没有概念,所以打算用canvas做几个小应用,实际的感受一下它的威力。

一个简单的流程图

拖拽节点,编辑节点信息,绑定自定义数据,通过连线元素连接节点

flow.gif

一个雪碧图工具

选择图片拖拽位置合成图,生成样式代码和配置信息,通过配置信息可以再次编辑

cssssprite.gif

功能点

不同于常规的前端开发,canvas是没有提供事件机制和自动更新机制的,也就是说开发者要自行实现这些功能(虽然很多库都提供能了这样的功能),如果没有这些基础的功能,在canvas上绘制的图形既不能交互,也不能更新。

  • 舞台刷新
  • 事件(mouseupmousedownmousemove
  • 自定义事件(可以给画布上的元素添加自定义事件)
  • 添加/移除/更新元素
  • 组合容器(把几个元素组合起来,看成一个整体进行操作)

舞台更新

在常规开发中,我们更改了一个dom的信息或者样式,除了该dom本身的样式更新,浏览器也会根据dom树和css来处理其他的dom节点,使得整个应用显示正常,但是在canvas中,它真的就是一块布,想更新其中某个元素,只能把它擦除了再绘制一个新的,而且在擦除的过程中还有可能擦除别的元素,所以实际上canvas的应用方式大多是不停的把canvas全部擦除,再把所有的元素绘制一遍,包括更新的和没更新的,也就是说我们需要记录画布上所有元素的信息。

// 舞台
class Stage {
    constructor() {
        // 初始化
    }
    // 清除整块画布
    clear() {
        this.ctx.clear(0, 0, this.canvas.width + 1, this.canvas.height + )
    }
    render() {
        // 以浏览器刷新的频率更新舞台
        requestAnimationFrame(Stage.prototype.render.bind(this));
        this.clear()
        // 再绘制所有元素
    }
}

事件

同样的,绘制的元素也需要有交互,我们没办法直接给画布上的元素添加事件,只能通过canvas的交互事件传递给元素。 当canvas触发mousedown的时候,我们找到画布上在这个范围类最顶层的元素,认为点击到了这个元素,此时添加mousemove事件 当canvas触发mousemove的时候,把move信息同时传递给点击元素 当canvas触发mouseup的时候,传递mouseup信息,同时清除mousemove事件

// 添加mousedown事件
this.canvas.addEventListener("mousedown", (e) => {
    /* 
        根据鼠标所在的xy找到这个位置且最上层的元素
        每个类型都实现一个判断是否点击到自身的方法,这个类需要自行定义,如矩形,除了绘制之外,还添加额外的判断
        例如一个矩形,this.x <= x && this.y <= y && this.x + this.w >= x && this.y + this.h >= y;
    */
    let clickElements = this.children.filter((item) => {
        return item.pointInElement && item.pointInElement(e.offsetX, e.offsetY, this.ctx);
    });
    // 在找到zindex最大的那个,我们在添加元素的时候,会给每个元素一个递增的zindex,后加的认为在上层
    let target = clickElements.find((item) => 
        item.zindex == Math.max(...clickElements.map((item) => item.zindex)));
    // 如果找到了,那么就触发这个元素的mousedown事件,否则就是点击了canvas空白处
    if(target) {
        this.target = target;
        this.target.dispatchEvent && this.target.dispatchEvent("click");
        // 处理mousemove
        this.canvas.addEventListener("mousemove", this.mouseMove, false);
    }
});
// 添加mousemove事件
mouseMove = (e) => {
    // 触发元素的mousemove事件
    // 每个类除了定义自身的绘制,还会定义一个更新自身xy的方法,在move的时候触发
    this.target.updatePosition && 
    this.target.updatePosition(this.clickX + moveX - this.targetDx, this.clickY + moveY - this.targetDy)
}
// 处理mouseup
document.addEventListener("mouseup", () => {
    this.target && this.target.dispatchEvent && this.target.dispatchEvent("mouseup");
    this.canvas.removeEventListener("mousemove", this.mouseMove, false);
});

这样就把canvas的点击事件都传递给画布上的元素上,这个元素不是原生的canvas api绘制的,而是进行了一层封装,除了绘制之外,还要增加一点逻辑来响应Stage,因为我们要记住每个实例的信息,用来重绘,如矩形Rect

class Rect {
    constructor(options) {
        this.x = options.x
        this.y = options.y
        this.w = options.w
        this.h = options.h
        this.offsetX = options.offsetX;
        this.offsetY = options.offsetY;
        this.color = options.color
    }
    draw(ctx) {
        ctx.beginPath();
        ctx.fillStyle = this.color;
        ctx.fillRect(this.x, this.y, this.w, this.h);
    }
    // 判断点是否在矩形内,用来响应点击事件
     pointInElement(x, y) {
        return this.x <= x && this.y <= y && this.x + this.w >= x && this.y + this.h >= y;
    }
    // 更新
    updatePosition(x, y) {
        this.x = x + this.offsetX;
        this.y = y + this.offsetY;
    }
}

自定义事件

前面已经把canvas的事件都传递到元素上了,接下来只需要在元素实例化的时候添加自定义的处理就可以了,比如一个简单的console

// rect响应Stage的自定义事件
class Rect {
    // 省略
    addEvent(key, fn) {
        this.event[key] = this.event[key] || [];
        this.event[key].push(fn);
    }
    dispatchEvent(key) {
        this.event[key] && this.event[key].forEach((item) => item(this));
    }
}
let r = new Rect({
    x: 0,
    y: 0,
    w: 100,
    h: 100,
    color: "red"
})
r.addEvent("click", (t) => {
    console.log("你点击了rect")
})

添加/移除/更新元素

由于我们设定了Stage的刷新机制,所以所有的元素都交给Stage来管理,直接调用canvas api绘制的内容是不是呈现在canvas上的

class Stage {
    // 省略
    add(child) {
        this.children.push(child);
        child.parent = this;
        this.render();
    }
    remove(child) {
        let index = this.children.findIndex((item) => item.id == child.id)
        index != -1 && this.children.splice(index, 1);
    }
}

组合容器

重点来了,很多时候我们要描述的内容并不是一个孤零零的对象,而是一个复杂的组合体,比如一个简单的文字板,是有一个文本和一个背景板组成的,当我们移动它的时候,指的是同时移动背景板和文字。 所以设计一个组合容器,可以往里面添加元素,各元素之间基于容器保持相对位置,那么操作这个组合就操作了里面所有的元素了。

// 组合容器
class Container {
    constructor(options) {
        // 初始化xywh等信息
    }
    // 添加元素到容器里
    add(child) {
        child.parent = this;
        this.children.push(child);
    }
    draw(ctx) {
        // 绘制容器里的所有元素
        this.children.forEach(item => {
            // 容器里的元素根据相对位置更新xy
            item.updatePosition(this.x, this.y);
            // 调用元素本身的绘制
            item.draw(ctx);
        })
    }
}

实现一个文字板

image.png

  • 一个文本元素Text
  • 一个矩形背景框Rect
  • 一个矩形边框Rect
  • 一个删除按钮Icon
  • 删除按钮点击销毁整个组件
import Container from "./Container"; // 组合容器
import Icon from "./Icon"; // 图片类
import Rect from "./Rect"; // 矩形类
import Stage from "./Stage"; // 舞台
import Word from "./Word"; // 文字类

class TextElm {
    constructor(option) {
        this.container = new Container({
            x: option.x,
            y: option.y,
            w: option.w,
            h: option.h
        });

        this.bg = new Rect({
            x: 0,
            y: 0,
            w: option.w,
            h: option.h,
            offsetX: 0,
            offsetY: 0,
            color: "pink"
        })

        this.icon = new Icon({
            offsetX: option.w - 20,
            offsetY: 0,
            w: 20,
            h: 20,
            src: "./close.png"
        }); // icon固定在容器右上角

        this.icon.addEvent("click", (t) => {
            this.container.destory();
        });

        this.word = new Word({
            text: option.text,
            offsetX: 0,
            offsetY: option.h / 2,
            color: "blue"
        }); // 文本垂直居中

        this.container.add(this.bg)
        this.container.add(this.icon);
        this.container.add(this.word);

        return this.container
    }
}

使用

flow2.gif

import Stage from "./Stage";
import TextElm from "./TextElm";

// 初始化一个800 * 700的舞台
let s2 = new Stage(document.getElementById("stage"));

// 在 (50, 50)的位置初始化一个200 * 50的文字板
let t1 = new TextElm({
    text: "hello 解决1",
    x: 350,
    y: 250,
    w: 200,
    h: 50,
    color: "blue",
    parent: s2
});
s2.add(t1);

// 在 (300, 300)的位置初始化一个200 * 50的文字板
let t2 = new TextElm({
    text: "world 哈哈2",
    x: 300,
    y: 400,
    w: 200,
    h: 50,
    color: "blue",
    parent: s2
});
s2.add(t2);

// 在 (300, 100)的位置初始化一个200 * 50的文字板
let t3 = new TextElm({
    text: "多连线3",
    x: 300,
    y: 100,
    w: 200,
    h: 50,
    color: "blue",
    parent: s2[代码片段](https://code.juejin.cn/pen/7130565445010063391)
});
s2.add(t3);

这样,一个简单的canvas库就完成了,通过组合容器和自定义事件,封装好基础的元素,可以只关注业务逻辑的实现和交互。