56

我从 fabric.js 中学到了什么

 5 years ago
source link: https://segmentfault.com/a/1190000019054853?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

熟悉 canvas 的朋友想必都使用或者听说过 Fabric.js,Fabric 算是一个元老级的 canvas 库了,从第一个版本发布到现在,已经有 8 年时间了。我近一年时间也在项目中使用,作为用户简单说说感受:

  1. 方便,只有想不到,没有做不到
  2. 源码写的真好,代码规范,注释清晰
  3. 社区真匮乏,国内资源尤其少
  4. 看文档不如看源码

优缺点都很鲜明,但总的来说,如果你要做一个在线编辑类的项目,比如在线 PPT,在线制图等应用,fabric 绝对是个很好的选择。

那么这一系列文章要写什么?这里不会主要介绍如何使用 fabric,主要写的内容是把在阅读源码过程中,把涉及到原理相关的知识总结出来,比如相关图形学知识、canvas 相关、fabric 中的设计思想等的相关知识。所以,如果你现在还对 fabric 不是很了解,建议先去官网找几个 demo 试一下。

下面我们进入这次的正题,这篇文章主要介绍 fabric.canvas 涉及到的部分内容。

从创建画布开始

fabric 创建画布很简单:

const canvas = new fabric.Canvas("domId", options);

在这样一行代码背后,fabric 主要做了下面这几件事情:

  • 创建缓存 canvas
  • 构建两层 canvas 元素:lower-canvas 和 upper-canvas
  • 绑定事件
  • 处理 retina 屏
  • ...

下面我把相关内容一一阐述。

canvas 缓存

介绍 canvas 缓存,fabric 中的缓存也是类似的道理,简单来说,_就是使用一个离屏 canvas 来做预渲染,在真实画布上用 drawImage 代替直接绘制图形_。

我们先来看个 例子 ,大家可以把 FPS meter 打开,切换按钮可以看到,不使用缓存和使用缓存 FPS 值差距还是挺大的,我电脑在使用缓存的时候基本在 60fps,不使用会降到 15fps 左右。大家可以打开控制台或者在 这里 查看代码。

下面列出主要的代码片段:

class Ball {
  constructor(x, y, vx, vy, useCache = true) {
    // ...
    if (useCache) {
      this.useCache = useCache;
      this.cacheCanvas = document.createElement("canvas");
      // 离屏 canvas 宽高取要渲染图形的宽高,不可以取真实 canvas 的宽高,否则会渲染大量无用区域
      this.cacheCanvas.width = 2 * (this.r + BORDER_WIDTH);
      this.cacheCanvas.height = 2 * (this.r + BORDER_WIDTH);
      this.cacheCtx = this.cacheCanvas.getContext("2d");
      this.cache();
    }
  }

  paint() {
    // 使用缓存直接使用创建的离屏canvas,否则直接绘制图形
    if (!this.useCache) {
      ctx.save();
      ctx.lineWidth = BORDER_WIDTH;
      ctx.beginPath();
      ctx.strokeStyle = this.color;
      ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
      ctx.stroke();
      ctx.restore();
    } else {
      ctx.drawImage(
        this.cacheCanvas,
        this.x - this.r,
        this.y - this.r,
        this.cacheCanvas.width,
        this.cacheCanvas.height
      );
    }
  }

  move() {
    // ...
  }

  cache() {
    // 绘制图形
    this.cacheCtx.save();
    this.cacheCtx.lineWidth = BORDER_WIDTH;
    this.cacheCtx.beginPath();
    this.cacheCtx.strokeStyle = this.color;
    this.cacheCtx.arc(
      this.r + BORDER_WIDTH,
      this.r + BORDER_WIDTH,
      this.r,
      0,
      2 * Math.PI
    );
    this.cacheCtx.stroke();
    this.cacheCtx.restore();
  }
}

解释一下二者区别:

drawImage

使用缓存的时候,有一点需要注意的是要控制好离屏 canvas 的大小,不可以直接取和渲染 canvas 的实际宽高,否则会渲染很多无用的空间,比如上面例子中每个离屏 canvas 的宽高只需要和对应图形的宽高一致。

this.cacheCanvas.width = 2 * (this.r + BORDER_WIDTH);
this.cacheCanvas.height = 2 * (this.r + BORDER_WIDTH);

上述代码中主要节省时间的地方在 paint 函数中使用 drawImage 会比直接绘制图形节省时间,那么是否所有场景都是这样呢?我们再来看下面这个 例子 .

这个例子和上面的只有绘制图形的代码不同:

// 从复杂图形变成了简单图形
cache() {
  this.cacheCtx.save();
  this.cacheCtx.lineWidth = BORDER_WIDTH;
  this.cacheCtx.beginPath();
  this.cacheCtx.strokeStyle = this.color;
  this.cacheCtx.arc(
    this.r + BORDER_WIDTH,
    this.r + BORDER_WIDTH,
    this.r,
    0,
    2 * Math.PI
  );
  this.cacheCtx.stroke();
  this.cacheCtx.restore();
}

只是 cache 方法中把复杂图形变成了简单的图形。但实际效果相差甚远,使用缓存和不使用性能差距并不大,甚至不使用时 fps 值还更高一些。

所以看来图形的复杂度,直接会影响 canvas 缓存的效果,我们在开发过程中,也不能盲目引入缓存,要权衡利弊。fabric 中缓存是默认开启的,同时也可以设置 objectCaching 为 false 禁用。

lower-canvas 和 upper-canvas

如果大家细心的话应该会发现,当我们执行 new fabric.Canvas('domeId') 的时候,在页面上 dom 元素就改变了,fabric 复制了一层 canvas 盖在了我们定义的 canvas 上面:

ra2uIjF.png!web

fabric 这样设计将渲染层和交互层做了分离,lower-canvas 只负责渲染元素;所有的交互,比如框选,事件处理都在 upper-canvas 上。

顺便提一下,fabric 提供了渲染静态画布的方法,如果你的画布不需要任何交互,只用来展示,那么可以用 new fabric.StaticCanvas('domId', options) 来初始化,这时候 dom 结构中就只有一个 canvas,没有 upper-canvas 了。

说到这里,很多同学可能会想到,事件是怎样绑定的呢?其实两个 canvas 大小等属性都是一致的,所以坐标也是可以对应上的,比如在 upper-canvas 上某个位置点击了一下,那么就可以去 lower-canvas 上就可以用这个坐标去找是否点击到了一个元素,那么问题来了,如何判断一个点在一个图形中呢?

如何判断点在图形中

这个问题网上有个比较普遍的方案,就是通过画一条射线,通过交点奇偶性来判断。如下图:

bua6zeI.gif

  1. 设目标点 P,使 P 点向任意一个方向画一条射线,保证不与图形的顶点相交;
  2. 记录射线与图形的交点数量 n;
  3. n 为奇数时,P 就在图形内,反之则在图形外。

而 fabric 中并没有用这种方法,原因很简单,这个算法是有前提的:_发出的射线不能与图形任何顶点相交。_ 这个前提对于我们主观来判断是很简单的,但程序中处理可能就需要大量的代码去判断是否与交点相交,如果相交再重新生成一条射线。

fabric 中使用的算法对上述算法进行了改进,我们结合下图来解释:

eMj2myN.gif

其中 e1 ~ e5 分别为多边形的边,P 为目标点,黑色实心点为多边形的顶点,r 为 P 延 X 轴发出的射线(不同于上面的方法,这里我们约定 r 射线只能延 X 轴发出)。

  1. 设目标点 P,使 P 延 X 轴方向画一条射线( y=P y ),设 intersectionCount = 0
  2. 遍历多边形的所有边,设边的顶点为 p1, p2

    1. 如果 p1 y < P y ,而且 p2 y < P y ,跳过(也就是这条边在 P 点下方)
    2. 如果 p1 y >= P y ,而且 p2 y >= P y ,跳过(也就是这条边在 P 点上方)
    3. 否则,设射线与这条边的交点为 S,如果 S x >= P x , intersectionCount 加 1
  3. 最终如果 intersectionCount 为奇数,则在图形内,反之则在图形外。

判断的部分用代码实现类似:

// point 目标点,lines多边形的所有边
function checkPoint(point, lines) {
  let intersectionCount = 0;
  let { x, y } = point;
  for (let i = 0; i < lines.length; i++) {
    let line = lines[i];
    // 两个顶点
    let { p1, p2 } = line;
    if ((p1.y < y && p2.y < y) || (p1.y >= y && p2.y >= y)) {
      continue;
    } else {
      const sx = ((y - p1.y) / (p2.y - p1.y)) * (p2.x - p1.x) + p1.x;
      if (sx >= x) {
        intersectionCount++;
      }
    }
  }
  return intersectionCount % 2 === 0;
}

这里 是个简单的例子。同时 这里 可以获取完整代码。

处理 Retina 屏

Retina 屏幕模糊的问题,直接给出处理方法,就不展开说了。

  1. canvas.width, canvas.height 放大至 dpi 倍
  2. canvas.style.width, canvas.style.height 设为原始 canvas 宽高
  3. ctx 缩放 dpi 倍

代码:

function initRetina(canvas, ctx) {
  const dpi = window.devicePixelRatio;
  canvas.style.width = canvas.width + "px";
  canvas.style.height = canvas.height + "px";
  canvas.setAttribute("width", canvas.width * dpi);
  canvas.setAttribute("height", canvas.height * dpi);
  ctx.scale(dpi, dpi);
}

查看 例子完整代码

小结

本篇文章主要针对 fabric.canvas 模块,介绍了相关 canvas 缓存,fabric 中判断点在图形中的算法以及如何处理 retina 屏幕的知识,作为系列的第一篇文章,可能会有很多问题,如有错误及意见,欢迎批评指正。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK