JS前端使用canvas实现扩展物体类和事件派发


Posted in Javascript onAugust 05, 2022

前言

虽然我们讲了这么多个章节,但其实目前为止就只有一个 Rect 类能用,略显单调。于是乎,为了让整个画布稍微生动一些,这个章节我们来尝试增加一个图片类,如果你以后需要扩展一个物体类,也是用同样的方法。

另外有时候我们还希望在物体属性改变时或者画布创建后做一些额外的事情,这个时候事件系统就派上用场啦,也就是我们常说的发布订阅,我觉的这是前端应用最广的设计模式没有之一了?。

FabricImage 图片类

话不多说,开撸走起?。先来看看 FabricImage 图片类的实现,我们可以想一下一个图片类应该具备什么样的功能?,可以看看下面图片类代码的调用方式找找灵感??:

FabricImage.fromURL(
    'https://p26-passport.byteacctimg.com/img/user-avatar/7470b65342454dd6699a6cf772652260~300x300.image',
    (img) => { canvas.add(img) }, // 这里需要手动回调添加物体
    { width: 200, height: 200, left: 300, top: 300 }
);
FabricImage.fromURL(
    './src/beidaihe.jpeg',
    (img) => { canvas.add(img) }, // 这里需要手动回调添加物体
    { width: 200, height: 200, left: 600, top: 400 }
);

上面代码展示了两种最常用的图片加载方式,一个是远程链接,一个是本地图片,调用方式看起来有些特殊,不过我们先不管这个,直接来实现它就行。既然要绘制图片,那肯定要先加载好才能用,这也是图片类特殊的地方,它是异步的,并且加载图片的方法是通用的,所以我们把它写在 Util 类里,来简单看下加载图片的代码(也许你在面试中遇见过?):

class Util {
    static loadImage(url) {
        return new Promise((resolve, reject) => { // 方便链式调用,promise 这玩意多写多熟悉就懂了
            const img = document.createElement('img');
            img.onload = () => { // 先进行事件监听,要在请求图片前
                img.onload = img.onerror = null;
                resolve(img);
            };
            img.onerror = () => {
                reject(new Error('Error loading ' + img.src));
            };
            img.src = url; // 这个才是真正去请求图片
        });
    }
}

代码不多也不难理解,那接下来就要看如何绘制了。在 canvas 中要想绘制图片也不难,大体过程就是把图片变成 img 标签,当做参数传给 ctx.drawImage 这个画布专用绘制方法,稍微要注意点的就是图片的宽高设置,我们会先取传入参数 options 中的宽高作为图片的大小,没传参数的话再取图片自身的宽高(因为此时图片已经加载完成,所以可以取到图片的信息),同样的来简单看下代码实现??:

class FabricImage extends FabricObject { // 继承基类是必须的
    public type: string = 'image'; // 类型标识
    public _element: HTMLImageElement;
    /** 默认通过 img 标签来绘制,因为最终都是要通过该标签绘制的 */
    constructor(element: HTMLImageElement, options) {
        super(options);
        this._initElement(element, options);
    }
    _initElement(element: HTMLImageElement, options) {
        this._element = element;
        this.setOptions(options);
        this._setWidthHeight(options);
        return this;
    }
    /** 设置图像大小 */
    _setWidthHeight(options) {
        this.width = 'width' in options ? options.width : this.getElement() ? this.getElement().width || 0 : 0;
        this.height = 'height' in options ? options.height : this.getElement() ? this.getElement().height || 0 : 0;
    }
    /** 核心:直接调用 drawImage 绘制图像 */
    _render(ctx: CanvasRenderingContext2D) {
        const x = -this.width / 2;
        const y = -this.height / 2;
        const elementToDraw = this._element;
        elementToDraw && ctx.drawImage(elementToDraw, x, y, this.width, this.height);
    }
    getElement() {
        return this._element;
    }
    /** 如果是根据 url 或者本地路径加载图像,本质都是取加载图片完成之后在转成 img 标签 */
    static fromURL(url, callback, imgOptions) {
        Util.loadImage(url).then((img) => {
            callback && callback(new FabricImage(img as HTMLImageElement, imgOptions));
        });
    }
}

看完上面的代码,你应该理解了前面为什么要那样调用,虽然看起来有点繁琐?。然后。。。一个简简单单的 FabricImage 类就写好啦。不过这里我再补充两个小点:

一个是我们可以将图片素材缓存起来,这样如果用到多张相同的图片就不用重复发请求啦;

另一个就是 imageSmoothingEnabled 属性,这个是 canvas 中用来设置图片是否平滑的属性,默认值为 true,表示平滑,false 则表示图片不平滑。比如将一张 50*50 的图像放大 3 倍的时候,canvas 会默认做一些抗锯齿处理使之平滑,如果不需要的话可以将其设置成 false,也算是种优化,具体可以看看 mdn 上这个具体例子,这里就作为知识点简单了解下,当然我也截了个示意图意思一下(仔细看?,一定能看出差别的):

JS前端使用canvas实现扩展物体类和事件派发

其实扩展一个类还是非常简单的,你只需要知道这个类会有哪些独特的自有属性,并搞定 _render() 方法即可?。

事件派发

因为这个章节内容比较少,所以我就把事件派发的内容也放在这里讲解了?。

有时候我们希望在物体初始化前后、状态改变前后、一些交互前后,能够触发相应的事件来实现自己的需求,比如画布被点击了我想...,物体被移动了我想...,这个就是典型的发布订阅模式,前端应用最广泛的设计模式,没有之一(当然只是我觉得),比如:

  • html 中的 addEventListener
  • vue 中的 EventBus
  • 各种库和插件暴露的一些钩子函数(或者说是生命周期)

早前这玩意我也没真正理解,总是看了就忘,因为总感觉这东西很抽象,说不上来这到底是个什么东西,所以这里我希望把它具象化,以便于理解。发布订阅它其实可以理解成一个简单的对象,就像下面这样:

// key 就是事件名,key 存储的值就是一堆回调函数
const eventObj = {
    eventName1: [cb1, cb2, ... ],
    eventName2: [cb1, cb2, cb3, ... ],
    ...
    // 比如下面这些常见的事件名
    click: [cb1, cb2, ... ],
    created: [cb1, cb2, cb3, ... ],
    mounted: [cb1, cb2, ... ],
}

我们最终要构造的就是这样一个对象,eventObj 相当于一个事件管理中心,当我们触发相应 eventName 的事件时(发布),就会找到 eventObj 里面 eventName 对应的那个数组,然后将里面的回调函数 cb 挨个遍历执行即可。那我们怎么向 eventObj 添加事件回调呢,很简单就是找到 eventName 对应的数组往里 push 就行(订阅),当然为了操作方便我们需要提供 eventObj.on、eventObj.off、eventObj.emit 等方法方便我们添加、触发和删除事件。

下面我们来看看具体实现,这东西写多了就是很简单的一件事情,写法也比较固定,写好了之后也基本不用改,实在不行 copy 也行?:

/**
 * 发布订阅,事件中心
 * 应用场景:可以在特定的时间点触发一系列事件(在本文主要就是渲染前后、初始化物体前后、物体状态改变时)
 */
export class EventCenter {
    private __eventListeners; // 就是上面说的 eventObj 那个对象
    /** 往某个事件里面添加回调,找到事件名所对应的数组往里push */
    on(eventName, handler) {
        if (!this.__eventListeners) {
            this.__eventListeners = {};
        }
        if (!this.__eventListeners[eventName]) {
            this.__eventListeners[eventName] = [];
        }
        this.__eventListeners[eventName].push(handler);
        return this;
    }
    /** 触发某个事件回调,找到事件名对应的数组拿出来遍历执行 */
    emit(eventName, options = {}) {
        if (!this.__eventListeners) {
            return this;
        }
        let listenersForEvent = this.__eventListeners[eventName];
        if (!listenersForEvent) {
            return this;
        }
        for (let i = 0, len = listenersForEvent.length; i < len; i++) {
            listenersForEvent[i] && listenersForEvent[i].call(this, options);
        }
        this.__eventListeners[eventName] = listenersForEvent.filter((value) => value !== false);
        return this;
    }
    /** 删除某个事件回调 */
    off(eventName, handler) {
        if (!this.__eventListeners) {
            return this;
        }
        if (arguments.length === 0) {
            // 如果没有参数,就是解绑所有事件
            for (eventName in this.__eventListeners) {
                this._removeEventListener.call(this, eventName);
            }
        } else {
            // 解绑单个事件
            this._removeEventListener.call(this, eventName, handler);
        }
        return this;
    }
    _removeEventListener(eventName, handler) {
        if (!this.__eventListeners[eventName]) {
            return;
        }
        let eventListener = this.__eventListeners[eventName];
        // 注意:这里我们删除监听一般都是置为 null 或者 false
        // 当然也可以用 splice 删除,不过 splice 会改变数组长度,这点要尤为注意
        if (handler) {
            eventListener[eventListener.indexOf(handler)] = false;
        } else {
            eventListener.fill(false);
        }
    }
}

希望这种模式大家能够达到默写的水平,对我们日后代码的理解也确实是很有帮助的。

然后接下来要做什么呢?很简单,就是让需要事件的类继承至这个事件类就可以了,然后在有需要的地方触发就行了,这里我们以画布为例,看下下面的代码你就知道这种套路了??(注意下面代码中注释的地方):

class Canvas extends EventCenter { // 继承
    _initObject(obj: FabricObject) {
        obj.setupState();
        obj.setCoords();
        obj.canvas = this;
        this.emit('object:added', { target: obj }); // 画布触发添加物体时间
        obj.emit('added'); // 物体触发被添加事件
    }
    renderAll() {
         this.emit('before:render');
         // 绘制所有物体...
         this.emit('after:render');
    }
    clear() {
        ...
        this.clearContext(this.contextContainer);
        this.clearContext(this.contextTop);
        this.emit('canvas:cleared'); // 触发画布清空事件
        this.renderAll();
        return this;
    }
    __onMouseMove(e: MouseEvent) {
        ...
        const target = this._currentTransform.target;
        if (this._currentTransform.action === 'rotate') { // 如果是旋转物体
            this.emit('object:rotating', { target, e });
            target.emit('rotating', { e });
        } else if (this._currentTransform.action === 'scale') { // 如果是缩放物体
            this.emit('object:scaling', { target, e });
            target.emit('scaling', { e });
        } else { // 如果是拖拽物体
            this.emit('object:moving', { target, e });
            target.emit('moving', { e });
        }
        ...
        this.emit('mouse:move', { target, e });
        target && target.emit('mousemove', { e });
    }
    __onMouseUp(e: MouseEvent) {
        if (target.hasStateChanged()) { // 物体状态改变了才派发事件
            this.emit('object:modified', { target });
            target.emit('modified');
        }
    }
}

因为 Canvas 类继承了 EventCenter 这个类,所以画布就有了订阅和发布的功能,同样的我们也可以让 FabricObject 这个物体基类继承 EventCenter,这样每个物体也有了发布订阅的功能。有同学可能会问,上面的代码只看到了 emit 事件,怎么没看到 on 和 off 事件呢?因为之前说了,库或者插件一般只提供钩子,上面 emit 的地方就可以称作钩子(怎么感觉有点像埋点?),而 on 和 off 事件则是我们开发时才需要写的。

有同学可能还是会疑惑为什么要这样,其实你把这个当做一种好的写法记住就行了,算是经验总结,写多了就能慢慢体会到。或者我们可以类比下浏览器的事件监听,想想页面中的元素是不是都可以有点击和鼠标移入移出事件,那页面上的元素种类也很多,它又是怎么实现的呢?其实它们都也继承于 EventTarget 类,所以就有了事件,怎么证明呢?我们可以在控制台随便打印一个元素看下(父级的)结果??:

JS前端使用canvas实现扩展物体类和事件派发

不能说是很像,只能说是一毛一样。而且一般情况下,如果有事件系统,我们大多都会把它放在顶层供其他类继承,可见这个类是很重要的,大家都想要它?。

这里还是再补充一个小点吧?:就是关于事件名的命名,举上面代码中的两个例子,大概长这样:

canvas:clearedobject:moving,为什么要加个冒号嘞,直接写一个英文单词不香吗?这个其实要看你系统复不复杂,简单的话用一个单词就可以了,复杂的话一般会像这样写 主体:动作,主要是为了方便区分,仅此而已(也只是我觉得),比如小程序里面的事件名就是这样。

小结

本个章节我们主要讲解了图片类和事件系统的实现,希望你能够记住以下几点:

  • 图片是异步的,加载完成之后需要将其变成 img 标签,再调用 ctx.drawImage 才能绘制到画布上
  • 如果有事件系统,我们大多都会把它放在顶层供其他类继承,可见它在前端有多受欢迎

然后这里是简版 fabric.js 的代码链接,有兴趣的可以看看,当然啦更建议直接去看 fabric.js 的源码。好啦,本次分享就到这里,下个章节会分享的是 canvas 中动画的实现?,又是这个系列最重要的章节之一

canvas ~ 开始真正的交互啦(七)?

canvas 中如何实现物体的框选(六)?

canvas 中如何实现物体的点选(五)?

canvas 中物体边框和控制点的实现(四)?

实现一个轻量 fabric.js 系列三(物体基类)?

实现一个轻量 fabric.js 系列二(画布初始化)?

实现一个轻量 fabric.js 系列一(摸透 canvas)?

以上就是JS前端使用canvas实现扩展物体类和事件派发的详细内容,更多关于canvas扩展物体类事件派发的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
JavaScript 获取当前时间戳的代码
Aug 05 Javascript
Jquery中删除元素的实现代码
Dec 29 Javascript
ajax与302响应代码测试
Oct 23 Javascript
js判断数据类型如判断是否为数组是否为字符串等等
Jan 15 Javascript
js判断设备是否为PC并调整图片大小
Feb 12 Javascript
jQuery中:eq()选择器用法实例
Dec 29 Javascript
Atitit.js的键盘按键事件捆绑and事件调度
Apr 01 Javascript
canvas 绘制圆形时钟
Feb 22 Javascript
vue获取input输入值的问题解决办法
Oct 17 Javascript
微信小程序云开发如何使用云函数生成二维码
May 18 Javascript
用JS实现一个简单的打砖块游戏
Dec 11 Javascript
如何制作自己的原生JavaScript路由
May 05 Javascript
JS前端canvas交互实现拖拽旋转及缩放示例
Aug 05 #Javascript
canvas 中如何实现物体的框选
Aug 05 #Javascript
JS前端使用canvas实现物体的点选示例
Aug 05 #Javascript
前端canvas中物体边框和控制点的实现示例
Aug 05 #Javascript
JS前端轻量fabric.js系列物体基类
Aug 05 #Javascript
JS前端轻量fabric.js系列之画布初始化
Aug 05 #Javascript
vue3 自定义图片放大器效果的示例代码
Jul 23 #Vue.js
You might like
PHP 设计模式系列之 specification规格模式
2016/01/10 PHP
CSDN轮换广告图片轮换效果
2007/03/27 Javascript
一个简单的jQuery插件ajaxfileupload.js实现ajax上传文件例子
2014/06/26 Javascript
javascript的日期对象、数组对象、二维数组使用说明
2014/12/22 Javascript
Bootstrap框架下下拉框select搜索功能
2020/03/26 Javascript
详解Vue2+Echarts实现多种图表数据可视化Dashboard(附源码)
2017/03/21 Javascript
protractor的安装与基本使用教程
2017/07/07 Javascript
详解nodejs中express搭建权限管理系统
2017/09/15 NodeJs
vue实现长图垂直居上 vue实现短图垂直居中
2017/10/18 Javascript
vue.js实现的经典计算器/科学计算器功能示例
2018/07/11 Javascript
详解vue-cli官方脚手架配置
2018/07/20 Javascript
在Vue-cli里应用Vuex的state和mutations方法
2018/09/16 Javascript
JavaScript链式调用实例浅析
2018/12/19 Javascript
layuiAdmin循环遍历展示商品图片列表的方法
2019/09/16 Javascript
Javascript 类型转换、封闭函数及常见内置对象操作示例
2019/11/15 Javascript
[01:45]绝对公平!DOTA2队长征召模式详解
2014/04/25 DOTA
[00:27]DOTA2荣耀之路2:Patience from zhou!
2018/05/24 DOTA
python实现决策树C4.5算法详解(在ID3基础上改进)
2017/05/31 Python
Python定时器实例代码
2017/11/01 Python
Python实现打印螺旋矩阵功能的方法
2017/11/21 Python
python selenium UI自动化解决验证码的4种方法
2018/01/05 Python
Python3.6使用tesseract-ocr的正确方法
2018/10/17 Python
解决python flask中config配置管理的问题
2019/07/26 Python
Python 操作 ElasticSearch的完整代码
2019/08/04 Python
win10系统Anaconda和Pycharm的Tensorflow2.0之CPU和GPU版本安装教程
2019/12/03 Python
Python面向对象程序设计之类和对象、实例变量、类变量用法分析
2020/03/23 Python
浅析Python面向对象编程
2020/07/10 Python
Html5适配iphoneX刘海屏的简单实现
2019/04/09 HTML / CSS
Fossil美国官网:Fossil手表、手袋、珠宝及配件
2017/02/01 全球购物
英国最大的正宗复古足球衫制造商和零售商:TOFFS
2018/06/21 全球购物
介绍一下linux的文件权限
2012/02/15 面试题
关于运动会的广播稿(10篇)
2014/09/12 职场文书
综治维稳工作汇报
2014/10/27 职场文书
教学副校长工作总结
2015/08/13 职场文书
Redis如何实现分布式锁
2021/08/23 Redis
《杜鹃的婚约》OP主题曲「凸凹」无字幕影像公开
2022/04/08 日漫