JS前端轻量fabric.js系列物体基类


Posted in Javascript onAugust 05, 2022

前言

在上个章节中我们已经创建了画布,接下来就可以进行物体的绘制了,那具体要怎么画呢?根据文章标题可以猜到应该是要抽象出一个物体基类,归纳出一些它们的共性,那它们能有啥共性呢,毕竟每个物体好像都是各画各的。对于这个问题大家可以先简单思考几秒钟再往下看?。。。

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。。。有的,看下下面这张图??:

JS前端轻量fabric.js系列物体基类

你会发现两种画法好像都没错,也都挺符合直觉,主要就是因为它们所定义的中心点不一样,所以就有了 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 是沿着坐标轴放大,并不一定是水平或竖直方向,假如物体旋转了,就是沿着旋转之后的坐标轴方向放大,如下图所示:

JS前端轻量fabric.js系列物体基类

说完了 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物体基类的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
事件冒泡是什么如何用jquery阻止事件冒泡
Mar 20 Javascript
js动态删除div元素基本思路及实现代码
May 08 Javascript
JavaScript的jQuery库插件的简要开发指南
Aug 12 Javascript
jQuery仿360导航页图标拖动排序效果代码分享
Aug 24 Javascript
Jquery对新插入的节点 绑定Click事件失效的解决方法
Jun 02 Javascript
React创建组件的三种方式及其区别
Jan 12 Javascript
微信JS SDK接入的几点注意事项(必看篇)
Jun 23 Javascript
用vue封装插件并发布到npm的方法步骤
Oct 18 Javascript
jquery ztree实现右键收藏功能
Nov 20 jQuery
vue-cli 引入、配置axios的方法
May 08 Javascript
layui富文本编辑器前端无法取值的解决方法
Sep 18 Javascript
vue集成一个支持图片缩放拖拽的富文本编辑器
Jan 29 Vue.js
JS前端轻量fabric.js系列之画布初始化
Aug 05 #Javascript
vue3 自定义图片放大器效果的示例代码
Jul 23 #Vue.js
JavaScript parseInt0.0000005打印5原理解析
Jul 23 #Javascript
JavaScript实现一键复制内容剪贴板
Jul 23 #Javascript
从原生JavaScript到React深入理解
Jul 23 #Javascript
JS前端监控采集用户行为的N种姿势
Jul 23 #Javascript
JS前端可扩展的低代码UI框架Sunmao使用详解
Jul 23 #Javascript
You might like
《PHP编程最快明白》第六讲:Mysql数据库操作
2010/11/01 PHP
让PHP更快的提供文件下载的代码
2012/06/13 PHP
php判断是否为ajax请求的方法
2016/11/29 PHP
highchart数据源纵轴json内的值必须是int(详解)
2017/02/20 PHP
JQuery打造PHP的AJAX表单提交实例
2009/11/03 Javascript
jquery插件unobtrusive实现片段式加载
2015/06/15 Javascript
jQuery 1.9.1源码分析系列(十)事件系统之主动触发事件和模拟冒泡处理
2015/11/24 Javascript
JavaScript正则表达式的分组匹配详解
2016/02/13 Javascript
Node.js中npm常用命令大全
2016/06/09 Javascript
jQuery实现点击后高亮背景固定显示的菜单效果【附demo源码下载】
2016/09/21 Javascript
JavaScript 中Date对象的格式化代码方法汇总
2017/09/06 Javascript
前端防止用户重复提交js实现代码示例
2018/09/07 Javascript
NodeJs 文件系统操作模块fs使用方法详解
2018/11/26 NodeJs
简单了解TypeScript中如何继承 Error 类
2019/06/21 Javascript
layui动态绑定事件的方法
2019/09/20 Javascript
如何优雅地取消 JavaScript 异步任务
2020/03/22 Javascript
vue在线动态切换主题色方案
2020/03/26 Javascript
vue 解决data中定义图片相对路径页面不显示的问题
2020/08/13 Javascript
[01:03]PWL开团时刻DAY6——别打我
2020/11/05 DOTA
用matplotlib画等高线图详解
2017/12/14 Python
urllib和BeautifulSoup爬取维基百科的词条简单实例
2018/01/17 Python
Python英文文章词频统计(14份剑桥真题词频统计)
2019/10/13 Python
Python爬虫爬取煎蛋网图片代码实例
2019/12/16 Python
如何在pycharm中安装第三方包
2020/10/27 Python
详解HTML5中的Communication API基本使用方法
2016/01/29 HTML / CSS
Booking.com缤客中国:全球酒店在线预订网站
2020/05/03 全球购物
越南母婴用品购物网站:Kids Plaza
2020/04/09 全球购物
仓管员岗位职责范文
2013/11/08 职场文书
应届生简历中的自我评价
2014/01/13 职场文书
开学季活动策划方案
2014/02/28 职场文书
2014年居委会工作总结
2014/12/09 职场文书
聘任证明怎么写
2015/03/02 职场文书
房产遗嘱范本
2015/08/06 职场文书
法制主题班会教案
2015/08/13 职场文书
新学期小学班主任工作计划
2019/06/21 职场文书
opencv-python图像配准(匹配和叠加)的实现
2021/06/23 Python