基于 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.js
、react-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 造成不良影响