🐳🐳🐳一份八千字超级详细的Canvas从0到1入门指南

3,860 阅读21分钟

一份八千字超级详细的Canvas从0到1入门指南

Canvas简介

提供了一个通过JavaScript 和 HTML<canvas>元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。在实际应用中,我们很少有直接操作canvas的需求,大部分情况都是直接操作canvas的库,比如说echarts,一些图片裁剪插件等。题主最近在看canvas的相关书籍准备从头学习一下canvas,这篇文章主要是一些canvas的总结和记录一些常用api方法。

注:我这边所有的代码实例都是基于ts+webpack环境实现的,有不懂的朋友可以去ts和[webpack】(webpack.docschina.org/)官网查看

  • 兼容性:IE9+,可以通过这个插件来兼容IE7、IE8

1. Canvas绘制几何图形

1.1 创建canvas元素步骤

  1. 创建canvas元素 默认宽为300,高为150
  2. 获取上下文对象context
  3. 开始绘制 坐标轴原点在屏幕左上角
const canvas = document.querySelector<HTMLCanvasElement>("#canvas") || new HTMLCanvasElement();
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;

注意这里设置canvas宽高时候,不要通过style去设置,因为这样设置之后我们获取canvas.width还是默认的宽高,我们要在canvas上面直接设置宽高

<canvas id="canvas" width="500" height="300" />

1.2 绘制直线图形

1.2.1 直线

  • ctx.moveTo(x, y): 移动画笔到(x,y)位置
  • ctx.lineTo(x1, y1): 让画笔画一条线从(x,y)到(x1,y1)
  • ctx.stroke():开始画
const canvas = document.querySelector<HTMLCanvasElement>("#canvas") || new HTMLCanvasElement();
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.moveTo(0, 0)
ctx.lineTo(300, 300)
ctx.stroke()

画多条直线

ctx.moveTo(200, 100)
ctx.lineTo(400, 100)
ctx.lineTo(400, 50)
ctx.moveTo(200, 300)
ctx.lineTo(400, 300)
ctx.stroke()

每次绘制如果没有moveTo,则从上一次的lineTo位置继续画,如果有则将画笔移动至moveTo位置再画像lineTo

1.2.2 矩形

  • ctx.strokeRect(x, y, width, height):从(x,y)的位置开始画一个width长,height高的空心矩形
  • 可以通过ctx.strokeStyle = "";修改边的颜色
ctx.strokeStyle = "red";
ctx.strokeRect(100, 100, 200, 100);

image.png strokeStyle和strokeRect的顺序不能反

  • ctx.fillRect(x, y, width, height):从(x,y)的位置开始画一个width长,height高的实心矩形
  • 可以通过ctx.fillStyle = "";修改填充的颜色
ctx.fillStyle = "purple"
ctx.fillRect(100, 100, 200, 100);

image.png fillStyle和fillRect的顺序不能反

  • rect(x, y, width, height):从(x,y)的位置开始画一个width长,height高的矩形,不同于上面两个的是,调用这个方法绘制的矩形不会立即显示出来,需要调用fill()或者stroke()方法才会显示出来,可以设置描边颜色和填充颜色
ctx.strokeStyle = "blue";
ctx.fillStyle = "pink"
ctx.rect(100, 100, 200, 100)
ctx.fill();
ctx.stroke();

image.png

  • ctx.clearRect(x, y, width, height):清除从(x,y)的位置width长,height高的矩形
  • 这里清除只是清除的填充(fill)颜色,描边(stroke)颜色不会清除
ctx.strokeStyle = "blue";
ctx.fillStyle = "pink"
ctx.rect(100, 100, 200, 100)
ctx.fill();
ctx.stroke();
ctx.clearRect(120, 120, 160, 60)

image.png

1.2.3 多边形

  • 多边形都是通过lineTo和moveTo来实现的

  • 三角形

ctx.moveTo(200, 100)
ctx.lineTo(200, 300)
ctx.lineTo(400, 200)
ctx.lineTo(200, 100)
ctx.stroke()

大家可以发挥想象,试着画一画六边形,五边形等

1.3 曲线图形

  • ctx.arc(x, y, radius, startAngle ,endAngle, anticlockwise):圆心位置为(x, y),半径为radius,开始角度startAngle,结束角度endAngle,画一个圆弧
  • anticlockwise:true逆时针画,false顺时针画
  • 角度:度数 x Math.PI/180

1.3.1 圆

  • 空心圆
ctx.arc(250, 250, 100, 0 ,360 * Math.PI/180);
ctx.stroke();
  • 实心圆
ctx.arc(250, 250, 100, 0 ,360 * Math.PI/180);
ctx.fill()
  • 同心圆 大家可以试一试同心圆怎么画,相信大家一定是这样画的:
ctx.arc(250, 250, 100, 0 ,360 * Math.PI/180);
ctx.stroke();
ctx.arc(250, 250, 50, 0 ,360 * Math.PI/180);
ctx.stroke();

画出来大家会发现两个圆连在一起了:

这是因为,我们画第一个大圆之后,并没有“抬笔”,导致第外圆的终点和内圆的起点连在一起了,我们只需要画完第一个大圆,”抬笔“再画内圆就行了,这时我们就需要下面两个api了:
  • ctx.beginPath():开始一个新路径
  • ctx.closePath():结束一个路径
  • 这两个方法一般是成对出现 修改代码:
// 开始一个路径
ctx.beginPath()
ctx.arc(250, 250, 100, 0 ,360 * Math.PI/180);
ctx.stroke();
// 抬笔动作
ctx.closePath()

// 再开始一个新的路径
ctx.beginPath()
ctx.arc(250, 250, 50, 0 ,360 * Math.PI/180);
ctx.stroke();
ctx.closePath()

注意:stroke()和fill()这两个方法只生效与当前路径,关闭之后就无效了!

1.3.1 圆弧

  • 绘制弧线时候,不需要关闭ctx.closePath()路径
ctx.beginPath()
ctx.arc(250, 250, 100, 0 ,60 * Math.PI/180);
ctx.stroke();

image.png

关闭路径

image.png

还有一种画圆弧方式:

  • ctx.arcTo(cx,cy,x2,y2,radius):(cx,cy)为控制点,(x2,y2)为圆弧的结束点,没有开始点的原因是因为用这种方式画圆弧,通长都是以lineTo()或者moveTo()为开始

image.png

例子:

ctx.moveTo(50, 100); 
ctx.lineTo(200, 100); // 画水平线,(200,100)相当于圆弧开始点
ctx.arcTo(250,100,250,200,50) // 控制点(250, 100), 圆弧结束点(250, 200), 半径50
ctx.lineTo(250, 250);
ctx.stroke();

image.png

这种方式比较难理解,大家可以自己画图慢慢理解一下。

1.4 贝塞尔曲线

贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量的。以上是百度百科的解释,其实贝塞尔曲线就是由一个点或者多个点去控制一条曲线。

1.4.1 二次贝塞尔曲线

  • ctx.quadraticCurveTo(cx, cy, x2, y2):与arcTo类似,贝塞尔曲线也是由三个坐标构成,(x1,y1)为起始点由moveTo或者lineTo提供,(cx,cy)为控制点,(x2,y2)为结束点。

二次贝塞尔曲线.gif

注意:二次贝塞尔曲线只有一个控制点

ctx.moveTo(100, 200)
ctx.quadraticCurveTo(150, 200, 200, 300)
ctx.stroke()

image.png

画个聊天气泡

ctx.moveTo(75, 25);
ctx.quadraticCurveTo(25, 25, 25, 62);
ctx.quadraticCurveTo(25, 100, 50, 100);
ctx.quadraticCurveTo(50, 120, 30, 125);
ctx.quadraticCurveTo(60, 120, 65, 100);
ctx.quadraticCurveTo(125, 100, 125, 62);
ctx.quadraticCurveTo(125, 25, 75, 25);
ctx.stroke();

image.png

1.4.2 三次贝塞尔曲线

  • ctx.bezierCurveTo(cx1 , cy1, cx2, cy2, x, y):三次贝塞尔曲线是由两个控制点和结束坐标(x,y)构成,同样的开始坐标也是由lineTo和moveTo提供。

三次贝塞尔曲线.gif

ctx.moveTo(200, 100);
ctx.bezierCurveTo(150, 50, 250, 200, 200, 200);
ctx.stroke();

image.png

1.4.3 四次贝塞尔曲线

图示:

四次贝塞尔曲线.gif

2. Canvas线条样式

2.1 lineWidth 线条粗细

  • ctx.lineWidth
ctx.beginPath()
ctx.lineWidth = 2
ctx.moveTo(100, 100)
ctx.lineTo(300, 100);
ctx.stroke();
ctx.closePath()
ctx.beginPath()
ctx.lineWidth = 10
ctx.moveTo(300, 100);
ctx.lineTo(300, 200);
ctx.stroke();
ctx.closePath()

2.2 lineCap 线条开始和结束处样式

  • ctx.lineCap
  • 是线条的开始和结束处,不是链接处
  • ctx.lineCap = 'butt' 默认值无线帽

image.png

  • ctx.lineCap = 'round' 圆形

image.png

  • ctx.lineCap = 'square' 方形

image.png

注意:这里设置除了默认值以外的样式会使线段长一点,因为会在线段的末端增加一段线帽,多出来一部分的长度是线段宽度的一半

2.3 lineJoin 线条连接处样式

  • ctx.lineJoin

  • 是线条的链接处,不是首尾末端

  • ctx.lineJoin = 'miter' 默认值,尖角

image.png

  • ctx.lineJoin = 'bevel' 斜角

image.png

  • ctx.lineJoin = 'round' 圆角

image.png

总结: image.png

2.4 setLineDash 设置线条虚实样式

  • ctx.setLineDash([实线宽度, 距离])
ctx.beginPath()
ctx.setLineDash([2, 2]);
ctx.moveTo(100, 100)
ctx.lineTo(300, 100);
ctx.stroke();
ctx.closePath()

image.png

image.png

3. 文本操作

3.1 文本操作方法

  • 绘制填充文本 ctx.fillText(文本, x, y, maxWidth) 超过maxWidth会被压缩到maxWidth长度
ctx.fillText("哈哈", 100, 100)
  • 绘制描边文本 ctx.strokeText(文本, x, y, maxWidth)
ctx.strokeText("哈哈", 100, 100)
  • 获取文本长度 measureText(文本) 是获取文本的UI长度,单位px,并不是文本的字数长度
const l = ctx.measureText("哈哈").width;
console.log(l); // 20

3.2 文本属性

  • ctx.font = "font-style font-weight font-size/line-height font-family"
ctx.font = "bold 100px Kai"
ctx.strokeText("哈哈", 100, 100)

image.png

  • 文本对齐方式 cxt.textAlign
ctx.font = "bold 30px 宋体"
ctx.moveTo(100, 100)
ctx.lineTo(100, 500)
ctx.stroke()
ctx.textAlign = 'start'
ctx.strokeText("哈哈", 100, 100)
ctx.textAlign = 'end'
ctx.strokeText("哈哈", 100, 150)
ctx.textAlign = 'center'
ctx.strokeText("哈哈", 100, 200)
ctx.textAlign = 'left'
ctx.strokeText("哈哈", 100, 250)
ctx.textAlign = 'right'
ctx.strokeText("哈哈", 100, 300)

image.png

  • 文本对齐方式 textBaseline

image.png

image.png

文本操作属性用的最多的还是font属性,对齐方式用的很少

4. 图形操作

4.1 图片基础操作

canvas通过 drawImage() 方法提供了图片导入,对图片进行一系列的裁剪,平铺等处理。

4.1.1 图片绘制

  • ctx.drawImage(image, dx, dy):image是图片元素,(dx,dy)是绘制图片左上角的坐标。
const img = new Image();
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.drawImage(img, 100, 100);
}

注意:如果是通过new创建的图片对象,需要在图片加载完成onload之后在进行操作

  • ctx.drawImage(image, dx, dy, dw, dh):dw和dh是绘制图片的宽高。
const img = new Image();
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.drawImage(img, 100, 100, 50, 50);
}
  • drawImage(image , sx, sy, sw, sh, dx, dy, dw, dh):(sx, sy)是从源图片的那个坐标点开始裁剪,裁剪的宽度sw和高度sh。 注意:这里的sx, sy, sw, sh是针对于源图片,dx, dy, dw, dh是画完的图片即canvas上的图片
const img = new Image();
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.drawImage(img, 50, 50, 50, 50, 0, 0, 100, 100)
}

这种方法可以实现雪碧图效果,只加载一次图片,通过裁剪不同位置从而实现显示不同的图片元素。

4.1.2 平铺效果

  • ctx.createPattern(element, type)
  • type类型有:
    • repeat:x,y轴全平铺
    • repeat-x:x轴平铺
    • repeat-y:y轴平铺
    • no-repeat:不平铺

平铺一般都是配合rect和arc圆来使用,element既可以平铺图片,也可以平铺视频甚至canvas元素。

  • 平铺图片
const img = new Image();
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.rect(0, 0, 300, 300)
  const pattern = ctx.createPattern(img, 'repeat') as CanvasPattern;
  ctx.fillStyle = pattern;
  ctx.fill();
}

image.png

  • 平铺canvas
  // 创建一个新的canvas
  const canvas2 = document.createElement('canvas')
  canvas2.width = 50;
  canvas2.height = 50;
  const ctx2 = canvas2.getContext('2d') as CanvasRenderingContext2D;
  ctx2.fillStyle = 'blue'
  ctx2.arc(25, 25, 25, 0, 360 * Math.PI/180);
  ctx2.fill()

  ctx.rect(0, 0, 300, 300)
  const pattern = ctx.createPattern(canvas2, 'repeat-x') as CanvasPattern;
  ctx.fillStyle = pattern;
  ctx.fill();

image.png

e.g.图片填充文字

  // 图片填充文字
  const pattern = ctx.createPattern(img, 'repeat') as CanvasPattern
  ctx.font = 'bold 100px Kai'
  ctx.fillStyle = pattern;
  ctx.fillText('CANVAS', 80, 100);

image.png

4.1.3 裁剪功能

裁剪功能需要三步:

  1. 创建一个裁剪模板,即把一张图片裁剪成什么形状
  2. 调用裁剪方法:cxt.clip()
  3. 把图片裁剪成第一步的形状
  // 1.创建裁减模板
  ctx.arc(50, 50, 50, 0, 360 * Math.PI/180);
  // 2.裁剪
  ctx.clip()
  // 3.绘制裁剪图片
  ctx.drawImage(img, 0, 0);

image.png

4.2 变形操作

4.2.1 平移

  • ctx.translate(x, y):将图形平移x,y距离 注意:平移之后,平移之前的图形不会清除
ctx.beginPath()
ctx.fillRect(0, 0, 100, 100)
setTimeout(() => {
  ctx.translate(200, 200);
  ctx.fillRect(0, 0, 100, 100)
}, 1000)
ctx.closePath()

image.png

4.2.2 缩放

  • ctx.scale(x, y):将图形沿x,y轴缩放 注意:缩放之后,图形的xy坐标也会被缩放, 线条的宽度也会缩放
ctx.fillRect(200, 200, 100, 100)
ctx.scale(1.5, 1.5)
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.fillRect(200, 200, 100, 100)
ctx.closePath()

image.png

我们可以通过translate将x,y左移缩放的距离

ctx.fillRect(200, 200, 100, 100)
ctx.translate(-100, -100)
ctx.scale(1.5, 1.5)
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.fillRect(200, 200, 100, 100)
ctx.closePath()

image.png

4.2.3 旋转

  • ctx.rotate(deg):将图形旋转deg度 注意:旋转的中心点是坐标原点
ctx.fillRect(200, 200, 50, 50)
ctx.rotate(10 * Math.PI/180)
ctx.fillRect(200, 200, 50, 50)

image.png

  • 可以通过translate修改旋转中心点
ctx.fillRect(200, 200, 50, 50)
ctx.translate(225, -95)
ctx.rotate(45 * Math.PI/180)
ctx.fillRect(200, 200, 50, 50)

image.png

4.2.4 一步到位写法

  • ctx.transform(a,b,c,d,e,f)
    • a 水平缩放
    • b 水平倾斜
    • c 垂直倾斜
    • d 垂直缩放
    • e 水平移动
    • f 垂直移动

ctx.translate(-100, -100) 就相当于 ctx.transform(1,0,0,1,-100,-100)

4.3 像素操作

  • getImageData(x, y, w, h) 获取图片像素数据

  • putImageData() 把像素数据重新绘制到canvas上面 注意getImageData()之前必须先将图片画在canvas上面

  • getImageData(x, y, w, h):获取canvas从(x,y)开始,宽w,高h的像素数据

const img = new Image()
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.drawImage(img, 0, 0);
  console.log(ctx.getImageData(0, 0, 500, 500));
}

image.png

其中data里面的数据就是像素数据,data里面的数据就分别对应r,g,b,a四个值,每四个循环一次:[R1,G1,B1,A1,R2,G2,B2,A2,...],并且RGBA分别都是数组结构

  • putImageData(pixiList, x, y):将像素数据绘制在(x,y)坐标
const img = new Image()
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.drawImage(img, 0, 0);
  const imageData = ctx.getImageData(0, 0, 500, 500)
  ctx.putImageData(imageData, 100, 100)
}

我们可以将获取到的数据处理之后再次输出到canvas上面

注意:如果你不想你的浏览器卡死,最好不要for循环打印data里面的数据!!!😒

4.3.1 颜色翻转(正负片效果)

  • 颜色翻转是将R、G、B三个值取反,即:255-R,255-G,255-B
  for (let i = 0; i < imageData.data.length; i+=4) {
    // 遍历每个像素
    imageData.data[i] = 255 - imageData.data[i] 
    imageData.data[i+1] = 255 - imageData.data[i+1] 
    imageData.data[i+2] = 255 - imageData.data[i+2] 
  }
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.putImageData(imageData, 0, 0)

4.3.2 黑白效果

  • 是取RGB三个颜色的平均值,然后给所有像素点都设置这个平均值
  for (let i = 0; i < imageData.data.length; i+=4) {
    // 遍历每个像素
    const avg = (imageData.data[i] + imageData.data[i+1]  + imageData.data[i+2] ) / 3
    imageData.data[i] = avg
    imageData.data[i+1] = avg
    imageData.data[i+2] = avg
  }

使用加权平均值效果会更好

const avg = imageData.data[i] * 0.3 + imageData.data[i] * 0.4 + imageData.data[i] * 0.3

分别对RGB取加权平均值实现复古效果

imageData.data[i] = imageData.data[i] * 0.39 + imageData.data[i + 1] * 0.76 + imageData.data[i+2] * 0.18
    imageData.data[i+1] = imageData.data[i] * 0.35 + imageData.data[i + 1] * 0.68 + imageData.data[i+2] * 0.16
    imageData.data[i+2] = imageData.data[i] * 0.27 + imageData.data[i + 1] * 0.53 + imageData.data[i+2] * 0.13

4.3.3 调整亮暗

  • 给每个RGB值加一个正数就是变亮,加个负数就是变暗
  for (let i = 0; i < imageData.data.length; i+=4) {
    const l = 80 // -80
    imageData.data[i] += l
    imageData.data[i+1] += l
    imageData.data[i+2] += l
  }

4.3.4 红色、绿色、蓝色蒙版

  • R取RGB的平均值,GB都是0,其它的蒙版同理
 for (let i = 0; i < imageData.data.length; i+=4) {
    const avg =  (imageData.data[i] + imageData.data[i+1]  + imageData.data[i+2] ) / 3
    imageData.data[i] = avg
    imageData.data[i+1] = 0
    imageData.data[i+2] = 0
  }

4.3.5 修改透明度

  • 将A值乘以一个小数就可以修改透明度
  for (let i = 0; i < imageData.data.length; i+=4) {
    imageData.data[i+3] = imageData.data[i+3] * 0.5
  }

大家可以想想怎么将一个图片转换成像素风,这里给大家一个思路,可以先将图片缩小,然后在放大,具体阈值,大家可以自己尝试。

4.3.6 创建像素区域

  • createImageData(w, h) 创建一个宽w,高h的像素操作区域
  • createImageData(imageData) 创建一个和imageData一样宽高的像素操作区域

一个区域进行像素操作

  const imageData2 = ctx.createImageData(300, 300)
   for (let i = 0; i < 300 * 300 * 4; i+=4) {
    imageData2.data[i] = 100
    imageData2.data[i+1] = 100
    imageData2.data[i+2] = 188
    imageData2.data[i+3] = 255
  }

5. 渐变与阴影

5.1 线性渐变

  • ctx.createLinearGradient(x1, y1, x2, y2):创建一个从(x1,y1)开始,到(x2,y2)结束的渐变对象
  • addColorStop(value, color); 给上面创建的渐变对象添加颜色,可以添加多个,value为渐变位置偏移量(0-1),color为渐变颜色
ctx.rect(0, 0, canvas.width, canvas.height)
const gnt = ctx.createLinearGradient(0, 0, canvas.width, 0);
gnt.addColorStop(0.1, 'red');
gnt.addColorStop(0.2, 'green');
gnt.addColorStop(0.3, 'blue');
gnt.addColorStop(0.4, 'purple');
ctx.fillStyle = gnt
ctx.fill()

image.png

  • 文字渐变
ctx.font = 'bold 50px Kai'
const gnt = ctx.createLinearGradient(0, 0, canvas.width, 0);
gnt.addColorStop(0, 'purple');
gnt.addColorStop(1, '#fff');
ctx.fillStyle = gnt
ctx.fillText("Canvas", 100, 100);

image.png

5.2 径向渐变

  • createRadialGradient(x1,y1,r1,x2,y2,r2):(x1,y1),r1是渐变开始的圆心坐标和半径,(x2,y2),r2是结束渐变的圆心坐标和半径
const gnt = ctx.createRadialGradient(250, 250, 100, 300, 300, 50);
gnt.addColorStop(0, 'red')
gnt.addColorStop(0.5, 'blue')
gnt.addColorStop(1, 'yellow')
ctx.fillStyle = gnt;
ctx.arc(250, 250, 100, 0, 2*Math.PI);
ctx.fill()

image.png

5.3 阴影

  • shadowOffsetX:阴影右偏移量
  • shadowOffsetY:阴影向下偏移量
  • shadowColor:阴影颜色
  • shadowBlur:阴影模糊程度,值越大越模糊
ctx.shadowOffsetX = 10;
ctx.shadowOffsetY = 10;
ctx.shadowColor = 'red'
ctx.shadowBlur = 5
ctx.fillRect(100, 100, 100, 100)

image.png

  • 给文字设置阴影
ctx.font = 'bold 50px Kai'
ctx.shadowOffsetX = 10;
ctx.shadowOffsetY = 10;
ctx.shadowColor = 'red'
ctx.shadowBlur = 5
ctx.fillText("Canvas", 100, 100);

image.png

  • 给图片设置阴影
ctx.shadowOffsetX = 10;
ctx.shadowOffsetY = 10;
ctx.shadowColor = 'red'
ctx.shadowBlur = 5
const img = new Image();
img.src = '/src/img/aaa.png'
img.onload = () => {
  ctx.drawImage(img, 100, 100, 100, 100)
}

image.png

注意:设置阴影的代码要写在绘制代码之前

6 canvas路径

在Canvas中除了矩形,其它所有的图形都是基于路径绘制的

6.1 开始路径和关闭路径

  • beginPath()和closePath()

6.1.1 开始路径

先看下面的例子:

ctx.moveTo(100, 100)
ctx.lineTo(300, 100)
ctx.strokeStyle = "red"
ctx.stroke()
ctx.moveTo(100, 200)
ctx.lineTo(300, 200)
ctx.strokeStyle = "blue"
ctx.stroke()

image.png

代码中我们画了两条直线,上面的是红色,下面的是蓝色,但是我们发现实际画出来的,上面是紫色,下面的是蓝色,这是因为第二次画的蓝色线,他设置的蓝色样式会影响第一次设置的红色样式,是直接覆盖上面,所以红色+蓝色,第一条直线会变成紫色。

Canvas是根据状态绘图的,每次绘制会检测所有的状态比如说:strokeStyle,fillStyle等,如果我们只是设置了一次,那么整个程序会一直用这个状态,如果我们每次都使用beginPath()一个新的路径,那么他们就会用各自的状态。

将上面的代码改成这样:

ctx.moveTo(100, 100)
ctx.lineTo(300, 100)
ctx.strokeStyle = "red"
ctx.stroke()
ctx.beginPath()
ctx.moveTo(100, 200)
ctx.lineTo(300, 200)
ctx.strokeStyle = "blue"
ctx.stroke()

image.png

加了beginPath,这样两个线条都显示正确的颜色了。

注意:判断是否属于同一路径的标准是是否使用了beginPath()方法,而不是视觉上是否有首尾连线

使用以下方法只是绘制图形,并不会开始新路径:moveTo()、lineTo()、strokeRect()、fillRect()、rect()、arc()、arcTo()、quadricCurveTo()和bezierCurveTo()

6.1.2 关闭路径

这里先理解几个概念:

  • 关闭路径:是指将一个路径的起点和终点链接起来,使其成为一个封闭的图形
  • 结束路径:是指开始一个新的路径,所以我们要想结束一个路径,开始新路径只有beginPath()

看这个例子:

ctx.arc(250, 250, 100, 0, Math.PI)
ctx.strokeStyle = 'red'
ctx.stroke();

当前是只有一个路径,但是我们没有关闭路径,所以这个圆弧没有密封。 image.png 添加closePath()

ctx.arc(250, 250, 50, 0, Math.PI)
ctx.strokeStyle = 'red'
ctx.closePath()
ctx.stroke();

这时候我们会发现,圆弧已经封闭了

image.png

注意:关闭路径要写在stroke之前

我们再新增一个蓝色的圆弧:

ctx.arc(250, 250, 50, 0, Math.PI)
ctx.strokeStyle = 'red'
ctx.closePath()
ctx.stroke();
ctx.arc(150, 250, 50, 0, Math.PI)
ctx.strokeStyle = 'blue'
ctx.closePath()
ctx.stroke();

image.png

我们会发现,第二个圆弧和第一个连起来了,并且,第一个红色的圆弧颜色也变了,这是因为clothPath是关闭路径,他只会将图形起点和终点连起来,并不会开始新的路径,现在相当于还是一个路径,状态共享,所以颜色叠加了,同时第二个圆弧的起点也是第一个圆弧的终点,所以他们连一起了,要想分开并显示正确的颜色,我们只需添加一下beginPath:

ctx.arc(250, 250, 50, 0, Math.PI)
ctx.strokeStyle = 'red'
ctx.closePath()
ctx.stroke();
ctx.beginPath()
ctx.arc(150, 250, 50, 0, Math.PI)
ctx.strokeStyle = 'blue'
ctx.closePath()
ctx.stroke();

image.png

大家一定要记住不要搞混关闭路径和结束路径!!!

6.2 判断某个点是否在当前路径

  • isPointInPath(x, y)
ctx.arc(250, 250, 50, 0, Math.PI)
ctx.closePath()
ctx.stroke()
console.log(ctx.isPointInPath(250, 250)); // true

注意:isPointInPath方法不支持fillRect和strokeRect方法,如果想使用矩形,可以用rect()方法代替

7. canvas状态

canvas是基于状态去绘图,如果我们不使用beginPath开始新路径,那么全局都是使用这一个状态,我们可以通过canvas提供的两个方法去储存和恢复状态。

7.1 clip() 方法

在使用clip()方法之前,要绘制一个基本图形,然后调用clip(),这个基本图形就会变成剪切区域,后面绘制的图形超过这个区域就不会显示出来

注意:fillRect和strokeRect方法不能作为剪切区域,可以使用rect代替!!!

// 1. 绘制基本图形
ctx.rect(100, 100, 100, 100)
// 2. 裁剪矩形作为剪切区域
ctx.clip()
// 3. 画个超出矩形范围的图片(填充颜色也行)
const img = new Image()
img.src = '/src/img/man.jpg'
img.onload = () => {
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}

image.png

7.2 save()和restore()

大家思考一下,上面我们裁减了图片,如果后面我们有不需要裁剪的图要画,这时候也是会被裁减的,怎样把后面的图片显示出来?我们只需要将裁剪之前的状态保存下来,然后开始一个新的路径去做裁剪操作,后面我们不需要裁剪了,我们只需要恢复到裁剪之前的状态就可以继续干后面的事情了。

调用save方法可以将当前状态保存下来,使用restore方法恢复保存的状态

// 1.将没有裁剪的环境保存下来
ctx.save();
// 2.开始裁剪操作
ctx.beginPath();
ctx.arc(250, 250, 100, 0, Math.PI * 2);
ctx.stroke();
ctx.clip();
const img = new Image();
img.src = "/src/img/man.jpg";
img.onload = () => {
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  fn()
};
function fn() {
  // 3.裁剪完成,恢复之前的状态,去加不需要裁剪的图
  ctx.restore();
  const img2 = new Image();
  img2.src = "/src/img/aaa.png";
  img2.onload = () => {
    ctx.drawImage(img2, 100, 250, 50, 50);
  };
}

image.png

流程大概是这样: image.png

之前创建两个不同颜色的线段是通过beginPath实现的,大家也可以试试用save和restore来实现。

8. canvas一些其它方法

8.1 toDataURL()

将canvas画布保存成一张图片,返回base64,可以通过 window.location = ctx.toDataURL("image/png")方法实现浏览器下载。

8.2 globalAlpha 属性

修改全局透明度,范围是0-1,要写在最前面,对全局生效。

8.3 globalCompositeOperation属性

正常在canvas绘制图形,一般都是后面覆盖前面的,可以通过这个属性来设置覆盖方式,有下面几种属性,大家可以自己尝试一下效果:

image.png

image.png

9. 事件操作

9.1 封装鼠标方法

    window.tools = {};
    window.tools.getMouse = function (element) {
      var mouse = { x: 0, y: 0 };
      element.addEventListener("mousemove", function (e) {
        var x, y;
        var e = e || window.event;
        if (e.pageX || e.pageY) {
          x = e.pageX;
          y = e.pageY;
        } else {
          x =
            e.clientX +
            document.body.scrollLeft +
            document.documentElement.scrollLeft;
          y =
            e.clientY +
            document.body.scrollTop +
            document.documentElement.scrollTop;
        }
        x -= element.offsetLeft;
        y -= element.offsetTop;
        mouse.x = x;
        mouse.y = y;
      });
      return mouse;
    };

使用:

// @ts-ignore
const mouse = window.tools.getMouse(canvas)
canvas.addEventListener('mousemove', function(){
  console.log(mouse.x, mouse.y);
})

9.2 键盘事件

window.addEventListener('keydown', function(e){
  console.log(e);
})

10. 物理数学和动画

10.1 动画 requestAnimationFrame

我们制作动画最好不要使用setInterval,setInterval本身性能不是很好,所以我们用requestAnimationFrame来代替,但是其存在兼容性,所以我们写一个工具方法,后面去引入使用。

window.requestAnimationFrame =
      window.webkitRequestAnimationFrame ||
      window.mozRequestAnimationFrame ||
      window.msRequestAnimationFrame ||
      window.oRequestAnimationFrame ||
      function (callback) {
        return window.setTimeout(callback, 1000 / 60);
      };

使用方法:

let x = 0;
(function move(){
  window.requestAnimationFrame(move)
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.beginPath()
  ctx.arc(x, 250, 50, 0, 2*Math.PI)
  ctx.stroke()
  x+=2
})()

10.2 三角函数

10.2.1 三角函数定义

首先先了解一下我们常用的六种三角函数:

  • sin(θ):Math.sin(θ*Math.PI/180)
  • cos(θ):Math.cos(θ*Math.PI/180)
  • tan(θ):Math.tan(θ*Math.PI/180)
  • arcsin(x/R):Math.asin(x/R)*(180/Math.PI)
  • arccos(y/R):Math.acos(x/y)*(180/Math.PI)
  • arctan(x/y):Math.atan(x/y)*(180/Math.PI)

具体对应关系如下图:

image.png

注意在JS中所有的角度都是弧度制,即:Math.PI = 180度,大家可以通过这种方式来转换 度数*Math.PI/180

  • 试着写一个跟随鼠标移动的箭头
class Arrow {
  x = 0;
  y = 0;
  ctx: CanvasRenderingContext2D;
  constructor(x: number, y: number, ctx: CanvasRenderingContext2D) {
    this.x = x;
    this.y = y;
    this.ctx = ctx;
    this.drawArrow(0);
  }
  drawArrow(deg: number) {
    this.ctx.save()
    this.ctx.translate(this.x, this.y)
    this.ctx.rotate(deg)
    this.ctx.moveTo(0, 0);
    this.ctx.lineTo(-50, -10);
    this.ctx.lineTo(20, -10);
    this.ctx.lineTo(20, -30);
    this.ctx.lineTo(50, 0);
    this.ctx.lineTo(20, 30);
    this.ctx.lineTo(20, 10);
    this.ctx.lineTo(-50, 10);
    this.ctx.lineTo(-50, -10);
    this.ctx.fillStyle = 'black'
    this.ctx.fill();
    this.ctx.restore()
    this.ctx.beginPath();
    this.ctx.arc(this.x, this.y, 2, 0, 2 * Math.PI);
    this.ctx.fillStyle = 'red'
    this.ctx.fill();
  }
  linePoint(x: number, y: number) {
    this.ctx.beginPath();
    this.ctx.setLineDash([2, 2]);
    this.ctx.moveTo(this.x, this.y);
    this.ctx.lineTo(x, y);
    this.ctx.stroke();
  }
  getDeg(x: number, y: number) {
    const deg = Math.atan2(y - this.y, x - this.x) * (180 / Math.PI);
    this.rotate(deg);
  }
  rotate(deg: number) {
    this.drawArrow(deg * Math.PI / 180)
  }
  doAnimate(x: number, y: number) {
    this.ctx.clearRect(0, 0, canvas.width, canvas.height);
    this.linePoint(x, y);
    this.getDeg(x, y);
  }
}

let arrow = new Arrow(100, 100, ctx);
// @ts-ignore
const mouse = window.tools.getMouse(canvas);
canvas.addEventListener("mousemove", function () {
  arrow.doAnimate(mouse.x, mouse.y);
});

如果有旋转的逻辑,最好绘制图形的时候,以屏幕左上角为原点绘制,使用translate()方法移动至需要的位置,这样旋转的时候就不会错位了

10.2.2 椭圆

首先要知道椭圆的方程:(x/a)² + (y/b)² = 1,即椭圆上任意一点(x,y)都满足这个方程

image.png 由上图可知椭圆每一点坐标为:

  • x = centerX + Math.cos(angle)*radiusX
  • y = centerY + Math.sin(angle)*radiusY
class Ellips {
  centerX = 0
  centerY = 0
  radiusX = 10
  radiusY = 5
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;
  constructor(canvas: HTMLCanvasElement, cx:number, cy:number, rx:number, ry:number) {
    this.centerX = cx;
    this.centerY = cy;
    this.radiusX = rx;
    this.radiusY = ry;
    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D
    this.draw()
  }
  draw() {
    this.ctx.beginPath()
    this.ctx.moveTo(this.centerX + this.radiusX, this.centerY)
    for (let deg = 1; deg < 360; deg++) {
      const x = this.centerX + Math.cos(deg * Math.PI/180)*this.radiusX 
      const y = this.centerY + Math.sin(deg * Math.PI/180)*this.radiusY
      this.ctx.lineTo(x, y)
    }
    this.ctx.closePath()
    this.ctx.stroke()
  }
}
new Ellips(canvas, 250, 250, 100, 50)

image.png

10.2.3 波形运动

  • 如果赋予canvas对象x坐标为正弦函数,则其会左右摇摆运动
const speed = 0.1 // 速度
const swing = 5 // 摆动幅度
function XMove(deg:number) {
  window.requestAnimationFrame(() => {
    ctx.beginPath()
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.translate(Math.sin(deg) * swing,0)
    ctx.arc(100, 250, 30, 0, 2 * Math.PI)
    ctx.stroke()  
    deg+=speed;
    XMove(deg)
  })
}
XMove(0)

captured.gif

  • 如果赋予canvas对象y坐标为正弦函数,x坐标正常增加常量,则会正弦函数轨迹运动
const speed = 0.1
const swing = 5
function XMove(deg:number) {
  window.requestAnimationFrame(() => {
    ctx.beginPath()
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.translate(1, Math.sin(deg) * swing)
    ctx.arc(30, 250, 30, 0, 2 * Math.PI)
    ctx.fill()  
    deg+=speed;
    XMove(deg)
  })
}
XMove(0)

captured (1).gif

10.3 加速运动

  • 匀加速运动
let v = 0;
let a = 0.1;
(function XMove() {
  window.requestAnimationFrame(() => {
    ctx.beginPath()
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.translate(v, 0)
    ctx.arc(40, 250, 30, 0, 2 * Math.PI)
    ctx.stroke()  
    v+=a;
    XMove()
  })
})()

大家可以考虑一下匀加速,再回头运动。

重力其实就是y轴方向的加速运动,同理摩擦力就是x轴向运动方向相反给的加速,可以给a乘一个小数。

canvas 基础内容就大概差不多这么多,学会上面API做点小特效还是没什么问题的,大家可以自己试着研究一下碰撞检测,有能力的童鞋可以尝试写个简单的物理引擎。

项目所有代码在这

参考资料:

莫振杰老师写的Canvas教程 Canvas MDN