canvas 入门

1,351 阅读16分钟

canvas 简介

什么是canvas?简单来说,canvas是h5中提供的一个元素,可以用来在网页上绘制图像或者动画,甚至可以进行实时视频的处理和渲染工作;

它最初由苹果内部使用自己 MacOS X WebKit 推出,供应用程序使用像仪表盘的构件和 Safari 浏览器使用。后来,有人通过 Gecko 内核的浏览器 (尤其是 Mozilla和Firefox),Opera 和 Chrome 和超文本网络应用技术工作组建议为下一代的网络技术使用该元素。
Canvas 是由 HTML 代码配合高度和宽度属性而定义出的可绘制区域。JavaScript 代码可以访问该区域,类似于其他通用的二维 API,通过一套完整的绘图函数来动态生成图形。
Mozilla 程序从 Gecko 1.8 (Firefox 1.5) 开始支持 <canvas>, Internet Explorer 从 IE9 开始 <canvas> 。Chrome 和 Opera 9+ 也支持 <canvas>
引自:www.runoob.com/w3cnote/htm…

基本使用

新建canvas

在我们的html中添加如下元素,就可以得到一个新的canvas画布

<canvas id="container" style="border:1px solid black;"></canvas>

此时并没有给这个元素设置宽高,它的默认width为300px、height为150px

<canvas> 元素

<canvas> 只有两个可选的属性:width、heigth 属性。
如果不给 <canvas> 设置 widht、height 属性时,则默认 width为300、height 为 150,单位都是 px。也可以使用 css 属性来设置宽高,但是如宽高属性和初始比例不一致,它会出现扭曲。所以,建议永远不要使用 css 属性来设置 <canvas> 的宽高。

>>>什么是canvas的扭曲?

<!--index.html-->
<!DOCTYPE html>
<html>

<head>
  <style>
    canvas {
      height: 300px;
      width: 300px;
      border: 1px solid black;
    }
  </style>
</head>

<body>
  <canvas width="100px" height="200px"></canvas>
  <script src='canvas.js'></script>
</body>

</html>
// canvas.js
const canvas = document.querySelector('canvas');

const ctx = canvas.getContext('2d');

// 绘制一个50*50的矩形
ctx.fillRect(10,10,50,50);

当我们执行上面的代码之后,本意是在画布上绘制一个50*50的矩形,但是实际上的效果却显然跟我们预期的不一致,这时候就是canvas发生了扭曲。可以这么理解,画布和形状是先渲染在页面上的,然后由css设置的宽高进行了拉伸,最后出现的就是拉伸后的结果。

替换内容

可以看到canvas并没有像img中一样的alt属性,在浏览器不支持canvas时,可以用下面的这种方式来进行文本替换

<canvas>
    你的浏览器不支持 canvas,请升级你的浏览器。
</canvas>

canvas 渲染上下文

从前文中可以看到,canvas在初始化的时候是一片空白的,那么如何进行绘图呢?就是通过canvas提供的上下文进行; canvas提供了多种渲染上下文

image.png

这里我们还是专注于2d上下文环境(后文简称为ctx),看它可以帮助我们做哪些事情。

栅格

在真正开始绘制之前,首先需要了解一下画布栅格(canvas grid)以及坐标空间。
假设我们创建了一个宽150px, 高150px的canvas元素。canvas元素默认被网格所覆盖。通常来说网格中的一个单元相当于canvas元素中的一像素。栅格的起点为左上角(坐标为(0,0))。所有元素的位置都相对于原点定位。所以图中蓝色方形左上角的坐标为距离左边(X轴)x像素,距离上边(Y轴)y像素(坐标为(x,y))。

image.png

绘制形状

不同于svg提供的多种默认形状,<canvas>只支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段),各种路径的组合绘制出所有其他类型的图形。canvas中提供了多种绘制路径的方法。

矩形

首先看如何绘制矩形 canvast 提供了三种方法绘制矩形:

1、fillRect(x, y, width, height):绘制一个填充的矩形;
2、strokeRect(x, y, width, height):绘制一个矩形的边框;
3、clearRect(x, y, widh, height):清除指定的矩形区域,然后这块区域会变的完全透明;

说明:这 3 个方法具有相同的参数。

  • x, y:指的是矩形的左上角的坐标。(相对于canvas的坐标原点)
  • width, height:指的是绘制的矩形的宽和高
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

// 绘制一个100*100的矩形,填充黑色
ctx.fillRect(25, 25, 100, 100);
// 清除60*60的矩形区域,背景为透明
ctx.clearRect(45, 45, 60, 60);
// 生成50*50的边框矩形区域
ctx.strokeRect(50, 50, 50, 50);

image.png

path 路径

使用路径绘制图形需要一些额外的步骤:

  1. 创建路径起始点

  2. 调用绘制方法去绘制出路径

  3. 把路径封闭

  4. 一旦路径生成,通过描边或填充路径区域来渲染图形。 下面是canvas提供的方法:

  5. beginPath() -- 新建一条路径,路径一旦创建成功,图形绘制命令被指向到路径上生成路径

  6. moveTo(x, y) -- 把画笔移动到指定的坐标(x, y)。相当于设置路径的起始点坐标。

  7. closePath() -- 闭合路径之后,图形绘制命令又重新指向到上下文中

  8. stroke() -- 通过线条来绘制图形轮廓

  9. fill() -- 通过填充路径的内容区域生成实心的图形 接下来开始绘制一些形状和图形

线段

首先绘制一条直线

ctx.beginPath();
ctx.moveTo(10,70);
ctx.lineTo(200,70);
ctx.stroke();

image.png 多次调用lineTo,我们可以得到多线段

ctx.beginPath();
ctx.moveTo(10,70);
ctx.lineTo(40,70);
ctx.lineTo(60,100);
ctx.lineTo(250,120);
ctx.stroke();

image.png

矩形

尝试使用closePath可以闭合当前形状,这里我们绘制了一个矩形:

ctx.beginPath();
ctx.moveTo(10,10);
ctx.lineTo(80,10);
ctx.lineTo(80,80);
ctx.lineTo(10,80);
ctx.closePath();
ctx.stroke();

image.png

还可以绘制三角形

ctx.beginPath();
ctx.moveTo(100,70);
ctx.lineTo(200,70);
ctx.lineTo(150,120);
ctx.closePath();
ctx.fill();

image.png

圆形及圆弧

上面都是使用直线进行绘画,再来看下ctx如何绘制曲线

ctx.beginPath();
ctx.arc(75, 75, 50, 0, Math.PI * 2, true); // 绘制
ctx.moveTo(110, 75);
ctx.arc(75, 75, 35, 0, Math.PI, false);   // 口(顺时针)
ctx.moveTo(65, 65);
ctx.arc(60, 65, 5, 0, Math.PI * 2, true);  // 左眼
ctx.moveTo(95, 65);
ctx.arc(90, 65, 5, 0, Math.PI * 2, false);  // 右眼
ctx.stroke();

image.png 可以看到使用arc方法来绘制了两个圆形,组成了左眼和右眼,并且绘制了一个圆弧。
arc函数如下:

void ctx.arc(x, y, r, startAngle, endAngle, anticlockwise)
// 以(x, y) 为圆心,以r 为半径,从 startAngle 弧度开始到endAngle弧度结束。
// anticlosewise 是布尔值,true 表示逆时针,false 表示顺时针(默认是顺时针)
  • 这里的度数都是弧度。
  • 0 弧度是指的 x 轴正方向。

还可以使用arcTo方法进行绘制

void ctx.arcTo(x1, y1, x2, y2, radius);
/* 使用当前的描点(前一个moveTo或lineTo等函数的止点)。根据当前描点与给定的控制点1连接的直
 线,和控制点1与控制点2连接的直线,作为使用指定半径的圆的切线,画出两条切线之间的弧线路径
 x1,y1为第一个控制点的坐标,x2,y2为第二个控制点坐标,radius为圆弧半径
*/

image.png

ctx.beginPath();
ctx.arc(75, 75, 50, 0, Math.PI * 2, true); // 绘制
ctx.moveTo(110, 75);
// 分别绘制两段圆弧
ctx.arcTo(110, 110, 65, 110, 35);
ctx.arcTo(40, 110, 40, 100, 35);
ctx.moveTo(65, 65);
ctx.arc(60, 65, 5, 0, Math.PI * 2, true);  // 左眼
ctx.moveTo(95, 65);
ctx.arc(90, 65, 5, 0, Math.PI * 2, false);  // 右眼
ctx.stroke();

image.png

贝塞尔曲线

quadraticCurveTo(cp1x, cp1y, x, y)
绘制二次贝塞尔曲线,cp1x,cp1y为一个控制点,x,y为结束点。

bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
绘制三次贝塞尔曲线,cp1x,cp1y为控制点一,cp2x,cp2y为控制点二,x,y为结束点。

如果有svg中绘制贝塞尔曲线的经历,这里的参数想必也都很好理解,或者可以根据下图来认识控制点和贝塞尔曲线:

二次贝塞尔曲线

b_2_big.gif

ctx.beginPath();
ctx.moveTo(10, 200); //起始点
//绘制二次贝塞尔曲线
ctx.quadraticCurveTo(40, 100, 200, 200);
ctx.stroke();

image.png

三次贝塞尔曲线

b_3_big.gif

ctx.beginPath();
ctx.moveTo(10, 20); //起始点
//绘制二次贝塞尔曲线
ctx.bezierCurveTo(20, 100, 100, 50, 200, 130);
ctx.stroke();

image.png

Path2d 对象 (Experimental)

path2d是一个实验中的功能

const rectangle = new Path2D();
rectangle.rect(10, 10, 50, 50);

const circle = new Path2D();
circle.moveTo(125, 35);
circle.arc(100, 35, 25, 0, 2 * Math.PI);

ctx.stroke(rectangle);
ctx.fill(circle);

// 使用svg path来初始化路径
const svgPath = new Path2D("M150 10 h 80 v 80 h -80 Z");
ctx.stroke(svgPath);

image.png

样式和颜色

上文中主要介绍了如何绘制不同的形状和路径,但是图案当然还需要一些色彩和样式作为补充。

颜色

要给图形增加颜色,可以使用这两个属性:fillStylestrokeStyle

fillStyle

ctx.fillStyle = 'rgb(200,100,200)';
ctx.fillRect(30, 30, 100, 100);

image.png

strokeStyle

ctx.strokeStyle = 'rgb(200,100,200)';
ctx.strokeRect(30, 30, 100, 100);

image.png

透明度

globalAlpha 这个属性影响到 canvas 里所有图形的透明度,有效的值范围是 0.0 (完全透明)到 1.0(完全不透明),默认是 1.0

ctx.fillStyle = 'rgb(200,100,200)';
ctx.globalAlpha = 0.2;
ctx.fillRect(30, 30, 100, 100);
ctx.globalAlpha = 1;
ctx.fillRect(150, 30, 100, 100);

image.png

我们还可以使用rgba的方式来设置透明度,也可以达到同样的效果:

ctx.fillStyle = 'rgb(200,100,200,0.2)';
ctx.fillRect(30, 30, 100, 100);
ctx.fillStyle = 'rgb(200,100,200)';
ctx.fillRect(150, 30, 100, 100);

image.png

线形

对于线的样式,canvas提供了很多的属性可以配置,依次来介绍一下:

lineWidth 设置当前绘线的粗细。属性值必须为正数。默认值是1.0。

for (var i = 0; i < 10; i++){
    ctx.lineWidth = 1+i;
    ctx.beginPath();
    ctx.moveTo(5+i*14,5);
    ctx.lineTo(5+i*14,140);
    ctx.stroke();
  }

image.png 需要注意的是,线宽是指给定路径的中心到两边的粗细,也就是在路径的两边各绘制线宽的一半。因为画布的坐标并不和像素直接对应,当需要获得精确的水平或垂直线的时候要特别注意。

在上面的例子中,用递增的宽度绘制了10条直线。最左边的线宽1.0单位。并且,最左边的以及所有宽度为奇数的线并不能精确呈现,这就是因为路径的定位问题。

如果我们「仔细观察」上图,可以发现两点问题:

  • 宽度1.0与宽度2.0从粗细上看起来没有什么差异;
  • 奇数粗细的线边缘都有些模糊。

再从下图来开始分析边的绘制过程:

image.png

图解来自MDN

如果你想要绘制一条从 (3,1) 到 (3,5),宽度是 1.0 的线条,你会得到像第二幅图一样的结果。实际填充区域(深蓝色部分)仅仅延伸至路径两旁各一半像素。而这半个像素又会以近似的方式进行渲染,这意味着那些像素只是部分着色,结果就是以实际笔触颜色一半色调的颜色来填充整个区域(浅蓝和深蓝的部分)。这就是上例中为何宽度为 1.0 的线并不准确的原因。

要解决这个问题,你必须对路径施以更加精确的控制。已知粗 1.0 的线条会在路径两边各延伸半像素,那么像第三幅图那样绘制从 (3.5,1) 到 (3.5,5) 的线条,其边缘正好落在像素边界,填充出来就是准确的宽为 1.0 的线条。

lineCap 属性决定了线端点的样式。它可以为下面的三种的其中之一:buttroundsquare。默认是 butt

// 创建路径
ctx.strokeStyle = '#09f';
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(140, 10);
ctx.moveTo(10, 140);
ctx.lineTo(140, 140);
ctx.stroke();

// 画线条
ctx.strokeStyle = 'black';
for (let i = 0; i < lineCap.length; i++) {
  ctx.lineWidth = 15;
  ctx.lineCap = lineCap[i];
  ctx.beginPath();
  ctx.moveTo(25 + i * 50, 10);
  ctx.lineTo(25 + i * 50, 140);
  ctx.stroke();
}

image.png

lineJoin 的属性值决定了图形中两线段连接处所显示的样子。它可以是这三种之一:round, bevel 和 miter。默认是 miter。

const lineJoin = ['round', 'bevel', 'miter'];
ctx.lineWidth = 10;
for (let i = 0; i < lineJoin.length; i++) {
  ctx.lineJoin = lineJoin[i];
  ctx.beginPath();
  ctx.moveTo(-5, 5 + i * 40);
  ctx.lineTo(35, 45 + i * 40);
  ctx.lineTo(75, 5 + i * 40);
  ctx.lineTo(115, 45 + i * 40);
  ctx.lineTo(155, 5 + i * 40);
  ctx.stroke();
}

image.png 在使用miter属性的时候,线段的外侧边缘会被延伸交汇于一点上。线段之间夹角比较大时,交点不会太远,但随着夹角变小,交点距离会呈指数级增大。miterLimit 属性就是用来设定外延交点与连接点的最大距离,如果交点距离大于此值,连接效果会变成了 bevel。

ctx.lineWidth = 10;
ctx.strokeStyle = '#000';
ctx.miterLimit = 5;
ctx.beginPath();
ctx.moveTo(0, 100);
for (let i = 0; i < 24; i++) {
  var dy = i % 2 == 0 ? 25 : -25;
  ctx.lineTo(Math.pow(i, 1.5) * 2, 75 + dy);
}
ctx.stroke();

image.png 可以看到,最左侧的几条线由于夹角太小而延长线太长,导致连接线样式更改为bevel。

虚线

可以用 setLineDash 方法和 lineDashOffset 属性来制定虚线样式。区别在于 setLineDash 方法接受一个数组,来指定线段与间隙的交替;lineDashOffset 属性设置起始偏移量。

ctx.beginPath();
ctx.setLineDash([4,2]);
ctx.strokeRect(10,10, 100, 100);

image.png

canvas 文本绘制

canvas 提供了两种方法来渲染文本:

fillText(text, x, y [, maxWidth])
在指定的(x,y)位置填充指定的文本,绘制的最大宽度是可选的.

ctx.font = "48px serif";
ctx.fillText("Hello world", 10, 50);

image.png strokeText(text, x, y [, maxWidth])
在指定的(x,y)位置绘制文本边框,绘制的最大宽度是可选的.

ctx.font = "48px serif";
ctx.strokeText("Hello world", 10, 50);

image.png 我们可以通过以下的属性来配置文本样式:
font = value
当前我们用来绘制文本的样式. 这个字符串使用和 CSS font 属性相同的语法. 默认的字体是 10px sans-serif

textAlign = value
文本对齐选项. 可选的值包括:start, end, left, right or center. 默认值是 start。

这里的textAlign="center"比较特殊。textAlign的值为center时候文本的居中是基于你在fillText的时候所给的x的值,也就是说文本一半在x的左边,一半在x的右边(可以理解为计算x的位置时从默认文字的左端,改为文字的中心,因此你只需要考虑x的位置即可)。所以,如果你想让文本在整个canvas居中,就需要将fillText的x值设置成canvas的宽度的一半。

textBaseline = value
决定文字垂直方向的对齐方式. 可选的值包括:top, hanging, middle, alphabetic, ideographic, bottom。默认值是 alphabetic

direction = value
文本方向。可能的值包括:ltr, rtl, inherit。默认值是 inherit

状态管理

Saving and restoring state 是绘制复杂图形时必不可少的操作,在这里我们简单介绍一下作为一个预备知识。

saverestore 方法是用来保存和恢复 canvas 状态的,都没有参数。

Canvas 的状态就是当前画面应用的所有样式变形的一个快照。

save() Canvas状态存储在栈中,每当save()方法被调用后,当前的状态就被推送到栈中保存。
一个绘画状态包括:

  1. 当前应用的变形(即移动,旋转和缩放)

  2. 各种样式的值(strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation)

  3. 当前的裁切路径(clipping path)

可以调用任意多次 save 方法。

restore()
每一次调用 restore 方法,上一个保存的状态就从栈中弹出,所有设定都恢复(类似数组的 pop())。

ctx.fillRect(0, 0, 150, 150);   // 使用默认设置绘制一个矩形
ctx.save();                  // 保存默认状态

ctx.fillStyle = 'red'       // 在原有配置基础上对颜色做改变
ctx.fillRect(15, 15, 120, 120); // 使用新的设置绘制一个矩形

ctx.save();                  // 保存当前状态
ctx.fillStyle = '#FFF'       // 再次改变颜色配置
ctx.fillRect(30, 30, 90, 90);   // 使用新的配置绘制一个矩形

ctx.restore();               // 重新加载之前的颜色状态
ctx.fillRect(45, 45, 60, 60);   // 使用上一次的配置绘制一个矩形

ctx.restore();               // 加载默认颜色配置
ctx.fillRect(60, 60, 30, 30);   // 使用加载的配置绘制一个矩形

image.png

裁剪路径

前面我们介绍了使用stroke、fill方法,canvas还提供了clip方法用来裁剪路径。
裁切路径和普通的 canvas 图形差不多,不同的是它的作用是遮罩,用来隐藏不需要的部分。
ctx.clip(path, fillRule)

fillRule
这个算法判断一个点是在路径内还是在路径外。
允许的值:
"nonzero": 非零环绕原则,默认的原则。

  • 在路径包围的区域中,随便找一点,向外发射一条射线,
  • 和所有围绕它的边相交,
  • 然后开启一个计数器,从0计数,
  • 如果这个射线遇到顺时针围绕,那么+1,
  • 如果遇到逆时针围绕,那么-1,
  • 如果最终值非0,则这块区域在路径内。 "evenodd": 奇偶环绕原则。
  • 在路径包围的区域中,随便找一点,向外发射一条射线,
  • 和所有围绕它的边相交,
  • 查看相交线的个数,如果为奇数,就填充,如果是偶数,就不填充。

image.png

对于箭头发起的区域,左为奇偶环绕规则,右为非零环绕规则

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;

const x = w / 2;
const y = h / 2;
const r = 200;
const start = -Math.PI / 2;
const end = Math.PI * 3 / 2;

ctx.arc(x, y, r, start, end);
ctx.fillStyle = '#D43D59';
ctx.fill();

ctx.beginPath();
ctx.moveTo(x, y - r);

// 顶点连下左
ctx.lineTo(x - r * Math.sin(Math.PI / 5), y + r * Math.cos(Math.PI / 5));

// 下左连上右
ctx.lineTo(x + r * Math.cos(Math.PI / 10), y - r * Math.sin(Math.PI / 10));

// 上右连上左
ctx.lineTo(x - r * Math.cos(Math.PI / 10), y - r * Math.sin(Math.PI / 10));

// 上左连下右
ctx.lineTo(x + r * Math.sin(Math.PI / 5), y + r * Math.cos(Math.PI / 5));

ctx.fillStyle = '#246AB2';
// fill填充规则---奇偶原则
ctx.fill('evenodd');

image.png path
需要剪切的 Path2D 路径。

变形 Transformations

translate

translate(x, y)

用来移动 canvas 的原点到指定的位置。
translate 方法接受两个参数。x 是左右偏移量,y 是上下偏移量,如下图所示。

image.png

ctx.save(); //保存坐原点平移之前的状态
ctx.translate(10, 10);
ctx.strokeRect(0, 0, 30, 30);
ctx.restore(); //恢复到最初状态
ctx.translate(50, 50);
ctx.fillRect(0, 0, 30, 30);

image.png

rotate(angle)
旋转坐标轴。

这个方法只接受一个参数:旋转的角度(angle),它是顺时针方向的,以弧度为单位的值。 旋转的中心是坐标原点。

image.png

ctx.fillStyle = "red";
ctx.save();

ctx.translate(100, 100);
ctx.rotate(Math.PI / 180 * 45);
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 100, 100);
ctx.restore();

ctx.save();
ctx.translate(0, 0);
ctx.fillRect(0, 0, 50, 50)
ctx.restore();

image.png

scale(x, y)
用来增减图形在 canvas 中的像素数目,对形状,位图进行缩小或者放大。

scale方法接受两个参数。x,y 分别是横轴和纵轴的缩放因子,它们都必须是正值。值比 1.0 小表示缩 小,比 1.0 大则表示放大,值为 1.0 时什么效果都没有。

默认情况下,canvas 的 1 单位就是 1 个像素。举例说,如果我们设置缩放因子是 0.5,1 个单位就变成对应 0.5 个像素,这样绘制出来的形状就会是原先的一半。同理,设置为 2.0 时,1 个单位就对应变成了 2 像素,绘制的结果就是图形放大了 2 倍。

ctx.fillStyle = "red";
ctx.scale(1,2);
ctx.fillRect(100, 20, 100, 100);

image.png

变形矩阵

transform(a, b, c, d, e, f)这个方法是将当前的变形矩阵乘上一个基于自身参数的矩阵.

image.png a (m11) 水平方向的缩放;
b(m12) 竖直方向的倾斜偏移;
c(m21) 水平方向的倾斜偏移;
d(m22) 竖直方向的缩放;
e(dx) 水平方向的移动;
f(dy) 竖直方向的移动;

我们可以猜测出默认的矩阵值(1,0,0,1,0,0);

setTransform(a, b, c, d, e, f)
这个方法会将当前的变形矩阵重置为单位矩阵,然后用相同的参数调用 transform 方法。如果任意一个参数是无限大,那么变形矩阵也必须被标记为无限大,否则会抛出异常。从根本上来说,该方法是取消了当前变形,然后设置为指定的变形,一步完成。

resetTransform()
重置当前变形为单位矩阵,它等价于ctx.setTransform(1, 0, 0, 1, 0, 0)。

ctx.transform(1, 1, 0, 1, 0, 0);
ctx.fillRect(0, 0, 100, 100);
ctx.resetTransform();
ctx.fillRect(110, 110, 100, 100);

image.png

canvas VS svg

svg相关的入门介绍可以看这里

canvas与svg相比,两种技术个人觉得并不好说哪种有绝对的优劣,只能说应用场景存在差异,但在一些js库的帮助下,这两者间的能力壁垒可能会有些突破,比如说zRender,使用数据驱动,并且提供类Dom事件模型,弥补了canvas本身弱事件操作的一些问题。

简单对两者做一些比较:

标题canvassvg
使用方式偏向于使用js程序式绘图,动态生成使用xml描述绘图
操作对象基于像素点(单canvas元素)基于svg的图形元素(多元素Rect、Path、Text等)
使用场景像素处理,适合动态渲染以及大数据量绘制大面积的小数据量,高保真场景(如打印等)
设计原理基于位图,不能改变大小,只能缩放基于矢量,能很好处理图形大小的改变
功能支持2d(图形、动画)、3d(webgl)绘制图形、滤镜、动画

参考资料

MDN:developer.mozilla.org/zh-CN/docs/…
菜鸟教程:www.runoob.com/w3cnote/htm…
canvas填充原则:www.jianshu.com/p/d4b8b5d93…