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 图片上传预览-兼容标准
Jun 01 Javascript
游览器中javascript的执行过程(图文)
May 20 Javascript
JavaScript给input的value赋值引发的关于基本类型值和引用类型值问题
Dec 07 Javascript
Javascript将数字转化成为货币格式字符串
Jun 22 Javascript
JS简单生成两个数字之间随机数的方法
Aug 03 Javascript
JS获取当前页面名称的简单实例
Aug 19 Javascript
javascript 正则表达式分组、断言详解
Apr 20 Javascript
bootstrap table单元格新增行并编辑
May 19 Javascript
给vue项目添加ESLint的详细步骤
Sep 29 Javascript
浅谈Webpack下多环境配置的思路
Jun 27 Javascript
微信小程序 下拉刷新及上拉加载原理解析
Nov 06 Javascript
vue中defineProperty和Proxy的区别详解
Nov 30 Vue.js
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
一个简单的域名注册情况查询程序
2006/10/09 PHP
dede3.1分页文字采集过滤规则详说(图文教程)续二
2007/04/03 PHP
php-msf源码详解
2017/12/25 PHP
新浪中用来显示flash的函数
2007/04/02 Javascript
使用Jquery Aajx访问WCF服务(GET、POST、PUT、DELETE)
2012/03/16 Javascript
javascript学习笔记(八) js内置对象
2012/06/19 Javascript
使用js 设置url参数
2013/07/08 Javascript
浅谈JavaScript中指针和地址
2015/07/26 Javascript
JS版元素周期表实现方法
2015/08/05 Javascript
详解maxlength属性在textarea里奇怪的表现
2015/12/27 Javascript
jQuery图片拖动组件Dropzone用法示例
2017/01/17 Javascript
js实现3D图片环展示效果
2017/03/09 Javascript
jstree单选功能的实现方法
2017/06/07 Javascript
用原生JS实现简单的多选框功能
2017/06/12 Javascript
ES6中Array.find()和findIndex()函数的用法详解
2017/09/16 Javascript
React+Antd+Redux实现待办事件的方法
2019/03/14 Javascript
微信小程序-可移动菜单的实现过程详解
2019/06/24 Javascript
详解Vite的新体验
2021/02/22 Javascript
python构造icmp echo请求和实现网络探测器功能代码分享
2014/01/10 Python
Python学习笔记(二)基础语法
2014/06/06 Python
Python数据分析中Groupby用法之通过字典或Series进行分组的实例
2017/12/08 Python
python实现简单http服务器功能
2018/09/17 Python
django 实现编写控制登录和访问权限控制的中间件方法
2019/01/15 Python
Python 字典中的所有方法及用法
2020/06/10 Python
Python 程序报错崩溃后如何倒回到崩溃的位置(推荐)
2020/06/23 Python
python中加背景音乐如何操作
2020/07/19 Python
Java Unsafe类实现原理及测试代码
2020/09/15 Python
Python hashlib模块的使用示例
2020/10/09 Python
香港草莓网土耳其网站:Strawberrynet TR
2017/03/02 全球购物
Unix控制后台进程都有哪些进程
2016/09/22 面试题
计算机大学生职业生涯规划书范文
2014/02/19 职场文书
2014年大学生预备党员思想汇报1000字
2014/09/13 职场文书
党的群众路线教育实践活动查摆剖析材料
2014/10/10 职场文书
晋江市人民政府党组群众路线教育实践活动整改方案
2014/10/25 职场文书
入党自传范文2015
2015/06/26 职场文书
作文之亲情600字
2019/09/23 职场文书