JS前端輕量fabric.js系列物體基類

前言

在上個章節中我們已經創建瞭畫佈,接下來就可以進行物體的繪制瞭,那具體要怎麼畫呢?根據文章標題可以猜到應該是要抽象出一個物體基類,歸納出一些它們的共性,那它們能有啥共性呢,畢竟每個物體好像都是各畫各的。對於這個問題大傢可以先簡單思考幾秒鐘再往下看🤔。。。

FabricObject 基類的實現

抽離共同屬性

我們要繪制某個物體,那不就是在畫佈的某個位置(top、left值)根據某些屬性(寬高大小等)畫上某個物體(比如矩形、多邊形、圖片或者路徑等等)嗎,並且之後還可以對每個物體進行一些交互操作(主要就是平移+旋轉+縮放)。這麼一說,是不是好像已經把物體的挺多共性給抽離出來呢(真的是萬物皆對象啊,前端同學在 canvas 中尤其能體會到這個思想)。

那麼,自然而然的我們就需要抽象出一個物體基類(FabricObject),其它物體(如 Rect)隻需要繼承這個物體基類,就能夠很方便的擁有一些通用能力,對於日後的維護和擴展也都是很友好的,看下面的代碼理解起來應該會更清晰👇🏻:

class FabricObject {
    /** 物體類型標識 */
    public type: string = 'object';
    /** 是否可見 */
    public visible: boolean = true;
    /** 是否處於激活態,也就是是否被選中 */
    public active: boolean = false;
    /** 物體位置的 top 值,就是 y */
    public top: number = 0;
    /** 物體位置的 left 值,就是 x */
    public left: number = 0;
    /** 物體的原始寬度 */
    public width: number = 0;
    /** 物體的原始高度 */
    public height: number = 0;
    /** 物體當前的縮放倍數 x */
    public scaleX: number = 1;
    /** 物體當前的縮放倍數 y */
    public scaleY: number = 1;
    /** 物體當前的旋轉角度 */
    public angle: number = 0;
    /** 默認水平變換中心 left | right | center */
    public originX: string = 'center';
    /** 默認垂直變換中心 top | bottom | center */
    public originY: string = 'center';
    /** 列舉常用的屬性 */
    public stateProperties: string[] = ('top left width height scaleX scaleY ' + 'angle fill originX originY ' + 'stroke strokeWidth ' + 'borderWidth visible').split(' ');
    ...
    constructor(options) {
        this.initialize(options); // 初始化各種屬性,就是簡單的賦值
    }
    initialize(options) {
        options && this.setOptions(options);
    }
    render() {} // 繪制物體的方法
    ...
}

上面代碼中有幾個比較容易混淆的點,就是 originX、originY 和 top、left,以及為啥不用 x、y 來表示物體位置呢?

解答之前,我們先來思考一個問題,如果要在畫佈的 (x, y) 處繪制一個 100*100 的矩形,這句話會有什麼歧義嗎?em。。。有的,看下下面這張圖👇🏻:

你會發現兩種畫法好像都沒錯,也都挺符合直覺,主要就是因為它們所定義的中心點不一樣,所以就有瞭 originX 和 originY。

  • 如果 originX = 'left', originY = 'top' 就是左圖那樣;
  • 如果 originX = 'center', originY = 'center' 就是右圖那樣;
  • 如果 originX = 'left', originY = 'bottom',那矩形就會畫在點(x, y) 的右上方;
  • 以此類推… 新版本的 fabric.js 默認采用的是左圖的方式,很早很早前是右圖的方式,當然你可以自己傳參設置,靈活性杠杠滴。然後,現在你是不是會覺得 top、left 相比於 x、y 來說會稍微語義化點😂。建議這幾個變量要好好理清一下,後續都是在此基礎上展開的。這裡我覺得還是用 center 會直觀點,所以這個系列采用的是右圖的方式,請務必記住。

抽離共同方法

物體最重要的一個方法就是 render 瞭,但是每個物體有各自獨特的繪制方法,能抽象出什麼呢?想想好像沒啥能抽的。確實是這樣,所以我們嘗試先直接繪制幾個普通物體,再通過它們看看能不能倒推出一些通用的東西。

假設要在 (100, 100) 的地方繪制一個 50*50 的矩形,並將其放大 2 倍,之後旋轉 45°,該怎麼畫呢?正常來說我們需要簡單計算一下,就像這樣:

  • 手動算下寬高 100*100
  • 手動算下旋轉之後各個頂點的坐標
  • 連接四個頂點 如果是在畫佈左下角畫一個邊長為 100 的、擺的比較正的等邊三角形呢,就像這樣△?那我們也需要簡單計算下:
  • 手動算下三角形每個頂點的坐標
  • 連接三個頂點
  • 如果加上旋轉,這個計算就更復雜瞭一些 又或者簡單點,我們在 (100, 100) 處畫個圓,然後將其旋轉 30°,並把半徑縮小 2 倍,那就要:
  • 因為是個圓,所以不用考慮旋轉,但是要算一下縮小後的半徑
  • 畫一個 (0, 2 * Math.PI) 的圓弧 所以上面三個小例子的共性就是:先計算再繪制嗎?不,不是的,我們在 canvas 中要改掉這種繪制的思想,而是要通過並善用變換坐標系來繪制物體,這個在上個章節末尾有提到,之所以這樣做,是因為它能夠節省很多計算和繪制成本。提到變換坐標系,這個東西很容易讓人蒙圈,但它絕對是一把利器,所以我們必須要搞定它,如果你不熟悉,還是希望能夠多動手練練,這樣才能拿捏它。
  • 那現在我們應該怎麼畫呢?就是能用變換就用變換,能不計算就不計算。來看看上面第一個畫矩形的例子,首先我們繪制矩形的方法是固定的 ctx.fillRect(-width/2, -height/2, width, height);,其中 width=50,height=50,然後就盡量不去動它。那怎麼畫出縮放和旋轉的效果,並且畫在點 (100, 100) 的地方呢?就是用到之前說的變換坐標系,簡單看下代碼:
ctx.save(); // 之前提到過瞭,你要修改 ctx 上的一些配置或者畫一個物體,最好先 save 一下,這是個好習慣
ctx.translate(100, 100); // 此時原點已經變到瞭 (100, 100) 的地方
ctx.scale(2, 2); // 坐標系放大兩倍
ctx.rotate(Util.degreesToRadians(45)); // 註意 canvas 中用的都是弧度(弧度 / 2 * Math.PI = 角度 / 360),所以需要簡單換算下
ctx.fillRect(-width/2, height/2, width, height); // 繪制矩形的方法固定不變,寬高一般也不會去修改
ctx.restore(); // 畫完之後還原 ctx 狀態,這是個好習慣

再來看看第二個例子,在左下角畫一個邊長為 100 的等邊三角形△,我們要做的就是先把原點移到三角形的某個頂點上(這裡我們當然拿左下角的頂點啦),然後通過不斷旋轉坐標系繪制三條邊,看下代碼👇🏻:

ctx.save();
ctx.translate(0, 畫佈高度); // 左下角變為(0, 0) 點瞭
ctx.rotate(Util.degreesToRadians(30)); // 準備畫左邊這條邊
ctx.moveTo(0, 0);
ctx.lineTo(100, 0);
ctx.rotate(Util.degreesToRadians(120)); // 準備畫右邊這條邊
ctx.lineTo(100, 0);
ctx.rotate(Util.degreesToRadians(120)); // 準備畫下面這條邊
ctx.lineTo(100, 0);
ctx.restore();

大傢可以在此基礎上畫一畫正多邊形,就能夠體會到旋轉的意思瞭。 至於第三個畫圓的例子,這裡也簡單放下代碼:

ctx.save();
ctx.translate(100, 100);
ctx.scale(2, 2);
ctx.arc(0, 0, r, 0, 2 * Math.PI); // 畫圓的方法始終不變
ctx.fill();
ctx.restore();

我們不再把物體上面的變換用於物體自身,而是用於坐標系,從而簡化瞭計算量和繪圖操作。

但可能還是不好看出來能抽象出什麼(其實就隻抽出瞭變換😂),所以讓我們來看看代碼吧👇🏻:

class FabricObject {
    /** 渲染物體的通用流程 */
    render(ctx: CanvasRenderingContext2D) {
        // 看不見的物體不繪制
        if (this.width === 0 || this.height === 0 || !this.visible) return;
         // 凡是要變換坐標系或者設置畫筆屬性都需要用先用 save 保存和再用 restore 還原,避免影響到其他東西的繪制
        ctx.save();
        // 1、坐標變換
        this.transform(ctx);
        // 2、繪制物體
        this._render(ctx);
        ctx.restore();
    }
    transform(ctx: CanvasRenderingContext2D) {
        ctx.translate(this.left, this.top);
        ctx.rotate(Util.degreesToRadians(this.angle));
        ctx.scale(this.scaleX, this.scaleY);
    }
    /** 具體由子類來實現,因為這確實是每個子類物體所獨有的 */
    _render(ctx: CanvasRenderingContext2D) {}
}

從上面的代碼中可以看到物體的繪制被分成瞭兩步:transform_render
對於 transform 建議大傢可以拿正多邊形和折線來找找感覺,本質就是 n 條線段通過 translate 來不斷改變線段起始位置,通過 rotate 改變方向,通過 scale 來改變線段長度,而繪制期間線段自身的長度其實並沒有改變,然後畫之前在腦海裡想一下每一條線段的效果,看看畫的是否與想的一致。記住核心思路(重要的事情說三遍📢):

  • 我們盡量不去改變物體的寬高和大小,而是通過各種變換來達到所需要的效果。
  • 我們盡量不去改變物體的寬高和大小,而是通過各種變換來達到所需要的效果。
  • 我們盡量不去改變物體的寬高和大小,而是通過各種變換來達到所需要的效果。 另外關於 transform 還要註意的是:
  • 變換是會疊加的,比如我 ctx.scale(2) 瞭之後又 ctx.scale(2),那最終的結果就是 ctx.scale(4),所以你還需要學會如何變換回去。一般有兩種方法:一種是配合 save 和 restore 使用,另一種就是往反方向進行變換。
  • 變換是有順序的,不同的順序最終繪制出來的效果也大不一樣,通常是 translate > rotate > scale,比較符合人的直覺。當然你要用其他順序也是可以的,那重點是什麼呢?重點是同一個庫或者引擎的內部實現用的是同一種順序就行。
  • 矩陣:其實這三種變換和矩陣是可以相互轉換的,就是把 transform 裡面的函數換個寫法而已,我們用矩陣的形式 matrix(a, b, c, d, tx, ty) 也能達到同樣的效果,但是矩陣更加強大並統一瞭寫法,而且除瞭三種基本的變換,還能達到其他效果,比如斜切 skew。關於矩陣的概念和寫法我們會在這個系列的最後幾個章節單獨講一下,目前我們可以暫且認為這三種變換和矩陣是等價的。

scale 是沿著坐標軸放大,並不一定是水平或豎直方向,假如物體旋轉瞭,就是沿著旋轉之後的坐標軸方向放大,如下圖所示:

說完瞭 transform,我們再來看看 _render,這個就真沒啥共性瞭,需要由子類自己實現。

Rect 類的實現

接下來就趁熱打鐵,我們以一個最簡單也最常用的 Rect 矩形類為例子來看看子類又是怎麼操作的,這裡直接上代碼,因為確實簡單👇🏻:

/** 矩形類 */
class Rect extends FabricObject {
    /** 矩形標識 */
    public type: string = 'rect';
    /** 圓角 rx */
    public rx: number = 0;
    /** 圓角 ry */
    public ry: number = 0;
    constructor(options) {
        super(options);
        this._initStateProperties();
        this._initRxRy(options);
    }
    /** 一些共有的和獨有的屬性 */
    _initStateProperties() {
        this.stateProperties = this.stateProperties.concat(['rx', 'ry']);
    }
    /** 初始化圓角值 */
    _initRxRy(options) {
        this.rx = options.rx || 0;
        this.ry = options.ry || 0;
    }
    /** 單純的繪制一個普普通通的矩形 */
    _render(ctx: CanvasRenderingContext2D) {
        let rx = this.rx || 0,
            ry = this.ry || 0,
            x = -this.width / 2,
            y = -this.height / 2,
            w = this.width,
            h = this.height;
        // 繪制一個新的東西,大部分情況下都要開啟一個新路徑,要養成習慣
        ctx.beginPath();
        // 從左上角開始向右順時針畫一個矩形,這裡就是單純的繪制一個規規矩矩的矩形
        // 不考慮旋轉縮放啥的,因為旋轉縮放會在調用 _render 函數之前處理
        // 另外這裡考慮瞭圓角的實現,所以用到瞭貝塞爾曲線,不然你可以直接畫成四條線段,再懶一點可以直接調用原生方法 fillRect 和 strokeRect
        // 不過自己寫的話自由度更高,也方便擴展
        ctx.moveTo(x + rx, y);
        ctx.lineTo(x + w - rx, y);
        ctx.bezierCurveTo(x + w, y, x + w, y + ry, x + w, y + ry);
        ctx.lineTo(x + w, y + h - ry);
        ctx.bezierCurveTo(x + w, y + h, x + w - rx, y + h, x + w - rx, y + h);
        ctx.lineTo(x + rx, y + h);
        ctx.bezierCurveTo(x, y + h, x, y + h - ry, x, y + h - ry);
        ctx.lineTo(x, y + ry);
        ctx.bezierCurveTo(x, y, x + rx, y, x + rx, y);
        ctx.closePath();
        if (this.fill) ctx.fill();
        if (this.stroke) ctx.stroke();
    }
}

現在我們已經有瞭一個最基礎也最為重要的一個物體:矩形。於是就可以將它添加到畫佈中,我們在上一章節的 Canvas 類中加一個 add 方法,如下代碼所示👇🏻:

class Canvas {
    /**
     * 添加元素
     * 目前的模式是調用 add 添加物體的時候就立馬渲染,如果一次性加入大量元素,就會做很多無用功
     * 所以可以優化一下,就是先批量添加元素(需要加一個變量標識),最後再統一渲染(手動調用 renderAll 函數即可),這裡先瞭解即可
    */
    add(...args): Canvas {
        this._objects.push(...args);
        this.renderAll();
        return this;
    }
    /** 在下層畫佈上繪制所有物體 */
    renderAll(): Canvas {
        // 獲取下層畫佈
        const ctx = this.contextContainer;
        // 清除畫佈
        this.clearContext(ctx);
        // 簡單粗暴的遍歷渲染
        this._objects.forEach(object => {
            // render = transfrom + _render
            object.render(ctx);
        })
        return this;
    }
}

現在我們隻需要傳入不同的參數就能在畫佈中創建形形色色的矩形瞭,而子類裡面的 _render 方法一般寫好瞭就行,很少會去動它。

大傢可以類比一下瀏覽器的盒模型,其實就是四四方方的矩形,然後用 css 中的 transfrom 做各種變換,也能達到各種效果,而元素的寬高大小並沒與改變。如果不理解為什麼要拆成 transform 和 _render 兩部分,大傢可以先記住,後面會體會到它的好。

當然你可以能還有其他疑問,比如我們就直接遍歷所有物體嘛,繪制的物體一多這樣寫不會有問題嗎?關於這類問題我會在後面的性能優化章節中講到,敬請期待,哈哈😄

本章小結

這裡就本章的內容進行一些小的總結,這個章節我們主要學習瞭如何寫一個物體基類 FabricObject 以及最簡單的子類實現 Rect,一般物體的繪制大體可分為兩步:

  • 1、先變換坐標系(這個很重要,繪制物體、邊框、控制點都是要考慮變換坐標系這個因素的)
  • 2、單純的繪制圖形(比如矩形,就是在原點繪制一個規規矩矩的、沒有旋轉、沒有縮放的矩形) 更為重要的是我們應該盡量不去改變物體的寬高和大小,而是通過各種變換來達到所需要的效果。另外還記得我們之前說過的畫佈主要分為兩層,上層用來交互,下層用來繪制,現在已經有瞭畫佈類和物體類,下層畫佈也就搞定瞭,接下來就可以搞搞上層交互瞭,那時大傢就能體會到這樣繪制物體的好處瞭。

這裡是簡版 fabric.js 的代碼鏈接,有興趣的可以看看,也可以動手去嘗試擴展一些子類。 好啦,今天的分享就到這裡,有什麼問題歡迎點贊評論留言,下期再見,拜拜 🏎 💨

實現一個輕量 fabric.js 系列二(畫佈初始化)🏖

實現一個輕量 fabric.js 系列一(摸透 canvas)💥

以上就是JS前端輕量fabric.js系列物體基類的詳細內容,更多關於前端fabric.js物體基類的資料請關註WalkonNet其它相關文章!

推薦閱讀: