Skip to content

快速开始

canvas 是 H5 新增加的标签 我们可以通过 JavaScript Canvas API(2D) 或 WebGL API(3D) 绘制图形及图形动画, 现阶段主要学习 2D 相关 API

html
<!DOCTYPE html>
<html lang="zh-cn">
  <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" />
    <meta name="referrer" content="no-referrer" />
    <title>Canvas and SVG</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
        padding: 20px;
      }
      #app {
        border: 1px solid #f00;
      }
    </style>
  </head>
  <body>
    <canvas id="app">Your browser does not support the canvas element.</canvas>
    <script src="./index.js" type="module"></script>
  </body>
</html>
js
window.onload = function () {
  const canvasDom = document.getElementById("app");

  // 2d:   canvas API 是 CanvasRenderingContext2D 类的实例
  // webgl: webgl API 是 WebGLRenderingContext    类的实例
  // 加上注释可以让编辑器显示代码提示
  /** @type {CanvasRenderingContext2D} */
  const ctx = canvasDom.getContext("2d");
  console.log(ctx instanceof CanvasRenderingContext2D); // true

  // 设置宽高属性
  // 注意了: 这是设置 canvasDom 的属性, 而不是样式
  // 这个dom对象是 HTMLCanvasElement 类的实例, 它本身就有
  // width 和 height 属性, 但是它不是 style 的属性
  canvasDom.width = window.innerWidth;
  canvasDom.height = window.clientHeight;
  console.log(ctx.canvas instanceof HTMLCanvasElement); // true
};

快速开始:体验用代码画图

js
window.onload = function () {
  function initCanvasDom() {
    const canvasDom = document.getElementById("app");
    canvasDom.width = window.innerWidth;
    canvasDom.height = window.innerHeight;
    return canvasDom;
  }

  // 0. 初始化dom & 获取 canvas上下文
  const canvasDom = initCanvasDom();
  const ctx = canvasDom.getContext("2d");

  // 1.将 "画笔" 放到指定的位置
  ctx.moveTo(50, 50);

  // 2.用 "画笔" 画一条线到指定的位置
  ctx.lineTo(100, 50);

  ctx.stroke();
};

思考 🤔

为什么不应该用 css 的方式去设置 canvas 的宽度和高度

注意我说到的是 不应该 而非 不可以, 实际上是可以通过 css, 去强行改变 canvas 元素显示的宽度和高度的, 既然可以改变那为什么不应该使用 css 设置宽高呢? 究其原因: 主要是因为 canvas 元素有 2 个尺寸

  1. 内在尺寸: 或者叫绘图的缓冲区尺寸, 可以通过 HTMLCanvasElement 类的 widthheight 属性来设置
  2. 显示尺寸: 展示在浏览器中的尺寸, 默认情况下, 是和 HTMLCanvasElement 类的 widthheight 相同, 如果非要不同就会拉伸/缩放
js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 200;
    canvasDom.height = 200;
    return canvasDom;
  }

  function drawLine(canvasDom) {
    const ctx = canvasDom.getContext("2d");
    ctx.moveTo(50, 50);
    ctx.lineTo(100, 50);
    ctx.stroke();
  }

  drawLine(initCanvasDom("app"));
  drawLine(initCanvasDom("app2"));
};
html
<!doctype html>
<html lang="zh-cn">
  <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" />
    <meta name="referrer" content="no-referrer" />
    <title>Canvas and SVG</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
        padding: 20px;
      }
      #app {
        background: #f00;
      }
      #app2 {
        background: #f00;
        width: 400px;
        height: 400px;
      }
    </style>
  </head>
  <body>
    <canvas id="app">Your browser does not support the canvas element.</canvas>
    <canvas id="app2">Your browser does not support the canvas element.</canvas>
    <script src="./index.js" type="module"></script>
  </body>
</html>

diff

画线条

绘制线条并设置样式

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  // 设置线条的粗细(高度)
  ctx.lineWidth = 10;

  // 设置线条的样式
  ctx.strokeStyle = "#f00";

  // 设置线条两端的样式
  ctx.lineCap = "round"; // butt(默认)/round/square

  // 设置线条链接处的样式(需要两条线才能看出效果来)
  ctx.lineJoin = "round"; // miter(默认)/round/bevel

  ctx.moveTo(50, 50);
  ctx.lineTo(200, 50);
  ctx.stroke();
};

lin-style

绘制多个线条

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  // line1
  ctx.moveTo(50, 50);
  ctx.lineTo(200, 50);
  ctx.lineWidth = 10;
  ctx.strokeStyle = "#f00";
  ctx.stroke();

  // line2
  // 如果没有调用 beginPath 开启新的绘制路径(拿起另外一支笔)
  // 那么前面 line1 的样式会被后面设置的样式给覆盖掉
  // 因此为了避免影响, 应该每次划线之前拿起新的一支笔(调用: beginPath)
  ctx.beginPath();
  ctx.moveTo(50, 100);
  ctx.lineTo(300, 100);
  ctx.lineWidth = 20;
  ctx.strokeStyle = "#0f0";
  ctx.stroke();
};

利用线条绘制一些简单图形

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  function drawLines(startPos = { x: 0, y: 0 }, linesPos = [], lineColor = "#f00", lineWidth = 10) {
    ctx.beginPath();
    ctx.moveTo(startPos.x, startPos.y);

    for (let i = 0; i < linesPos.length; i++) {
      const linePos = linesPos[i];
      ctx.lineTo(linePos.x, linePos.y);
    }

    ctx.strokeStyle = lineColor;
    ctx.lineWidth = lineWidth;
    ctx.stroke();
  }

  // 三角形
  drawLines({ x: 100, y: 100 }, [
    { x: 100, y: 200 },
    { x: 200, y: 200 },
    // 默认情况下不会从最后一个点链接到第一个点,
    // 所以需要最后一个点和第一个点位置一样,这样才能闭合
    { x: 100, y: 100 },
  ]);

  // 正方形
  drawLines({ x: 100, y: 300 }, [
    { x: 200, y: 300 },
    { x: 200, y: 400 },
    { x: 100, y: 400 },
    { x: 100, y: 300 },
  ]);
};

preview

由上图可以发现, 利用最后一个点和第一个点重合的方式闭合划线的方式, 会让连接处出现 "瑕疵"

那有没有办法可以避免这个 "瑕疵" 呢? 让最后一个点自动连接到起始点

闭合路径

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  function drawLines(startPos = { x: 0, y: 0 }, linesPos = [], lineColor = "#f00", lineWidth = 10) {
    ctx.beginPath();
    ctx.moveTo(startPos.x, startPos.y);

    for (let i = 0; i < linesPos.length; i++) {
      const linePos = linesPos[i];
      ctx.lineTo(linePos.x, linePos.y);
    }

    // 闭合路径:让最后一个点自动连接到起始点
    ctx.closePath();

    ctx.strokeStyle = lineColor;
    ctx.lineWidth = lineWidth;
    ctx.stroke();
  }

  // 三角形
  drawLines({ x: 100, y: 100 }, [
    { x: 100, y: 200 },
    { x: 200, y: 200 },
    // 默认情况下不会从最后一个点链接到第一个点,
    // 所以需要最后一个点和第一个点位置一样,这样才能闭合
    // { x: 100, y: 100 },
  ]);

  // 正方形
  drawLines({ x: 100, y: 300 }, [
    { x: 200, y: 300 },
    { x: 200, y: 400 },
    { x: 100, y: 400 },
    // { x: 100, y: 300 },
  ]);
};

绘制虚线

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  function drawLine(startPos = { x: 0, y: 0 }, dashes = [5]) {
    ctx.beginPath();
    ctx.moveTo(startPos.x, startPos.y);
    ctx.lineTo(startPos.x + 300, startPos.y);
    ctx.strokeStyle = "red";
    ctx.lineWidth = 10;
    ctx.setLineDash(dashes);
    ctx.stroke();
  }

  drawLine({ x: 100, y: 150 }, [5]);
  console.log("lineDash-1", ctx.getLineDash());
  // [5, 5]

  drawLine({ x: 100, y: 250 }, [5, 10]);
  console.log("lineDash-2", ctx.getLineDash());
  // [5, 10]

  drawLine({ x: 100, y: 300 }, [5, 10, 15]);
  console.log("lineDash-3", ctx.getLineDash());
  // [5, 10, 15, 5, 10, 15]

  drawLine({ x: 100, y: 350 }, [5, 10, 15, 20]);
  console.log("lineDash-4", ctx.getLineDash());
  // [5, 10, 15, 20]
};

dash

填充图形

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  function drawRect(positionArray = [], color = "#f00") {
    if (positionArray.length < 3) {
      return;
    }
    const [startPos, ...linesPos] = positionArray;
    ctx.beginPath();
    ctx.moveTo(startPos.x, startPos.y);
    for (let i = 0; i < linesPos.length; i++) {
      const item = linesPos[i];
      ctx.lineTo(item.x, item.y);
    }
    ctx.closePath();

    ctx.strokeStyle = color;
    ctx.lineWidth = 10;
    ctx.stroke();

    // 设置填充颜色并且填充
    ctx.fillStyle = color;
    ctx.fill();
  }

  drawRect([
    { x: 100, y: 100 },
    { x: 300, y: 100 },
    { x: 300, y: 300 },
    { x: 100, y: 300 },
  ]);
};

非零环绕原则

注意

只有在同一条路径路径中才会遵守这个绘制原则, 如果是不同的路径没有这个规则的影响

  1. 每次填充前从图形的中心拉出一条线, 看路径上与之相交的点是顺时针还是逆时针
  2. 默认 x = 0 如果是逆时针那么 x + 1, 如果是顺时针那么 x - 1
  3. 最后如果 x = 0, 就不会填充, 如果结果非零那么就会填充

注: 这个规则中的 x 是表示一个未知数, 不是坐标位置的意思

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  // 大的矩形是顺时针, 所以: x += 1
  ctx.moveTo(100, 100);
  ctx.lineTo(300, 100);
  ctx.lineTo(300, 300);
  ctx.lineTo(100, 300);
  ctx.closePath();

  // 小的矩形也是顺时针 x += 1
  ctx.moveTo(150, 150);
  ctx.lineTo(250, 150);
  ctx.lineTo(250, 250);
  ctx.lineTo(150, 250);
  ctx.closePath();

  // 注: 我只 stroke 绘制了一次, 且是同一个 path
  const fillColor = "#f00";
  ctx.strokeStyle = fillColor;
  ctx.lineWidth = 10;
  ctx.fillStyle = fillColor;
  ctx.stroke();
  ctx.fill();

  // 最后: 大矩形的 x = 1, 小矩形的 x = 2, 所以都会填充

  ///////////////////////////////////////////////////////
  // 这是第二个 canvas, 注意对比: 小矩形的绘制顺序不同 //
  ///////////////////////////////////////////////////////
  const canvasDom2 = initCanvasDom("app2");
  const ctx2 = canvasDom2.getContext("2d");

  // 大的矩形是顺时针, 所以: x += 1
  ctx2.moveTo(100, 100);
  ctx2.lineTo(300, 100);
  ctx2.lineTo(300, 300);
  ctx2.lineTo(100, 300);
  ctx2.closePath();

  // 小的矩形也是顺时针, 所以: x -= 1
  ctx2.moveTo(250, 150);
  ctx2.lineTo(150, 150);
  ctx2.lineTo(150, 250);
  ctx2.lineTo(250, 250);
  ctx2.closePath();

  // 注: 我只 stroke 绘制了一次, 且是同一个 path
  ctx2.strokeStyle = fillColor;
  ctx2.lineWidth = 10;
  ctx2.fillStyle = fillColor;
  ctx2.stroke();
  ctx2.fill();

  // 最后: 大矩形的 x = 1, 小矩形的 x = 0, 所以小矩形不会绘制
};
html
<!DOCTYPE html>
<html lang="zh-cn">
  <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" />
    <meta name="referrer" content="no-referrer" />
    <title>Canvas and SVG</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
        padding: 20px;
      }
      #app,
      #app2 {
        border: 1px solid #f00;
      }
    </style>
  </head>
  <body>
    <canvas id="app">Your browser does not support the canvas element.</canvas>
    <canvas id="app2">Your browser does not support the canvas element.</canvas>
    <script src="./index.js" type="module"></script>
  </body>
</html>

shunni

最后看效果图, 说明, 结论没错: preview

练习:绘制折线图表格

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  const gridSize = 50;
  const { width, height } = ctx.canvas;
  const rows = Math.floor(height / gridSize);
  const cols = Math.floor(width / gridSize);

  // 绘制网格
  for (let i = 0; i < rows; i++) line chart{
    const y = i * gridSize - 0.5;
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(width, y);
    ctx.stroke();
  }

  for (let i = 0; i < cols; i++) {
    const x = i * gridSize - 0.5;
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x, height);
    ctx.stroke();
  }

  // 计算坐标系起点 和 终点
  const originPos = {
    x: gridSize,line chart
    y: height - gridSize,
  };
  const targetPos = {
    x: width - gridSize,
    y: gridSize,
  };

  // 绘制 x 轴
  ctx.beginPath();
  ctx.moveTo(originPos.x, originPos.y);
  ctx.lineTo(targetPos.x, originPos.y);
  ctx.strokeStyle = "#f00";
  ctx.stroke();

  // 绘制x轴箭头line chart
  ctx.beginPath();
  ctx.lineTo(targetPos.x - 10, originPos.y - 5);
  ctx.lineTo(targetPos.x - 10, originPos.y + 5);
  ctx.lineTo(targetPos.x, originPos.y);
  ctx.closePath();
  ctx.fillStyle = "#f00";
  ctx.fill();
  ctx.stroke();

  // 绘制 y 轴
  ctx.beginPath();
  ctx.moveTo(originPos.x, originPos.y);
  ctx.lineTo(originPos.x, targetPos.y);
  ctx.strokeStyle = "#f00";
  ctx.stroke();

  // 绘制y轴箭头
  ctx.beginPath();
  ctx.lineTo(originPos.x - 5, targetPos.y + 10);
  ctx.lineTo(originPos.x + 5, targetPos.y + 10);
  ctx.lineTo(originPos.x, targetPos.y);
  ctx.closePath();
  ctx.fillStyle = "#f00";
  ctx.fill();
  ctx.stroke();

  // 模拟折线图点的坐标数据
  const tableData = [
    { x: 100, y: 300 },
    { x: 200, y: 200 },
    { x: 300, y: 250 },
    { x: 400, y: 150 },
    { x: 400, y: 150 },
  ];

  // 绘制折线图所有的点
  const dotSize = 10;
  const halfDotSize = dotSize / 2;
  for (let i = 0; i < tableData.length; i++) {
    const item = tableData[i];
    ctx.beginPath();
    ctx.moveTo(item.x - halfDotSize, item.y - halfDotSize);
    ctx.lineTo(item.x + dotSize - halfDotSize, item.y - halfDotSize);
    ctx.lineTo(item.x + dotSize - halfDotSize, item.y + dotSize - halfDotSize);
    ctx.lineTo(item.x - halfDotSize, item.y + dotSize - halfDotSize);
    ctx.closePath();
    ctx.fillStyle = "#f00";
    ctx.fill();
    ctx.strokeStyle = "#f00";
    ctx.stroke();
  }

  // 绘制折线图所有点之间的连线
  const [start, ...dots] = tableData;
  ctx.beginPath();
  ctx.moveTo(start.x, start.y);
  ctx.lineWidth = 2;
  for (let i = 0; i < dots.length; i++) {
    const dot = dots[i];
    ctx.lineTo(dot.x, dot.y);
  }
  ctx.strokeStyle = "#f00";
  ctx.stroke();
};
js
class LineChart {
  canvasContext = null;
  constructor(width = 300, height = 300) {
    const canvasDom = document.createElement("canvas");
    canvasDom.width = width;
    canvasDom.height = height;
    this.canvasContext = canvasDom.getContext("2d");
    document.body.appendChild(canvasDom);
  }

  drawGrid(gridSize = 50) {
    const ctx = this.canvasContext;
    const { width, height } = ctx.canvas;
    const rows = Math.floor(height / gridSize);
    const cols = Math.floor(width / gridSize);

    // 绘制行
    for (let i = 0; i < rows; i++) {
      const y = i * gridSize - 0.5;
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(width, y);
      ctx.stroke();
    }

    // 绘制列
    for (let i = 0; i < cols; i++) {
      const x = i * gridSize - 0.5;
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, height);
      ctx.stroke();
    }
  }

  // 绘制坐标系
  drawCoor(gridSize = 50, coorColor = "#f00") {
    const ctx = this.canvasContext;
    const { width, height } = ctx.canvas;

    // 计算坐标系起点 和 终点
    const originPos = {
      x: gridSize,
      y: height - gridSize,
    };
    const targetPos = {
      x: width - gridSize,
      y: gridSize,
    };
    this.originPos = originPos;
    this.targetPos = targetPos;

    // 绘制 x 轴
    ctx.beginPath();
    ctx.moveTo(originPos.x, originPos.y);
    ctx.lineTo(targetPos.x, originPos.y);
    ctx.strokeStyle = coorColor;
    ctx.stroke();

    // 绘制x轴箭头
    ctx.beginPath();
    ctx.lineTo(targetPos.x - 10, originPos.y - 5);
    ctx.lineTo(targetPos.x - 10, originPos.y + 5);
    ctx.lineTo(targetPos.x, originPos.y);
    ctx.closePath();
    ctx.fillStyle = coorColor;
    ctx.fill();
    ctx.stroke();

    // 绘制 y 轴
    ctx.beginPath();
    ctx.moveTo(originPos.x, originPos.y);
    ctx.lineTo(originPos.x, targetPos.y);
    ctx.strokeStyle = coorColor;
    ctx.stroke();

    // 绘制y轴箭头
    ctx.beginPath();
    ctx.lineTo(originPos.x - 5, targetPos.y + 10);
    ctx.lineTo(originPos.x + 5, targetPos.y + 10);
    ctx.lineTo(originPos.x, targetPos.y);
    ctx.closePath();
    ctx.fillStyle = coorColor;
    ctx.fill();
    ctx.stroke();
  }

  // 绘制折线图所有的点
  drawDots(tableData = [], dotSize = 10, dotColor = "#f00") {
    const ctx = this.canvasContext;
    const halfDotSize = dotSize / 2;
    for (let i = 0; i < tableData.length; i++) {
      const item = tableData[i];
      ctx.beginPath();
      ctx.moveTo(item.x - halfDotSize, item.y - halfDotSize);
      ctx.lineTo(item.x + dotSize - halfDotSize, item.y - halfDotSize);
      ctx.lineTo(item.x + dotSize - halfDotSize, item.y + dotSize - halfDotSize);
      ctx.lineTo(item.x - halfDotSize, item.y + dotSize - halfDotSize);
      ctx.closePath();
      ctx.fillStyle = dotColor;
      ctx.fill();
      ctx.strokeStyle = dotColor;
      ctx.stroke();
    }
  }

  // 绘制折线图所有点之间的连线
  drawLines(tableData = [], lineWidth = 2, lineColor = "#f00") {
    const ctx = this.canvasContext;
    const [start, ...dots] = tableData;
    ctx.beginPath();
    ctx.moveTo(start.x, start.y);
    ctx.lineWidth = lineWidth;
    for (let i = 0; i < dots.length; i++) {
      const dot = dots[i];
      ctx.lineTo(dot.x, dot.y);
    }
    ctx.strokeStyle = lineColor;
    ctx.stroke();
  }

  // 绘制柱状图矩形(后续会学到)
  drawBars(tableData = [], gridSize = 50, barColor = "#f00") {
    const ctx = this.canvasContext;
    for (const item of tableData) {
      ctx.fillRect(item.x, item.y, gridSize, this.originPos.y - item.y);
    }
  }
}

window.onload = function () {
  const lineChart = new LineChart(500, 500);
  lineChart.drawGrid();
  lineChart.drawCoor();

  const tableData = [
    { x: 100, y: 300 },
    { x: 200, y: 200 },
    { x: 300, y: 250 },
    { x: 400, y: 150 },
    { x: 400, y: 150 },
  ];
  lineChart.drawDots(tableData);
  lineChart.drawLines(tableData);
};

line-chart

绘制/清除矩形

  • rect 绘制矩形但并不会立即 stroke 或 fill,需要手动 stroke/fill 才能绘制, 并且调用多次也是一个 Path, 需要手动 beginPath
  • strokeRect 绘制矩形并且立即 stroke
  • fillRect 绘制矩形并且立即 fill
  • clearRect 清除矩形, 清除矩形内的填充颜色和线条等

者 3 个函数的参数都是一样的: rect(x, y, width, height)

  • x: 起始点的 x 坐标
  • y: 起始点的 y 坐标
  • width: 矩形的宽度
  • height: 矩形的高度
js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  ctx.strokeStyle = "#f00";
  ctx.rect(10, 10, 100, 100); // 这个就等同于下面这 5 行代码
  // ctx.moveTo(10, 10);
  // ctx.lineTo(100, 10);
  // ctx.lineTo(100, 100);
  // ctx.lineTo(10, 100);
  // ctx.closePath();
  ctx.stroke(); // 需要手动 stroke/fill 才会绘制出来

  ctx.strokeStyle = "#0f0";
  ctx.strokeRect(120, 120, 100, 100); // 这个代码就等同于下面这 7 行代码
  // ctx.beginPath();
  // ctx.moveTo(120, 120);
  // ctx.lineTo(120 + 100, 120);
  // ctx.lineTo(120 + 100, 120 + 100);
  // ctx.lineTo(120, 120 + 100);
  // ctx.closePath();
  // ctx.stroke();

  ctx.fillStyle = "#00f";
  ctx.fillRect(230, 230, 100, 100); // 这个代码就等同于下面这 7 行代码
  // ctx.beginPath();
  // ctx.moveTo(230, 230);
  // ctx.lineTo(230 + 100, 230);
  // ctx.lineTo(230 + 100, 230 + 100);
  // ctx.lineTo(230, 230 + 100);
  // ctx.closePath();
  // ctx.fill();

  // 清空所有内容: 调用后上面绘制的内容全部被清除
  const { width, height } = ctx.canvas;
  ctx.clearRect(0, 0, width, height);
};

渐变色

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  // 创建一个线性渐变 createLinearGradient(x1, y1, x2, y2)
  // x1, y1 和 x2, y2 的坐标可以构成一条线, 这线就是渐变的方向
  // 渐变起点: x1=10  y1=10
  // 渐变终点: x2=110 y2=10
  // 为什么是 10, 20, 110, 20 因为渐变方案从 0 开始计算
  // 因为矩形起始点离左边有 10 像素所以需要 矩形宽度 + 10
  // const gradient = ctx.createLinearGradient(10, 20, 110, 20); // 从左到右渐变
  const gradient = ctx.createLinearGradient(10, 20, 110, 120); // 从左上到右下渐变

  // 添加色标: addColorStop(progress, color)
  // progress 是一个值在 0-1 表示渐变进度
  // color    颜色
  gradient.addColorStop(0, "#f00");
  gradient.addColorStop(0.5, "#0f0");
  gradient.addColorStop(1, "#00f");

  // 填充矩形
  ctx.fillStyle = gradient;
  ctx.fillRect(10, 20, 100, 100);
};

绘制圆弧

基础知识了解

  • 角度: 一个圆 360 度(角度), 一个半圆是 180 度
  • 弧度: 一个圆 π, 一个半圆是 π/2
角度转弧度公式:
  因为: 180角度 = π 弧度
  所以: 1角度 = π/180
  所以: 弧度 = 角度 * π/180
        90角度 * π/180 = π/2

弧度转角度
  因为: π 弧度 = 180角度
  所以: 1 弧度 = 180/π
  所以: 角度 = 弧度 * 180/π
       π/2 * 180/π = 90 角度

绘制圆形

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  // ctx.arc(x, y, round, startAngle, endAngle, isCounterclockwise);
  // x,y:        圆心坐标
  // round:      半径
  // startAngle: 起始角度
  // endAngle:   结束角度
  // isCounterclockwise: 是否是逆时针方向绘制, 默认false(顺时针方向)

  ctx.arc(100, 100, 50, 0, Math.PI, false);
  ctx.strokeStyle = "#f00";
  ctx.stroke();

  // 两个半圆, 一个逆时针, 一个顺时针, 组合成一个圆
  ctx.beginPath();
  ctx.arc(100, 100, 50, 0, Math.PI, true);
  ctx.strokeStyle = "#0f0";
  ctx.stroke();

  // 直接绘制一个圆形
  ctx.beginPath();
  ctx.arc(200, 200, 50, 0, Math.PI * 2);
  ctx.strokeStyle = "#00f";
  ctx.stroke();
};

绘制扇形

所谓扇形, 起始就是四分之一的圆形

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  ctx.moveTo(200, 200); // 设置起点为圆心的坐标点
  ctx.arc(200, 200, 50, 0, Math.PI / 2);
  ctx.strokeStyle = "#00f";
  ctx.closePath(); // 直接闭合路径
  ctx.stroke();

  // 第二个逆时针扇形:从360°(2π)到270°(3π/2), 逆时针绘制90°扇形
  ctx.beginPath();
  ctx.moveTo(200, 200);
  ctx.arc(200, 200, 50, Math.PI * 2, (Math.PI * 3) / 2, true);
  ctx.closePath(); // 回到圆心,形成扇形区域
  ctx.strokeStyle = "#0f0";
  ctx.stroke();
};

绘制饼状图

所谓的饼状图起始就是多个扇形组合而成, 仅此而已

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");
  const ctx = canvasDom.getContext("2d");

  // 设定圆心的位置为画布的正中间
  const rx = Math.floor(ctx.canvas.width / 2);
  const ry = Math.floor(ctx.canvas.height / 2);

  /*
  // 4 等分圆形
  // 0-90
  ctx.moveTo(rx, ry);
  ctx.arc(rx, ry, 100, 0, Math.PI / 2);
  ctx.fillStyle = "#f00";
  ctx.fill();

  // 90-180
  ctx.beginPath()
  ctx.moveTo(rx, ry);
  ctx.arc(rx, ry, 100, Math.PI / 2, Math.PI);
  ctx.fillStyle = "#0f0";
  ctx.fill();

  // 180-270
  ctx.beginPath()
  ctx.moveTo(rx, ry);
  ctx.arc(rx, ry, 100, Math.PI, Math.PI + Math.PI / 2);
  ctx.fillStyle = "#00f";
  ctx.fill();

  // 270-360
  ctx.beginPath()
  ctx.moveTo(rx, ry);
  ctx.arc(rx, ry, 100, Math.PI + Math.PI / 2, Math.PI * 2);
  ctx.fillStyle = "#ff0";
  ctx.fill();
  */

  function randomColor() {
    return `#${Math.floor(Math.random() * 0xffffff).toString(16)}`;
  }

  let startAngle = 0;
  for (let i = 1; i <= 4; i++) {
    const endAngle = (i * Math.PI) / 2;
    ctx.beginPath();
    ctx.moveTo(rx, ry);
    ctx.arc(rx, ry, 100, startAngle, endAngle);
    ctx.fillStyle = randomColor();
    ctx.fill();
    ctx.closePath();
    startAngle = endAngle;
  }
};

cookie

绘制文字

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 500;
    canvasDom.height = 500;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");

  /** @type {CanvasRenderingContext2D} */
  const ctx = canvasDom.getContext("2d");

  const { width, height } = ctx.canvas;
  const halfWidth = Math.floor(width / 2);
  const halfHeight = Math.floor(height / 2);

  // 参考坐标线
  ctx.moveTo(halfWidth, halfHeight);
  ctx.lineTo(0, halfHeight);
  ctx.lineTo(width, halfHeight);
  ctx.moveTo(halfWidth, halfHeight);
  ctx.lineTo(halfWidth, 0);
  ctx.lineTo(halfWidth, height);
  ctx.stroke();

  // 绘制矩形: 以矩形的左上角作为参考坐标
  // 绘制文字: 以文字的左下角作为参考坐标
  ctx.fillStyle = "#f00";
  ctx.fillRect(halfWidth, halfHeight, 100, 100);

  // 设置文字的大小和样式
  ctx.font = "30px 微软雅黑";

  // 设置文字垂直方向的对齐方式, 以绘制文字的 Y 坐标作为参考点
  ctx.textBaseline = "bottom"; // top/bottom/middle/alphabetic/ideographic

  // 设置文字水平方向的对齐方式, 以绘制文字的 X 坐标作为参考点
  ctx.textAlign = "left"; // start/center /end/left/right

  // 文字描边
  // ctx.strokeText("hello world", halfWidth, halfHeight);

  // 填充文字(实心的)
  ctx.fillText("hello world", halfWidth, halfHeight);
};

text

绘制图片

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 600;
    canvasDom.height = 600;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");

  /** @type {CanvasRenderingContext2D} */
  const ctx = canvasDom.getContext("2d");

  const img = new Image();
  img.onload = function () {
    // 如果只有 3 个参数: drawImage(img, x, y)
    // img: 是需要绘制的 图片
    // x,y: 后面两个参数就是设置图片开始绘制的位置(图片左上角)
    // ctx.drawImage(img, 10, 10);

    // 如果只有 5 个参数: drawImage(img, x, y, w, h)
    // img: 是需要绘制的 图片
    // x,y: 设置图片开始绘制的位置(图片左上角)
    // w,h: 设置绘制的图片的宽度/高度(会拉伸)
    // ctx.drawImage(img, 10, 10, 300, 100);

    // 如果有 9 个参数: drawImage(img, imgX1, imgY1, imgX2, imgY2, x, y, w, h)
    // img: 是需要绘制的图片(被截取后的图片)
    // imgX1,imgY1: 以原图片的左上角作为参考, 设置截取的开始位置
    // imgX2,imgY2: 以原图片的左上角作为参考, 设置截取的结束位置
    // x,y: 设置图片开始绘制的位置(图片左上角)
    // w,h: 设置绘制的图片的宽度/高度(会拉伸)
    ctx.drawImage(img, 10, 10, 150, 150, 100, 100, 120, 120);
  };
  img.src = "https://raw.githubusercontent.com/liaohui5/images/refs/heads/main/images/20220420132604.png";
};

draw-image

形变

canvas 中与 css 的形变不同, canvas 中所有的形变操作, 都是针对坐标系, 而不是绘制的图形

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 600;
    canvasDom.height = 600;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");

  /** @type {CanvasRenderingContext2D} */
  const ctx = canvasDom.getContext("2d");

  // 整个坐标系向 x,y 移动 100, 而不是绘制的矩形移动
  ctx.translate(100, 100);

  ctx.fillStyle = "#f00";
  ctx.fillRect(50, 50, 100, 100);
  ctx.fillRect(0, 0, 10, 10);

  // 如果注释上面的 ctx.translate, 把形变的操作放到
  // 绘制图形的后面, 就会发现矩形并不会移动
  // ctx.translate(100, 100);
};

translate

合成模式

在 canvas 中, 不像 DOM 那样有 z-index 层的概念, 当在 canvas 上绘制图形、图像或文本时,这些新内容不会总是简单地 覆盖 已有内容

globalCompositeOperation 决定了新绘制的像素(称为源 source) 和 canvas 上已经存在的像素(称为 目标 destination) 之间如何组合 最终决定显示什么颜色

js
window.onload = function () {
  function initCanvasDom() {
    const canvasDom = document.getElementById("canvas");
    canvasDom.width = window.innerWidth;
    canvasDom.height = window.innerHeight;
    return canvasDom;
  }

  const canvasDom = initCanvasDom();

  /** @type {CanvasRenderingContext2D} */
  const ctx = canvasDom.getContext("2d");
  // 注: 所谓隐藏其实就是像素是透明的
  // 这个属性取值非常多, 但是日常开发中, 有这10个基本可以搞定了
  // 如果搞不定, 具体可以查看 mdn 文档, 有更详细的解读例子

  // 两个矩形重叠的区域显示绿色(说明重叠区显示后绘制的颜色)
  // ctx.globalCompositeOperation = "source-over"; // 默认值

  // 两个矩形重叠的区域显示红色矩形(说明重叠区域显示先绘制的颜色)
  // ctx.globalCompositeOperation = "destination-over";

  // 红色矩形:全部隐藏 绿色矩形:两个矩形重叠的区域隐藏,不重叠的区域显示
  // ctx.globalCompositeOperation = "source-out";

  // 将两个矩形重叠区域的颜色进行混合(红+绿=黄)
  // ctx.globalCompositeOperation = "lighter";

  // 红色矩形:全部隐藏 绿色矩形:全部显示(也就是说仅显示后绘制的矩形)
  // ctx.globalCompositeOperation = "copy";

  // 将两个矩形重叠的区域隐藏
  // ctx.globalCompositeOperation = "xor";

  // 将两个矩形重叠的区域颜色变黑
  // ctx.globalCompositeOperation = "multiply";

  // 将两个矩形重叠的区域颜色变亮(效果类似 lighter)
  // ctx.globalCompositeOperation = "screen";

  // 保留了底层的亮度, 同时采用了顶层的色调和色度: 混合两个矩形重叠区域的颜色
  // ctx.globalCompositeOperation = "color";

  // 保持底层的色调和色度, 同时采用顶层的亮度
  // ctx.globalCompositeOperation = "luminosity";

  // 红色矩形 destination
  ctx.fillStyle = "#f00";
  ctx.fillRect(100, 100, 100, 100);

  // 绿色矩形 source
  ctx.fillStyle = "#0f0";
  ctx.fillRect(150, 150, 100, 100);

  // destination
  ctx.fillStyle = "#383838";
  ctx.fillRect(400, 100, 100, 100);

  // source
  ctx.fillStyle = "#ff0000";
  ctx.fillRect(450, 150, 100, 100);
};

图形交互

默认情况下, JS 无法和 canvas 内绘制的图形进行交互, 但是可以用事件触发的位置和 canvas 绘制的图形的位置去判断, 是否点击了绘制的图形

js
window.onload = function () {
  function initCanvasDom(id) {
    const canvasDom = document.getElementById(id);
    canvasDom.width = 600;
    canvasDom.height = 600;
    return canvasDom;
  }

  const canvasDom = initCanvasDom("app");

  /** @type {CanvasRenderingContext2D} */
  const ctx = canvasDom.getContext("2d");

  // rect1
  const rect = {
    x: 50,
    y: 50,
    width: 100,
    height: 100,
  };
  ctx.fillStyle = "#f00";
  ctx.rect(rect.x, rect.y, rect.width, rect.height);
  ctx.fill();

  // rect2
  // ctx.beginPath();
  ctx.rect(200, 200, 100, 100);
  ctx.fill();

  ctx.canvas.addEventListener("click", (e) => {
    // 手动判断位置
    const { offsetX, offsetY } = e;
    // const maxRectX = rect.x + rect.width;
    // const maxRectY = rect.y + rect.height;
    // if (offsetX >= rect.x && offsetX <= maxRectX && offsetY >= rect.y && offsetY <= maxRectY) {
    //   console.log("inside the rect");
    // } else {
    //   console.log("outside");
    // }

    // 使用 isPointInPath 方法判断:
    // 注意: 默认情况下, rect1 rect2 都会返回 true
    // 但是如果绘制 rect2 时, 开启了新路径 ctx.beginPath()
    // 那么就只有点击 rect2 会返回 true, rect1 会返回 false
    if (ctx.isPointInPath(offsetX, offsetY)) {
      console.log("inside the rect");
    } else {
      console.log("outside");
    }
  });
};

使用 HTMLCanvasElement 实例方法压缩图片体积

这个类除了 getContext 方法外, 还有一个 toBlob 方法比较常用:

  • toBlob 将 HTMLCanvasElement 对象转为一个 Blob 对象
js
window.onload = function () {
  const inputDom = document.getElementById("fileInput");
  inputDom.addEventListener("change", async (e) => {
    const [file] = e.target.files;
    const blob = await compressImageAsync(file);

    // upload blob ...
    console.log("blob", blob);
  });

  // 压缩图片
  function compressImageAsync(file, quality = 0.5, imageType = "image/png") {
    return new Promise((resolve, reject) => {
      const url = URL.createObjectURL(file);
      const image = new Image();
      image.src = url;
      image.onerror = () => reject("compressImageAsync: failed to load image");
      image.onload = function () {
        const canvasDom = document.createElement("canvas");

        // 这里只是压缩但是并不缩放, 如果需要也可以等比例缩放 canvas 宽高
        canvasDom.width = image.width;
        canvasDom.height = image.height;

        /** @type {CanvasRenderingContext2D} */
        const ctx = canvasDom.getContext("2d");
        ctx.drawImage(image, 0, 0); // 将图片画到 canvas 上

        // 第一个参数是压缩完后的回调函数, 压缩后的 blob 是参数
        // 第二个参数是压缩后图片的文件类型 jpg/png
        // 第三个参数是压缩的质量取值是 0~1 数值越低, 图片越模糊
        canvasDom.toBlob(resolve, imageType, quality);
      };
    });
  }
};
html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Canvas Demo</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <input type="file" id="fileInput" />
    <script type="module" src="./index.js"></script>
  </body>
</html>

Canvas 练习

  • 画板工具
  • 实现截图
  • 刮刮乐效果
  • 弹幕效果
  • 五子棋
  • 贪食蛇

画板工具

preview

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>canvas draw board</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
      }
      .tool-bar {
        width: 100%;
        padding: 10px 20px;
        position: fixed;
        top: 0;
        left: 0;
        z-index: 10;
        background: #f8f8f8;
        border-bottom: 1px solid #eee;
        display: flex;
      }
      .tool-bar .item {
        display: inline-flex;
        align-items: center;
        margin-right: 20px;
      }
      .tool-bar .item .label {
        margin-right: 10px;
      }
      .tool-bar .item .status {
        justify-self: flex-end;
      }
    </style>
  </head>
  <body>
    <div class="tool-bar">
      <div class="item">
        <span class="label">选择颜色</span>
        <input type="color" id="colorPicker" />
      </div>
      <div class="item">
        <span class="label">笔画粗细</span>
        <input type="range" id="lineWidthPicker" min="1" max="20" />
      </div>
      <div class="item">
        <button id="clearButton" class="label">清除画布</button>
      </div>
      <div class="item">
        <button id="eraserButton" class="label">橡皮擦</button>
      </div>
      <div class="item">
        <button id="penButton" class="label">画笔</button>
      </div>
      <div class="item">
        <button id="undoButton" class="label">撤销</button>
      </div>
      <div class="item">
        <button id="redoButton" class="label">恢复</button>
      </div>
      <div class="item status">
        <span class="label" id="statusBar"></span>
      </div>
    </div>
    <script type="module" src="./index.js"></script>
  </body>
</html>
js
import Stack from "./stack.js";

/** @type {CanvasRenderingContext2D} */
let canvas2dContext = null;

const PEN_TYPES = {
  PEN: {
    id: "PEN",
    label: "画笔",
  },
  ERASER: {
    id: "ERASER",
    label: "橡皮擦",
  },
};

const state = {
  // 鼠标按下时的位置
  mouseDownPos: {
    x: 0,
    y: 0,
  },
  lineCap: "round", // 画笔线条端点样式
  lineWidth: 10, // 当前画笔宽度
  pen: PEN_TYPES.PEN, // "画笔" or "橡皮擦"
  penColor: "#000000", // 当前画笔颜色
  cachePenColor: null, // 缓存的画笔颜色(切换到橡皮擦时使用)
  backgroundColor: "#FFFFFF", // 画布背景色(橡皮擦颜色)
  drawStack: new Stack(), // 绘制操作栈
  undoStack: new Stack(), // 撤销操作栈
  getOptions() {
    // 获取当前画笔配置
    return {
      penColor: this.penColor,
      lineCap: this.lineCap,
      lineWidth: this.lineWidth,
    };
  },
  setPenColor(color) {
    this.penColor = color;
    syncCanvasOptions(canvas2dContext, this.getOptions());
  },
  clearCachePenColor() {
    this.cachePenColor = null;
  },
  setCachePenColor(color) {
    this.cachePenColor = color;
  },
  setLineWidth(width) {
    this.lineWidth = width;
    syncCanvasOptions(canvas2dContext, this.getOptions());
  },
  setMouseDownPos(pos) {
    this.mouseDownPos = pos;
  },
  getPenLabel() {
    return `当前工具:${this.pen.label}`;
  },
  setPen() {
    this.pen = PEN_TYPES.PEN;
    this.setPenColor(this.cachePenColor); // restore pen color
    this.clearCachePenColor(); // clear cache
    initStatusBar();
  },
  setEraser() {
    this.pen = PEN_TYPES.ERASER;
    this.setCachePenColor(this.penColor); // cache current pen color
    this.setPenColor(this.backgroundColor); // set pen color to background color
    initStatusBar();
  },
  clearStacks() {
    this.drawStack.clear();
    this.undoStack.clear();
  },
  createDrawRecord() {
    this.drawStack.push({
      id: Math.random().toString(36).substring(2, 10),
      moveTo: [],
      lineTo: [],
      options: this.getOptions(),
    });
  },
  setLastRecordMoveTo(pos) {
    const lastRecord = this.drawStack.peek();
    lastRecord.moveTo = pos;
  },
  setLastRecordLineTo(pos) {
    const lastRecord = this.drawStack.peek();
    lastRecord.lineTo.push(pos);
  },
};

window.onload = init;

function init() {
  initCanvas();
  syncCanvasOptions(canvas2dContext, state.getOptions()); // initial sync
  initColorPicker();
  initLineWidthPicker();
  initClearButton();
  initEraserButton();
  initPenButton();
  initUndoButton();
  initRedoButton();
  initStatusBar();
}

function initCanvas() {
  const canvasDom = document.createElement("canvas");
  const ctx = canvasDom.getContext("2d");
  canvas2dContext = ctx; // global variable assignment
  canvasDom.width = window.innerWidth;
  canvasDom.height = window.innerHeight;
  canvasDom.addEventListener("mousedown", handleMouseDown, false);
  document.body.appendChild(canvasDom);
}

function initColorPicker() {
  const colorPickerDom = document.getElementById("colorPicker");
  if (!colorPickerDom) return;
  colorPickerDom.addEventListener("input", (e) => {
    state.setPenColor(e.target.value);
  });
  colorPickerDom.value = state.penColor; // initialize color
}

function initLineWidthPicker() {
  const lineWidthPickerDom = document.getElementById("lineWidthPicker");
  if (!lineWidthPickerDom) return;
  lineWidthPickerDom.addEventListener("change", (e) => {
    state.setLineWidth(parseInt(e.target.value));
  });
  lineWidthPickerDom.value = state.lineWidth; // initialize picker value
}

function initClearButton() {
  const clearButtonDom = document.getElementById("clearButton");
  if (!clearButtonDom) return;
  clearButtonDom.addEventListener("click", clearAll, false);
}

function initEraserButton() {
  const eraserButtonDom = document.getElementById("eraserButton");
  if (!eraserButtonDom) return;
  eraserButtonDom.addEventListener("click", () => state.setEraser(), false);
}
function initPenButton() {
  const penButtonDom = document.getElementById("penButton");
  if (!penButtonDom) return;
  penButtonDom.addEventListener("click", () => state.setPen(), false);
}

function initStatusBar() {
  const statusBarDom = document.getElementById("statusBar");
  if (!statusBarDom) return;
  statusBarDom.innerText = state.getPenLabel();
}

function initUndoButton() {
  const undoButtonDom = document.getElementById("undoButton");
  if (!undoButtonDom) return;
  undoButtonDom.addEventListener("click", undo, false);
}

function initRedoButton() {
  const redoButtonDom = document.getElementById("redoButton");
  if (!redoButtonDom) return;
  redoButtonDom.addEventListener("click", redo, false);
}

function syncCanvasOptions(ctx, options) {
  const { penColor, lineCap, lineWidth } = options;
  ctx.strokeStyle = penColor;
  ctx.fillStyle = penColor;
  ctx.lineCap = lineCap;
  ctx.lineWidth = lineWidth;
}

function handleMouseDown(e) {
  const { clientX, clientY } = e;

  state.setMouseDownPos({
    x: clientX,
    y: clientY,
  });
  drawPoint(clientX, clientY);

  const canvasDom = canvas2dContext.canvas;
  canvasDom.addEventListener("mousemove", handleMouseMove, false);
  canvasDom.addEventListener("mouseup", handleMouseUp, false);
}

function handleMouseMove(e) {
  const { clientX: x2, clientY: y2 } = e;
  const { x: x1, y: y1 } = state.mouseDownPos;
  drawLine({ x1, y1, x2, y2 });
}

function handleMouseUp(_e) {
  const canvasDom = canvas2dContext.canvas;
  canvasDom.removeEventListener("mousemove", handleMouseMove, false);
  canvasDom.removeEventListener("mouseup", handleMouseUp, false);
}

function drawPoint(x, y) {
  state.createDrawRecord();
  state.setLastRecordMoveTo([x, y]);
  $drawPoint(x, y);
}

function drawLine({ x1, y1, x2, y2 }) {
  state.setLastRecordLineTo([x1, y1, x2, y2]);
  canvas2dContext.beginPath();
  canvas2dContext.moveTo(x1, y1);
  canvas2dContext.lineTo(x2, y2);
  canvas2dContext.stroke();
  state.setMouseDownPos({ x: x2, y: y2 });
}

function $drawPoint(x, y) {
  canvas2dContext.beginPath();
  canvas2dContext.arc(x, y, canvas2dContext.lineWidth / 2, 0, Math.PI * 2);
  canvas2dContext.fill();
}

function clearAll() {
  const canvasDom = canvas2dContext.canvas;
  canvas2dContext.clearRect(0, 0, canvasDom.width, canvasDom.height);
}

function drawLineBatch(lines) {
  canvas2dContext.beginPath();
  for (const line of lines) {
    const [x1, y1, x2, y2] = line;
    canvas2dContext.moveTo(x1, y1);
    canvas2dContext.lineTo(x2, y2);
  }
  canvas2dContext.stroke();
}

function redraw() {
  clearAll();
  for (const record of state.drawStack.items) {
    const { moveTo, lineTo, options } = record;
    // sync options for each record
    syncCanvasOptions(canvas2dContext, options);
    $drawPoint(moveTo[0], moveTo[1]);
    drawLineBatch(lineTo);
  }

  // restore current options
  syncCanvasOptions(canvas2dContext, state.getOptions());
}

function undo() {
  if (state.drawStack.isEmpty()) {
    console.log("drawStack is empty, cannot undo");
    return;
  }
  const record = state.drawStack.pop();
  state.undoStack.push(record);

  console.log("poped record:", record);
  console.log("drawStack:", state.drawStack.items);
  console.log("undoStack:", state.undoStack.items);
  redraw();
}

function redo() {
  if (state.undoStack.isEmpty()) {
    console.log("undoStack is empty, cannot redo");
    return;
  }
  const record = state.undoStack.pop();
  state.drawStack.push(record);

  console.log("poped record:", record);
  console.log("drawStack:", state.drawStack.items);
  console.log("undoStack:", state.undoStack.items);
  redraw();
}
js
/**
 * 栈是一种受限的线性数据结构,相较于数组来说只能后进先出
 */
export default class Stack {
  items = [];

  /**
   * 获取栈的总长度
   * @returns number
   */
  size() {
    return this.items.length;
  }

  /**
   * 栈是否为空
   * @returns boolean
   */
  isEmpty() {
    return this.size() === 0;
  }

  /**
   * 出栈
   * @returns T | undefined
   */
  pop() {
    return this.items.pop();
  }

  /**
   * 入栈
   * @param item
   */
  push(item) {
    this.items.push(item);
  }

  /**
   * 查看栈顶元素(但是不会执行出栈操作)
   * @returns T | undefined
   */
  peek() {
    return this.items[this.items.length - 1];
  }

  /**
   * 清栈
   */
  clear() {
    this.items = [];
  }

  /**
   * 字符串序列化
   * @returns string
   */
  toString() {
    return "[" + this.items.toString() + "]";
  }
}

实现截图功能

screen-shot

js
/** @type {CanvasRenderingContext2D} */
let canvas2dContext;

const state = {
  // 通过文件选择框选中的文件对应的图片元素
  imageElement: null,
  width: 0,
  height: 0,
  setImageElement(dom) {
    this.width = dom.width;
    this.height = dom.height;
    this.imageElement = dom;
  },
  getImageElement() {
    return this.imageElement;
  },

  // 获取图片尺寸
  getImageSize() {
    const { width, height } = this;
    return { width, height };
  },

  // 鼠标按下时的坐标
  canvasMouseDownPos: {
    x: 0,
    y: 0,
  },
  setCanvasMouseDownPos(x, y) {
    this.canvasMouseDownPos = {
      x,
      y,
    };
  },

  // 截屏选取区域的坐标
  screenShotPos: [0, 0, 0, 0],
  setScreenShotPos(data) {
    this.screenShotPos = data;
  },
  getScreenShotPos() {
    return this.screenShotPos;
  },
};

// init
window.onload = function init() {
  initFileInput();
};

function initFileInput() {
  const fileInputDom = document.getElementById("fileInput");
  fileInputDom.addEventListener("change", (e) => {
    loadImage(e.target.files[0]);
  });
}

function loadImage(file) {
  const url = URL.createObjectURL(file);
  const image = new Image();
  image.onload = function () {
    // 图片加载完之后, 将图片元素的信息保存到 state
    // 然后初始化 canvas, 将图片/遮罩画出来
    // 最后 revokeObjectURL
    state.setImageElement(image);
    initCanvas();
    drawImage();
    drawImageMask();
    URL.revokeObjectURL(url);
  };
  image.src = url;
}

function initCanvas() {
  const canvasDom = document.createElement("canvas");
  const ctx = canvasDom.getContext("2d");
  canvas2dContext = ctx;
  const { width, height } = state.getImageSize();
  canvasDom.width = width;
  canvasDom.height = height;
  canvas2dContext.canvas.addEventListener("mousedown", handleCanvasMouseDown, false);
  document.body.appendChild(canvasDom);
}

function drawImage() {
  const image = state.getImageElement();
  canvas2dContext.drawImage(image, 0, 0);
}

function drawImageMask(opacity = 0.5) {
  const { width, height } = canvas2dContext.canvas;
  canvas2dContext.fillStyle = `rgba(0,0,0,${opacity})`;
  canvas2dContext.fillRect(0, 0, width, height);
}

function handleCanvasMouseDown(e) {
  state.setCanvasMouseDownPos(e.offsetX, e.offsetY);
  canvas2dContext.canvas.addEventListener("mousemove", handleCanvasMouseMove, false);
}

function handleCanvasMouseMove(e) {
  canvas2dContext.canvas.addEventListener("mouseup", handleCanvasMouseUp, false);
  const { offsetX, offsetY } = e;
  const { x, y } = state.canvasMouseDownPos;
  const rectWidth = offsetX - x;
  const rectHeight = offsetY - y;
  const { width, height } = state.getImageSize();

  // 保存截取区域的4个坐标 x1,y1 x2,y2
  state.setScreenShotPos([x, y, rectWidth, rectHeight]);

  // 清除画布
  canvas2dContext.clearRect(0, 0, width, height);

  // 重新绘制 mask 和 截取选中部分
  drawImageMask();
  drawScreenShot(rectWidth, rectHeight);
}

function handleCanvasMouseUp() {
  drawScreenShotImage();
  canvas2dContext.canvas.removeEventListener("mousedown", handleCanvasMouseDown, false);
  canvas2dContext.canvas.removeEventListener("mousemove", handleCanvasMouseMove, false);
}

function drawScreenShot(rectWidth, rectHeight) {
  // globalCompositeOperation docs:
  // https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
  // 仅保留现有画布内容和新形状不重叠的部分
  canvas2dContext.globalCompositeOperation = "destination-out";
  canvas2dContext.fillStyle = "#000";

  const { x, y } = state.canvasMouseDownPos;
  canvas2dContext.fillRect(x, y, rectWidth, rectHeight);

  // 在现有画布内容的后面绘制新的图形
  canvas2dContext.globalCompositeOperation = "destination-over";
  drawImage();
}

function drawScreenShotImage() {
  const screenShotPos = state.getScreenShotPos();
  const imageData = canvas2dContext.getImageData(...screenShotPos);
  const [_x1, _y1, w, h] = screenShotPos;
  createPreview(w, h, imageData);
}

function createPreview(width, height, imageData) {
  const canvasDom = document.createElement("canvas");
  canvasDom.width = width;
  canvasDom.height = height;
  const ctx = canvasDom.getContext("2d");
  ctx.putImageData(imageData, 0, 0);
  document.body.appendChild(canvasDom);
}
html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Canvas Demo</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
      }
    </style>
  </head>
  <body>
    <div>
      <input type="file" id="fileInput" accept="image/*" />
    </div>
    <script type="module" src="./index.js"></script>
  </body>
</html>

刮刮乐刮奖效果

guaguale

其实就是默认给元素加一个默认的遮罩,然后将设置 globalCompositeOperation=destination-out 那么用鼠标划过的位置就会透明, 那么底下的元素就会展示出来, 那么就得到了类似刮奖的效果

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Canvas Demo</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
      }
      .container {
        width: 400px;
        height: 200px;
        position: relative;
      }
      .container #mask,
      .container #anwser {
        position: absolute;
        top: 0;
        left: 0;
      }
      #anwser {
        z-index: 1;
      }
      #mask {
        z-index: 2;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <canvas id="mask"></canvas>
      <canvas id="anwser"></canvas>
    </div>
    <script type="module" src="./index.js"></script>
  </body>
</html>
js
// global states
const state = {
  width: 400,
  height: 200,
  isOpenable: false,
  setIsOpening(value) {
    this.isOpenable = value;
  },
};

function initAnwser() {
  const { width, height } = state;
  const anwserDom = document.getElementById("anwser");
  anwserDom.width = width;
  anwserDom.height = height;

  /** @type {CanvasRenderingContext2D} */
  const ctx = anwserDom.getContext("2d");
  const text = Math.random() > 0.5 ? "再来一瓶" : "谢谢惠顾";
  ctx.fillStyle = "#f00";
  ctx.font = "32px '微软雅黑'";
  ctx.textAlign = "center";
  ctx.fillText(text, width / 2, height / 2);
}

function initMask() {
  const { width, height } = state;
  const maskDom = document.getElementById("mask");
  maskDom.width = width;
  maskDom.height = height;

  /** @type {CanvasRenderingContext2D} */
  const ctx = maskDom.getContext("2d");

  // draw mask
  ctx.fillStyle = "#ccc";
  ctx.fillRect(0, 0, 400, 200);

  // bind events
  maskDom.addEventListener("mousedown", () => {
    state.setIsOpening(true);
  });
  maskDom.addEventListener("mousemove", (e) => {
    if (!state.isOpenable) {
      return;
    }
    // 设置这个属性后, 后续画的路径会让canvas透明
    ctx.globalCompositeOperation = "destination-out";

    const { offsetX, offsetY } = e;
    ctx.arc(offsetX, offsetY, 20, 0, Math.PI * 2);
    ctx.fill();
  });
  maskDom.addEventListener("mouseup", () => {
    state.setIsOpening(false);
  });
}

window.onload = function () {
  initAnwser();
  initMask();
};

弹幕效果

danmu

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Canvas Demo</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="container">
      <ul class="tool-bar">
        <li class="tool-bar-item">
          <span>弹幕颜色</span>
          <input type="color" id="danmu-color" value="#f8f8f8" />
        </li>
        <li class="tool-bar-item">
          <span>字体大小</span>
          <input type="range" id="danmu-size" min="15" max="25" step="1" value="20" />
        </li>
        <li class="tool-bar-item">
          <span>弹幕速度</span>
          <select id="danmu-speed">
            <option value="1">慢</option>
            <option selected="selected" value="2">默认</option>
            <option value="4">快</option>
          </select>
        </li>
        <li class="tool-bar-item">
          <input type="text" id="danmu-text" />
          <button id="send-danmu">发送</button>
        </li>
      </ul>

      <div class="video-container">
        <video
          id="video"
          src="https://lf3-static.bytednsdoc.com/obj/eden-cn/nupenuvpxnuvo/xgplayer_doc/xgplayer-demo-720p.mp4"
          controls
        ></video>
      </div>
    </div>

    <script type="module" src="./index.js"></script>
  </body>
</html>
css
* {
  margin: 0;
  padding: 0;
}
body {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}
.container {
  padding: 20px;
  width: 1000px;
  margin: 0 auto;
}

.container .tool-bar {
  list-style: none;
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 15px 20px;
  margin-bottom: 20px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-radius: 10px;
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
  flex-wrap: wrap;
}

.container .tool-bar li.tool-bar-item {
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: white;
  font-weight: 500;
}

.container .tool-bar span {
  font-size: 14px;
  min-width: 80px;
}

.container .tool-bar input[type="color"] {
  width: 40px;
  height: 40px;
  border: 2px solid white;
  border-radius: 6px;
  cursor: pointer;
  transition: transform 0.2s;
}

.container .tool-bar input[type="color"]:hover {
  transform: scale(1.1);
}

.container .tool-bar input[type="range"] {
  width: 120px;
  cursor: pointer;
  accent-color: #fff;
}

.container .tool-bar select,
.container .tool-bar input[type="text"],
.container .tool-bar button {
  padding: 8px 12px;
  border: 2px solid white;
  border-radius: 6px;
  font-size: 14px;
  background: rgba(255, 255, 255, 0.95);
  color: #333;
  cursor: pointer;
  transition: all 0.2s;
  font-weight: 500;
}

.container .tool-bar input[type="text"] {
  min-width: 150px;
}

.container .tool-bar select:hover,
.container .tool-bar input[type="text"]:hover {
  background: white;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.container .tool-bar button {
  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
  color: white;
  border: none;
  min-width: 80px;
}

.container .tool-bar button:hover {
  box-shadow: 0 6px 12px rgba(245, 87, 108, 0.4);
  transform: translateY(-2px);
}

.container .tool-bar button:active {
  transform: translateY(0);
}

.container #video {
  width: 100%;
  border: 1px solid #ccc;
}
js
import { createVideoDanmu } from "./VideoDanmu.js";

window.onload = function init() {
  const danmuColorDom = document.getElementById("danmu-color");
  const danmuSizeDom = document.getElementById("danmu-size");
  const danmuSpeedDom = document.getElementById("danmu-speed");
  const danmuTextDom = document.getElementById("danmu-text");
  const danmuSendDom = document.getElementById("send-danmu");
  const videoDom = document.getElementById("video");

  const videoDanmuInst = createVideoDanmu(videoDom);
  videoDanmuInst.loadData([
    {
      text: "我太喜欢这首歌了",
      color: "#ff0000",
      fontSize: 20,
      speed: 2,
      showtime: 3,
    },
    {
      text: "你们好你们好你们好你们好你们好",
      color: "#00ff00",
      fontSize: 20,
      speed: 3,
      showtime: 6,
    },
    {
      text: "这首歌很好听",
      color: "#ffffff",
      fontSize: 20,
      speed: 1,
      showtime: 7,
    },
    {
      text: "我太喜欢这首歌了",
      color: "#ffffff",
      fontSize: 20,
      speed: 3,
      showtime: 8,
    },
    {
      text: "我太喜欢这首歌了2",
      color: "#ff0000",
      fontSize: 20,
      speed: 4,
      showtime: 10,
    },
    {
      text: "你们好你们好你们好你们好你们好2",
      color: "#00ff00",
      fontSize: 20,
      speed: 3,
      showtime: 12,
    },
    {
      text: "这首歌很好听222",
      color: "#ffffff",
      fontSize: 20,
      speed: 2,
      showtime: 14,
    },
    {
      text: "我太喜欢这首歌了222",
      color: "#ffffff",
      fontSize: 20,
      speed: 2,
      showtime: 15,
    },
  ]);

  videoDom.addEventListener("play", () => videoDanmuInst.play(), false);
  videoDom.addEventListener("pause", () => videoDanmuInst.pause(), false);
  danmuSendDom.addEventListener("click", () => {
    const text = danmuTextDom.value.trim();
    if (text.length === 0) {
      console.log("请输入弹幕内容");
      return;
    }
    if (text.length === 1) {
      console.log("不能发送单个字符");
      return;
    }
    const color = danmuColorDom.value;
    const fontSize = parseInt(danmuSizeDom.value);
    const speed = parseInt(danmuSpeedDom.value);
    const showtime = videoDom.currentTime;
    const danmu = {
      color,
      fontSize,
      speed,
      text,
      showtime,
    };
    videoDanmuInst.loadData([danmu]);
    danmuTextDom.value = "";
  });
};
js
export class VideoDanmu {
  /** @type {CanvasRenderingContext2D} */
  canvasContext = null;

  // 是否暂停弹幕
  isPaused = true;

  // 弹幕池数据
  danmuPool = [];

  // 默认弹幕样式配置
  styleOptions = {
    text: "",
    color: "#ff0000",
    speed: 6,
    fontSize: 30,
    fontFamily: "Hack",
  };

  // 可显示弹幕的区域份数(总视频高度 / 2 = 可显示弹幕高度)
  visibleAreaHeight = 2;

  constructor(videoDom, styleOpts = {}) {
    this.videoDom = videoDom;
    this.styleOptions = Object.assign(this.styleOptions, styleOpts);
    this.init();
    this.render();
  }

  init() {
    this.initConvas();
    this.initDom();
  }

  initConvas() {
    // 用于绘制弹幕
    const canvasDom = document.createElement("canvas");
    this.canvasContext = canvasDom.getContext("2d");

    // 用于计算弹幕宽度 4GTW: forGetTextWidth
    const canvasDom2 = document.createElement("canvas");
    this.canvasCtx4GTW = canvasDom2.getContext("2d");
  }

  initDom() {
    const container = document.createElement("div");
    const videoDom = this.videoDom;

    // container style
    container.style.position = "relative";
    container.width = videoDom.offsetWidth;
    container.height = videoDom.offsetHeight;

    // canvas style
    const canvasDom = this.canvasContext.canvas;
    canvasDom.width = videoDom.offsetWidth;
    canvasDom.height = videoDom.offsetHeight;
    canvasDom.style.position = "absolute";
    canvasDom.style.top = 0;
    canvasDom.style.left = 0;

    // append to dom tree
    const parentNode = videoDom.parentNode;
    container.append(canvasDom);
    container.append(videoDom);
    parentNode.append(container);
  }

  getVisibleAreaHeight() {
    return this.canvasContext.canvas.height / this.visibleAreaHeight;
  }

  setVisibleAreaHeight(n) {
    if (n < 1 || n > 5) {
      throw new Error("setVisibleAreaHeight: please give an number between 1 and 5");
    }
    this.visibleAreaHeight = n;
  }

  play() {
    this.isPaused = false;
    this.render();
  }

  pause() {
    this.isPaused = true;
  }

  loadData(data) {
    if (!Array.isArray(data)) {
      throw new Error("loadData: please give an array");
    }

    const danmus = data.map((item) => this.formatDanmu(item));
    this.danmuPool.push(...danmus);

    console.log("loadData:", danmus);
  }

  formatDanmu(item) {
    // {
    //   text: "",           // 弹幕内容
    //   color: "#ff0000", // 弹幕颜色
    //   speed: 2,           // 弹幕滚动速度
    //   showtime: 8,        // 弹幕展示时间
    //   fontSize: 20,       // 弹幕字体大小
    //   fontFamily:'Hack',  // 弹幕字体
    //   width:  ?           // 弹幕宽度
    //   x:      ?           // 弹幕起始 x 坐标
    //   y:      ?           // 弹幕起始 y 坐标
    //   isDone: false,      // 是否已经展示过了
    // }
    const danmu = Object.assign({}, this.styleOptions, item);
    const { text, fontSize, fontFamily } = danmu;
    const danmuWidth = this.getDanmuWidth(text, fontSize, fontFamily);

    // 计算起始位置: 默认在最右边
    // x: canvas宽度 + 弹幕本身的宽度
    // y: fontSize + canvas可显示弹幕区域随机
    // 弹幕起始位置放在画布右侧外,这样才会从右向左进入画面
    const startX = this.canvasContext.canvas.width + danmuWidth;
    const startY = random(fontSize, this.getVisibleAreaHeight() - fontSize);

    return Object.assign(danmu, {
      isDone: false,
      width: danmuWidth,
      x: startX,
      y: startY,
    });
  }

  // 获取弹幕的宽度
  getDanmuWidth(text, fontSize, fontFamily) {
    this.canvasCtx4GTW.font = `${fontSize}px ${fontFamily}`;
    const { width } = this.canvasCtx4GTW.measureText(text);
    return width;
  }

  drawDanmu(danmu) {
    // 只有 视频的播放时间大于等于弹幕的展示时间
    // 或者 弹幕还没有展示过, 此时: 才会绘制否则
    // 一次性绘制太多可能会导致卡顿
    if (!danmu.isDone && this.videoDom.currentTime < danmu.showtime) {
      return;
    }

    // 移动坐标, 不停往左移动
    danmu.x -= danmu.speed;

    // 绘制
    const { text, color, fontSize, x, y } = danmu;
    const ctx = this.canvasContext;
    ctx.shadowColor = "#000000";
    ctx.shadowBlur = 2;
    ctx.font = `${fontSize}px '${danmu.fontFamily}'`;
    ctx.fillStyle = color;
    ctx.fillText(text, x, y);

    // 如果已经移动到最左边了
    if (danmu.x <= -danmu.width) {
      danmu.isDone = true;
    }
  }

  renderDanmu() {
    const items = this.danmuPool;
    // console.log("renderDanmu count:", items.length);
    for (const danmu of items) {
      this.drawDanmu(danmu);
    }
  }

  removeDoneDanmu() {
    this.danmuPool = this.danmuPool.filter((item) => !item.isDone);
  }

  render() {
    this.clear();
    this.removeDoneDanmu();
    this.renderDanmu();
    !this.isPaused && requestAnimationFrame(() => this.render());
  }

  clear() {
    const { width, height } = this.canvasContext.canvas;
    this.canvasContext.clearRect(0, 0, width, height);
  }
}

/**
 * 返回指定范围内的随机数
 * @param min - 最小值
 * @param max - 最大值
 * @returns 返回值
 */
export function random(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

export const createVideoDanmu = (videoEl, options) => new VideoDanmu(videoEl, options);

五子棋游戏

five-chesses

js
window.addEventListener("DOMContentLoaded", () => {
  init();
});

/** @type {CanvasRenderingContext2D} */
let _canvas2dContext;
const setContext = (ctx) => {
  _canvas2dContext = ctx;
};
const getContext = () => _canvas2dContext;

// 棋子的类型
const CHESS_TYPE = {
  BLACK: {
    name: "黑棋",
    color: "#000",
    value: 0,
  },
  WHITE: {
    name: "白棋",
    color: "#fff",
    value: 1,
  },
};

// 全局的状态管理
const state = {
  width: 800, /////////// 棋盘总宽度
  height: 800, ////////// 棋盘中高度
  columns: 15, ////////// 棋盘列数
  rows: 15, ///////////// 棋盘行数
  gridSize: 50, ///////// 棋盘网格大小
  gridColor: "#f00", // 棋盘网格颜色
  chessRadius: 20, ////// 棋子的半径
  isBlack: true, //////// 黑棋先手
  chessData: [], //////// 棋盘上的棋子数据
  winChessCount: 5, ///// 五子棋: 当然是连续5个算赢
  switchPlayer() {
    state.isBlack = !state.isBlack;
  },
  getChess() {
    return this.isBlack ? CHESS_TYPE.BLACK : CHESS_TYPE.WHITE;
  },
  getChessName() {
    return this.getChess().name;
  },
  getChessColor() {
    return this.getChess().color;
  },
  getChessValue() {
    return this.getChess().value;
  },
  getChessData(rowIndex, colIndex) {
    return this.chessData[colIndex][rowIndex];
  },
  hasChessData(rowIndex, colIndex) {
    // 判断某个位置是否有棋子了, 不能重复下棋子到同一个位置
    const value = this.getChessData(rowIndex, colIndex);
    return [CHESS_TYPE.BLACK.value, CHESS_TYPE.WHITE.value].includes(value);
  },
  putChessData(rowIndex, colIndex) {
    // 给棋盘数据设置棋子数据
    this.chessData[colIndex][rowIndex] = this.getChessValue();
    this.putChessCount += 1;
  },
};

function init() {
  initCanvasContext();
  drawGrids();
  initCanvasEvents();
  renderToDom(document.getElementById("app"));
}

function initCanvasContext() {
  const canvasDom = document.createElement("canvas");
  setContext(canvasDom.getContext("2d"));

  const { width, height } = state;
  canvasDom.width = width;
  canvasDom.height = height;
}

function drawGrids() {
  const ctx = getContext();
  const { columns, rows, gridSize, width, height, gridColor, chessData } = state;

  ctx.strokeStyle = gridColor;
  const endX = width - gridSize;
  for (let i = 1; i <= rows; i++) {
    const y = i * gridSize;
    ctx.moveTo(gridSize, y);
    ctx.lineTo(endX, y);
    ctx.stroke();
  }

  const endY = height - gridSize;
  for (let i = 1; i <= columns; i++) {
    const x = i * gridSize;
    ctx.moveTo(x, gridSize);
    ctx.lineTo(x, endY);
    ctx.stroke();
  }

  const chessDataCount = Math.max(columns, rows);
  for (let i = 0; i <= chessDataCount; i++) {
    chessData.push([]);
  }
}

// 渲染到页面上
function renderToDom(containerDOM) {
  createTipDom(containerDOM);
  const ctx = getContext();
  containerDOM.append(ctx.canvas);
  updateTipMsg();
}

// 创建提示信息元素
function createTipDom(containerDOM) {
  const tipDiv = document.createElement("div");
  tipDiv.id = "tip";
  containerDOM.append(tipDiv);
}

// 更新提示信息
function updateTipMsg() {
  const tipDiv = document.getElementById("tip");
  if (!tipDiv) return;
  tipDiv.textContent = `请 ${state.getChessName()} 落子`;
}

// 初始化 canvas 交互事件
function initCanvasEvents() {
  const ctx = getContext();
  ctx.canvas.addEventListener("click", (e) => {
    const { gridSize } = state;
    const { offsetX, offsetY } = e;

    // 判断其实是否在棋盘之内
    if (!isInBoard(offsetX, offsetY)) {
      console.log("请在棋盘内点击", { offsetX, offsetY });
      return;
    }

    // 让棋子下在线的交汇处
    const rowIndex = getGridIndex(offsetX, gridSize);
    const colIndex = getGridIndex(offsetY, gridSize);
    const x = rowIndex * gridSize;
    const y = colIndex * gridSize;
    console.log({ x, y, rowIndex, colIndex });

    if (state.hasChessData(rowIndex, colIndex)) {
      alert("这个位置已经有棋子了!");
      return;
    }

    putChess(x, y, rowIndex, colIndex);
    if (isWin(rowIndex, colIndex)) {
      restartGame(`${state.getChessName()}已经赢了!`);
      return;
    }
    state.switchPlayer();
    updateTipMsg();
  });
}

// 判断其实是否在棋盘之内
function isInBoard(x, y) {
  const { width, height, gridSize } = state;
  const halfGridSize = gridSize / 2;
  const minX = halfGridSize;
  const minY = halfGridSize;
  const maxX = width - halfGridSize;
  const maxY = height - halfGridSize;
  return x > minX && x < maxX && y > minY && y < maxY;
}

// 计算点击的是第几个格子
function getGridIndex(pos, gridSize) {
  // 其实就是要得到距离 v, 最近(gridSize)的倍数数字
  // v 就是点击的位置(但是有可能不是 gridSize 的倍数数字)
  // Math.floor((x + 25) / 50) * 50, 这样就可以得到
  // 50 的倍数并向下取整, +25是因为是向下取整所以需要加上半
  // 个格子宽度, 这样点击一个格子右侧的时候才会准确
  const halfGridSize = gridSize / 2;
  return Math.floor((pos + halfGridSize) / gridSize);
}

// 放置棋子
function putChess(x, y, rowIndex, colIndex) {
  drawChess(x, y);
  state.putChessData(rowIndex, colIndex);
}

// 绘制棋子
function drawChess(x, y) {
  const ctx = getContext();
  ctx.beginPath();
  ctx.arc(x, y, state.chessRadius, 0, Math.PI * 2);
  ctx.fillStyle = state.getChessColor();
  ctx.fill();
  ctx.closePath();
}

// 判断是否已经赢了:任意一个方向连续有5个棋子就算赢棋了
function isWin(rowIndex, colIndex) {
  return (
    // 竖向查找
    checkRows(rowIndex, colIndex) ||
    // 横向查找
    checkCols(rowIndex, colIndex) ||
    // 左上 -> 右下 查找
    checkBackslash(rowIndex, colIndex) ||
    // 左下 -> 右上 查找
    checkSlash(rowIndex, colIndex)
  );
}

// 通用的方向检查函数
function checkDirection({ rowIndex, colIndex, directions, limit }) {
  const targetChess = state.getChessValue();
  let lineChessCount = 1; // 在一行的棋子个数

  // 初始化两个方向的搜索信息
  const [dir1, dir2] = directions;
  const search1 = {
    rowIndex: rowIndex + dir1.rowIndex,
    colIndex: colIndex + dir1.colIndex,
    isNeed: true,
  };
  const search2 = {
    rowIndex: rowIndex + dir2.rowIndex,
    colIndex: colIndex + dir2.colIndex,
    isNeed: true,
  };

  let index = limit;
  while (index > 0) {
    if (lineChessCount === state.winChessCount) {
      return true;
    }

    // 检查第一个方向
    if (
      search1.isNeed &&
      search1.rowIndex > 0 &&
      search1.rowIndex <= limit &&
      search1.colIndex > 0 &&
      search1.colIndex <= limit
    ) {
      const chess = state.getChessData(search1.rowIndex, search1.colIndex);
      if (chess === targetChess) {
        lineChessCount += 1;
        search1.rowIndex += dir1.rowIndex;
        search1.colIndex += dir1.colIndex;
      } else {
        search1.isNeed = false;
      }
    }

    // 检查第二个方向
    if (
      search2.isNeed &&
      search2.rowIndex > 0 &&
      search2.rowIndex <= limit &&
      search2.colIndex > 0 &&
      search2.colIndex <= limit
    ) {
      const chess = state.getChessData(search2.rowIndex, search2.colIndex);
      if (chess === targetChess) {
        lineChessCount += 1;
        search2.rowIndex += dir2.rowIndex;
        search2.colIndex += dir2.colIndex;
      } else {
        search2.isNeed = false;
      }
    }

    index--;
  }
  return false;
}

// 竖向查找是否有5个颜色相同的棋子
function checkRows(rowIndex, colIndex) {
  return checkDirection({
    rowIndex,
    colIndex,
    directions: [
      { rowIndex: -1, colIndex: 0 }, // 向上
      { rowIndex: 1, colIndex: 0 }, // 向下
    ],
    limit: state.rows,
  });
}

// 横向查找是否有5个颜色相同的棋子
function checkCols(rowIndex, colIndex) {
  return checkDirection({
    rowIndex,
    colIndex,
    directions: [
      { rowIndex: 0, colIndex: -1 }, // 向左
      { rowIndex: 0, colIndex: 1 }, // 向右
    ],
    limit: state.columns,
  });
}

// 左上 -> 右下 查找(反斜线)
function checkBackslash(rowIndex, colIndex) {
  return checkDirection({
    rowIndex,
    colIndex,
    directions: [
      { rowIndex: -1, colIndex: -1 }, // 左上
      { rowIndex: 1, colIndex: 1 }, // 右下
    ],
    limit: Math.max(state.columns, state.rows),
  });
}

// 左下 -> 右上 查找(斜线)
function checkSlash(rowIndex, colIndex) {
  return checkDirection({
    rowIndex,
    colIndex,
    directions: [
      { rowIndex: 1, colIndex: -1 }, // 左下
      { rowIndex: -1, colIndex: 1 }, // 右上
    ],
    limit: Math.max(state.columns, state.rows),
  });
}

// 一方胜利, 重启游戏
function restartGame(message) {
  requestIdleCallback(() => {
    // 异步弹出通知, 让棋子先画出来
    // 否则还没来得及渲染棋子就直接alert卡住了
    window.alert(message);
    window.location.reload(true);
  });
}
html
<!doctype html>
<html lang="zh-cn">
  <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" />
    <meta name="referrer" content="no-referrer" />
    <link rel="stylesheet" href="./styles.css" />
    <title>title</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./index.js"></script>
  </body>
</html>
css
* {
  margin: 0;
  padding: 0;
}

#app {
  padding: 30px 0;
}

#app canvas {
  display: block;
  background: #ccc;
  margin: 0 auto;
}

#app #tip {
  margin-bottom: 20px;
  font-size: 18px;
  font-weight: bold;
  color: #f00;
  text-align: center;
}

Released under the MIT License.