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获取当页面上鼠标光标位置和触发事件的对象的代码
Dec 09 Javascript
Javascript实现计算个人所得税
May 10 Javascript
JS实现微信弹出搜索框 多条件查询功能
Dec 13 Javascript
基于jquery二维码生成插件qrcode
Jan 07 Javascript
微信小程序 点击控件后选中其它反选实例详解
Feb 21 Javascript
Angular中ng-bind和ng-model的区别实例详解
Apr 10 Javascript
浅谈react受控组件与非受控组件(小结)
Feb 09 Javascript
微信小程序之圆形进度条实现思路
Feb 22 Javascript
JavaScript继承与多继承实例分析
May 26 Javascript
Vue组件为什么data必须是一个函数
Jun 11 Javascript
jQuery实现增删改查
Dec 22 jQuery
gojs实现蚂蚁线动画效果
Feb 18 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网站基础优化方法小结
2008/09/29 PHP
在WINDOWS中设置计划任务执行PHP文件的方法
2011/12/19 PHP
PHP程序员基本要求和必备技能
2014/05/09 PHP
CakePHP框架Session设置方法分析
2017/02/23 PHP
使用javascript访问XML数据的实例
2006/12/27 Javascript
JS 控制小数位数的实现代码
2011/08/02 Javascript
JavaScript中常用的运算符小结
2012/01/18 Javascript
Javascript Objects详解
2014/09/04 Javascript
jQuery Ajax调用WCF服务详细教程
2015/03/31 Javascript
纯css实现窗户玻璃雨滴逼真效果
2015/08/23 Javascript
Javascript实现的SHA-256加密算法完整实例
2016/02/02 Javascript
通过隐藏iframe实现无刷新上传文件操作
2016/03/16 Javascript
js事件处理程序跨浏览器解决方案
2016/03/27 Javascript
jQuery弹出遮罩层效果完整示例
2016/09/13 Javascript
AngularJS bootstrap启动详解及实例代码
2016/09/14 Javascript
angularJS 指令封装回到顶部示例详解
2017/01/22 Javascript
vue axios整合使用全攻略
2018/05/24 Javascript
vue实现文字加密功能
2019/09/27 Javascript
jQuery实现图片切换效果
2020/10/19 jQuery
[01:31:22]Ti4 循环赛第四日附加赛LGD vs Mouz
2014/07/13 DOTA
python str与repr的区别
2013/03/23 Python
Python中operator模块的操作符使用示例总结
2016/06/28 Python
解决Python3.8运行tornado项目报NotImplementedError错误
2020/09/02 Python
Python 使用SFTP和FTP实现对服务器的文件下载功能
2020/12/17 Python
详解HTML5中CSS外观属性
2020/09/10 HTML / CSS
欧舒丹加拿大官网:L’Occitane加拿大
2017/10/29 全球购物
CheapTickets泰国:廉价航班,查看促销价格并预订机票
2019/12/28 全球购物
数据库基础的一些面试题
2012/02/25 面试题
使用索引(Index)有哪些需要考虑的因素
2016/10/19 面试题
Delphi CS笔试题
2014/01/04 面试题
机电一体化专业推荐信
2013/12/03 职场文书
迟到检讨书400字
2014/01/13 职场文书
职工趣味运动会方案
2014/02/10 职场文书
2014社区三八妇女节活动总结
2014/03/01 职场文书
电话客服专员岗位职责
2014/06/28 职场文书
海弦WR-800F
2022/04/05 无线电