canvas庫fabric.js踩坑

fabric.js簡介

眾所周知,canvas的api繁雜,對一般的前端er來說不太友好,加上平時一般也不會自己手寫canvas,所以一般開發者對canvas的涉獵可能并不太深(我看紅寶書的時候canvas是直接跳過的)。而當需要使用canvas開發一些定制化的需求時,echarts,antv系列,可能就無法滿足了,這個時候或許fabric會是一個比較好的選擇,fabric提供一種類似面向對象的方法來編寫canvas,比原生稍微方便一些(然鵝官方文檔太難看懂了)

故事背景

近期的一個項目中,有這么一個需求:拖拽縮放元素并且進行連線,本來我第一反應是用antv/g6去實現的,但是需要對拖拽的元素縮放并且拖拽的容器需要放文字和圖表,如果使用g6的話,縮放容器,里面的內容改變不太利索(實際是我對g6不太熟),另一個重要的問題是g6元素里面放圖表的話只能放g2(而且需要單獨安裝插件)并且不支持諸如tooltip等等功能,簡單來說只能用個閹割版的(示例:https://antv-g6.gitee.io/zh/examples/item/customNode#lineChartNode)。因此我最初想的是使用vue-grid-layout(github&&文檔)進行拖拽與縮放,畫線使用canvas。這樣做的好處是第三方組件已經把拖拽和縮放功能全都封裝好了,dom元素嵌入echarts和文本縮放也相當方便(vue-echarts的autoresize,文本使用flex布局加overflow:auto),當然畫線又是一個大問題,關鍵點就是線要和拖拽的元素接上,簡而言之就是坐標計算了。考慮到畫布里面還要放圖(拖拽的元素連線到圖上)以及要實現連線的時候鼠標移動需要不停的重繪線,最終在同事的推薦下決定使用fabric.js來實現canvas部分。然后就發現這東西用起來一言難盡...

踩坑記錄與解決

1.官方文檔
就算你英語很好看他的文檔也會很別扭的,建議直接看官方DEMO找自己要的,不懂的百度谷歌,最后把查找文檔作為補充以及檢查是否有新版api和網上的古早文章不同。

2.在vue中使用

import Fabric from 'fabric';
new Fabric.fabric.Canvas('xxx',{});

目前只能這樣用

3.繪制本地圖片有問題
我嘗試過fabric.Image.fromURL('xxx/xxx.png',function(){})以及new Image().src這兩種發現貌似都不能放本地圖片地址(類似@/assets/...這種),可能是我使用的方式不對,最后只剩下一種方法可用了:

const imgDOM = document.getElementById('xxx');
imgDOM.onload = () => {
   const imgInstanceFirst = new Fabric.fabric.Image(imgDOM, {});
   this.fabricObj.add(imgInstanceFirst);
    // 將圖片層級降為最低
   imgInstanceFirst.sendToBack();
};

這種方法首先需要在頁面上放一個隱藏的img元素,結果一開始fabric還讀不到只能通過onload事件來獲取,但這樣會導致畫布重繪時無法執行onload,最后一個繪制圖片被我寫成這樣了

        // 繪制人體背景圖
        drawBodyImg() {
            const imgInstance = this.getBodyImgInstance();
            if (imgInstance) {
                this.fabricObj.add(imgInstance);
                imgInstance.sendToBack();
                return;
            }
            const imgDOM = document.getElementById('bodyImg');
            // 初始化時即使是已經存在于html中的imgdom對象也需要在onload事件中獲取,否則fabric渲染不出來
            imgDOM.onload = () => {
                // FIXME:某些未知情況暫時無法判斷 妥協做法初始化時渲染兩次并移除第一次渲染的圖
                this.fabricObj.remove(imgInstance);
                const imgInstanceFirst = new Fabric.fabric.Image(imgDOM, {...});
                this.fabricObj.add(imgInstanceFirst);
                imgInstanceFirst.sendToBack();
            };
        },
        // 嘗試獲取人體圖實例
        getBodyImgInstance() {
            const imgDOM = document.getElementById('bodyImg');
            const imgInstance = new Fabric.fabric.Image(imgDOM, {...});
            if (imgInstance.height) {
                return imgInstance;
            } else {
                return null;
            }
        },

sendToBack方法是為了確保在后面畫線的時候線能在圖的上面一層顯示(貌似fabric是按照先后繪制順序排層級的,先繪制的層級最高,于是我們需要將圖的層級降到最低)

------------- 2021.03.30更新---------

可能是頁面結構太復雜的緣故,上面的方法有小概率執行時圖片還沒加載好,導致最后畫布里面其它內容都出來了結果最重要的圖沒了,最終我搞出來的解決辦法是,在img標簽上直接綁定load事件,執行load時將組件內設置的狀態修改,并監聽這個狀態的變化來執行圖片渲染到canvas畫布的過程。

        <img
          v-show="false"
          id="bodyImg"
          src="@/assets/img/body.png"
          alt=""
          @load="loadBodyImg"
        />
        // .......
    watch: {
        // 圖片加載有時會比fabric加載慢
        bodyImgLoaded() {
            if (this.fabricObj) {
                // 避免重復加載
                const imgarr = this.fabricObj.getObjects().filter(v => {
                    return v._element && v.nodeName === 'IMG'; // 從控制臺打印獲取到fabricObj圖片內部屬性
                })
                if (!imgarr.length) {
                    this.drawBodyImg();
                }
            }
        }
    }
// .....
        loadBodyImg() {
            this.bodyImgLoaded = true;
        },
        // 正常加載時還是先執行這個方法,兩邊都有判斷,不會重復執行,而且必定有一邊會執行
        drawBodyImg() {
            const imgInstance = this.getBodyImgInstance();
            if (imgInstance) {
                this.fabricObj.add(imgInstance);
                imgInstance.sendToBack();
            }
        },

-----------------------------

  1. 去除canvas對象的選中樣式以及功能
    fabric會默認給每一個繪制出來的canvas對象加上縮放,旋轉等功能,你會看到畫布上的對象有一堆的點。我是這樣做的
    初始化fabric對象
            this.fabricObj = new Fabric.fabric.Canvas('canvasPart', {
                selection: false, // 不可框選
                skipTargetFind: false // 保留選中操作(在canvas對象中去掉選中樣式)
            });

畫圖(畫線除了selectable其它類似,因為我的項目需要選中線)

        const imgInstance = new Fabric.fabric.Image(imgDOM, {
                selectable: false, // 去掉選中的效果
                hasControls: false, // 關閉圖層控件
                hoverCursor: 'default'
            });

因為我需要點擊線的時候彈出刪除菜單,所以不能在初始化的時候直接skipTargetFind: true,我要做的是去除選中的樣式和大部分功能,保留選中時能獲取到選中對象,一旦這個屬性設為true則會取消所有選中樣式和功能,不需要在canvas對象里面再單獨配置了。

  1. 繪制三次貝塞爾曲線
    領導認為直線不好看,UI直接整了一個三次貝塞爾曲線,所以有兩個問題,第一是如何在fabric里面繪制貝塞爾曲線,主要是用Path方法(應該就是svg的畫法,注意M和C要大寫),(x1,y1) (x2, y2)分別是起點和終點,c1和c2是控制點坐標(三次貝塞爾曲線需要兩個控制點)
/**
 * @description: 使用fabric繪制展示用的三次貝塞爾曲線
 * @param {Object} fabricObj 組件內已經生成的fabric對象
 * @param {Array<number>} start 起點坐標
 * @param {Array<number>} end 終點坐標
 * @param {String} strokeColor 線的顏色(展示用的默認灰色)
 * @return {*}
 */
export function drawCubicBezierCurve(fabricObj, start, end, strokeColor = '#768C8C') {
    const x1 = start[0];
    const y1 = start[1];
    const x2 = end[0];
    const y2 = end[1];
    const c1 = calcControlPoint(start, end).c1;
    const c2 = calcControlPoint(start, end).c2;
    const line = new Fabric.fabric.Path(`M ${x1} ${y1}C${c1[0]},${c1[1]},${c2[0]},${c2[1]},${x2},${y2}`, {
        stroke: strokeColor,
        hoverCursor: 'default',
        fill: false,
        hasControls: false // 關閉圖層控件
    });
    fabricObj.add(line);
}

第二,計算三次貝塞爾曲線的控制點,這里面用了向量運算...

/**
 * @description: 已知起點和終點近似計算三次貝塞爾曲線控制點
 * @param {Array<number>} start 起點坐標
 * @param {Array<number>} end 終點坐標
 * @param {Number} curvature 曲率(默認0.1)
 * @return {Object}
 */
export function calcControlPoint(start, end, curvature = 0.1) {
    const x1 = start[0];
    const y1 = start[1];
    const x2 = end[0];
    const y2 = end[1];
    const cx1 = x1 + (x2 - x1) / 3 + (y2 - y1) * curvature;
    const cy1 = y1 + (y2 - y1) / 3 + (x1 - x2) * curvature;
    const cx2 = x1 + (x2 - x1) * 2 / 3 + (y1 - y2) * curvature;
    const cy2 = y1 + (y2 - y1) * 2 / 3 + (x2 - x1) * curvature;
    return {
        c1: [Math.abs(cx1), Math.abs(cy1)],
        c2: [Math.abs(cx2), Math.abs(cy2)]
    };
}
  1. 最后碰到的一個很嚴重的問題,屏幕縮放問題
    fabric.js里面的坐標系不能識別系統的縮放(是系統設置里面的縮放而非瀏覽器本身的縮放),相信一般人windows電腦都會選擇系統推薦縮放吧,1080p甚至2k4k分辨率如果用原始比例的話字太小了,結果我把頁面從我的外接屏拖到筆記本的屏幕上時fabric里面的坐標系直接崩壞了...

網上找的檢測屏幕縮放比例的方法(可以檢測到系統分辨率改變)

// 檢測屏幕縮放比例
export function detectZoom() {
    let ratio = 0;
    const screen = window.screen;
    const ua = navigator.userAgent.toLowerCase();
    if (window.devicePixelRatio !== undefined) {
        ratio = window.devicePixelRatio;
    } else if (~ua.indexOf('msie')) {
        if (screen.deviceXDPI && screen.logicalXDPI) {
            ratio = screen.deviceXDPI / screen.logicalXDPI;
        }
    } else if (window.outerWidth !== undefined && window.innerWidth !== undefined) {
        ratio = window.outerWidth / window.innerWidth;
    }
    return ratio;
}

然后在初始化fabric對象時需要重新計算寬高(canvasLayout為畫布上一級的父元素)

            this.fabricObj = new Fabric.fabric.Canvas('canvasPart', {
                selection: false, // 不可框選
                skipTargetFind: false // 保留選中操作(在Line中去掉選中樣式)
            });
            const boxDOM = document.getElementById('canvasLayout');
            const width = boxDOM.offsetWidth / this.pageZoom;
            const height = boxDOM.offsetHeight / this.pageZoom;
            this.fabricObj.setWidth(width);
            this.fabricObj.setHeight(height);
            this.fabricObj.renderAll();

pageZoom主要在拖動元素時計算元素與線的連接點坐標用到了,這個系統里面只要vue-grid-layout元素有改變,我就要重新計算線的起點并重繪線,通過這種辦法實現了dom元素和canvas元素的綁定,聽起來很low的樣子,不過最后功能是都實現了。

參考文章(還有些講fabric的api的文章找不到了...)
https://github.com/hujiulong/blog/issues/1

fabric視頻教程(我還沒看過,可能有些內容存在過時)
https://www.bilibili.com/video/BV1at411q7bt

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,030評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,310評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,951評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,796評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,566評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,055評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,142評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,303評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,799評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,683評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,899評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,409評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,135評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,520評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,757評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,528評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,844評論 2 372

推薦閱讀更多精彩內容