JS前端使用canvas實(shí)現(xiàn)擴(kuò)展物體類和事件派發(fā)
前言
雖然我們講了這么多個(gè)章節(jié),但其實(shí)目前為止就只有一個(gè) Rect 類能用,略顯單調(diào)。于是乎,為了讓整個(gè)畫布稍微生動(dòng)一些,這個(gè)章節(jié)我們來嘗試增加一個(gè)圖片類,如果你以后需要擴(kuò)展一個(gè)物體類,也是用同樣的方法。
另外有時(shí)候我們還希望在物體屬性改變時(shí)或者畫布創(chuàng)建后做一些額外的事情,這個(gè)時(shí)候事件系統(tǒng)就派上用場啦,也就是我們常說的發(fā)布訂閱,我覺的這是前端應(yīng)用最廣的設(shè)計(jì)模式?jīng)]有之一了??。
FabricImage 圖片類
話不多說,開擼走起??。先來看看 FabricImage 圖片類的實(shí)現(xiàn),我們可以想一下一個(gè)圖片類應(yīng)該具備什么樣的功能??,可以看看下面圖片類代碼的調(diào)用方式找找靈感????:
FabricImage.fromURL( 'https://p26-passport.byteacctimg.com/img/user-avatar/7470b65342454dd6699a6cf772652260~300x300.image', (img) => { canvas.add(img) }, // 這里需要手動(dòng)回調(diào)添加物體 { width: 200, height: 200, left: 300, top: 300 } ); FabricImage.fromURL( './src/beidaihe.jpeg', (img) => { canvas.add(img) }, // 這里需要手動(dòng)回調(diào)添加物體 { width: 200, height: 200, left: 600, top: 400 } );
上面代碼展示了兩種最常用的圖片加載方式,一個(gè)是遠(yuǎn)程鏈接,一個(gè)是本地圖片,調(diào)用方式看起來有些特殊,不過我們先不管這個(gè),直接來實(shí)現(xiàn)它就行。既然要繪制圖片,那肯定要先加載好才能用,這也是圖片類特殊的地方,它是異步的,并且加載圖片的方法是通用的,所以我們把它寫在 Util 類里,來簡單看下加載圖片的代碼(也許你在面試中遇見過??):
class Util { static loadImage(url) { return new Promise((resolve, reject) => { // 方便鏈?zhǔn)秸{(diào)用,promise 這玩意多寫多熟悉就懂了 const img = document.createElement('img'); img.onload = () => { // 先進(jìn)行事件監(jiān)聽,要在請(qǐng)求圖片前 img.onload = img.onerror = null; resolve(img); }; img.onerror = () => { reject(new Error('Error loading ' + img.src)); }; img.src = url; // 這個(gè)才是真正去請(qǐng)求圖片 }); } }
代碼不多也不難理解,那接下來就要看如何繪制了。在 canvas 中要想繪制圖片也不難,大體過程就是把圖片變成 img 標(biāo)簽,當(dāng)做參數(shù)傳給 ctx.drawImage
這個(gè)畫布專用繪制方法,稍微要注意點(diǎn)的就是圖片的寬高設(shè)置,我們會(huì)先取傳入?yún)?shù) options 中的寬高作為圖片的大小,沒傳參數(shù)的話再取圖片自身的寬高(因?yàn)榇藭r(shí)圖片已經(jīng)加載完成,所以可以取到圖片的信息),同樣的來簡單看下代碼實(shí)現(xiàn)????:
class FabricImage extends FabricObject { // 繼承基類是必須的 public type: string = 'image'; // 類型標(biāo)識(shí) public _element: HTMLImageElement; /** 默認(rèn)通過 img 標(biāo)簽來繪制,因?yàn)樽罱K都是要通過該標(biāo)簽繪制的 */ 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; } /** 設(shè)置圖像大小 */ _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; } /** 核心:直接調(diào)用 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; } /** 如果是根據(jù) url 或者本地路徑加載圖像,本質(zhì)都是取加載圖片完成之后在轉(zhuǎn)成 img 標(biāo)簽 */ static fromURL(url, callback, imgOptions) { Util.loadImage(url).then((img) => { callback && callback(new FabricImage(img as HTMLImageElement, imgOptions)); }); } }
看完上面的代碼,你應(yīng)該理解了前面為什么要那樣調(diào)用,雖然看起來有點(diǎn)繁瑣??。然后。。。一個(gè)簡簡單單的 FabricImage 類就寫好啦。不過這里我再補(bǔ)充兩個(gè)小點(diǎn):
一個(gè)是我們可以將圖片素材緩存起來,這樣如果用到多張相同的圖片就不用重復(fù)發(fā)請(qǐng)求啦;
另一個(gè)就是 imageSmoothingEnabled
屬性,這個(gè)是 canvas 中用來設(shè)置圖片是否平滑的屬性,默認(rèn)值為 true,表示平滑,false 則表示圖片不平滑。比如將一張 50*50
的圖像放大 3 倍的時(shí)候,canvas 會(huì)默認(rèn)做一些抗鋸齒處理使之平滑,如果不需要的話可以將其設(shè)置成 false,也算是種優(yōu)化,具體可以看看 mdn 上這個(gè)具體例子,這里就作為知識(shí)點(diǎn)簡單了解下,當(dāng)然我也截了個(gè)示意圖意思一下(仔細(xì)看??,一定能看出差別的):
其實(shí)擴(kuò)展一個(gè)類還是非常簡單的,你只需要知道這個(gè)類會(huì)有哪些獨(dú)特的自有屬性,并搞定 _render()
方法即可??。
事件派發(fā)
因?yàn)檫@個(gè)章節(jié)內(nèi)容比較少,所以我就把事件派發(fā)的內(nèi)容也放在這里講解了??。
有時(shí)候我們希望在物體初始化前后、狀態(tài)改變前后、一些交互前后,能夠觸發(fā)相應(yīng)的事件來實(shí)現(xiàn)自己的需求,比如畫布被點(diǎn)擊了我想...,物體被移動(dòng)了我想...,這個(gè)就是典型的發(fā)布訂閱模式,前端應(yīng)用最廣泛的設(shè)計(jì)模式,沒有之一(當(dāng)然只是我覺得),比如:
- html 中的 addEventListener
- vue 中的 EventBus
- 各種庫和插件暴露的一些鉤子函數(shù)(或者說是生命周期)
早前這玩意我也沒真正理解,總是看了就忘,因?yàn)榭偢杏X這東西很抽象,說不上來這到底是個(gè)什么東西,所以這里我希望把它具象化,以便于理解。發(fā)布訂閱它其實(shí)可以理解成一個(gè)簡單的對(duì)象,就像下面這樣:
// key 就是事件名,key 存儲(chǔ)的值就是一堆回調(diào)函數(shù) const eventObj = { eventName1: [cb1, cb2, ... ], eventName2: [cb1, cb2, cb3, ... ], ... // 比如下面這些常見的事件名 click: [cb1, cb2, ... ], created: [cb1, cb2, cb3, ... ], mounted: [cb1, cb2, ... ], }
我們最終要構(gòu)造的就是這樣一個(gè)對(duì)象,eventObj 相當(dāng)于一個(gè)事件管理中心,當(dāng)我們觸發(fā)相應(yīng) eventName 的事件時(shí)(發(fā)布),就會(huì)找到 eventObj 里面 eventName 對(duì)應(yīng)的那個(gè)數(shù)組,然后將里面的回調(diào)函數(shù) cb 挨個(gè)遍歷執(zhí)行即可。那我們?cè)趺聪?eventObj 添加事件回調(diào)呢,很簡單就是找到 eventName 對(duì)應(yīng)的數(shù)組往里 push 就行(訂閱),當(dāng)然為了操作方便我們需要提供 eventObj.on、eventObj.off、eventObj.emit 等方法方便我們添加、觸發(fā)和刪除事件。
下面我們來看看具體實(shí)現(xiàn),這東西寫多了就是很簡單的一件事情,寫法也比較固定,寫好了之后也基本不用改,實(shí)在不行 copy 也行??:
/** * 發(fā)布訂閱,事件中心 * 應(yīng)用場景:可以在特定的時(shí)間點(diǎn)觸發(fā)一系列事件(在本文主要就是渲染前后、初始化物體前后、物體狀態(tài)改變時(shí)) */ export class EventCenter { private __eventListeners; // 就是上面說的 eventObj 那個(gè)對(duì)象 /** 往某個(gè)事件里面添加回調(diào),找到事件名所對(duì)應(yīng)的數(shù)組往里push */ on(eventName, handler) { if (!this.__eventListeners) { this.__eventListeners = {}; } if (!this.__eventListeners[eventName]) { this.__eventListeners[eventName] = []; } this.__eventListeners[eventName].push(handler); return this; } /** 觸發(fā)某個(gè)事件回調(diào),找到事件名對(duì)應(yīng)的數(shù)組拿出來遍歷執(zhí)行 */ 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; } /** 刪除某個(gè)事件回調(diào) */ off(eventName, handler) { if (!this.__eventListeners) { return this; } if (arguments.length === 0) { // 如果沒有參數(shù),就是解綁所有事件 for (eventName in this.__eventListeners) { this._removeEventListener.call(this, eventName); } } else { // 解綁單個(gè)事件 this._removeEventListener.call(this, eventName, handler); } return this; } _removeEventListener(eventName, handler) { if (!this.__eventListeners[eventName]) { return; } let eventListener = this.__eventListeners[eventName]; // 注意:這里我們刪除監(jiān)聽一般都是置為 null 或者 false // 當(dāng)然也可以用 splice 刪除,不過 splice 會(huì)改變數(shù)組長度,這點(diǎn)要尤為注意 if (handler) { eventListener[eventListener.indexOf(handler)] = false; } else { eventListener.fill(false); } } }
希望這種模式大家能夠達(dá)到默寫的水平,對(duì)我們?nèi)蘸蟠a的理解也確實(shí)是很有幫助的。
然后接下來要做什么呢?很簡單,就是讓需要事件的類繼承至這個(gè)事件類就可以了,然后在有需要的地方觸發(fā)就行了,這里我們以畫布為例,看下下面的代碼你就知道這種套路了????(注意下面代碼中注釋的地方):
class Canvas extends EventCenter { // 繼承 _initObject(obj: FabricObject) { obj.setupState(); obj.setCoords(); obj.canvas = this; this.emit('object:added', { target: obj }); // 畫布觸發(fā)添加物體時(shí)間 obj.emit('added'); // 物體觸發(fā)被添加事件 } renderAll() { this.emit('before:render'); // 繪制所有物體... this.emit('after:render'); } clear() { ... this.clearContext(this.contextContainer); this.clearContext(this.contextTop); this.emit('canvas:cleared'); // 觸發(fā)畫布清空事件 this.renderAll(); return this; } __onMouseMove(e: MouseEvent) { ... const target = this._currentTransform.target; if (this._currentTransform.action === 'rotate') { // 如果是旋轉(zhuǎn)物體 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()) { // 物體狀態(tài)改變了才派發(fā)事件 this.emit('object:modified', { target }); target.emit('modified'); } } }
因?yàn)?Canvas
類繼承了 EventCenter
這個(gè)類,所以畫布就有了訂閱和發(fā)布的功能,同樣的我們也可以讓 FabricObject
這個(gè)物體基類繼承 EventCenter
,這樣每個(gè)物體也有了發(fā)布訂閱的功能。有同學(xué)可能會(huì)問,上面的代碼只看到了 emit 事件,怎么沒看到 on 和 off 事件呢?因?yàn)橹罢f了,庫或者插件一般只提供鉤子,上面 emit 的地方就可以稱作鉤子(怎么感覺有點(diǎn)像埋點(diǎn)??),而 on 和 off 事件則是我們開發(fā)時(shí)才需要寫的。
有同學(xué)可能還是會(huì)疑惑為什么要這樣,其實(shí)你把這個(gè)當(dāng)做一種好的寫法記住就行了,算是經(jīng)驗(yàn)總結(jié),寫多了就能慢慢體會(huì)到?;蛘呶覀兛梢灶惐认聻g覽器的事件監(jiān)聽,想想頁面中的元素是不是都可以有點(diǎn)擊和鼠標(biāo)移入移出事件,那頁面上的元素種類也很多,它又是怎么實(shí)現(xiàn)的呢?其實(shí)它們都也繼承于 EventTarget 類,所以就有了事件,怎么證明呢?我們可以在控制臺(tái)隨便打印一個(gè)元素看下(父級(jí)的)結(jié)果????:
不能說是很像,只能說是一毛一樣。而且一般情況下,如果有事件系統(tǒng),我們大多都會(huì)把它放在頂層供其他類繼承,可見這個(gè)類是很重要的,大家都想要它??。
這里還是再補(bǔ)充一個(gè)小點(diǎn)吧??:就是關(guān)于事件名的命名,舉上面代碼中的兩個(gè)例子,大概長這樣:
canvas:cleared
和 object:moving
,為什么要加個(gè)冒號(hào)嘞,直接寫一個(gè)英文單詞不香嗎?這個(gè)其實(shí)要看你系統(tǒng)復(fù)不復(fù)雜,簡單的話用一個(gè)單詞就可以了,復(fù)雜的話一般會(huì)像這樣寫 主體:動(dòng)作
,主要是為了方便區(qū)分,僅此而已(也只是我覺得),比如小程序里面的事件名就是這樣。
小結(jié)
本個(gè)章節(jié)我們主要講解了圖片類和事件系統(tǒng)的實(shí)現(xiàn),希望你能夠記住以下幾點(diǎn):
- 圖片是異步的,加載完成之后需要將其變成 img 標(biāo)簽,再調(diào)用
ctx.drawImage
才能繪制到畫布上 - 如果有事件系統(tǒng),我們大多都會(huì)把它放在頂層供其他類繼承,可見它在前端有多受歡迎
然后這里是簡版 fabric.js 的代碼鏈接,有興趣的可以看看,當(dāng)然啦更建議直接去看 fabric.js 的源碼。好啦,本次分享就到這里,下個(gè)章節(jié)會(huì)分享的是 canvas 中動(dòng)畫的實(shí)現(xiàn)??,又是這個(gè)系列最重要的章節(jié)之一
canvas 中如何實(shí)現(xiàn)物體的框選(六)??
canvas 中如何實(shí)現(xiàn)物體的點(diǎn)選(五)??
canvas 中物體邊框和控制點(diǎn)的實(shí)現(xiàn)(四)??
實(shí)現(xiàn)一個(gè)輕量 fabric.js 系列三(物體基類)??
實(shí)現(xiàn)一個(gè)輕量 fabric.js 系列二(畫布初始化)??
實(shí)現(xiàn)一個(gè)輕量 fabric.js 系列一(摸透 canvas)??
以上就是JS前端使用canvas實(shí)現(xiàn)擴(kuò)展物體類和事件派發(fā)的詳細(xì)內(nèi)容,更多關(guān)于canvas擴(kuò)展物體類事件派發(fā)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript編程中實(shí)現(xiàn)對(duì)象封裝特性的實(shí)例講解
JavaScript可以在一定程度上以面向?qū)ο蠓绞竭M(jìn)行編程,而封裝是面向?qū)ο笾械囊粋€(gè)重要特性,本文就來分享阮一峰老師對(duì)JavaScript編程中實(shí)現(xiàn)對(duì)象封裝特性的實(shí)例講解2016-06-06微信小程序動(dòng)態(tài)的加載數(shù)據(jù)實(shí)例代碼
這篇文章主要介紹了 微信小程序動(dòng)態(tài)的加載數(shù)據(jù)實(shí)例代碼的相關(guān)資料,需要的朋友可以參考下2017-04-04JavaScript實(shí)現(xiàn)棧結(jié)構(gòu)詳細(xì)過程
這篇文章主要介紹了JavaScript實(shí)現(xiàn)棧結(jié)構(gòu)詳細(xì)過程,棧即stack它是一種受限的線性表,后進(jìn)先出LIFO,更多具體的內(nèi)容,需要的小伙伴參考下面文章的詳細(xì)內(nèi)容2021-12-12微信小程序 小程序制作及動(dòng)畫(animation樣式)詳解
這篇文章主要介紹了微信小程序 小程序制作及動(dòng)畫詳解的相關(guān)資料,這里對(duì)小程序制作進(jìn)行了詳解,介紹動(dòng)畫部分的知識(shí),需要的朋友可以參考下2017-01-01JavaScript loader原理簡單總結(jié)示例解析
這篇文章主要為大家介紹了JavaScript loader原理簡單總結(jié)示例解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-08-08echart實(shí)現(xiàn)大屏動(dòng)效示例詳解
這篇文章主要為大家介紹了echart實(shí)現(xiàn)大屏動(dòng)效示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08微信小程序 滾動(dòng)到某個(gè)位置添加class效果實(shí)現(xiàn)代碼
這篇文章主要介紹了微信小程序 滾動(dòng)到某個(gè)位置添加class效果實(shí)現(xiàn)代碼的相關(guān)資料,需要的朋友可以參考下2017-04-04