前言
在掌握了 Canvas 的基础 API 后,实现一个具备生产力的画板工具是进阶的最佳实践。本文将深入解析如何利用 事件监听、ImageData 状态快照 以及 合成模式,实现一个支持实时绘图、无限撤销和丝滑橡皮擦功能的网页画板。
一、 基础画笔:线条的艺术
实现思路
- 初始化:配置画布上下文,设置
lineCap(线头)和lineJoin(拐角)为round可使线条更丝滑。 - 设置画笔属性
- 监听鼠标事件实现绘图:
- 鼠标按下时,记录当前鼠标位置开始绘图
- 鼠标移动时,判断当前是否是绘图状态,是的话将前一点以当前点连接渲染出线条,并实时更新终点
- 鼠标松开时,将绘图状态设置为结束
代码实现
<!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 步)。