JS-Canvas 进阶实战:手把手教你实现一个带“撤销”与“橡皮擦”的智能画板(附源码)

0 阅读4分钟

前言

在掌握了 Canvas 的基础 API 后,实现一个具备生产力的画板工具是进阶的最佳实践。本文将深入解析如何利用 事件监听ImageData 状态快照 以及 合成模式,实现一个支持实时绘图、无限撤销和丝滑橡皮擦功能的网页画板。

一、 基础画笔:线条的艺术

实现思路

  1. 初始化:配置画布上下文,设置 lineCap(线头)和 lineJoin(拐角)为 round 可使线条更丝滑。
  2. 设置画笔属性
  3. 监听鼠标事件实现绘图:
    • 鼠标按下时,记录当前鼠标位置开始绘图
    • 鼠标移动时,判断当前是否是绘图状态,是的话将前一点以当前点连接渲染出线条,并实时更新终点
    • 鼠标松开时,将绘图状态设置为结束

代码实现

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <style>
  </style>
</head>

<body>

  <canvas id="drawCanvas" width="600" height="600" style="border: 1px solid aqua"></canvas>
  <script>
    // 画布初始化
    const canvas = document.getElementById('drawCanvas');
    if (canvas) {


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

      // 画笔设置
      ctx.strokeStyle = '#000';
      ctx.lineWidth = 5;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      // 绘图逻辑
      let isDrawing = false;
      let lastX, lastY;

      //鼠标按下
      canvas.addEventListener('mousedown', (e) => {
        isDrawing = true;
        [lastX, lastY] = [e.offsetX, e.offsetY];
      });

      //鼠标移动
      canvas.addEventListener('mousemove', (e) => {
        if (!isDrawing) return;
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(e.offsetX, e.offsetY);
        ctx.stroke();
        [lastX, lastY] = [e.offsetX, e.offsetY];
      });
      // 鼠标松开
      window.addEventListener('mouseup', () => isDrawing = false);
    }
  </script>
</body>

</html>

二、 撤销功能:快照栈管理

实现思路

Canvas 是位图,无法撤销单个路径,因此我们使用 状态快照(Snapshots) 方案。

  • 设置一个historyStack数组与指针currentStep存储每次画笔绘制的快照与回退位置
  • 每次画笔画完时,利用 getImageData 将此时的快照保存在数组里面并更新回退位置
  • 执行回退操作时,利用 putImageData 将前一个快照从数组中取出并更新在画布上

代码实现

let historyStack = [];
let currentStep = -1;

function saveState() {
  // 核心:保存当前画布所有像素
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  historyStack.push(imageData);
  currentStep = historyStack.length - 1;
}

function handleCancel() {
  if (currentStep < 0) return;
  
  // 如果是第一步,则直接清空画布
  if (currentStep === 0) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    historyStack = [];
    currentStep = -1;
    return;
  }

  historyStack.pop(); // 弹出当前步
  currentStep--;
  // 渲染回退后的最后一步
  ctx.putImageData(historyStack[currentStep], 0, 0);
}

三、 橡皮擦:神奇的合成模式

实现思路

橡皮擦并不是“画白色”,而是把颜色擦除(透明)

  • 首先设置一个擦除标记与橡皮擦的基础样式,橡皮擦样式初始为一个不显示的绝对定位元素
  • 鼠标点击时将擦除标记设置为ture
  • 监听鼠标移动,鼠标移动且擦除标记为ture调用擦除方法(将canvas的globalCompositeOperation属性设置为estination-out,擦除旧图形中与新图形重叠的部分,接着使用ctx.fillRect()填充擦除区域,可使用save()restore()优化绘图性能)
// 橡皮擦
let isBeginErase = false;
let isErasing = false;
let eraserSize = 20; // 方形橡皮擦边长
let eraserColor = "#CCCCCC"; // 橡皮擦颜色
const cursor = document.createElement("div");
cursor.classList.add("eraser-cursor");
document.body.appendChild(cursor);

//擦除函数
function handleErase() {
  isBeginErase = !isBeginErase;
}

canvas.addEventListener("mousedown", (e) => {
  if (!isBeginErase) return;
  if (isBeginErase) isErasing = true;
  [lastX, lastY] = [e.offsetX, e.offsetY];
});

canvas.addEventListener("mousemove", (e) => {
  if (!isBeginErase) return;
  if (isErasing) {
    cursor.style.display = isErasing ? "block" : "none";
    cursor.style.left = e.clientX - eraserSize / 2 + "px";
    cursor.style.top = e.clientY - eraserSize / 2 + "px";
    eraseSquare(e.clientX, e.clientY);
  }
});

canvas.addEventListener("mouseup", () => {
  isErasing = false;
  cursor.style.display = "none";
  if (isBeginErase) {
    saveState();// 画完保存当前状态
  }
});

// 擦除
function eraseSquare(x, y) {
  ctx.save();
  ctx.globalCompositeOperation = "destination-out";
  ctx.fillRect(x - eraserSize / 2, y - eraserSize / 2, eraserSize, eraserSize);
  ctx.restore();
}


四、 综合应用与性能优化

性能小贴士

  • willReadFrequently:在使用 getImageData 频繁读取像素时,在获取上下文时开启 { willReadFrequently: true } 可以显著提升性能。
  • save/restore:在切换橡皮擦模式时,务必使用 save() 记录状态并在绘图结束后 restore(),避免 globalCompositeOperation 全局污染。

完整演示

<!doctype html>
<html>
  <body>
    <div>
      <canvas id="drawCanvas" width="800" height="600" style="border: 1px solid #ddd"></canvas>
      <div>
        <button onclick="handleDraw()">画画</button>
        <button onclick="handleCancel()">撤销</button>
        <button onclick="handleErase()">擦除</button>
      </div>
    </div>

    <script>
      const canvas = document.getElementById('drawCanvas')
      const ctx = canvas.getContext('2d', { willReadFrequently: true }) //

      // 画笔设置
      ctx.strokeStyle = '#000'
      ctx.lineWidth = 5
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'

      // 绘图逻辑
      let isDrawing = false
      let isBeginDraw = false
      let lastX, lastY

      // 画布历史记录
      let historyStack = [],
        currentStep = -1

      // 橡皮擦
      let isBeginErase = false
      let isErasing = false
      let eraserSize = 20 // 方形橡皮擦边长
      let eraserColor = '#CCCCCC' // 橡皮擦颜色
      const cursor = document.createElement('div')
      cursor.classList.add('eraser-cursor')
      document.body.appendChild(cursor)

      //开始画画
      function handleDraw() {
        isBeginDraw = !isBeginDraw
        isBeginErase = false
      }

      //擦除函数
      function handleErase() {
        isBeginDraw = false
        isBeginErase = !isBeginErase
      }

      canvas.addEventListener('mousedown', (e) => {
        if (!isBeginDraw && !isBeginErase) return
        if (isBeginDraw) isDrawing = true
        if (isBeginErase) isErasing = true
        console.log('isErasing', isErasing)
        ;[lastX, lastY] = [e.offsetX, e.offsetY]
      })

      canvas.addEventListener('mousemove', (e) => {
        if (!isBeginDraw && !isBeginErase) return
        if (isDrawing) {
          ctx.beginPath()
          ctx.moveTo(lastX, lastY)
          ctx.lineTo(e.offsetX, e.offsetY)
          ctx.stroke()
          ;[lastX, lastY] = [e.offsetX, e.offsetY]
        } else if (isErasing) {
          console.log('isErasing')
          cursor.style.display = isErasing ? 'block' : 'none'
          cursor.style.left = e.clientX - eraserSize / 2 + 'px'
          cursor.style.top = e.clientY - eraserSize / 2 + 'px'
          eraseSquare(e.clientX, e.clientY)
        }
      })

      canvas.addEventListener('mouseup', () => {
        isDrawing = false
        isErasing = false
        cursor.style.display = 'none'
        if (isBeginDraw || isBeginErase) {
          saveState()
        }
      })

      // 画完保存当前状态
      function saveState() {
        // 获取当前画布像素数据
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
        historyStack.push(imageData)
        currentStep = historyStack.length - 1
        console.log(historyStack)
      }

      // 撤销
      function handleCancel() {
        if (currentStep < 0) return
        if (currentStep === 0) {
          ctx.clearRect(0, 0, canvas.width, canvas.height)
          historyStack = []
          currentStep = -1
          return
        }
        const imageData = historyStack.pop()
        currentStep--
        console.log(currentStep)
        ctx.putImageData(historyStack[currentStep], 0, 0)
      }

      // 擦除
      function eraseSquare(x, y) {
        ctx.save() 
        ctx.globalCompositeOperation = 'destination-out'
        ctx.fillRect(x - eraserSize / 2, y - eraserSize / 2, eraserSize, eraserSize)
        ctx.restore()
      }
    </script>
  </body>

  <style>
    .eraser-cursor {
      position: absolute;
      width: 20px;
      height: 20px;
      border-radius: 6px;
      background: rgba(204, 204, 204, 0.5);
      border: 1px solid #999;
      pointer-events: none;
      display: none;
    }
  </style>
</html>


五、 面试模拟题

Q1:为什么不用 clearRect 做橡皮擦?

参考回答: clearRect 只能清除矩形区域。如果你想实现“圆形橡皮擦”或者“像画笔一样涂抹”的橡皮擦效果,必须使用 globalCompositeOperation = "destination-out" 配合路径绘制,这样可以跟随鼠标轨迹实现不规则的擦除。

Q2:快照栈(historyStack)过大会导致内存溢出吗?

参考回答: 会。如果画布很大(如 4K 分辨率),每一个 ImageData 都会消耗数兆内存。在实际工程中,我们会限制栈的深度(例如最多存 20 步)。