涂鸦新手必看:用Canvas手把手打造你的专属画板(附实战技巧)

涂鸦新手必看:用Canvas手把手打造你的专属画板(附实战技巧)

“我不想再被产品经理追着改图了,我要自己画!”——某位凌晨三点还在调线宽的前端

引言:为什么前端开发者都想自己做一个涂鸦画板?

因为画板是前端界的“Hello World” Plus
它看起来人畜无害,实际暗藏杀机:事件系统、性能优化、移动端兼容、状态管理、图形学入门……全藏在“随便画两笔”这个简单动作背后。
做完之后,你会惊喜地发现——除了不会画画,其他都会了

今天这篇,咱们就一边吐槽一边敲代码,从零到一撸一个能跑、能画、能撤销、不卡顿、还适配手机的画板。
代码管饱,注释管够,复制粘贴就能跑,跑不通你来打我(别真打,我怕疼)。


Canvas绘图基础:别被“2D上下文”这五个字吓到

1. 先整一块“黑板”

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>我的画板我做主</title>
  <style>
    /* 让画布全屏,不留白边,防止手机滑动 */
    html, body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      touch-action: none; /* 禁用默认触摸行为 */
    }
    canvas {
      display: block;
      background: #fff;
      cursor: crosshair;
    }
  </style>
</head>
<body>
  <canvas id="board"></canvas>

  <script>
    const canvas = document.getElementById('board');
    const ctx = canvas.getContext('2d'); // 传说中的“2D上下文”

    // 画布撑满窗口,随时resize
    function resize() {
      const dpr = window.devicePixelRatio || 1;
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr); // 搞定高分屏模糊
      // 重新设置画笔样式,防止被scale污染
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
    }
    window.addEventListener('resize', resize);
    resize();
  </script>
</body>
</html>

上面这段,复制保存成html,双击就能打开
如果你看到一块白屏,恭喜,你已经拥有了宇宙中最贵的画纸——浏览器窗口。


核心功能拆解:画笔、橡皮擦与颜色选择

2. 让鼠标变成“神笔马良”

// 状态管理,比React简单,但别小看
let isDrawing = false;
let color = '#000000';
let lineWidth = 4;

// 统一拿坐标,兼容PC & 触屏
function getPos(e) {
  if (e.touches) {
    return { x: e.touches[0].clientX, y: e.touches[0].clientY };
  }
  return { x: e.clientX, y: e.clientY };
}

// 开始画
canvas.addEventListener('mousedown', start);
canvas.addEventListener('touchstart', start);

function start(e) {
  isDrawing = true;
  const { x, y } = getPos(e);
  ctx.beginPath();
  ctx.moveTo(x, y);
}

// 移动画
canvas.addEventListener('mousemove', move);
canvas.addEventListener('touchmove', move);

function move(e) {
  if (!isDrawing) return;
  const { x, y } = getPos(e);
  ctx.strokeStyle = color;
  ctx.lineWidth = lineWidth;
  ctx.lineTo(x, y);
  ctx.stroke();
}

// 收笔
canvas.addEventListener('mouseup', stop);
canvas.addEventListener('touchend', stop);
canvas.addEventListener('mouseleave', stop);

function stop() {
  isDrawing = false;
}

到这里,你已经可以用鼠标鬼画符了。
想换颜色?在控制台敲 color = 'salmon' 立刻变身三文鱼色。

3. 橡皮擦:不是“白色画笔”,是“像素橡皮”

const eraser = document.createElement('button');
eraser.textContent = '橡皮擦';
document.body.appendChild(eraser);

eraser.addEventListener('click', () => {
  // 把globalCompositeOperation变成橡皮模式
  ctx.globalCompositeOperation = 'destination-out';
  lineWidth = 20; // 橡皮要胖一点
});

// 恢复画笔
const brush = document.createElement('button');
brush.textContent = '画笔';
document.body.appendChild(brush);
brush.addEventListener('click', () => {
  ctx.globalCompositeOperation = 'source-over';
  lineWidth = 4;
});

globalCompositeOperation 这名字长到想打人,但记住两个值就够:
source-over 是正常画笔,destination-out 是橡皮。
别再拿白色画笔冒充橡皮了,丢人。


撤销重做:让用户“后悔”也有路可走

4. 命令模式?太麻烦,直接“截图”

const history = [];
let step = -1;

// 保存当前画布快照
function saveState() {
  step++;
  if (step < history.length) history.length = step; // 删掉废弃的未来
  history.push(canvas.toDataURL());
}

// 初始化空白也算一步
saveState();

// 每次画完保存
function stop() {
  isDrawing = false;
  saveState();
}

// 撤销
function undo() {
  if (step > 0) {
    step--;
    const img = new Image();
    img.src = history[step];
    img.onload = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, 0, 0);
    };
  }
}

// 重做
function redo() {
  if (step < history.length - 1) {
    step++;
    const img = new Image();
    img.src = history[step];
    img.onload = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, 0, 0);
    };
  }
}

// 绑定按钮
const undoBtn = document.createElement('button');
undoBtn.textContent = '撤销';
undoBtn.onclick = undo;
document.body.appendChild(undoBtn);

const redoBtn = document.createElement('button');
redoBtn.textContent = '重做';
redoBtn.onclick = redo;
document.body.appendChild(redoBtn);

简单粗暴,toDataURL一把梭
画一步拍一张“照片”,回退就是翻相册
缺点?占内存。100步就能吃几十M,别在诺基亚上测试


性能优化:让画板丝滑到“德芙”级别

5. 离屏Canvas:双缓冲了解一下

// 离屏画布,幕后工作者
const offCanvas = document.createElement('canvas');
const offCtx = offCanvas.getContext('2d');
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;

// 把主绘制搬到离屏
function move(e) {
  if (!isDrawing) return;
  const { x, y } = getPos(e);
  offCtx.strokeStyle = color;
  offCtx.lineWidth = lineWidth;
  offCtx.lineTo(x, y);
  offCtx.stroke();
  // 一次性把离屏内容拷到主屏
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(offCanvas, 0, 0);
}

原理:** backstage 画完再整体投影**,避免每一帧都重绘。
画长线不卡,妈妈再也不用担心我掉帧

6. 节流+防抖:鼠标疯了也不掉链子

function throttle(fn, wait) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last > wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

// 把move事件包一层
canvas.addEventListener('mousemove', throttle(move, 16)); // 60FPS

16ms 是黄金间隔,再短肉眼也看不出,再长就肉眼可见地卡。


移动端适配:触控事件和“高清屏”双重暴击

7. 触控事件:不止一个手指

// 禁止页面滚动
document.body.addEventListener('touchmove', e => e.preventDefault(), { passive: false });

// 只认单指绘画
function getPos(e) {
  if (e.touches && e.touches.length === 1) {
    return { x: e.touches[0].clientX, y: e.touches[0].clientY };
  }
  return { x: e.clientX, y: e.clientY };
}

多指操作会触发缩放,直接 preventDefault 干掉。
想做双指放大?先读完Pointer Events规范再说。

8. 高清屏:模糊克星 devicePixelRatio

// 已在resize里写过,再贴一遍,怕你们忘了
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);

不缩放?1px 会被高清屏强行拉伸成 2px,线条胖成狗


常见Bug排查:那些年我们一起踩过的坑

症状病因解药
线条断成点mousemove 里没 beginPathbeginPath 挪到 start
触摸无效没阻止默认滚动touchmovepreventDefault
清空后还能撤销出旧图saveState 时机不对清空后立刻 saveState()
橡皮擦留下白痕用了白色画笔冒充橡皮globalCompositeOperation
手机画出屏幕没禁止 touch-actionCSS 加 touch-action: none

进阶玩法:把画板玩出花

9. 一键导出:让作品逃离浏览器

const saveBtn = document.createElement('button');
saveBtn.textContent = '保存为PNG';
saveBtn.onclick = () => {
  const link = document.createElement('a');
  link.download = 'masterpiece.png';
  link.href = canvas.toDataURL();
  link.click();
};
document.body.appendChild(saveBtn);

右键另存为?太低端
一键下载,产品经理都鼓掌

10. 多人协作:WebSocket + 操作广播(伪代码)

// 本地画一步,立刻广播
ws.send(JSON.stringify({ type: 'draw', x, y, color, lineWidth }));

// 收到别人动作,照样画
ws.onmessage = msg => {
  const { type, x, y, color, lineWidth } = JSON.parse(msg.data);
  if (type === 'draw') {
    ctx.strokeStyle = color;
    ctx.lineWidth = lineWidth;
    ctx.lineTo(x, y);
    ctx.stroke();
  }
};

真正的协作还要做冲突合并、版本号、心跳检测
但今天篇幅有限,先跑通能画再说

11. 手势擦除:用“摇一摇”清空如何?

let lastX = 0, lastY = 0, lastTime = 0;
window.addEventListener('devicemotion', e => {
  const { acceleration } = e;
  const jerk = Math.abs(acceleration.x + acceleration.y + acceleration.z);
  if (jerk > 30 && Date.now() - lastTime > 1000) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    saveState();
    lastTime = Date.now();
    alert('画板已清空,别甩了,再甩手机飞了!');
  }
});

实测甩锅力度大于30即可触发,甩到飞起请自备手机绳。


彩蛋:把代码打包成“渐进式Web应用”

// 注册Service Worker,离线也能画
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js').then(() => {
    console.log('sw.js 注册成功,离线画板已就绪');
  });
}

再配合 manifest.json安装到桌面,图标换成你家猫,领导以为你写了个原生App


结语:画板只是起点,画布才是宇宙

今天我们从一块白屏开始,一路打怪升级:
画线、橡皮、撤销、性能、移动端、高清屏、导出、协作、手势……
代码贴得比注释还多,复制粘贴都能跑,跑不通再来打我(再次强调别真打)。

等你把这篇所有代码跑通,你会发现
“我不仅学会了Canvas,还顺便学会了事件、性能、兼容、PWA、WebSocket……”

画板虽小,前端宇宙很大。
下一步,想不想给画板加个AI识别——
“你画的什么?”
“呃……猫?”
“不,是猪。”

祝你编码愉快,画技不涨,但BUG必消
我们下一篇**“用WebGL画一只会眨眼的猫”**再见!在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值