Canvas扫盲

基于 canvas,我们可以通过 js 绘制 2D 图形,而同样使用 canvas 元素的 WebGL API 则可以用于绘制硬件加速的 2D 和 3D 图形。现在对 canvas 的应用很普遍,比如画板工具、图表绘制(echarts)、处理视频、图片/富文本编辑器、excel、游戏等

基本使用

在 react 项目中简单画个矩形如下:

function App() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  function drawRect() {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");
    ctx.fillStyle = "green";
    ctx.fillRect(10, 10, 150, 100);
  }
  return (
    <div>
      <canvas ref={canvasRef} width={300} height={200}>
        你的浏览器不支持canvas,请升级浏览器{" "}
      </canvas>
      <button onClick={drawRect}>render</button>
    </div>
  );
}

Canvas 元素默认的 width 和 height 属性值是 300 和 150 像素,如果直接使用样式类或者 style 属性来设置宽高时,如果新设置的宽度和高度与默认值不同,就会导致 Canvas 元素被缩放

canvas 创造了一个固定大小的画布,它公开了一个或多个渲染上下文,其可以用来绘制和处理要展示的内容。所以要绘制图形前,需要先获取到 渲染上下文

const ctx = canvas.getContext("2d");
// 可以用以下方法检测浏览器对canvas的支持性
// if (canvas.getContext){}

图形绘制

Canvas 使用的是 W3C 坐标系,坐标空间参考如下:

普通图形

canvas 支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段)

绘制矩形

// 绘制填充矩形
ctx.fillRect(x, y, width, height);
// 绘制矩形边框
ctx.strokeRect(x, y, width, height);
// 清除矩形区域
ctx.clearRect(x, y, width, height);
// 或者用下面的方法绘制
// ctx.rect(x, y, w, h);
// ctx.stroke();
// ctx.fill();
// 拐角样式,默认尖角/斜角 bevel/圆角 round
// cxt.lineJoin = 'bevel'

绘制直线

ctx.beginPath();
// 定义直线的起点坐标
ctx.moveTo(10, 10);
// 定义直线的终点坐标
ctx.lineTo(50, 10);
// 沿着坐标点顺序的路径绘制直线
// 设置虚线
// cxt.setLineDash([10, 20]) // 10px间隔20px
ctx.stroke();
// 关闭当前的绘制路径
ctx.closePath();

绘制三角形

ctx.beginPath();
ctx.moveTo(75, 50);
ctx.lineTo(100, 75);
ctx.lineTo(100, 25);
// 会自动让所有形状闭合,区别与stroke
ctx.fill();

绘制圆

ctx.beginPath();
ctx.arc(100, 75, 50, 0, 2 * Math.PI);
ctx.stroke();
ctx.closePath();
// ctx.arc(x, y, r, sAngle, eAngle, counterclockwise);
// x,y:圆心坐标; r:半径大小;
// sAngle:起始角,以弧度计(弧的圆形的三点钟位置是 0 度)
// eAngel:结束角,以弧度计
// counterclockwise:可选。规定应该逆时针还是顺时针绘图。False = 顺时针,true = 逆时针

设置阴影

// 水平位移
ctx.shadowOffsetX = 10;
// 垂直位移
ctx.shadowOffsetY = 10;
// 设置模糊度
ctx.shadowBlur = 5;
// 设置阴影颜色
ctx.shadowColor = "rgba(0,0,0,0.5)";
// ctx.fillstyle = "#000"
// ctx.fillRect(100, 100, 100, 100)

阴影绘制是比较耗费性能的,尽量禁用阴影,其他优化手段有缓存阴影(预定义 canvas 渲染上下文)和优化阴影属性,尽可能使用较小的阴影模糊半径和偏移量,从而减少计算量。同时,使用 rgba() 颜色表示法,而不是十六进制颜色表示法,因为 rgba() 颜色表示法支持 alpha 透明度属性,而十六进制颜色表示法则不支持

不得不说,原生的 api 挺不方便的,所以大部分情况我们都会用 canvas 的封装库或者自己封装一些常用的绘制函数,减小使用成本

文本

const text = "Hello world";
ctx.font = "48px serif";
// 文本对齐方式
ctx.textAlign = "center";
// 填充颜色
ctx.fillStyle = "#000";
// 设置内容和坐标
ctx.fillText(text, 10, 50);
// 空心字
// ctx.strokeText(text, 10, 50);
// 获取文本宽度
// ctx.measureText(text).width

绘制文本都是比较耗费性能的,可以尽量避免,优化策略有缓存文本(offScreenCanvas 或 image 缓存)、禁止文本变换、使用字体缓存(在 DOM 上缓存 font)

加载图片

可以通过 drawImage 加载图片

// 正常加载图片
ctx.drawImage(image, x, y, width, height);
// 图片裁减,s前缀参数标识了裁减的范围
context.drawImage(img, sx, sy, swidth, sheight, x, y, width, height);

图片源可以是 HTMLImageElement、HTMLVideoElement、HTMLCanvasElement、ImageBitmap。ImageBitmap 是一个高性能的位图,创建过程是异步的(createImageBitmap,在另一个线程创建,不阻塞主线程),它可以从上述的所有源以及其他几种源中生成。并且可以在 worker 中创建,然后借助离屏 canvas 实现低延迟的绘制

加载视频的原理其实也是基于 drawImage。更多详情可以参考 使用图像

动画

动画原理其实就是不断的清空画布和重绘,可以用 requestAnimationFrame 来实现

为了避免动画卡顿,还需要尽可能减少动画的前置计算逻辑,可以分享个我们实际项目中的小车行驶场景,要通过一系列参数和算法计算出多辆小车的下一个位置,如果是前端计算,耗时会比较长,也就不可避免要造成卡顿,最终方案是后端同事封装了 c++函数,然后我们再将其封装成 wasm 模块供前端调用,从而减小计算耗时

优化

监控帧率

可以借助三方库 stats.jsreact-stats。自己实现的话,需要计算 1s 内的帧数得出 fps,可以参考以下代码:

var fps = 0;
var startTime = performance.now();

function loop(timestamp) {
  var progress = timestamp - startTime;
  fps++;
  if (progress >= 1000) {
    console.log(fps + "fps");
    fps = 0;
    startTime = timestamp;
  }
  window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);

性能优化

可以先看下 MDN 的优化建议

常见的性能优化手段如下:

  • requestAnimationFrame 优化高频重绘
  • 分层绘制
  • 可视范围画布
  • 脏矩形优化(局部重绘)
  • 离屏 canvas

这一块后面进阶篇再仔细探究吧,感觉还是挺有趣的

三方库

我用的比较多的是 fabricjs,是一个比较出名的一个 canvas 库,不过国内用的人很少。该库封装了大量的图形对象,声明式绘制图形,然后也做了性能优化,比如分层绘制、offscreen、objectCache、脏矩形优化等,这些后面会整理篇 canvas 的进阶文章来串讲下

先定义一个全局的 Fabric 对象

import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';
const CANVAS_ID = 'map-canvas';

class MyCanvas {
    initialize () {
      let myCanvas = new fabric.Canvas(CANVAS_ID);
      myCanvas.selection = false;
      myCanvas.setWidth(canvasWidth);
      myCanvas.setHeight(canvasHeight);
      // 鼠标事件监听
      canvas.on('mouse:wheel', (evt) => {
        // actionManager 是一个事件管理器,统筹所有的用户操作和事件订阅
        // rafThrottle 是节流函数
        rafThrottle(() = actionManager.handleMouseWheel(evt));
      });
      canvas.on('mouse:move', (evt) => {
        rafThrottle(() => actionManager.handleMouseMove(evt));
      });
      // 对象选中监听
      canvas.on('selection:created', (e: any) => {
        actionManager.handleObjectSelect(e);
      });
      canvas.on('selection:updated', (e: any) => {
        actionManager.handleObjectSelect(e);
      });
      canvas.on('selection:cleared', (e: any) => {
        actionManager.handleObjectUnSelect();
      });
      canvas.on('object:moving', (e: any) => {
        actionManager.handleObjectMove(e);
      });
      // 对象变动监听,包括位置、大小、角度等变化的监听
      canvas.on('object:modified', (e: any) => {
        actionManager.handleObjectModified(e);
      });
    }
}

在组件中使用

useEffect(() => {
  new MyCanvas().initialize()
}, [])
// ...
<canvas id={CANVAS_ID}>Map canvas</canvas>

更多使用参考我之前的总结 https://blog.zhouweibin.top/fabric/fabric-practice/

其他三方库

  • Konva.js
  • p5.js
  • Paper.js

基本没有用过,就不深入探究之间的区别了

参考

常见问题

对比 DOM

参考 https://zhuanlan.zhihu.com/p/143829714

对比 SVG

参考 https://github.com/abcrun/abcrun.github.com/issues/13

1px 线条模糊

本质原因是 Canvas 元素默认的坐标系是以像素的中心点为基准的,比如你尝试在 (0.5, 0.5)(或者其他像素点的中心点)处绘制一条 1px 的线,那么实际上它会被绘制成两条跨越了两个像素的线段,而不是你期望的一条 1px 线。解决方法:

  • translate 将绘制区域向右移动半个像素的距离
  • 虚线绘制
  • globalCompositeOperation

文字锯齿

  • 使用 CSS 进行抗锯齿处理 <canvas id="myCanvas" style="image-rendering: optimizeSpeed; -webkit-font-smoothing: antialiased;"></canvas>
  • 使用 textBaseline 属性对齐锚点 context.textBaseline = "middle"; // 设置对齐锚点为垂直方向的中心点
  • 如果需要对较小字体进行平滑处理,也可以考虑使用 WebFont 或 SVG 标签等技术来达到最佳效果。

cavans 污染

  • 只从可信的源获取图像数据,并且仅允许 trusted-image-source 进行绘制
  • 限制 Canvas 对外部的访问权限,只授权访问某些特定的域或者协议
  • 禁止用户输入代码并在 Canvas 上执行,或者在执行前先进行验证或者过滤,确保输入的内容不会对 Canvas 造成不良影响

推荐资源