JS前端使用canvas实现扩展物体类和事件派发
作者:尤水就下
前言
虽然我们讲了这么多个章节,但其实目前为止就只有一个 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 上这个具体例子,这里就作为知识点简单了解下,当然我也截了个示意图意思一下(仔细看😂,一定能看出差别的):
其实扩展一个类还是非常简单的,你只需要知道这个类会有哪些独特的自有属性,并搞定 _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 类,所以就有了事件,怎么证明呢?我们可以在控制台随便打印一个元素看下(父级的)结果👇🏻:
不能说是很像,只能说是一毛一样。而且一般情况下,如果有事件系统,我们大多都会把它放在顶层供其他类继承,可见这个类是很重要的,大家都想要它😳。
这里还是再补充一个小点吧🤯:就是关于事件名的命名,举上面代码中的两个例子,大概长这样:
canvas:cleared
和 object:moving
,为什么要加个冒号嘞,直接写一个英文单词不香吗?这个其实要看你系统复不复杂,简单的话用一个单词就可以了,复杂的话一般会像这样写 主体:动作
,主要是为了方便区分,仅此而已(也只是我觉得),比如小程序里面的事件名就是这样。
小结
本个章节我们主要讲解了图片类和事件系统的实现,希望你能够记住以下几点:
- 图片是异步的,加载完成之后需要将其变成 img 标签,再调用
ctx.drawImage
才能绘制到画布上 - 如果有事件系统,我们大多都会把它放在顶层供其他类继承,可见它在前端有多受欢迎
然后这里是简版 fabric.js 的代码链接,有兴趣的可以看看,当然啦更建议直接去看 fabric.js 的源码。好啦,本次分享就到这里,下个章节会分享的是 canvas 中动画的实现😎,又是这个系列最重要的章节之一
实现一个轻量 fabric.js 系列一(摸透 canvas)💥
以上就是JS前端使用canvas实现扩展物体类和事件派发的详细内容,更多关于canvas扩展物体类事件派发的资料请关注脚本之家其它相关文章!