涂鸦新手必看:用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 里没 beginPath | 把 beginPath 挪到 start |
| 触摸无效 | 没阻止默认滚动 | touchmove 加 preventDefault |
| 清空后还能撤销出旧图 | saveState 时机不对 | 清空后立刻 saveState() |
| 橡皮擦留下白痕 | 用了白色画笔冒充橡皮 | 改 globalCompositeOperation |
| 手机画出屏幕 | 没禁止 touch-action | CSS 加 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画一只会眨眼的猫”**再见!


被折叠的 条评论
为什么被折叠?



