美文网首页全栈笔记
canvas库fabric.js踩坑

canvas库fabric.js踩坑

作者: 韭菜的故事 | 来源:发表于2021-03-22 20:08 被阅读0次

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

相关文章

网友评论

    本文标题:canvas库fabric.js踩坑

    本文链接:https://www.haomeiwen.com/subject/fwhthltx.html