JS前端使用canvas實現擴展物體類和事件派發(fā)
前言
雖然我們講了這么多個章節(jié),但其實目前為止就只有一個 Rect 類能用,略顯單調。于是乎,為了讓整個畫布稍微生動一些,這個章節(jié)我們來嘗試增加一個圖片類,如果你以后需要擴展一個物體類,也是用同樣的方法。
另外有時候我們還希望在物體屬性改變時或者畫布創(chuàng)建后做一些額外的事情,這個時候事件系統(tǒng)就派上用場啦,也就是我們常說的發(fā)布訂閱,我覺的這是前端應用最廣的設計模式沒有之一了??。
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 = () => { // 先進行事件監(jiān)聽,要在請求圖片前
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 類就寫好啦。不過這里我再補充兩個小點:
一個是我們可以將圖片素材緩存起來,這樣如果用到多張相同的圖片就不用重復發(fā)請求啦;
另一個就是 imageSmoothingEnabled 屬性,這個是 canvas 中用來設置圖片是否平滑的屬性,默認值為 true,表示平滑,false 則表示圖片不平滑。比如將一張 50*50 的圖像放大 3 倍的時候,canvas 會默認做一些抗鋸齒處理使之平滑,如果不需要的話可以將其設置成 false,也算是種優(yōu)化,具體可以看看 mdn 上這個具體例子,這里就作為知識點簡單了解下,當然我也截了個示意圖意思一下(仔細看??,一定能看出差別的):

其實擴展一個類還是非常簡單的,你只需要知道這個類會有哪些獨特的自有屬性,并搞定 _render() 方法即可??。
事件派發(fā)
因為這個章節(jié)內容比較少,所以我就把事件派發(fā)的內容也放在這里講解了??。
有時候我們希望在物體初始化前后、狀態(tài)改變前后、一些交互前后,能夠觸發(fā)相應的事件來實現自己的需求,比如畫布被點擊了我想...,物體被移動了我想...,這個就是典型的發(fā)布訂閱模式,前端應用最廣泛的設計模式,沒有之一(當然只是我覺得),比如:
- html 中的 addEventListener
- vue 中的 EventBus
- 各種庫和插件暴露的一些鉤子函數(或者說是生命周期)
早前這玩意我也沒真正理解,總是看了就忘,因為總感覺這東西很抽象,說不上來這到底是個什么東西,所以這里我希望把它具象化,以便于理解。發(fā)布訂閱它其實可以理解成一個簡單的對象,就像下面這樣:
// key 就是事件名,key 存儲的值就是一堆回調函數
const eventObj = {
eventName1: [cb1, cb2, ... ],
eventName2: [cb1, cb2, cb3, ... ],
...
// 比如下面這些常見的事件名
click: [cb1, cb2, ... ],
created: [cb1, cb2, cb3, ... ],
mounted: [cb1, cb2, ... ],
}
我們最終要構造的就是這樣一個對象,eventObj 相當于一個事件管理中心,當我們觸發(fā)相應 eventName 的事件時(發(fā)布),就會找到 eventObj 里面 eventName 對應的那個數組,然后將里面的回調函數 cb 挨個遍歷執(zhí)行即可。那我們怎么向 eventObj 添加事件回調呢,很簡單就是找到 eventName 對應的數組往里 push 就行(訂閱),當然為了操作方便我們需要提供 eventObj.on、eventObj.off、eventObj.emit 等方法方便我們添加、觸發(fā)和刪除事件。
下面我們來看看具體實現,這東西寫多了就是很簡單的一件事情,寫法也比較固定,寫好了之后也基本不用改,實在不行 copy 也行??:
/**
* 發(fā)布訂閱,事件中心
* 應用場景:可以在特定的時間點觸發(fā)一系列事件(在本文主要就是渲染前后、初始化物體前后、物體狀態(tài)改變時)
*/
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;
}
/** 觸發(fā)某個事件回調,找到事件名對應的數組拿出來遍歷執(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;
}
/** 刪除某個事件回調 */
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];
// 注意:這里我們刪除監(jiān)聽一般都是置為 null 或者 false
// 當然也可以用 splice 刪除,不過 splice 會改變數組長度,這點要尤為注意
if (handler) {
eventListener[eventListener.indexOf(handler)] = false;
} else {
eventListener.fill(false);
}
}
}
希望這種模式大家能夠達到默寫的水平,對我們日后代碼的理解也確實是很有幫助的。
然后接下來要做什么呢?很簡單,就是讓需要事件的類繼承至這個事件類就可以了,然后在有需要的地方觸發(fā)就行了,這里我們以畫布為例,看下下面的代碼你就知道這種套路了????(注意下面代碼中注釋的地方):
class Canvas extends EventCenter { // 繼承
_initObject(obj: FabricObject) {
obj.setupState();
obj.setCoords();
obj.canvas = this;
this.emit('object:added', { target: obj }); // 畫布觸發(fā)添加物體時間
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') { // 如果是旋轉物體
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');
}
}
}
因為 Canvas 類繼承了 EventCenter 這個類,所以畫布就有了訂閱和發(fā)布的功能,同樣的我們也可以讓 FabricObject 這個物體基類繼承 EventCenter,這樣每個物體也有了發(fā)布訂閱的功能。有同學可能會問,上面的代碼只看到了 emit 事件,怎么沒看到 on 和 off 事件呢?因為之前說了,庫或者插件一般只提供鉤子,上面 emit 的地方就可以稱作鉤子(怎么感覺有點像埋點??),而 on 和 off 事件則是我們開發(fā)時才需要寫的。
有同學可能還是會疑惑為什么要這樣,其實你把這個當做一種好的寫法記住就行了,算是經驗總結,寫多了就能慢慢體會到。或者我們可以類比下瀏覽器的事件監(jiān)聽,想想頁面中的元素是不是都可以有點擊和鼠標移入移出事件,那頁面上的元素種類也很多,它又是怎么實現的呢?其實它們都也繼承于 EventTarget 類,所以就有了事件,怎么證明呢?我們可以在控制臺隨便打印一個元素看下(父級的)結果????:

不能說是很像,只能說是一毛一樣。而且一般情況下,如果有事件系統(tǒng),我們大多都會把它放在頂層供其他類繼承,可見這個類是很重要的,大家都想要它??。
這里還是再補充一個小點吧??:就是關于事件名的命名,舉上面代碼中的兩個例子,大概長這樣:
canvas:cleared 和 object:moving,為什么要加個冒號嘞,直接寫一個英文單詞不香嗎?這個其實要看你系統(tǒng)復不復雜,簡單的話用一個單詞就可以了,復雜的話一般會像這樣寫 主體:動作,主要是為了方便區(qū)分,僅此而已(也只是我覺得),比如小程序里面的事件名就是這樣。
小結
本個章節(jié)我們主要講解了圖片類和事件系統(tǒng)的實現,希望你能夠記住以下幾點:
- 圖片是異步的,加載完成之后需要將其變成 img 標簽,再調用
ctx.drawImage才能繪制到畫布上 - 如果有事件系統(tǒng),我們大多都會把它放在頂層供其他類繼承,可見它在前端有多受歡迎
然后這里是簡版 fabric.js 的代碼鏈接,有興趣的可以看看,當然啦更建議直接去看 fabric.js 的源碼。好啦,本次分享就到這里,下個章節(jié)會分享的是 canvas 中動畫的實現??,又是這個系列最重要的章節(jié)之一
實現一個輕量 fabric.js 系列一(摸透 canvas)??
以上就是JS前端使用canvas實現擴展物體類和事件派發(fā)的詳細內容,更多關于canvas擴展物體類事件派發(fā)的資料請關注腳本之家其它相關文章!
相關文章
mini?webpack打包基礎解決包緩存和環(huán)依賴
這篇文章主要為大家介紹了mini?webpack打包基礎解決包緩存和環(huán)依賴示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-09-09

