Skip to content

canvas交互设计

分层设计

  • canvas渲染层
  • canvas交互层
  • canvas缓冲层

canvas中如何找到事件触发对象

包围盒原理

很显然就是用一个矩形把整个物体框起来,也就是所谓的包围盒。包围盒顾名思义就是能够把物体全部包起来的盒子,常见的有 OBB、AABB、球模型等等,按顺序分别如下图所示:

其中 AABB 最为简单,应用也最为广泛,它的全称是 Axis-aligned bounding box,也就是边平行于坐标轴的包围盒,理解和计算起来都非常容易,就是取物体所有顶点(也可叫做离散点)坐标的最大最小值,就像下面这样:

js
class Utils {
    // 一个物体通常是一堆点的集合
    static makeBoundingBoxFromPoints(points: Point[]) {
        const xPoints = points.map(point => point.x);
        const yPoints = points.map(point => point.y);
        const minX = Util.min(xPoints);
        const maxX = Util.max(xPoints);
        const minY = Util.min(yPoints);
        const maxY = Util.max(yPoints);
        const width = Math.abs(maxX - minX);
        const height = Math.abs(maxY - minY);
        return {
            left: minX,
            top: minY,
            width: width,
            height: height,
        };
    }
}

这种包围盒不仅易于理解、效率高,并且在碰撞检测中效果明显,比如一般我们判断两个物体是否发生碰撞通常都会先判断它们的包围盒是否相交,如果连包围盒都不相交,那么两个物体一定不相交,就不用再进行其他精确繁琐的计算了,是性价比很高的一种方法。事实上大部分碰撞检测算法通常也分为这两步(包围盒计算+精确计算)。 当然它的缺点也是比较明显的,假如我们有一个很斜很长的三角形,那画出来的包围盒就比较冗余,就像下图这样:

这时候用 OBB(Oriented Bounding Box)包围盒就会精确很多,就像下面这样:

它能够有效贴合物体,但是计算麻烦些,有兴趣可以自行搜索一下。然后这里再简单说一下球模型,就是用一个球将物体包围起来,那怎么计算这个球的大小呢,就是要算出球心和半径,我们可以直接将所有顶点坐标相加取平均值,当做球心,再计算出离球心最远的顶点的距离,将其当做半径即可。 显然我们采用的是 AABB 包围盒。又因为包围盒是每个物体所共有的,所以它会被加在 FabricObject 物体基类里,并且应该是在绘制物体之后才绘制,因为相对来说它的层级较高,当然在 canvas 中没有层级的概念,它就是一幅画,只是后面绘制的会覆盖之前绘制的,简单看下代码:

js
class FabricObject {
    render() {
        ...
        // 坐标系变换
        this.transform(ctx);
        // 绘制物体
        this._render(ctx);
        // 如果是选中态
        if (this.active) {
            // 绘制物体边框
            this.drawBorders(ctx);
            // 绘制物体四周的控制点,共⑨个
            this.drawControls(ctx);
        }
        ...
    }
}

检测触发点是否在包围盒内

射线检测法

射线的无穷远处一定在多边形外,这样我们才能根据交点的奇偶性来倒推位置关系。数学就是这么巧妙的和前端结合起来了,一些复杂的效果归根到底还是数学的抽象

js
class Canvas {
    _initEvents() {
        // 首先肯定要添加事件监听啦
        Util.addListener(this.upperCanvasEl, 'mousemove', this._onMouseMove.bind(this));
    }
    _onMouseMove(e: MouseEvent) {
        // 如果是 hover 事件,我们只需要改变鼠标样式,并不会重新渲染
        const style = this.upperCanvasEl.style;
        // findTarget 的过程就是看鼠标有没有 hover 到某个物体上
        const target = this.findTarget(e);
        // 设置鼠标样式
        if (target) {
            this._setCursorFromEvent(e, target);
        } else {
            style.cursor = this.defaultCursor;
        }
    }
    /** 检测是否有物体在鼠标位置 */
    findTarget(e: MouseEvent): FabricObject {
        let target;
        // 从后往前遍历所有物体,判断鼠标点是否在物体包围盒内
        for (let i = this._objects.length; i--; ) {
            const object = this._objects[i];
            if (object && this.containsPoint(e, object)) {
                target = object;
                break;
            }
        }
        if (target) return target;
    }
}
class FabricObject {
    /**
     * 射线检测法:以鼠标坐标点为参照,水平向右做一条射线,求坐标点与多边形的交点个数
     * 如果和物体相交的个数为偶数点则点在物体外部;如果为奇数点则点在内部
     * 在 fabric 中的点选多边形其实就是点选矩形,所以针对矩形做了一些优化
     */
    _findCrossPoints(ex: number, ey: number, lines): number {
        let b1, // 射线的斜率
            b2, // 边的斜率
            a1,
            a2,
            xi, // 射线与边的交点 x
            // yi, // 射线与边的交点 y
            xcount = 0,
            iLine; // 当前边

        // 遍历包围盒的四条边
        for (let lineKey in lines) {
            iLine = lines[lineKey];

            // 优化1:如果边的两个端点的 y 值都小于鼠标点的 y 值,则跳过
            if (iLine.o.y < ey && iLine.d.y < ey) continue;
            // 优化2:如果边的两个端点的 y 值都大于等于鼠标点的 y 值,则跳过
            if (iLine.o.y >= ey && iLine.d.y >= ey) continue;

            // 优化3:如果边是一条垂线
            if (iLine.o.x === iLine.d.x && iLine.o.x >= ex) {
                xi = iLine.o.x;
                // yi = ey;
            } else {
                // 执行到这里就是一条普通斜线段了
                // 用 y=kx+b 简单算下射线与边的交点即可
                b1 = 0;
                b2 = (iLine.d.y - iLine.o.y) / (iLine.d.x - iLine.o.x);
                a1 = ey - b1 * ex;
                a2 = iLine.o.y - b2 * iLine.o.x;

                xi = -(a1 - a2) / (b1 - b2);
                // yi = a1 + b1 * xi;
            }
            // 只需要计数 xi >= ex 的情况
            if (xi >= ex) {
                xcount += 1;
            }
            // 优化4:因为 fabric 中的点选只需要用到矩形,所以根据矩形的特质,顶多只有两个交点,于是就可以提前结束循环
            if (xcount === 2) {
                break;
            }
        }
        return xcount;
    }
}

点在多边形内的其他判断方法

其实判断点是否在多边形内部还有其他方法,比如:

  • 用 canvas 自身的 api isPointInPath
  • 将多边形切割成多个三角形,然后判断点是否在某个三角形内部
  • 转角累加法
  • 面积法
  • ...

这里我稍微说下另一种比较有意思的方法,如果不理解射线检测法的同学,我们还能这么搞: 假设矩形旋转了一定角度,那我们将鼠标坐标点也旋转一下,这样旋转后的坐标点就不就又和矩形是同一个水平垂直方向吗,就像下图这样: 上述方法的核心要点就是将鼠标点换算成物体自身坐标系下的点(写成矩阵的形式会比较方便点),然后再用原始的方法判断即可,是不是看起来也挺方便的样子。

穿透

现在我们来扩充下另外一个知识点,就是目前我们点选物体的时候,其实是点选包围盒,当点到物体四周空白区域的时候,物体也是会被选中的,如果不想把空白区域也算在物体的点击范围内(比如 png 图片),那该怎么做呢?这个东西挺有意思的,可以停个几秒种,思考一下。

显然我们要在上文所说的 findTarget 中做文章,除了判断点是否在包围盒内,还要进一步判断点击的是不是空白的地方,所谓空白,一定程度上可以理解成是透明的地方。于是这就要用到前几个章节提到过的第三个画布 cacheCanvasEl 缓存画布,在点击到了包围盒之后我们还需要把这个物体画到这个缓存画布上,然后用 getImageData 来获取鼠标位置所在点的像素信息,当然我们允许有误差,所以会取这个鼠标点周围的一小块正方形的像素信息,接着遍历每个像素,如果找到一个像素中 rgba 的 a 的值 > 0 就说明至少有一个颜色存在,亦即不透明,退出循环,否则就是透明的,最后清除 getImageData 变量,清除缓冲层画布即可。是不是有种豁然开朗的感觉,有了思路,代码实现起来就比较简单了:

js
class Canvas {
    /**
     * 用缓冲层判断物体是否透明,目前默认都是不透明,可以加一些参数属性,比如允许有几个像素的误差
     * @param {FabricObject} target 物体
     * @param {number} x 鼠标的 x 值
     * @param {number} y 鼠标的 y 值
     * @param {number} tolerance 允许鼠标的误差范围
     * @returns
     */
    _isTargetTransparent(target: FabricObject, x: number, y: number, tolerance: number = 0) {
        // 1、在缓冲层绘制物体
        // 2、通过 getImageData 获取鼠标位置的像素数据信息
        // 3、遍历像素数据,如果找到一个 rgba 中的 a 的值 > 0 就说明至少有一个颜色,亦即不透明,退出循环
        // 4、清空 getImageData 变量,并清除缓冲层画布
        let cacheContext = this.contextCache;
        this._draw(cacheContext, target);

        if (tolerance > 0) { // 如果允许误差
            if (x > tolerance) {
                x -= tolerance;
            } else {
                x = 0;
            }
            if (y > tolerance) {
                y -= tolerance;
            } else {
                y = 0;
            }
        }

        let isTransparent = true;
        let imageData = cacheContext.getImageData(x, y, tolerance * 2 || 1, tolerance * 2 || 1);

        for (let i = 3; i < imageData.data.length; i += 4) { // 只要看第四项透明度即可
            let temp = imageData.data[i];
            isTransparent = temp <= 0;
            if (isTransparent === false) break; // 找到一个颜色就停止
        }

        imageData = null;
        this.clearContext(cacheContext);
        return isTransparent;
    }
}

怎么样,这个方法看起来还是有点意思的,而且通俗易懂。当然了,这对不同物体可以有不同的检测方法:比如物体是一个几何图形,假设是正多边形,同样的,我们希望选中的是正多边形,而不是正多边形包围盒所形成的的矩形,这时候只需要把点选物体包围盒的逻辑改成点选正多边形的逻辑即可,同样采用的是射线检测法;如果物体是条线段,就变成了点是否在线上的检测;如果是个圆,那就更简单了,诸如此类。

此外还有一种空间换时间的取巧方法,就是在创建物体的时候在离屏 canvas 上多绘制一个和这个物体形状大小一样的纯色物体,画布上的物体都有各自的颜色并且唯一,然后做一个 { color: object } 的映射,之后我们点选的时候主要是通过点击坐标获取到对应离屏 canvas 上的纯颜色,再根据映射取出对应的物体即可,这也是一种方法。

canvas中的物体框选

框选的实现

拖蓝选区(鼠标拖拽区域)的实现方式?

这个区域由鼠标按下的点和拖动的终点组成,通过这两点我们就能够确认一个规规矩矩的矩形(边和 xy 轴平行),那在哪里绘制呢?还记得我们之前说过的么,所有的交互都是在上层画布进行的,所以它理所当然的应该绘制在上层画布,并且这样一来还可以避免重绘所有的物体。

抬起鼠标的时候又要做些什么呢?

首先要做的就是把上层画布的拖蓝选区清除掉,再来就是不可避免的要遍历所有物体,找出和这个拖蓝选区有交集的所有物体。显然这又是一个数学问题,等价于判断两个矩形是否相交。

仔细想想两个矩形相交会有什么效果呢?

它们的边必相交,所以问题又可以转化为判断两个矩形的边是否相交。那如何判断两个矩形的边是否相交呢,稍微一想,最根本的就是判断两条边是否相交,这么一来,是不是稍微明朗了一点😄。具体一点就是:假设现在有物体 A 和物体 B,我们可以用 A 的第一条边去遍历 B 的每条边,如果能找到一个交点就说明两个物体相交;否则继续用 A 的第二条边去遍历 B 的每条边,以此类推,如果遍历完了所有的还是没有交点,则说明物体 A、B 不相交。当然这种方法还不够完全,少了一种特例,就是物体 A、B 还可能是包含与被包含的关系,比如物体被拖蓝选区完全包围,它们的边是没有交点的,所以我们也应该囊括这种情况,这种包含关系判断起来就比较简单了,就是比较下两个物体的最大最小 xy 值即可。 经过上面简单的推论不难得出,最基本的判断就是看两条线段是否相交,常规的解法就是:

  • 因为每条线段的端点是已知的,所以能求出两条线段所在的直线方程(注意直线和线段的措词,后面内容也是)
  • 如果两条直线斜率相同,那两条线段肯定不相交
  • 如果斜率不同,就需要联立方程组求解
  • 不过这个求解结果是直线的交点,最后还要简单校验下这个解是不是在两个线段的坐标范围内才行

这个就是最朴实无华的解法啦,我们先这么理解就行。其实在图形学中,类似这种运算都是用向量来计算的,比如用向量叉乘来判断线段是否相交,fabric.js 中也是用这样的思想,不过这个系列我并没有强调向量的概念,因为容易劝退,所以这些内容我会在这个系列的最后几个章节中单独写一篇来讲解,这里就简单贴下代码,可跳过:

js
/**
 * 判断两条线段是否想交
 * @param a1 线段1 起点
 * @param a2 线段1 终点
 * @param b1 线段2 起点
 * @param b2 线段3 终点
 */
static intersectLineLine(a1: Point, a2: Point, b1: Point, b2: Point): Intersection {
    // 向量叉乘公式 `a✖️b = (x1, y1)✖️(x2, y2) = x1y2 - x2y1`
    let result,
        // b1->b2向量 与 a1->b1向量的向量叉乘
        ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x),
        // a1->a2向量 与 a1->b1向量的向量叉乘
        ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x),
        // a1->a2向量 与 b1->b2向量的向量叉乘
        u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
    if (u_b !== 0) {
        let ua = ua_t / u_b,
            ub = ub_t / u_b;
        if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
            result = new Intersection('Intersection');
            result.points.push(new Point(a1.x + ua * (a2.x - a1.x), a1.y + ua * (a2.y - a1.y)));
        } else {
            result = new Intersection('No Intersection');
        }
    } else {
        // u_b == 0时,角度为0或者180 平行或者共线不属于相交
        if (ua_t === 0 || ub_t === 0) {
            result = new Intersection('Coincident');
        } else {
            result = new Intersection('Parallel');
        }
    }
    return result;
}

现在假设我们通过上面的方法找到了所有与拖蓝选区相交的物体,那之后要做什么呢?

可以看到框选的最终效果就是用一个更大的包围盒把所有物体都框起来,最终生成的也只有外面的包围盒和控制点,被包裹的物体则只进行边框绘制,而没有控制点。里面的物体好绘制,就是把物体设置成选中态即可,只是不绘制控制点(多加一个变量的事)。那外面的包围盒呢,怎么将这个大的包围盒和多个物体进行关联呢?通过下面的 Group 类。

Group类的实现

一个大的包围盒和多个物体,能想到什么呢? 其实我们所有的物体是不是都在画布中,画布就可以看做是一个很大的包围盒,框住所有物体,所有物体也都依附于这个画布,这很形象,也顺便引出了接下来要介绍的组(Group)的概念。Group 本身也继承于 FabricObject 类,它也是个物体,只不过这个物体下面还会有很多个小物体;至于组的包围盒,和一个普通物体类似,找出所有子物体的最大最小 xy 值即可,这里我们直接看代码应该会更好理解:

js
/**
 * Group 类,可用于自己手动组合几个物体,也可以用于拖蓝选区包围的物体
 * Group 虽然继承至 FabricObject,但是要注意获取某些属性有时是没有的,因为子物体的属性各不相同
 */
class Group extends FabricObject {
    public type: string = 'group';
    public objects: FabricObject[]; // 组中所有的物体
    constructor(objects: FabricObject[], options: any = {}) {
        super(options);
        this.objects = objects || [];
        this._calcBounds(); // 计算组的包围盒
        this._updateObjectsCoords(); // 更新组中的物体信息
    }
    /** 计算组的包围盒 */
    _calcBounds() {
        // 就是求子物体中所有 objects 的最大最小 xy 值
    }
    /** 更新所有子物体的坐标值,像这种情况子物体都是以父元素的坐标系为参考,而不是画布的坐标系为参考 */
    _updateObjectsCoords() {
        let groupDeltaX = this.left,
            groupDeltaY = this.top;

        this.objects.forEach((object) => {
            let objectLeft = object.get('left'),
                objectTop = object.get('top');

            object.set('originalLeft', objectLeft);
            object.set('originalTop', objectTop);

            object.set('left', objectLeft - groupDeltaX);
            object.set('top', objectTop - groupDeltaY);

            object.setCoords();

            // 当有选中组的时候,不显示子物体的控制点
            object.orignHasControls = object.hasControls;
            object.hasControls = false;
        });
    }
    /** 将物体添加到 group 中 */
    add(object: FabricObject) {
        this.objects.push(object);
        return this;
    }
    /** 将物体从 group 中移除 */
    remove(object: FabricObject) {
        Util.removeFromArray(this.objects, object);
        return this;
    }
    /** 将物体添加到 group 中,并重新计算位置尺寸等 */
    addWithUpdate(object: FabricObject): Group {
        this._restoreObjectsState();
        this.objects.push(object);
        this._calcBounds();
        this._updateObjectsCoords();
        return this;
    }
    /** 将物体从组中移除,并重新计算组的大小位置 */
    removeWithUpdate(object: FabricObject) {
        this._restoreObjectsState();
        Util.removeFromArray(this.objects, object);
        object.setActive(false);
        this._calcBounds();
        this._updateObjectsCoords();
        return this;
    }
    /** 组的渲染会特殊一点,它主要是子物体的渲染,但是组的变换会影响所有子物体的变换 */
    render(ctx: CanvasRenderingContext2D) {
        ctx.save();
        this.transform(ctx); // 组有自身的变换,会影响所有子物体
        for (let i = 0, len = this.objects.length; i < len; i++) { // 遍历绘制组中所有物体
            let object = this.objects[i],
            object.render(ctx); // 回顾一下:每个物体的 render = 每个物体的 transform + 每个物体的 _render
        }
        if (this.active) { // 组是否被选中
            this.drawBorders(ctx);
            this.drawControls(ctx);
        }
        ctx.restore();
        this.setCoords();
    }
}

所以我们把 Group 当做一个普通的大物体就行,里面的子物体该怎么绘制还是怎么绘制,当 hover 和 click 的时候只要判断 Group 的包围盒即可,里面的子物体是不用去遍历的,因为它们是一个整体。但是要注意的是上面代码中的 _updateObjectsCoords 方法,当我们把某些物体放进一个 Group 的时候,需要修改其 top 和 left 值,使其位置变为相对 Group 的位置,而不是相对于画布的位置,这点要尤其注意,类似这种嵌套关系,子物体的位置一般都是相对于其父元素来说的,而不是画布的位置。回过头来再说说框选,当鼠标抬起的时候,我们会找出与拖蓝选区相交的所有物体:

如果只有一个物体与之相交的话,其实就变成了普通点选的情况,我们直接将该物体的置为选中态即可 如果有多个物体相交,那就需要临时创建一个 Group 实例,叫 _activeGroup,将这些物体都添加进来,然后对这个临时组完成一些操作之后再销毁这个组即可 来看下核心代码,也是很通俗易懂的:

js
class Canvas {
    /** 
     * 获取拖蓝选区包围的元素
     * 如果只有一个物体,那就是普通的点选;如果有多个物体,那就生成一个组
     */
    _findSelectedObjects(e: MouseEvent) {
        let objects: FabricObject[] = [], // 存储最终框选的元素
            x1 = this._groupSelector.ex,
            y1 = this._groupSelector.ey,
            x2 = x1 + this._groupSelector.left,
            y2 = y1 + this._groupSelector.top,
            selectionX1Y1 = new Point(Math.min(x1, x2), Math.min(y1, y2)),
            selectionX2Y2 = new Point(Math.max(x1, x2), Math.max(y1, y2));

        for (let i = 0, len = this._objects.length; i < len; ++i) {
            let currentObject = this._objects[i];
            // 物体是否与拖蓝选区相交或者被选区包含,用到的就是前面说过的多边形相交算法,具体的算法会在文末附上
            if (currentObject.intersectsWithRect(selectionX1Y1, selectionX2Y2) || currentObject.isContainedWithinRect(selectionX1Y1, selectionX2Y2)) {
                currentObject.setActive(true);
                objects.push(currentObject);
            }
        }
        if (objects.length === 1) { // 如果只有一个物体被选中
            this.setActiveObject(objects[0], e);
        } else if (objects.length > 1) { // 如果有多个物体被选中
            const newGroup = new Group(objects);
            this.setActiveGroup(newGroup);
        }
        this.renderAll();
    }
    setActiveGroup(group: Group): Canvas {
        this._activeGroup = group;
        if (group) {
            group.canvas = this;
            group.setActive(true);
        }
        return this;
    }
}

上面代码中要注意的就是我们还需要对 renderAll 这个绘制方法做一些修改,就是把所有激活的物体都放到最后绘制,就像下面这样:

js
class Canvas {
  renderAll(): Canvas {
    ...
    // 先将物体排个序,这样才能体现出层级关系,简单来说就是先绘制未激活物体,再绘制激活物体
    const sortedObjects = this._chooseObjectsToRender();
    for (let i = 0, len = sortedObjects.length; i < len; ++i) {
        this._draw(canvasToDrawOn, sortedObjects[i]);
    }
    return this;
  }
  /** 将所有物体分成两个组,一组是未激活态,一组是激活态,然后将激活组放在最后,这样就能够绘制到最上层 */
  _chooseObjectsToRender() {
    // 当前有没有激活的物体
    let activeObject = this.getActiveObject();
    // 当前有没有激活的组(也就是多个物体)
    let activeGroup = this.getActiveGroup();
    // 最终要渲染的物体顺序,也就是把激活的物体放在后面绘制
    let objsToRender = [];

    if (activeGroup) { // 如果选中多个物体
        const activeGroupObjects = [];
        for (let i = 0, length = this._objects.length; i < length; i++) {
            let object = this._objects[i];
            if (activeGroup.contains(object)) {
                activeGroupObjects.push(object);
            } else {
                objsToRender.push(object);
            }
        }
        objsToRender.push(activeGroup);
    } else if (activeObject) { // 如果只选中一个物体
        let index = this._objects.indexOf(activeObject);
        objsToRender = this._objects.slice();
        if (index > -1) {
            objsToRender.splice(index, 1);
            objsToRender.push(activeObject);
        }
    } else { // 所有物体都没被选中
        objsToRender = this._objects;
    }
  return objsToRender;
}

当然如果是框选或点击到空白处,只要把所有物体的 active 属性都设置 false 就行了。但有同学肯定又会有疑问了,上面这样的排序绘制好像并不能精确控制每个物体的层级关系,如果我们需要做个上移一层、下移一层的功能该怎么搞呢?这个也很简单,在 html 中也已经给了我们答案,就是用 z-index,我们给每个物体多加一个 zIndex 属性就行了,之后直接用 zIndex 排序就行。其实在 canvas 上绘制东西和浏览器展示页面内容这个过程很像很像,很多思想都是共通的,比如盒模型、元素的继承、transform、zIndex、top、left 等常见的 css 属性,以及后续会提到的事件监听,只不过我们习惯了用 html 和 css 去描绘这个页面,而 canvas 需要我们用 js 去描述,canvas 库则是提供了这个桥梁,极大方便了我们开发。

事件派发

有时候我们希望在物体初始化前后、状态改变前后、一些交互前后,能够触发相应的事件来实现自己的需求,比如画布被点击了我想...,物体被移动了我想...,这个就是典型的发布订阅模式,前端应用最广泛的设计模式,比如:

  • html 中的 addEventListener
  • vue 中的 EventBus
  • 各种库和插件暴露的一些钩子函数(或者说是生命周期)

事件中心代码:

js
/**
 * 发布订阅,事件中心
 * 应用场景:可以在特定的时间点触发一系列事件(在本文主要就是渲染前后、初始化物体前后、物体状态改变时)
 */
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;
    }
    /** 触发某个事件回调,找到事件名对应的数组拿出来遍历执行 */
    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];
        // 注意:这里我们删除监听一般都是置为 null 或者 false
        // 当然也可以用 splice 删除,不过 splice 会改变数组长度,这点要尤为注意
        if (handler) {
            eventListener[eventListener.indexOf(handler)] = false;
        } else {
            eventListener.fill(false);
        }
    }
}

然后接下来要做什么呢?很简单,就是让需要事件的类继承至这个事件类就可以了,然后在有需要的地方触发就行了,这里我们以画布为例,看下下面的代码你就知道这种套路了(注意下面代码中注释的地方):

js
class Canvas extends EventCenter { // 继承
    _initObject(obj: FabricObject) {
        obj.setupState();
        obj.setCoords();
        obj.canvas = this;
        this.emit('object:added', { target: obj }); // 画布触发添加物体时间
        obj.emit('added'); // 物体触发被添加事件
    }
    renderAll() {
         this.emit('before:render');
         // 绘制所有物体...
         this.emit('after:render');
    }
    clear() {
        ...
        this.clearContext(this.contextContainer);
        this.clearContext(this.contextTop);
        this.emit('canvas:cleared'); // 触发画布清空事件
        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()) { // 物体状态改变了才派发事件
            this.emit('object:modified', { target });
            target.emit('modified');
        }
    }
}

因为 Canvas 类继承了 EventCenter 这个类,所以画布就有了订阅和发布的功能,同样的我们也可以让 FabricObject 这个物体基类继承 EventCenter,这样每个物体也有了发布订阅的功能。

有同学可能会问,上面的代码只看到了 emit 事件,怎么没看到 on 和 off 事件呢?因为之前说了,库或者插件一般只提供钩子,上面 emit 的地方就可以称作钩子(怎么感觉有点像埋点),而 on 和 off 事件则是我们开发时才需要写的。

动画

动画的本质

先来看看在 canvas 库中调用动画的一般方式吧,比如我们要让一个矩形动起来,大体是下面这样的用法:

js
rect.animate(
    { top: 50, left: 400, angle: 45 }, // 要动画的属性
    { duration: 1000, onChange: canvas.renderAll.bind(canvas) } // 动画执行时间和手动渲染
);

画布重新绘制,只要重绘的足够多足够快,根据人的视觉残留效应,就形成了动画。

动画的实现

既然动画的本质就是值的改变,那这个值的改变和哪些因素有关呢?根据刚才的例子我们可以知道大概有以下四个因素:

  • 初始值:startValue
  • 结束值:endValue
  • 值的变化时间:duration
  • 怎么变(匀速、缓动还是弹动):easing(一个熟悉的单词出现了)

显然动画也是一个通用的东西,所以我们把它写在 Util 工具类里,代码不多,直接食用就行👇🏻:

ts
interface IAnimationOption {
    /** 初始值 */
    startValue?: number;
    /** 最终值 */
    endValue?: number;
    /** 执行时间 */
    duration?: number;
    /** 缓动函数 */
    easing?: Function;
    /** 动画一开始的回调 */
    onStart?: Function;
    /** 属性值改变都会进行的回调 */
    onChange?: Function;
    /** 属性值变化完成进行的回调 */
    onComplete?: Function;
}
class Util {
    static animate(options: IAnimationOption) {
        window.requestAnimationFrame((timestamp: number) => { // requestAnimationFrame 会有个默认参数 timestamp,单位毫秒,表示开始去执行回调函数的时刻
            // 初始化一些变量
            let start = timestamp || +new Date(), // 开始时间
                duration = options.duration || 500, // 动画时间
                finish = start + duration, // 结束时间
                time, // 当前时间
                onChange = options.onChange || (() => {}), // 值改变进行的回调
                easing = options.easing || ((t, b, c, d) => -c * Math.cos((t / d) * (Math.PI / 2)) + c + b), // 缓动函数,不用管名字,简单理解为一个普通函数即可,它会返回一个数值
                startValue = options.startValue || 0, // 初始值
                endValue = options.endValue || 100, // 结束值
                byValue = options.byValue || endValue - startValue; // 值的变化范围
                
            function tick(ticktime: number) { // tick 的主要任务就是根据当前时间更新值
                time = ticktime || +new Date();
                let currentTime = time > finish ? duration : time - start; // 当前已经执行了多久时间(介于0~duration)
                onChange(easing(currentTime, startValue, byValue, duration)); // 根据当前时间和 easing 函数算出当前的动画值是多少,easing 理解成一个普通函数就行,它会返回一个值,就像这样:curVal = f(x) = easing(currentTime)
                if (time > finish) { // 动画结束
                    options.onComplete && options.onComplete(); // 动画完成的回调
                    return;
                }
                window.requestAnimationFrame(tick); // 循环调用 tick,不断更新值,从而形成了动画
            }

            options.onStart && options.onStart(); // 动画开始前的回调
            tick(start); // 开始动画
        });
    }
}

相信上面的注释应该解释的清清楚楚、明明白白。不过还是要着重讲下其中的两个点:

为什么使用 requestAnimationFrame 这个 api 来完成动画?

应该也是个老生常谈的问题了,因为 setInterval 和 setTimeout 不准,很容易出问题,比如执行时机不准确、切换页面回来会堆积执行、不流畅等,并且它们也不是专门为动画而生(当然如果你不习惯用 requestAnimationFrame 也可以直接把它换成 setTimeout,方便自己理解);而 requestAnimationFrame 是按帧率刷新的,跟着帧率走的期间我们就可以不用做很多无用功,能够更好的知道绘制下一帧的最佳时机,也比较流畅。它们的一个最主要的区别就是:setInterval 和 setTimeout 是主动告诉浏览器什么时候去绘制;而 requestAnimationFrame 则是浏览器在它觉得可以绘制下一帧的时候通知我们(你品,你细品,就有那味了)。当然我们肯定不能直接傻傻的像下面这样调用👇🏻:

js
// 假设要从左到右运动
let left = 100;
function tick() {
    left++; // 更新值
    window.requestAnimationFrame(tick);
}
tick();

因为每个屏幕刷新频率不一样,如果像上面这样写,在有的电脑上就会快一些,有的电脑上就会慢一些,不仅如此在页面切换到后台的时候帧率也会降低,就会导致各种问题,这显然不是我们期望的。所以要怎么做呢?我们应该是以时间为维度来播放动画,因为时间对我们来说流逝的速度是一样的,所以在动画一开始的时候需要记录下开始时间 start,之后动画播放到哪里都会以这个开始时间为基准,回头看看刚才代码中计算当前动画执行了多长时间的方式:let currentTime = time > finish ? duration : time - start;,就是以 start 为基准的,这点很重要。

第二点是关于 easing 函数

虽然好像接触过,但还是会有很多同学对此感到疑惑,所以接下来我会专门讲下这方面的内容,比如:这个函数是干嘛的、是怎么推导的、最终又是得到什么结果、和我们平时说的缓动函数是一个东西吗等等之类的。

动画的推导

在讲解 onChange(easing(currentTime, startValue, byValue, duration)) 这个东西之前,我们先来看看如何让每个物体都具有动画的方法,就是在物体基类中扩展就行了,瞟一眼就行👇🏻:

js
class FabricObject { // 物体基类
    _animate(property, to, options: IAnimationOption = {}) { // 某个属性要变化到哪里
        options = Util.clone(options);
        let currentValue = this.get(property); // 获取初始值
        if (!options.from) options.from = currentValue; // 一般不传初始值的话就默认取当前属性值
        Util.animate({
            startValue: options.from,
            endValue: to,
            easing: options.easing, // 决定了值如何变化,常用的就缓动和弹动
            duration: options.duration,
            onChange: (value) => { // value 是 easing 函数的返回值,本质就是值的计算,value = easing()
                this.set(property, value); // 重新设置属性值
                options.onChange && options.onChange(); // 值改变之后,调用 onChange 回调就会重新渲染画布,数据和视图分开的优点又体现了出来
            },
            onComplete: () => {
                this.setCoords(); // 更新物体自身的一些坐标值等
                options.onComplete && options.onComplete(); // 动画结束的回调
            },
        });
    }
}

然后再强调一下,动画的核心就是值的变化,Util.animate 中的 easing 函数其实就是计算动画播放到 (0, duration) 中间某一时刻的值是多少,仅此而已。再来简单说下 easing 函数吧,一般可以叫它缓动函数。

它是首先是一个函数,并且会返回一个数值,类似于 y = f(x),在我们的例子中就是 value = easing(time, beginValue, changeValue, duration)。这个函数有四个参数(当前时间、初始值、变化量 = 结束值-初始值、动画时间),返回的是当前时间点所对应的值 value,显然后面三个参数是已知的,也是固定的,唯一会变化的就是当前时间,它的取值范围就是从 0 到 duration。执行动画的时候其实就是改变这个当前时间,根据当前时间我们代入 easing 函数就能够得到对应的 value 值。

可能有同学还是不懂这个缓动函数,其实是因为被上面的公式唬住了,公式都是推导之后的简便写法,直接去看式子是很难理解的,单凭公式在脑海中想象出动画效果也不太现实,所以这里给大家简单推导一下这种式子怎么来的,以最简单的匀速运动为例子,看看下面这张图👇🏻:

上面这个过程很显然,也不用怎么推导,下面我们来看另一个更加通用的例子,首先随便拿一个函数 y = x * x(其他的也行),顺便简单画下函数图像👇🏻:

绿色代表起点,也就是动画起始值,红色代表终点,也就是动画结束值。x 轴就是动画时间,y 轴就是当前的动画值,为了方便和统一,我们需要把时间换算成 [0, 1] 的范围,0 就是起点,1 就是终点,y 轴代表的值也是一样的道理。然后我们的起点和终点就是(0,0)和(1,1)点(注意:虽然xy的范围都是0到1,看起来是个正方形,但它们的单位或者说表达的意思是不一样的,不要混淆了),起点和终点是固定不变的,中间的曲线可以随便怎么画,那怎么将它写成一个缓动函数呢?我们先看看 x 轴代表什么,x 是一个取值范围从0到1的变量,看看我们的缓动函数有啥变量呢,就一个 currentTime,但是 currentTime 的取值范围是从 [0, duration],所以我们需要把它映射成[0, 1],其实也就是把 currentTime / duration 就行,然后用 currentTime / duration 代替 x;那 y 呢,y 根据 x 算出来的值,代表的是当前这个时间点所对应的值,也就是我们缓动函数的 value 值,它的取值范围在 [startValue, startValue + byValue] 之间,所以我们也需要将其变成[0, 1],所以 value 的值变成了这样(value - startValue) / byValue,那么现在 y 值也有了,我们就可以将它们直接代入 y = x * x 这个初始公式,就像这样:

js
① y = x * x
👇🏻 代入 x、y
② (value - startValue) / byValue = (currentTime / duration) * (currentTime / duration)
👇🏻 整理一下
③ value = (currentTime / duration) * (currentTime / duration) * byValue + startValue
👇🏻 简化一下(简化英文单词而已😂)
value = (t, b, c, d) => ((t/d) * (t/d) * c + b)

这个效果其实就是 easeInQuad 先慢后快的缓入效果,其他函数也是一样的推导方式,只要你能写出来。不过即便知道了怎么推导,你也很难有个直观的效果,其实常见和常用的就那么几个,网上也有大把封装好的和演示的,有个印象就行(比如可以搜一下 Tween.js)。当然你也可以看函数图像简单猜一下效果,具体就是看每一点的斜率,斜率越趋近于水平就越慢,斜率越趋近于竖直就越快;如果你的函数曲线中有 y 值超出了 1,就说明中间点在某一时刻会超过终点,如果有 y 值小于 0,就说明有中间点有某一时刻会小于起始点,大概是这么个意思😂。 缓动函数有个很大的特点,就是起点和终点位置是确定的,中间位置你可以随便算,可快可慢,可以超出终点,也可以小于起点,具体什么效果,你可以随便写个方程运行试试,然后再根据效果调试。相信你肯定见过下面这种类型的图:

canvas 性能优化原理

前言

这个章节我们主要讲的是 canvas 中一些具体的性能优化思路。虽然平时在写页面的时候不需要太过关注,但是在 cavnas 中是 hin 容易写出卡顿、甚至崩溃的代码的,所以讲一下大体思路是很有必要的。当然天下乌鸦一般黑,道理都是一样的🥳。

我们知道 canvas 渲染是一个不断擦除和重绘的过程,一般会配合 requestAnimationFrame 使用,要想让人觉得流畅,就要把每一帧的时间控制在 16ms 以内,当然越少越好,而每一帧主要又包含两个部分:计算和渲染。于是乎要想提高 canvas 的性能,无非就是从这两方面下手:

  • 计算:

    • 减少数据量
    • 加快处理
  • 绘制:

    • 尽可能少的绘制
    • 尽可能快的绘制

其实它们又好像是一个意思,就是能偷懒就偷懒,能少做一点就绝不多做,能不重新绘制的时候就不重新绘制,必须要绘制的时候就少绘制一些东西。接下来咱们就基于这些基本原则进行详细说明。

尽可能少的绘制

这是最重要的原则,也是效果最为显著的手段,因为所有的绘制都是有成本的:

  • 执行各种逻辑、各种计算
  • js 调用 canvas api 进行绘制
  • 浏览器把渲染后的结果呈现在屏幕上(通常是另一个渲染线程)
  • ...

刚才我们说每一帧的时间有 16ms,但实际上是更少的。所以尽可能少的绘制是必须的,下面就来看看基于这个原则的一些实用方法。

可视区外的不绘制

这个是最简单也是最直白的想法了,就是超出画布可视区我们就不进行绘制,那具体是怎么操作嘞🤔?就是我们在重绘画布的时候,肯定是需要遍历每个物体然后才能把每个元素画上去的,当遍历到某个物体时,我们可以先判断该物体是否在画布可视区内,亦即物体的 AABB 包围盒是否在画布这个矩形内,不在的话就直接跳过,一般 canvas 库都会提供物体的坐标点信息,所以还是很好判断的。

分层

分层顾名思义就是生成多个 canvas,然后将它们依次堆叠即可。很多文章都会提到说把动态物体和静态物体进行分层,这确实是一种很经典的做法,比如可以将不常动的背景单独放在一个 canvas 里。不过其实不一定是要放在 canvas 中,放在一个普通 div 里也是 ok 的。那除了动静分层,还有什么其他分层原则吗🤔?有的,比如:

  • 按功能分层

    • 这个思想在 fabric.js(一个canvas 库)中应该算是很明显了,该库的实现用了两层 canvas,上层 canvas 主要负责响应各种交互事件,下层 canvas 则专注于单纯的渲染画布,适当的分层有利于代码清晰、理顺逻辑和 bug 调试。
  • 临时分层

    • 当我们在一个复杂的画布中拖动一个物体时,为了避免重绘该图层,可以将拖动的物体单独移出到另外一个 canvas 中渲染,当拖拽结束时再将物体移动回原来的图层。

当然层数也不建议创建太多,毕竟是要占内存的,通常可以是 5 个以内(其实看情况啦🥳)。另外要注意的就是分层会导致层与层之间的顺序是固定的,对于物体之间总是相互交错的时候它并不是一个很好的选择。

批量绘制

假如画布中有很多个物体在运动,每个物体都会触发重新渲染,那画布就会一直重绘,这其实没有必要,所以我们可以优化一下,使之只重绘一次,好比现在前端的框架,如果我们在短时间内改变同一个值,页面是不会反复渲染的,而是等到下一个周期再统一执行。虽说叫批量绘制,其实就是统一到下一帧执行,给人的感觉更像是防抖, 说起来好像很简单,写起来其实也还好,这里简单贴下代码加深下印象:

js
batchRender() {  
    if (!this.pending) {
      this.pending = true;
      requestAnimFrame(() => {
        this.render();
        this.pending = false;
      });
    }
    return this;
}

局部绘制

通常情况下,为了简单起见,我们都是用 clearRect 清空整个画布,然后重新绘制所有物体的。但事实上并非所有物体都是需要重绘的,此时局部更新就派上用场了。用的还是 clearRect 这个 api,它是可以传递参数的,类似矩形的用法,形如这样:clearRect(x, y, w, h),那么我们就可以用该方法清空要重绘的动态区域(就是个矩形),然后通过 clip 这个 api 来限制绘制范围,之后进行正常绘制即可。很多文章虽然都提到了这一点,但是具体怎么操作并没有说的很清楚🤯,所以下面就来详细说明下。

首先我们要找出需要局部绘制的区域,这需要亿点点计算,也被称为脏矩形检测。想想我们之所以要清空矩形选区是因为什么,是因为该矩形区域内的物体状态发生了改变,所以矩形区域理应由这些变化的物体来决定。

局部绘制还有个很核心的问题就是我们怎么知道一个物体是不是需要重绘呢?那就要看物体的状态有没有改变,也就是如何感知数据的变化,巧了,这个也和现在前端的框架很像,一般有以下几种方法:

  • 数据劫持
  • 入口收敛
  • 对数据进行 diff

虽然脏矩形看起来很实用,不过它也有局限性,比如:

它不适合画布大部分区域都是动态的场景,因为这种情况下我们算出来的包围盒大小可能就会占画布大小的百分之八九十,相当于不仅要重绘整个画布,还多了个计算的过程,所以这也是一个权衡的过程。 局部绘制 hin 容易造成残影,容易产生一些奇奇怪怪的 bug。根本原因是最小只能绘制 1px 的问题(大家应该有听过 1px 模糊的问题),基于这个原因,导致物体计算出来的大小与实际绘制出来的大小会有一些偏差,所以我们需要将计算出来的包围盒分别向上向下取整一下,也就是向外扩充一圈。同样的,浮点数也会造成这样的问题,不过也是一样的处理方法。此外还需要考虑到阴影的影响,计算包围盒的时候需要加上阴影的大小。特殊点的还要考虑折线之间的拐角,canvas 在实现拐角的时候会在线段连接处做一些额外处理,使折线看起来更自然美观一些,也会导致我们绘制出来的拐角比计算的要大。

另外再补充几个小点:

  • clip 也能是其他图形,但没必要,AABB 包围盒效率还是很高的,就像碰撞检测一样,一般也是先检测 AABB 包围盒的碰撞。
  • 除了物体的属性变化外,添加物体、删除物体或层级调节都是需要重绘的。
  • 局部绘制并不一定只是重绘动态物体,与之相关的静态物体也是需要重绘的。
  • 合并完包围盒之后,还可以和画布这个大包围盒取下交集,因为我们只需要绘制可视区域。

尽可能快的绘制

减少指令代码

  • 同绘制一样,指令的执行也是有成本的,所以能少一点是一点。比如一般我们画一个新的图形都会调用一次 beginPath,这样比较好保证每个图形互不干扰。但如果画的是 n 条连续的线段,我们就不用一直 beginPath 了,只要在一开始调用一次就行,绘制内容一多,省下来的时间还是很可观的。
  • 有的时候相同的指令能达到同样的效果,比如 putImage 和 drawImage 都可以将图像绘制到 canvas 中,但是 putImageData 是一项开销极大的操作,而 drawImage 的效率会更高,并且 drawImage 还能将某个 canvas 绘制到另一个 canvas 中,所以同等条件下优先使用 drawImage。
  • 对 cavnas 上下文赋值的开销远大于对一个普通对象赋值的开销,毕竟 canvas 不是一个普通对象,比如当你调用了 ctx.lineWidth = 10 时,浏览器就会立马去做一些事情(最基本的比如处理非法输入等),等到我们调用 stroke 等真正绘制的 api 时,就能够直接绘制了)。类似的属性还有 font、shadow、text 等。这个大家简单去循环个几十万次就能对比出结果了。但不是说我们就不去设置了,而是要知道是否设置的合理,避免无谓的开销。

缓存

缓存应该是各种优化手段中最重要的一把利器了,不仅仅是在 canvas 中,各个领域也是应用广泛,尤其是各种数据缓存,所以我们得好好讲讲。想想我们是怎么将物体绘制到画布中的,是不是每个物体都会有一个自己的 render 方法,然后一笔一笔地画出来,如果每次重绘都要把 render 里面的代码拿出来一条一条执行,好像不是太好。。。那有没有什么更快的绘制方法呢?有的,那就是缓存,一个时空转换的超典型。这里我们以绘制矩形为例子吧,简单看下矩形的 render 方法:

ts
 _render(ctx: CanvasRenderingContext2D) {
    const rx = this.rx || 0,
        ry = this.ry || 0,
        x = -this.width / 2,
        y = -this.height / 2,
        w = this.width,
        h = this.height;

    // 绘制一个新的东西,大部分情况下都要开启一个新路径,要养成习惯
    ctx.beginPath();

    // 从左上角开始顺时针画矩形,这里就是单纯的绘制一个规规矩矩的带圆角的矩形,
    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();
}

上面的代码说明我们每次去重绘矩形的时候都得一行一行的执行代码,一笔一笔的去画出来,那如果我们能一次性地画出来是不是会好点呢?这时候 drawImage 就派上用场了(怎么又是你🤔)。首先在第一绘制的时候,肯定是要一笔一笔画的,这个毋庸置疑,但是到了第二次的时候,因为之前已经画过一次了,所以我们可以把第一次的绘制好的结果缓存起来,再次绘制的时候直接调用 drawImage 把刚才的缓存拿过来即可,那这个缓存具体是啥样呢?来看看下面的代码加深理解👇🏻:

ts
_createCacheCanvas: function() {
    this._cacheCanvas = Utils.createCanvasElement(); // 这个就是缓存,就是创建一个 canvas 元素,你可以当做是离屏 canvas
    this._cacheContext = this._cacheCanvas.getContext('2d');
    this._updateCacheCanvas();
}

不在页面上展示的 canvas 都可以称为离屏 canvas,canvas 也不一定要绘制到页面中。然后矩形的绘制方法也要随之更改👇🏻:

ts
_render(ctx: CanvasRenderingContext2D) {
    if (this.shouldCache()) { // 如果开启缓存
        this.renderCache(); // 在离屏 canvas 中绘制
        this.drawCacheOnCanvas(ctx);  // 将离屏 canvas 用 drawImage 绘制到页面的 canvas 中
    }
}

现在我们再次绘制矩形的时候就把代码从原来的好几步变成了一步(也是尽可能少的执行代码的一种体现),drawImage 这玩意还是很快的,妥妥的空间换时间。一般情况下如果物体的状态没有改变,都可以直接利用缓存。那变了怎么处理呢?比如拖拽控制点对物体进行缩放,那缓存不就失效了吗,是这样没错,但是我们可以做一些优化,就是在拖拽的过程中仍然用缓存来绘制,只不过会有拉伸、模糊的副作用,最后在缩放结束时及时更新缓存并重新绘制新的缓存就可以了,说起来晦涩,那就看看下面这个动图感受一下😄:

当然如果你不想拉伸过程中变模糊,也可以关掉这个功能,也是一种权衡。其实缓存对于复杂图形的影响还是很大的,比如导入一个复杂的 svg,有兴趣的可以点下这个官方链接(fabric缓存对比)感受下效果,对比贼明显,大概像下面这样子(左图是有缓存,右图是无缓存,明显卡顿,因为缩放过程中需要不停重绘),不过可能看不太出来😂:

缓存是 canvas 中优化的一个重点,其核心就是利用 drawImage 这个 api,它适用于一些偏静态的物体,如果物体的状态时常改变,那缓存就会一直更新,也就失去了缓存的意义。这里再举个具体的例子加深下印象吧:在线 Excel 表格大家应该都用过(用 canvas 写的那种),假设表格向上滚动了 100px,想想是不是只有 100px 的区域发生了改变,而剩余部分是不变的,所以我们只需要重新绘制那 100px,另外的部分用 drawIamge 从缓存里面拿出来贴上去即可。

小提示:预渲染的本质也是缓存,就是预先在离屏 canvas 上绘制相似或重复的图形,用的时候也是直接 drawImage 即可。

滤镜

滤镜是一种对图形像素进行处理的方法,虽然 canvas 支持多种常用滤镜,但是它的性能开销比较大,所以要尤为注意。比如我们需要对整个 canvas 做一个全局滤镜的话,通常会在循环绘制每个物体的时候应用上这个效果,显然不是很好,这时候可以先将所有物体都正常绘制到离屏 canvas 上,然后再把这个离屏 canvas 绘制到页面的画布上,此时再应用滤镜效果,这样就可以把多次滤镜改成一次应用,效果还是挺不错的。等等🤔。。。话是这样说,但如果不是全局统一滤镜呢?要是有的用滤镜1,有的用滤镜2咋整。em。。。这是个好问题,不过解决办法也是一样的,我们可以把应用相同滤镜的物体都在一个离屏 canvas 中绘制,然后再分批绘制到主画布中,换汤不换药。

减少计算

通常情况下,渲染比计算的开销大很多,除非我们写了一些复杂度较高的算法或者业务逻辑,或者使用姿势不当,不然一般很难改。不过这里还是要简单过下一些减少计算的方法。

减少数据量

一般来说计算耗时是因为要计算的东西太多,所以一个首要的目标就是减少数据量,比如绘制一些复杂的图表或动效,我们可以:

  • 不计算可视区域外的东西
  • 按条件过滤掉部分数据
  • 抽稀算法:这个和过滤有点类似,但不是单纯的过滤,抽稀算法能够在减少数据的基础上保证整个数据的形没有太多偏差,啥意思呢?比如一条曲线,如果是单纯的过滤,比如只留下奇数点,那绘制出来的曲线就很难维持原有的曲线形状,但是抽稀算法会尽可能的贴近原有图形,这个有兴趣的可以自行查阅看看。

加快数据处理速度

假设数据就是那么多,计算开销实在大,我们又该怎么做呢?可以这样子搞:

  • 先说下遍历

    • 细心的同学会发现,很多情况下我们都需要遍历所有物体才行,有没有什么方法能够加快遍历呢?有的,那就是分区,比如我们把画布分成左上、左下、右上、右下四个区域,然后把物体都分别分配到这四个区域,现在假设我们点击了画布的左上角,那就只需要遍历左上区域的物体就行。em🤔。。。听起来好像好不错,但是我们得维护这个物体和所在区域关系才行,当物体的位置或大小变了,就需要重新分配区域。em。。。有点道理。那这样就没问题了吗,当然不,如果物体分布不均匀呢,假设我们所有的物体都恰好在左上角(就是这么杠🏋🏻),那不还是得遍历所有物体才行,em。。。是这样没错,所以有个专业名词 n 叉树上场了,比如四叉树,大概意思就是:首先整体还是一棵树,只不过每个节点的子节点限制在四个,然后每个节点附属 x 个物体,当某个节点的物体超过 x 个,则将该节点继续往下划分四个子节点,将多余的物体放在新增的几个子节点中,以此类推,这样就解决了刚才说的物体划分不均匀的情况,当某个区域物体很多的话,就会有很多节点划分,反之亦然。当然它也是有代价的,就是要去维护这棵树,不过遍历的时候那速度是杠杠滴,尤其是在某个位置查找某个物体,感兴趣的可以自行百度😂。
  • 分批计算或延迟计算

    • 既然计算不过来那就将计算的任务进行拆分,然后在每个 requestAnimationFrame 或 requestIdleCallback 回调中慢慢执行,虽然这样做会使得代码复杂度增高、执行任务的总时间变长,但是不会导致卡顿。当然这种方法有一个前提就是我们允许任务是异步的,并不需要立马感知到结果,所以用的时候需要确定使用场景。
  • 上 web worker

    • web worker 是个好东西,性能很好,兼容性也不错。canvas 中一些复杂的逻辑和算法搬到 web worker 中运行是个不错的选择。不过和主线程来回通信所消耗的成本也是这个方法需要考虑的,因为它仅仅是操作数据并不是操作 canvas。不过现在有一个在实验中的功能 transferControlToOffscreen 可以实现操作数据的同时可以顺带将效果映射到原来的 canvas 中,是个不错的特性。
  • 上 webgl

    • 通常情况下,不同的绘制方法能够绘制的物体数量也会有所不同。svg 一般是 1000 多左右,canvas 一般在 3000 多,再多要想不卡顿就得用 webgl 了。webgl 通过引入一个与 OpenGL ES 2.0 非常一致的 api 使得我们可以利用用户设备的硬件图形加速功能(GPU 的并行能力)。当需要执行大量绘制任务时,它的性能远超 cavnas 2d。
  • 试试 gpu.js

  • 试试 webAssembly

声明