Canvas-系列
Canvas 2D 基础
Canvas 2D 进阶
Canvas 2D 贝塞尔曲线
Canvas 2D 事件
开发了一个 Canvas 2D 渲染引擎

前言

平时看到的网页本质上是图片,HTML 和 CSS 经过浏览器渲染流程,最终光栅化为位图。W3C 也陆续提供了更多的标签和 CSS3 属性,以完成更多的设计需求。

但越来越复杂的页面效果在 DOM 结构下很难实现,大量、复杂的 DOM 渲染处理性能堪忧,推出更多的 CSS 属性也难以覆盖需求。

于是 Canvas 诞生了,通过 JS 在 <canvas> 标签形成的画布区域内绘制图形,跳过了 HTML 和 CSS 的渲染,就像直接编写绘制指令一样,性能也更好。

兼容性:IE9+,移动端支持良好,Can I use Canvas?

学习 canvas 离不开线性代数、三角函数的知识,为了实现更多的效果,会需要更多的数学公式,当然,这些公式基本理解、会用就行。

参考:
Canvas-MDN
Canvas 从入门到劝朋友放弃(图解版)
案例+图解带你一文读懂Canvas🔥🔥(2W+字)
Canvas 保姆级教程(上):绘制篇
CanvasStudy
Canvas系列教程(从入门到精通)【详细解读】

canvas 标签

<canvas> 是 HTML5 新增的标签,形成一个画布区域,允许使用 JS 在画布上绘制图形。

与 SVG 对比:

  1. SVG:基于 XML,矢量图,使用 DOM 操作,适合图形变化较少的场景。
  2. Canvas:基于 JS,位图,适合图形变化较多的场景。

两种常用 JS API:

  1. Canvas API:getContext('2d'),用于 2D 绘图。
  2. WebGL API:getContext('webgl'),用于 3D 绘图,也能实现 2D 绘图。

我们常说的 Canvas,以及本文所述的,是指使用 Canvas API 在 <canvas> 上绘制 2D 图形。

大小(宽高)

<canvas> 具有两个大小。一是作为 HTML 标签元素的大小,二是作为画布的大小(绘图表面大小)。

默认画布大小为 300px × 150px,元素大小为其内容大小,所以也是 300px × 150px。

标签的 width 和 height 属性设置的是画布大小,而 CSS 设置的是元素大小

1
2
3
4
5
6
7
<style>
canvas {
width: 400px;
height: 400px;
}
</style>
<canvas width="200" height="200"></canvas>

通过 JS 获取和控制大小:

注意:当改变画布大小时,画布会被清空,且上下文对象的属性值会被重置

1
2
3
4
5
6
7
8
9
10
11
12
13
const canvas = document.querySelector('canvas');
// 获取画布大小
console.log("画布大小", canvas.width, canvas.height); // 画布大小 200 200

// 获取元素大小
let box = canvas.getBoundingClientRect();
console.log("元素大小", box.width, box.height); // 元素大小 400 400

// 修改元素大小
canvas.style.width = "600px";
canvas.style.height = "600px";
box = canvas.getBoundingClientRect();
console.log("修改元素大小", box.width, box.height); // 修改元素大小 600 600

现在绘制个图形,一个位置在 (10, 10) 的 50px × 50px 蓝色正方形。

1
2
3
4
// 获取画布上下文
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'blue';
ctx.fillRect(10, 10, 50, 50);

但实际测量会发现,图形的位置和大小均扩大了一倍,这是因为元素大小是画布大小的两倍,导致画布内容被浏览器自动拉伸了两倍,以填充元素大小。

缩放

当画布大小和元素大小不一致时,画布内容会被缩放以适应元素大小,这会导致图形变形、坐标偏移。

缩放规则:坐标也遵循这一规则。
图形实际大小 = 内容大小 × (元素大小 / 画布大小)

案例:
元素宽度是画布宽度的两倍,而元素高度是画布高度的一半,还是绘制一个 50px × 50px 的正方形。

1
2
3
4
5
6
7
<style>
canvas {
width: 400px;
height: 100px;
}
</style>
<canvas width="200" height="200"></canvas>

实际测量,正方形的大小为 100px × 25px。

所以,为了避免缩放问题,通常不设置元素大小

渲染上下文

<canvas> 只提供了一个绘图表面,JS 通过渲染上下文上的方法来操作画布。

使用 getContext(ctxType) 获取渲染上下文:

  1. 2d:获取二维渲染上下文(CanvasRenderingContext2D)。
  2. webgl、experimental-webgl:获取 WebGL1(OpenGL ES 2.0) 的三维渲染上下文(WebGLRenderingContext)。
  3. webgl2、experimental-webgl2:获取 WebGL2(OpenGL ES 3.0) 的三维渲染上下文(WebGL2RenderingContext)。
  4. bitmaprenderer:创建一个只提供将 canvas 内容替换为指定 ImageBitmap 功能的 ImageBitmapRenderingContext。

画布栅格

Canvas 沿用了计算机图形学的坐标系,原点在左上角,x 轴向右,y 轴向下。

画布被看不见的网格所覆盖,每个网格即是一个逻辑像素。

绘制形状

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

其他类型的图形都是通过若干条路径组合而成的。
Canvas 提供了许多生成不同路径的方法,如直线、弧、贝塞尔曲线等,这使得绘制复杂图形变得容易。

矩形

仅有三种方法绘制矩形:
fillRect(x, y, width, height) 绘制填充矩形。填充色为当前的 fillStyle
strokeRect(x, y, width, height) 绘制矩形边框。边框色为当前的 strokeStyle。
clearRect(x, y, width, height) 清除指定矩形区域,让清除部分完全透明。

x 与 y 为矩形的左上角坐标。

1
2
3
ctx.fillRect(50, 50, 100, 100);
ctx.clearRect(70, 70, 60, 60);
ctx.strokeRect(80, 80, 40, 40);

clearRect 清除整个画布:

1
ctx.clearRect(0, 0, canvas.width, canvas.height);

路径

图形的基本元素是路径。路径是通过各种样式的线段或曲线相连形成的不同形状的点的集合

使用路径绘制图形的步骤:

  1. beginPath() 清空路径列表,即开始绘制新路径。
  2. moveTo(x, y) 移动笔触到指定的坐标点,作为子路径的起点。
  3. 使用各种路径绘制命令在路径列表中积累子路径
  4. (可选) closePath() 封闭连续子路径,即使用直线连接起点和终点。多次使用 moveTo 可能会导致子路径不连续,无法闭合。
  5. 描边 stroke() 或填充 fill() 路径区域,绘制积累的子路径。fill 会自动闭合路径。
1
2
3
4
5
ctx.beginPath(); // 开始新路径
ctx.moveTo(50, 50); // 移动笔触
ctx.***(); // 调用若干路径绘制命令、设置样式
ctx.closePath(); // 封闭路径
ctx.stroke(); // 描边路径

移动笔触 moveTo

moveTo(x, y) 移动笔触到指定的坐标点,不绘制任何内容。

  1. 设置子路径起点。
  2. 绘制一些不连续的路径。

就像写字时,先把笔移动到纸上的某个位置,然后开始书写。

直线 lineTo

lineTo(x, y) 从当前笔触位置绘制一条直线到指定的坐标点。

1
2
3
4
5
6
7
// 一个等腰三角形
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(150, 50);
ctx.lineTo(100, 150);
ctx.closePath();
ctx.stroke();

圆弧 arc

arc(x, y, radius, startAngle, endAngle, anticlockwise?) 绘制圆弧路径。

  1. (x, y) 圆心坐标。
  2. radius 半径。
  3. 路径从起始弧度 startAngle结束弧度 endAngle
  4. anticlockwise 可选,是否逆时针绘制,默认为 false。

弧度 = ( Math.PI / 180 ) × 角度

1
2
3
4
5
6
7
8
9
10
11
// 从半圆到全圆
for (i = 0; i < 12; i++) {
ctx.beginPath();
let radius = 20;
let x = radius + i * (radius * 2.5);
let y = radius;
let startAngle = 0;
let endAngle = Math.PI + (Math.PI * i) / 12;
ctx.arc(x, y, radius, startAngle, endAngle);
ctx.fill();
}

矩形 rect

rect(x, y, width, height) 绘制矩形路径。
绘制一个左上角坐标为 (x,y),宽高为 width 以及 height 的矩形。笔触会移动到 (x,y)。

不同于直接绘制矩形的三个方法,rect 只是定义了一个矩形的路径。

1
2
3
4
5
ctx.beginPath();
ctx.rect(50, 50, 100, 100);
// 从(50, 50)到(150, 150)
ctx.lineTo(150, 150);
ctx.stroke();

贝塞尔曲线

贝塞尔曲线通过外部控制点绘制复杂的曲线。

quadraticCurveTo(cp1x, cp1y, x, y) 绘制二次贝塞尔曲线。
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 绘制三次贝塞尔曲线。

当前坐标为起始点,cp* 为外部控制点,(x, y) 为结束点。

一些可视化工具:
Canvas贝塞尔曲线绘制工具
二次贝塞尔曲线调试器
三次贝塞尔曲线调试器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 二次贝塞尔曲线,聊天气泡
ctx.beginPath();
ctx.moveTo(75, 25);
ctx.quadraticCurveTo(25, 25, 25, 62.5);
ctx.quadraticCurveTo(25, 100, 50, 100);
ctx.quadraticCurveTo(50, 120, 30, 125);
ctx.quadraticCurveTo(60, 120, 65, 100);
ctx.quadraticCurveTo(125, 100, 125, 62.5);
ctx.quadraticCurveTo(125, 25, 75, 25);
ctx.stroke();

// 三次贝塞尔曲线,心形
ctx.beginPath();
ctx.moveTo(275, 40);
ctx.bezierCurveTo(275, 37, 270, 25, 250, 25);
ctx.bezierCurveTo(220, 25, 220, 62.5, 220, 62.5);
ctx.bezierCurveTo(220, 80, 240, 102, 275, 120);
ctx.bezierCurveTo(310, 102, 330, 80, 330, 62.5);
ctx.bezierCurveTo(330, 62.5, 330, 25, 300, 25);
ctx.bezierCurveTo(285, 25, 275, 37, 275, 40);
ctx.stroke();

参考:
用canvas绘制一个曲线动画——深入理解贝塞尔曲线

Path2D

Path2D 用于声明一个路径,即创建可以保留并重用的路径。

Path2D() 创建一个新的 Path2D 实例对象,拥有所有路径绘制方法。

1
2
3
new Path2D();
new Path2D(path); // 克隆Path对象
new Path2D(d); // 从SVG建立Path对象
1
2
3
4
5
6
const p1 = new Path2D();
p1.moveTo(50, 50);
p1.lineTo(150, 50);
p1.lineTo(100, 150);
p1.closePath();
ctx.stroke(p1); // 使用路径

path.addPath(path[, transform]) 将 path 添加到当前路径。

transform 是一个 DOMMatrix 对象,用于转换 path 的坐标。

1
2
3
4
5
const p1 = new Path2D("M10 10 h 80 v 80 h -80 Z"); // 可以使用 SVG path 初始化路径
const p2 = new Path2D();
p2.rect(50, 50, 100, 100);
p2.addPath(p1);
ctx.stroke(p2); // 在绘制p2时,也会绘制p1

样式

图形样式

fillStyle 设置图形的填充样式,对应 fill() 方法。
strokeStyle 设置图形的描边样式,对应 stroke() 方法。

默认值都为 #000,可以接受色值、渐变对象和图案对象。

globalAlpha 设置绘制透明度。

色值

接受的色值格式和 CSS 差不多,但不能用 linear-gradient 等函数。

1
2
3
ctx.strokeStyle = "blue";
ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
ctx.fillStyle = "#FFA500";

渐变对象

渐变分为两种:

  1. 线性渐变:createLinearGradient(x0, y0, x1, y1) 创建一个线性渐变对象。渐变的起点 (x0,y0),终点 (x1,y1)。
  2. 径向渐变:createRadialGradient(x0, y0, r0, x1, y1, r1) 创建一个径向渐变对象。定义了两个圆,一个以 (x0,y0) 为原点,半径为 r0 的圆,一个以 (x1,y1) 为原点,半径为 r1 的圆

都返回 CanvasGradient 对象,使用 addColorStop(offset, color) 添加颜色。offset 偏移量为 0~1 的值,表示颜色的位置。

1
2
3
4
5
6
7
8
9
10
11
12
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(150, 50);
ctx.lineTo(100, 150);
// 创建线性渐变
const lg = ctx.createLinearGradient(50, 50, 150, 150);
// 添加颜色
lg.addColorStop(0, "#000");
lg.addColorStop(0.5, "blue");
lg.addColorStop(1, "#fff");
ctx.fillStyle = lg;
ctx.fill();

图案对象

createPattern(image, repetition) 创建一个图案对象,用于填充图形。文档

  1. image 图像对象,可以是 <img><video><canvas>、ImageBitmap、ImageData、Blob、CanvasRenderingContext2D
  2. repetition 重复方式,可选值:repeat、repeat-x、repeat-y、no-repeat,默认为 repeat
1
2
3
4
5
6
7
8
9
const img = new Image();
img.src = "1.png";
// 等待图片加载完成
img.onload = () => {
// 创建图案
const ptrn = ctx.createPattern(img, "no-repeat");
ctx.fillStyle = ptrn;
ctx.fillRect(50, 50, 100, 100);
};

createPattern 不仅可以用于创建图案,还能将图案用作填充或描边,而 drawImage 只能用于将图像绘制到画布上。

阴影 shadow

  1. shadowColor 阴影颜色。
  2. shadowBlur 阴影模糊程度,默认为 0 为无模糊。
  3. shadowOffsetX 阴影水平偏移量。正值向右。
  4. shadowOffsetY 阴影垂直偏移量。正值向下。
1
2
3
4
5
ctx.shadowOffsetX = 10;
ctx.shadowOffsetY = 10;
ctx.shadowBlur = 10;
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(20, 20, 100, 100);

线条样式

宽度 lineWidth

lineWidth 设置线条的宽度,单位 px,默认为 1。

绘制 0-10 的线条
1
2
3
4
5
6
7
for (let i = 0; i < 10; i++) {
ctx.beginPath();
ctx.lineWidth = i + 1;
ctx.moveTo(50 + i * 20, 10);
ctx.lineTo(50 + i * 20, 100);
ctx.stroke();
}

1px 的线条宽度上和 2px 差不多,但却模糊,这个问题在后面有详解

端点 lineCap

lineCap 设置线条的端点样式,可选值:butt(默认)、round、square。

从左到右分别是 butt、round、square
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const lineCaps = ["butt", "round", "square"];
lineCaps.forEach((lineCap, i) => {
ctx.lineWidth = 15;
ctx.lineCap = lineCap;
ctx.beginPath();
ctx.moveTo(50 + i * 50, 10);
ctx.lineTo(50 + i * 50, 100);
ctx.stroke();
});
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = "blue";
ctx.moveTo(10, 10);
ctx.lineTo(200, 10);
ctx.moveTo(10, 100);
ctx.lineTo(200, 100);
ctx.stroke();

连接点 lineJoin

lineJoin 设置线条的连接样式,可选值:miter(默认)、bevel、round。

  1. miter:斜接,尖角。
  2. bevel:平角。
  3. round:圆角。
从上到下分别是 miter、bevel、round
1
2
3
4
5
6
7
8
9
10
11
const lineJoins = ["miter", "bevel", "round"];
ctx.lineWidth = 12;
lineJoins.forEach((lineJoin, i) => {
ctx.lineJoin = lineJoin;
ctx.beginPath();
ctx.moveTo(50, 20 + i * 40);
ctx.lineTo(100, 50 + i * 40);
ctx.lineTo(150, 20 + i * 40);
ctx.lineTo(200, 50 + i * 40);
ctx.stroke();
});

斜接限制 miterLimit

miterLimit 设置斜接的限制比例,为 lineWidth 的倍数
用于限制 lineJoin 值为 miter 时,两条线的斜接长度。斜接长度是指线条交接处内角顶点到外角顶点的长度。

默认为 10,表示最大斜接长度为线条宽度的 10 倍,0、负数、Infinity 和 NaN 都会被忽略。

角度越小,斜接长度越长,超过限制时 lineJoin 会变成 bevel。

1
2
3
4
5
6
7
8
9
10
ctx.lineWidth = 10;
ctx.lineJoin = "miter";
ctx.miterLimit = 8;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(50 + i * 80, 10);
ctx.lineTo(60 + i * 5 + i * 80, 100);
ctx.lineTo(70 + i * 10 + i * 80, 10);
ctx.stroke();
}

第一个角的斜接长度超过限制,变成了 bevel,后面的都是 miter,但角度越大,斜接长度越小。

虚线 lineDash

  1. setLineDash(segments) 设置虚线样式。segments 是一个数组,表示虚线的线段和间隙的长度。空数组切换回至实线模式。
  2. lineDashOffset 设置虚线的起始偏移量。正数向左偏移,负数向右偏移。
  3. getLineDash() 获取当前虚线样式。返回 segments 数组。

segments 中,元素的索引 index 为奇数表示线段长度,偶数 index 表示间隙长度。如 [5, 15] 表示 5px 线段,15px 间隙。若 segments 的长度为奇数,则会重复数组元素,如 [5] 表示 [5, 5],即 5px 线段,5px 间隙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let y = 15;
function drawLineDash(segments) {
ctx.beginPath();
ctx.setLineDash(segments);
ctx.moveTo(50, y);
ctx.lineTo(500, y);
ctx.stroke();
y += 20;
}
drawLineDash([]); // 实线
drawLineDash([1, 1]);
drawLineDash([10, 10]);
drawLineDash([20, 5]);
drawLineDash([15, 3, 3, 3]);
drawLineDash([20, 3, 3, 3, 3, 3, 3, 3]);
drawLineDash([12, 3, 3]); // 重复 [12, 3, 3, 12, 3, 3]

利用 lineDashOffset 可以让虚线动起来,即蚂蚁线效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 蚂蚁线
let offset = 0;
function drawAntLine() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setLineDash([12, 5, 3, 5]);
ctx.lineDashOffset = -offset; // 向右偏移,正转
ctx.strokeRect(10, 10, 100, 100);
}
function march() {
offset++;
if (offset > 25) {
offset = 0;
}
drawAntLine();
setTimeout(march, 20);
}
march();

绘制文本

fillText(text, x, y [, maxWidth]) 在 (x,y) 填充指定的文本,可选最大宽度。
strokeText(text, x, y [, maxWidth]) 在 (x,y) 绘制文本边框,可选最大宽度。

样式

1、font 字体样式,和 CSS 一样,默认字体是 10px sans-serif。

2、textAlign 文本对齐方式,默认为 start。

1
ctx.textAlign = "left" || "right" || "center" || "start" || "end";

3、textBaseline 文本基线位置,即文本的垂直对齐方式,默认为 alphabetic。

  1. top,文本基线在文本块的顶部。
  2. hanging,文本基线是悬挂基线。
  3. middle,文本基线在文本块的中间。
  4. alphabetic,文本基线是标准的字母基线。
  5. ideographic,文字基线是表意字基线;如果字符本身超出了 alphabetic 基线,那么 ideographic 基线位置在字符本身的底部。
  6. bottom,文本基线在文本块的底部。
1
2
3
4
5
6
7
8
9
10
11
12
13
const textBaselines = ['top', 'hanging', 'middle', 'alphabetic', 'ideographic', 'bottom'];
ctx.font = '20px Arial';
textBaselines.forEach((baseline, i) => {
const x = 50;
const y = i * 40 + 50;
ctx.beginPath();
ctx.strokeStyle = 'blue';
ctx.moveTo(0, y);
ctx.lineTo(500, y);
ctx.stroke();
ctx.textBaseline = baseline;
ctx.fillText(`abcdefghijklmnopqrstuvwxyz(${baseline})`, x, y);
});

4、direction 文本方向,默认为 inherit。会影响 textAlign 的表现。

1
2
3
4
ctx.direction = "ltr" || "rtl" || "inherit";
// ltr,文本方向从左向右
// rtl,文本方向从右向左
// inherit,继承父元素的方向

预测量文本宽度

measureText(text) 返回 TextMetrics 对象,包含文本的宽度、像素等信息。不受最大宽度等外部因素影响。

TextMetrics 属性
1
2
3
4
5
6
7
8
9
10
11
12
width:基于当前上下文字体,计算内联字符串的宽度。
actualBoundingBoxLeft:从 textAlign 属性确定的对齐点到文本矩形边界左侧的距离,使用 CSS 像素计算;正值表示文本矩形边界左侧在该对齐点的左侧。
actualBoundingBoxRight:从 textAlign 属性确定的对齐点到文本矩形边界右侧的距离。
fontBoundingBoxAscent:从 textBaseline 属性标明的水平线到渲染文本的所有字体的矩形最高边界顶部的距离。
fontBoundingBoxDescent:从 textBaseline 属性标明的水平线到渲染文本的所有字体的矩形边界最底部的距离。
actualBoundingBoxAscent:从 textBaseline 属性标明的水平线到渲染文本的矩形边界顶部的距离。
actualBoundingBoxDescent:从 textBaseline 属性标明的水平线到渲染文本的矩形边界底部的距离。
emHeightAscent:从 textBaseline 属性标明的水平线到线框中 em 方块顶部的距离。
emHeightDescent:从 textBaseline 属性标明的水平线到线框中 em 方块底部的距离。
hangingBaseline:从 textBaseline 属性标明的水平线到线框的 hanging 基线的距离。
alphabeticBaseline:从 textBaseline 属性标明的水平线到线框的 alphabetic 基线的距离。
ideographicBaseline:从 textBaseline 属性标明的水平线到线框的 ideographic 基线的距离。
1
2
3
4
5
6
7
8
9
const text = "Abcdefghijklmnop";
ctx.font = "italic 50px serif"; // italic 斜体
const textMetrics = ctx.measureText(text);
ctx.fillText(text, 50, 50);
// 两种计算文本宽度的方法
console.log(textMetrics.width); // 400
console.log(
textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,
); // 404

由于字母倾斜/斜体导致字符的宽度可能超出其预定的宽度,因此 actualBoundingBoxLeft 和 actualBoundingBoxRight 的总和可能会比内联盒子的宽度(width)更大。

绘制图像

步骤:

  1. 获取图片资源
  2. 使用 drawImage() 绘制图像。

drawImage

1、基础用法(三个参数)

1
drawImage(image, x, y);

2、指定宽高缩放(五个参数)

1
drawImage(image, x, y, width, height);

3、裁剪图像(九个参数)

1
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

获取图像资源

drawImage() 允许任何的画布图像源,如 HTMLImageElement、SVGImageElement、HTMLVideoElement、HTMLCanvasElement、ImageBitmap、OffscreenCanvas 或 VideoFrame。

利用 canvas 绘制视频。

1
2
3
4
5
6
7
8
9
10
const video = document.querySelector("video");
function render() {
window.requestAnimationFrame(render);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const proportion = video.videoWidth / video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.width / proportion);
}
video.onloadeddata = function (e) {
render();
};