Canvas 从入门到入土

1,845 阅读9分钟

起步

Canvas 是一个可以使用 JavaScript 来绘制图形的 HTML 元素。下面我们来创建一个简单的例子:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas</title>
</head>
<body>
  <canvas id="canvas"></canvas>
  <script>
    const canvas = document.querySelector('#canvas')
    const context = canvas.getContext('2d')
    context.fillStyle = 'red'
    context.fillRect(10, 10, 150, 100)
  </script>
</body>
</html>

在上面的代码中,我们通过document.querySelector('#canvas') 获得 canvas 元素的引用。接着,使用HTMLCanvasElement.getContext() 方法获取 canvas 元素的渲染上下文,通过它来进行图形的绘制。context.fillStyle = 'red' 用红色来填充图形。context.fillRect(10, 10, 150, 100) 绘制一个长方形,并将它的左上角放在 (10, 10),把它的大小设置成宽 150 高 100。

image-20220727232113205

这里需要注意的是:

  • canvas 有默认的高度(150px)和宽度(300px),可以通过 widthheight 两个属性设置它的宽高。
  • 不能通过 CSS 来设置 canvas 标签的宽高,不然内容会被拉伸。

图形

直线

绘制直线需要了解三个函数:

  • moveTo(x, y) 起点坐标 (x, y)
  • lineTo(x, y) 下一个点的坐标 (x, y)
  • stroke() 将所有坐标连起来
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
context.moveTo(50, 50)
context.lineTo(250, 250)
context.lineTo(300, 400)
context.stroke()
context.moveTo(200, 100)
context.lineTo(400, 100)
context.stroke()

image-20220727235135669

三角形

画三条直线拼在一起就是一个三角形了

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
context.moveTo(100, 50)
context.lineTo(400, 250)
context.lineTo(100, 250)
context.lineTo(100, 50)
context.stroke()

image-20220727235522363

矩形

矩形就不要手动连线了,Canvas API 给提供了三种绘制矩形的方法:

  • strokeRect(x, y, width, height) 绘制一个矩形的边框。

  • fillRect(x, y, width, height) 绘制一个填充的矩形。

  • clearRect(x, y, width, height) 清除指定矩形区域。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
context.strokeRect(50, 50, 200, 150)
context.fillRect(100, 150, 200, 100)
context.clearRect(100, 175, 100, 50)

image-20220728000739969

使用 arc(x, y, radius, startAngle, endAngle, counterclockwise) 来绘制圆或圆弧:

  • xy 为圆心坐标,radius 为半径。
  • startAngle 开始角度,endAngle 结束角度,两者单位均为弧度。
  • counterclockwise 绘制的角度,false 为顺时针(默认),true 为逆时针。
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
context.arc(100, 100, 80, Math.PI * 0.4, Math.PI * 2)
context.stroke()

image-20220728003341550

再画一个圆试试:

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
context.arc(100, 100, 80, Math.PI * 0.4, Math.PI * 2)
context.arc(250, 100, 80, 0, Math.PI * 2)
context.stroke()

image-20220728003538039

这似乎不是我们想要的结果,先画的圆和后画的圆被连在了一起,其实这是因为在咱们每次新建路径的时候都需要开启和闭合路径(beginPathclosePath),这样不同路径之间才不会相互干扰。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
// 关闭路径后绘制
context.beginPath()
context.arc(100, 100, 60, 0, Math.PI)
context.closePath()
context.stroke()
// 关闭路径前绘制
context.beginPath()
context.arc(250, 100, 60, 0, Math.PI)
context.stroke()
context.closePath()
// 填充
context.beginPath()
context.arc(400, 100, 60, 2, Math.PI * 2)
context.closePath()
context.fill()

image-20220728004301186

现在就正常了,stroke 是通过线条来绘制图形的轮廓,而 fill 是填充路径的内容区域绘制实心的图形,同时我们注意到关闭路径后用 stroke 绘制和关闭前是不一样的。

除了 arc 外,还可以通过 arcTo(x1, y1, x2, y2, radius) 来绘制圆弧:

  • x1y1 为控制点坐标,x2y2 为结束点的坐标。
  • 开始点的坐标一般为 moveTolineTo 提供。
  • radius 为半径。

image-20220728012925373

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.beginPath()
context.moveTo(100, 100)
context.lineTo(200, 100)
context.arcTo(300, 100, 300, 200, 100)
context.stroke()
context.closePath()

context.fillStyle = 'red'
context.beginPath()
context.arc(100, 100, 3, 0, Math.PI * 2)
context.fill()
context.beginPath()
context.arc(200, 100, 3, 0, Math.PI * 2)
context.fill()
context.beginPath()
context.arc(300, 100, 3, 0, Math.PI * 2)
context.fill()
context.beginPath()
context.arc(300, 200, 3, 0, Math.PI * 2)
context.fill()
context.beginPath()
context.arc(200, 200, 3, 0, Math.PI * 2)
context.fill()

椭圆

使用 ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise) 来绘制椭圆:

  • xy 为圆心坐标,radiusX 为x轴半径,radiusY 为y轴半径。
  • rotation 椭圆的旋转角度,startAngle 开始角度,endAngle 结束角度,三者单位均为弧度。
  • counterclockwise 绘制的角度,false 为顺时针(默认),true 为逆时针。
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.beginPath()
context.ellipse(100, 100, 60, 30, 0, 0, Math.PI * 2)
context.closePath()
context.stroke()

context.beginPath()
context.ellipse(250, 100, 60, 30, Math.PI * 0.5, 0, Math.PI * 1.5)
context.stroke()
context.closePath()

context.beginPath()
context.ellipse(400, 120, 60, 60, 0, Math.PI, Math.PI * 2)
context.fill()
context.closePath()

image-20220728005742104

贝塞尔曲线

二次贝塞尔曲线 quadraticCurveTo(cpx, cpy, x, y)cpxcpy 为控制点坐标,xy 为结束点坐标,起点坐标一般为 moveTolineTo 提供。

三次贝塞尔曲线 bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 有两个控制点。

image-20220728015313440

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.beginPath()
context.moveTo(50, 50)
context.quadraticCurveTo(150, 150, 200, 50)
context.stroke()

context.beginPath()
context.moveTo(280, 100)
context.bezierCurveTo(380, 20, 400, 290, 480, 250)
context.stroke()

const drawDot = (x, y) => {
  context.beginPath()
  context.arc(x, y, 3, 0, Math.PI * 2)
  context.fill()
}

context.fillStyle = 'red'
drawDot(50, 50)
drawDot(150, 150)
drawDot(200, 50)
drawDot(280, 100)
drawDot(380, 20)
drawDot(400, 290)
drawDot(480, 250)

样式

线条的样式

  • 使用 lineWidth 来设置线条的宽度,默认为1,单位是 px。

  • 使用 lineCap = 'butt'(默认) | 'square' | 'round' 来设置线条开始和结尾处的样式。

  • 使用 lineJoin = 'miter'(默认) | 'round' | 'bevel' 来设置线条拐角的样式。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.beginPath()
context.moveTo(50, 20)
context.lineTo(450, 20)
context.stroke()

context.beginPath()
context.lineWidth = 6
context.lineCap = 'square'
context.moveTo(50, 60)
context.lineTo(450, 60)
context.stroke()

context.beginPath()
context.lineWidth = 18
context.lineCap = 'round'
context.moveTo(50, 100)
context.lineTo(450, 100)
context.stroke()

context.beginPath()
context.lineJoin = 'round'
context.moveTo(50, 150)
context.lineTo(150, 150)
context.lineTo(200, 250)
context.stroke()

context.beginPath()
context.lineJoin = 'bevel'
context.moveTo(250, 250)
context.lineTo(300, 150)
context.lineTo(400, 150)
context.stroke()

image-20220728021741616

虚线

使用 setLineDash(segments) 方法可以将描边设置成虚线,segments 是一个数组,有三种情况:

  • 一个值 [10],实线和空白长度都是 10px。
  • 两个值 [10, 20],实线长度是 10px,空白长度是 20px。
  • 三个值 [20, 5, 10],实线 20px,空白 5px,实线 10px,空白 20px,实线 5px,空白 10px...
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.lineWidth = 10

context.beginPath()
context.setLineDash([10])
context.moveTo(50, 50)
context.lineTo(450, 50)
context.stroke()

context.beginPath()
context.setLineDash([10, 20])
context.moveTo(50, 150)
context.lineTo(450, 150)
context.stroke()

context.beginPath()
context.setLineDash([20, 5, 10])
context.moveTo(50, 230)
context.lineTo(450, 230)
context.stroke()

context.beginPath()
context.setLineDash([20, 5, 10])
// 起点往左偏移10px
context.lineDashOffset = 10
context.moveTo(50, 270)
context.lineTo(450, 270)
context.stroke()

image-20220728023546194

透明度

设置 globalAlpha 属性或者使用有透明度的样式都可以绘制有透明度的图形。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.beginPath()
context.fillStyle = 'rgba(192, 168, 100, 0.25)'
context.fillRect(50, 40, 200, 100)

context.beginPath()
context.strokeStyle = 'rgba(10, 10, 10, 0.25)'
context.strokeRect(50, 160, 200, 100)

context.beginPath()
context.globalAlpha = 0.25
context.fillStyle = 'green'
context.arc(380, 150, 80, 0, Math.PI * 2)
context.fill()

image-20220728140032496

渐变

线性渐变 createLinearGradient(x1, y1, x2, y2),径向渐变 createRadialGradient(x0, y0, r0, x1, y1, r1),两种渐变都需要用 addColorStop(offset, color) 方法来添加颜色。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

const gradient1 = context.createLinearGradient(50, 20, 400, 20)
gradient1.addColorStop(0, '#fce38a')
gradient1.addColorStop(1, '#f38181')
context.beginPath()
context.fillStyle = gradient1
context.fillRect(50, 20, 400, 20)

const gradient2 = context.createLinearGradient(50, 60, 400, 20)
// 从0.5才开始渐变
gradient2.addColorStop(0.5, '#fce38a')
gradient2.addColorStop(1, '#f38181')
context.beginPath()
context.fillStyle = gradient2
context.fillRect(50, 60, 400, 20)

const gradient3 = context.createRadialGradient(150, 180, 50, 150, 180, 10)
gradient3.addColorStop(0, '#1abc9c')
gradient3.addColorStop(0.25, '#3498db')
gradient3.addColorStop(0.5, '#9b59b6')
gradient3.addColorStop(0.75, '#7f8c8d')
gradient3.addColorStop(1, '#dfe4ea')
context.beginPath()
context.fillStyle = gradient3
context.fillRect(90, 120, 120, 120)

image-20220728142931874

非零环绕规则

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.beginPath()
// 顺时针 计数器 +1
context.arc(150, 150, 50, 0, Math.PI * 2)
// 逆时针 计数器 -1 而它又经过外层圆 外层圆的计数器为 1 最终内层的值为 0 所以不会被填充
context.arc(150, 150, 25, 0, Math.PI * 2, true)
context.fill()

context.beginPath()
// 顺时针
context.moveTo(250, 100)
context.lineTo(400, 100)
context.lineTo(400, 200)
context.lineTo(250, 200)
context.lineTo(250, 100)
// 逆时针
context.moveTo(300, 125)
context.lineTo(300, 175)
context.lineTo(350, 175)
context.lineTo(350, 125)
context.lineTo(300, 125)
context.fill()

image-20220728144512491

文本

填充和描边

使用 fillText(text, x, y, maxWidth) 方法绘制字体;使用 strokeText(text, x, y, maxWidth) 方法绘制描边字体,strokeStyle 可以设置描边颜色:

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.font = '60px consolas'
context.strokeStyle = 'green'
context.strokeText('Hello World!', 50, 100)
context.fillText('Hello World!', 50, 200)

image-20220728154654153

样式

  • font = '10px sans-serif'(默认值) 绘制文本的样式。
  • textAlign = 'left' | 'right' | 'center' | 'start'(默认值) | 'end' 文本对齐方式。
  • direction = 'inherit'(默认值) | 'ltr' | 'rtl' ltr 表示文本从左向右,rtl 表示文本从右向左。
  • textBaseline,基线对齐选项,决定文字垂直方向的对齐方式。

测量

使用 measureText() 方法测量文本,返回一个 TextMetrics 对象。

阴影

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.font = '50px consolas'
// 设置阴影颜色
context.shadowColor = '#cccccc'
// 设置填充颜色
context.fillStyle = '#ee7934'
// X轴上的阴影
context.shadowOffsetX = 10
// Y轴上的阴影
context.shadowOffsetY = 10
// 阴影的模糊程度
context.shadowBlur = 5
context.fillText('Hi Canvas !', 100, 50)
context.fillRect(100, 100, 200, 60)
context.shadowOffsetX = -10
context.shadowOffsetY = -10
context.shadowBlur = 5
context.fillText('Hi Canvas !', 100, 250)

image-20220728154835648

图片

在 canvas 中可以使用 drawImage() 来绘制图片。

  • drawImage(image, dx, dy)image 是要渲染的图片对象,dx dy 是图片左上角的坐标。
  • drawImage(image, dx, dy, dw, dh)dw dh 是图片的宽度和高度。
  • drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh),截取图片,sx sy 是裁剪框的左上角坐标,sw sh 是裁剪框的宽高。
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

const image = new Image()
image.src = 'https://image-1304160910.file.myqcloud.com/avatar_2.png'

image.onload = () => {
  context.drawImage(image, 100, 100, 500, 500, 30, 50, 200, 200)
  context.drawImage(image, 260, 50, 200, 200)
}

image-20220728162315291

变形

状态的保存和恢复

save():保存画布的所有状态,restore():恢复上次的状态。状态存储在栈中,调用 save() 状态入栈保存状态,调用 restore() 将保存状态从栈中弹出。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.fillStyle = 'red'
context.fillRect(50, 40, 400, 50)
context.save()
context.fillStyle = 'green'
context.fillRect(50, 120, 400, 50)
context.restore()
context.fillRect(50, 210, 400, 50)

image-20220728164222399

移动、旋转和缩放

  • 移动:translate(x, y)

  • 旋转:rotate(angle),顺时针旋转,单位是弧度。

  • 缩放:scale(x, y),默认值为 1。

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

context.fillStyle = '#a29bfe'
context.save()
context.save()

// 原点变成(50, 50)
context.translate(50, 50)
context.fillRect(0, 0, 100, 50)

context.restore()
// 逆时针旋转 30°
context.rotate(-Math.PI / 6)
context.fillRect(100, 200, 50, 50)

context.restore()
// x轴缩放2倍,y轴不变
context.scale(2, 1)
context.fillRect(150, 200, 50, 50)

image-20220728170122936

Transform

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

    • a 水平方向的缩放

    • b 竖直方向的倾斜偏移

    • c 水平方向的倾斜偏移

    • d 竖直方向的缩放

    • e 水平方向的移动

    • f 竖直方向的移动

  • setTransform(a, b, c, d, e, f) 将当前的变形矩阵重置为单位矩阵,然后用相同的参数调用 transform 方法。

  • resetTransform() 方法为重置当前变形为单位矩阵,效果等同于 setTransform(1, 0, 0, 1, 0, 0)

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

const sin = Math.sin(Math.PI / 6)
const cos = Math.cos(Math.PI / 6)

context.translate(150, 150)
for (let i = 0, c = 0; i <= 12; i++) {
  c = Math.floor(255 / 12 * i)
  context.fillStyle = 'rgb(' + c + ',' + c + ',' + c + ')'
  context.fillRect(0, 0, 100, 10)
  context.transform(cos, sin, -sin, cos, 0, 0)
}

context.setTransform(1, sin, 0, 1, 300, 100)
context.fillStyle = 'rgba(255, 128, 255, 0.5)'
context.fillRect(0, 0, 100, 100)

image-20220728172116942

合成和裁剪

合成

合成的图形受限于绘制的顺序。如果我们不想受限于绘制的顺序,那么我们可以利用 globalCompositeOperation 属性来改变这种情况。

Compositing 示例 - Web API 接口参考 | MDN (mozilla.org)

裁剪

裁剪的作用是遮罩,用来隐藏不需要的部分,所有在路径以外的部分都不会在 canvas 上绘制出来。

  • clip(),将当前正在构建的路径转换为当前的裁剪路径。
  • clip(path, fillRule)path 为需要剪切的 Path2D 路径,fillRule 为判断是在路径内还是在路径外,允许的值有 nonzero:非零环绕原则(默认值),evenodd:奇偶环绕原则。
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

const image = new Image()
image.src = 'https://image-1304160910.file.myqcloud.com/avatar_2.png'

image.onload = () => {
  context.arc(150, 150, 100, 0, Math.PI * 2)
  context.clip()
  context.drawImage(image, 50, 50, 200, 200)
  // 效果一致
  // const path2d = new Path2D()
  // path2d.arc(150, 150, 100, 0, Math.PI * 2)
  // context.clip(path2d)
  // context.drawImage(image, 50, 50, 200, 200)
}

image-20220728173432840

下载图片

const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')

const image = new Image()
image.src = 'https://image-1304160910.file.myqcloud.com/avatar_2.png'

image.onload = () => {
  context.arc(150, 150, 100, 0, Math.PI * 2)
  context.clip()
  context.drawImage(image, 50, 50, 200, 200)
}

const button = document.createElement('button')
button.innerText = '下载图片'
button.style.display = 'block'
document.body.appendChild(button)

button.addEventListener('click', () => {
  const base64 = canvas.toDataURL('image/png')
  const a = document.createElement('a')
  a.download = '图片.png'
  a.href = base64
  a.click()
})

本文的所有代码位于:stitch007/canvas (github.com)