Day 35 BOSS 战:简易画板 — 第 5 周技能总决战
本周 6 天学的每个技能,今天要在一个真实作品里全部用上:DPR 适配(Day 29)让画板在 Retina 屏上清晰锐利;平滑笔迹(Day 30)让线条不再锯齿;颜色与粗细(Day 31)提供创作自由度;橡皮擦(Day 32 的 destination-out)是真擦除不是画白;文字工具(Day 33)支持在画布上落款;拖拽绘制 + 状态驱动(Day 34)是整个画板的交互骨架。做完这个画板,你对 Canvas 2D 基础的理解就从"会用 API"升级到"能造工具"。
目录
一、需求分析与技术方案
1.1 产品需求清单
| 功能 | 说明 | 用到的本周技能 |
|---|---|---|
| 画笔 | 按住拖动绘制平滑线条 | Day 30 平滑算法 + Day 34 拖拽 |
| 橡皮擦 | 真擦除(露出透明背景),非画白 | Day 32 destination-out |
| 颜色选择 | 预设色板 + 自定义 | Day 31 fillStyle |
| 粗细调节 | 滑块 1-40px | Day 30 lineWidth |
| 文字工具 | 点击位置输入文字 | Day 33 fillText |
| 撤销/重做 | Ctrl+Z / Ctrl+Y | ImageData 快照栈 |
| 清空 | 一键清空画布 | clearRect |
| 导出 PNG | 下载作品图片 | toDataURL |
| 触摸支持 | 手机也能画 | Day 34 touch 三件套 |
1.2 技术选型决策
笔迹平滑方案?
❌ 直接连点(lineTo 折线)→ 锯齿感明显
✅ 二次贝塞尔中点法(Day 30 的 drawSmoothLine)→ 圆滑自然
橡皮擦方案?
❌ 画背景色 → 背景透明时露馅,且擦不掉文字
✅ destination-out 合成 → 真·擦除,透明区域导出 PNG 也正确
撤销方案?
❌ 存操作记录重放 → 复杂、文字工具难重放
✅ ImageData 快照栈 → 简单粗暴,内存可控(限制栈深度)
二、架构设计:状态驱动 + 工具模式
2.1 整体架构图
┌──────────────────────────────────────────────┐
│ 用户交互 │
│ mousedown / move / up / touch │
└──────────────────┬───────────────────────────┘
↓ 修改状态
┌──────────────────────────────────────────────┐
│ 画板状态 AppState │
│ tool: "pen" | "eraser" | "text" │
│ color / lineWidth / undoStack / redoStack │
└──────────────────┬───────────────────────────┘
↓ 读取状态渲染
┌──────────────────────────────────────────────┐
│ 渲染层(Canvas) │
│ 主画布(已提交内容)+ 实时笔画直接绘制 │
└──────────────────────────────────────────────┘
2.2 核心状态类型定义
/** 工具类型:可辨识联合的基础标签 */
type ToolKind = "pen" | "eraser" | "text";
/** 画板核心状态 —— 所有交互只改这里,渲染只读这里 */
interface AppState {
tool: ToolKind; // 当前工具
color: string; // 画笔颜色(橡皮擦不用)
lineWidth: number; // 线宽(橡皮擦自动放大 3 倍)
drawing: boolean; // 是否正在绘制中
strokePoints: Point[]; // 当前笔画的轨迹点(实时收集)
undoStack: ImageData[]; // 撤销栈(快照)
redoStack: ImageData[]; // 重做栈
}
interface Point { x: number; y: number }
const state: AppState = {
tool: "pen",
color: "#00ff88",
lineWidth: 4,
drawing: false,
strokePoints: [],
undoStack: [],
redoStack: [],
};
三、逐模块实现
3.1 画布初始化(Day 29 复用)
// paint-board.ts —— 简易画板主程序
import { bootCanvas } from "../day29/canvas-boot.js";
const canvas = document.querySelector("#board") as HTMLCanvasElement;
const { ctx, cssWidth, cssHeight } = bootCanvas(canvas);
3.2 事件绑定:鼠标 + 触摸统一处理
/**
* 启动画板交互:把鼠标与触摸统一到同一套 start/move/end 回调
* 好处:核心绘制逻辑只写一份,两套事件只是"翻译官"
*/
function bindEvents(): void {
// --- 鼠标 ---
canvas.addEventListener("mousedown", (e) => {
const p = eventToCanvas(canvas, e);
startStroke(p);
});
canvas.addEventListener("mousemove", (e) => {
if (!state.drawing) return; // 没在画就忽略(性能)
const p = eventToCanvas(canvas, e);
moveStroke(p);
});
window.addEventListener("mouseup", endStroke); // ⭐ window 上监听:拖出画布也能结束
// --- 触摸(统一翻译成同一套回调) ---
canvas.addEventListener("touchstart", (e) => {
e.preventDefault(); // 阻止页面滚动
const t = e.touches[0];
startStroke({ x: t.clientX - canvas.getBoundingClientRect().left,
y: t.clientY - canvas.getBoundingClientRect().top });
}, { passive: false }); // ⚠ 要 preventDefault 必须 passive: false
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
if (!state.drawing) return;
const t = e.touches[0];
const rect = canvas.getBoundingClientRect();
moveStroke({ x: t.clientX - rect.left, y: t.clientY - rect.top });
}, { passive: false });
canvas.addEventListener("touchend", (e) => {
e.preventDefault();
endStroke();
}, { passive: false });
}
/** 视口坐标 → 画布坐标(Day 34 的换算函数) */
function eventToCanvas(c: HTMLCanvasElement, e: MouseEvent): Point {
const rect = c.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
3.3 笔画三阶段:start / move / end
/**
* 开始一笔:拍照存快照(供撤销)+ 记录起点
*/
function startStroke(p: Point): void {
if (state.tool === "text") {
insertText(p); // 文字工具走独立流程(见 3.5)
return;
}
// ⭐ 撤销的关键:落笔【之前】把当前画面存进撤销栈
pushUndoSnapshot();
state.drawing = true;
state.strokePoints = [p]; // 轨迹从起点开始收集
// 橡皮擦切换合成模式(真擦除);画笔保持默认
if (state.tool === "eraser") {
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = state.lineWidth * 3; // 橡皮擦放大 3 倍更好用
ctx.strokeStyle = "rgba(0,0,0,1)"; // 颜色无所谓,只借形状
} else {
ctx.strokeStyle = state.color;
ctx.lineWidth = state.lineWidth;
ctx.lineCap = "round"; // 圆头: Day 30
ctx.lineJoin = "round";
}
}
/**
* 笔画进行中:收集轨迹点 + 实时把"最后两个点"画出来
*
* ⚠ 为什么只画最后一段而不是整条重画?
* 实时整条重画(clear + drawSmoothLine 全轨迹)也行,但点多了每帧都全量重绘;
* 增量画最后一段性能最好。整条平滑交给 endStroke 的贝塞尔收尾。
*/
function moveStroke(p: Point): void {
state.strokePoints.push(p);
const pts = state.strokePoints;
const n = pts.length;
if (n < 2) return;
// 增量绘制:从上一段的中点,以倒数第二点为控制点,画到最新点与上一点的终点
ctx.beginPath();
ctx.moveTo(pts[n - 2].x, pts[n - 2].y);
ctx.lineTo(pts[n - 1].x, pts[n - 1].y);
ctx.stroke();
}
/**
* 结束一笔:恢复状态 + 清空轨迹
*/
function endStroke(): void {
if (!state.drawing) return;
state.drawing = false;
state.strokePoints = [];
ctx.globalCompositeOperation = "source-over"; // ⭐ 必须恢复,否则画笔也变橡皮!
ctx.lineWidth = state.lineWidth; // 恢复线宽
}
3.4 撤销 / 重做:快照栈
const MAX_UNDO = 30; // 内存保险丝:最多存 30 步
/** 把当前画面压入撤销栈(落笔前调用) */
function pushUndoSnapshot(): void {
// ⚠ getImageData 用的是物理像素坐标(不受 ctx.scale 影响)!
// Day 29 我们 canvas.width = cssWidth * dpr,所以要取全尺寸:
state.undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
state.redoStack = []; // 新笔画清空重做栈(标准编辑器行为)
if (state.undoStack.length > MAX_UNDO) {
state.undoStack.shift(); // 超限丢最老的
}
}
/** 撤销:弹出撤销栈顶 → 当前画面压入重做栈 → 恢复弹出的快照 */
function undo(): void {
const snap = state.undoStack.pop();
if (!snap) return;
state.redoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
ctx.putImageData(snap, 0, 0);
}
/** 重做:镜像逻辑 */
function redo(): void {
const snap = state.redoStack.pop();
if (!snap) return;
state.undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
ctx.putImageData(snap, 0, 0);
}
// 快捷键
window.addEventListener("keydown", (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key === "z") { e.preventDefault(); undo(); }
if (e.key === "y") { e.preventDefault(); redo(); }
});
3.5 文字工具(Day 33 复用)
/**
* 在点击位置插入文字:用临时 input 浮在页面上输入,回车确认后画上画布
* (Canvas 没有原生文本输入框,这是业界通行做法)
*/
function insertText(p: Point): void {
pushUndoSnapshot(); // 文字也算一步操作,可撤销
const input = document.createElement("input");
input.style.position = "fixed";
input.style.left = `${canvas.getBoundingClientRect().left + p.x}px`;
input.style.top = `${canvas.getBoundingClientRect().top + p.y - 14}px`;
input.style.fontSize = `${16 + state.lineWidth}px`;
input.placeholder = "输入文字,回车确认";
document.body.appendChild(input);
input.focus();
const commit = (): void => {
if (input.value.trim()) {
ctx.fillStyle = state.color;
ctx.font = `${16 + state.lineWidth}px sans-serif`;
ctx.textBaseline = "top"; // 点击点即文字顶部,直觉自然
ctx.fillText(input.value, p.x, p.y);
}
input.remove();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") input.remove(); // Esc 取消
});
input.addEventListener("blur", commit); // 失焦也提交
}
3.6 工具栏与导出
/** 清空画布(可撤销!先存快照) */
function clearBoard(): void {
pushUndoSnapshot();
ctx.clearRect(0, 0, cssWidth, cssHeight); // 注意用 CSS 尺寸(ctx 已 scale)
}
/** 导出 PNG:透明背景合成白底(否则透明区在部分看图软件显示黑底) */
function exportPNG(): void {
const out = document.createElement("canvas");
out.width = canvas.width;
out.height = canvas.height;
const octx = out.getContext("2d")!;
octx.fillStyle = "#ffffff"; // 先铺白底
octx.fillRect(0, 0, out.width, out.height);
octx.drawImage(canvas, 0, 0); // 再叠画布内容
const link = document.createElement("a");
link.download = `paint-${Date.now()}.png`;
link.href = out.toDataURL("image/png");
link.click();
}
// --- 工具栏事件(HTML 略:色板若干色块 + 粗细滑块 + 6 个按钮) ---
document.querySelectorAll("[data-tool]").forEach((btn) => {
btn.addEventListener("click", () => {
state.tool = (btn as HTMLElement).dataset.tool as ToolKind;
});
});
document.querySelector("#size")!.addEventListener("input", (e) => {
state.lineWidth = Number((e.target as HTMLInputElement).value);
});
document.querySelector("#undo")!.addEventListener("click", undo);
document.querySelector("#redo")!.addEventListener("click", redo);
document.querySelector("#clear")!.addEventListener("click", clearBoard);
document.querySelector("#save")!.addEventListener("click", exportPNG);
四、核心难点攻关
难点 1:橡皮擦为什么会"擦不掉"?
// 症状:切到橡皮擦后画了几笔,切回画笔 —— 画笔也变成擦的了!
// 原因:globalCompositeOperation 是【状态】,endStroke 忘了恢复就泄漏了
// ✅ 解法已写在 endStroke:
ctx.globalCompositeOperation = "source-over"; // 每笔结束必恢复默认
// ⭐ 教训(呼应 Day 32 的阴影泄漏):ctx 的所有状态都是"粘"的,
// 每次绘制前显式设置完整状态,或结束后恢复 —— 二选一,必须做
难点 2:getImageData / putImageData 与 DPR 的恩怨
// getImageData/putImageData 操作的是【物理像素缓冲区】,无视 ctx.scale!
// 所以快照必须用 canvas.width / canvas.height(物理尺寸):
ctx.getImageData(0, 0, canvas.width, canvas.height); // ✅ 全量
ctx.getImageData(0, 0, cssWidth, cssHeight); // ❌ 只截了左上 1/dpr 区域
// 而 clearRect 走绘制管线(受 ctx.scale 影响),用 CSS 尺寸:
ctx.clearRect(0, 0, cssWidth, cssHeight); // ✅ 全量
记忆口诀:put/get ImageData 用物理,绘制类 API 用 CSS。
难点 3:单指触摸 preventDefault 的 passive 坑
// 症状:手机上 preventDefault 报错 "Unable to preventDefault inside passive..."
// 原因:Chrome 56+ 默认 touch 监听是 passive: true(为了滚动性能,禁止 preventDefault)
// 解法:显式声明 { passive: false }
canvas.addEventListener("touchmove", handler, { passive: false });
五、验收标准与自测
5.1 功能验收清单
- [ ] Retina 屏(或浏览器缩放 200%)画出的线依然清晰不模糊
- [ ] 画笔线条圆滑无锯齿,粗细随滑块变化
- [ ] 橡皮擦擦过的区域是真透明(导出 PNG 后与白色对比可验证)
- [ ] 文字工具:点击 → 输入 → 回车上画布,Esc 取消
- [ ] Ctrl+Z 撤销 5 步,Ctrl+Y 重做 5 步,行为符合直觉
- [ ] 撤销栈超过 30 步不内存爆炸(DevTools Memory 面板确认)
- [ ] 手机浏览器可正常画画,画时页面不滚动
- [ ] 导出的 PNG 打开后是白底 + 完整画作
5.2 代码质量自检
- [ ] 交互只改
state,绘制逻辑只读state(状态驱动) - [ ] 每笔结束后 ctx 状态全部复位(合成模式 / 线宽)
- [ ] 没有重复的坐标换算代码(函数复用)
- [ ] 代码分模块:boot / events / stroke / history / toolbar 各司其职
六、进阶挑战(选做)
- 平滑升级:把
moveStroke的增量直线改为 Day 30 的二次贝塞尔中点法实时平滑(提示:保存上一中点,每 move 画一段 quadraticCurveTo)。 - 矩形/圆形工具:拖拽预览形状(需要在第二层"预览画布"上绘制,松手才提交主画布——为第 7 周多层画布埋伏笔)。
- 压力感应:
PointerEvent.pressure获取压感(数位板),线宽随压力变化。 - 性能压测:快速乱画 30 秒,用 Performance 面板录制,确认帧率稳定 60FPS、无 Long Task 超过 50ms。
七、常见坑点速查
| # | 坑 | 症状 | 一句话解法 |
|---|---|---|---|
| 1 | 合成模式泄漏 | 切回画笔还在擦 | endStroke 恢复 source-over |
| 2 | 快照尺寸错误 | 撤销后画面只恢复一角 | getImageData 用 canvas.width(物理) |
| 3 | clearRect 尺寸错误 | 清不干净 | clearRect 用 cssWidth(CSS) |
| 4 | 触摸 passive 报错 | preventDefault 无效 | { passive: false } |
| 5 | 拖出画布松手卡死 | drawing 永远 true | mouseup 监听 window |
| 6 | 导出黑底 | 透明区在别的软件变黑 | 导出前铺白底再 drawImage |
| 7 | 重做栈逻辑乱 | 撤销后画新笔,重做把旧内容"复活" | 新笔画清空 redoStack |
| 8 | 移动端双击缩放 | 快速画两笔页面放大 | viewport meta + touch-action: none |
八、周总结与知识图谱
8.1 第 5 周技能全景
第 5 周 Canvas 基础(Day 29-35)
├── Day 29 入门与坐标系
│ ├── DPR 适配三步曲(分辨率×dpr + ctx.scale)
│ └── 矩形三件套 fillRect / strokeRect / clearRect
├── Day 30 路径与线条
│ ├── beginPath 两阶段(描草稿 → 显影)
│ ├── lineCap / lineJoin 圆滑关键
│ └── 二次贝塞尔中点平滑法
├── Day 31 渐变与填充
│ ├── 线性 / 径向渐变(色标 addColorStop)
│ └── createPattern 平铺
├── Day 32 阴影与合成
│ ├── 阴影四件套(用完必须关!)
│ └── destination-out 橡皮擦 / lighter 叠加发光
├── Day 33 文本与图片
│ ├── textAlign / textBaseline 锚点系统
│ ├── measureText 动态排版
│ └── drawImage 九参裁剪 + 跨域 crossOrigin
├── Day 34 交互与动画
│ ├── getBoundingClientRect 坐标换算
│ ├── rAF + dt 帧率无关动画
│ └── 状态驱动渲染思想
└── Day 35 BOSS:简易画板 🎯
├── 状态驱动 + 工具模式架构
├── 快照栈撤销 / 重做
├── 真·橡皮擦(合成模式)
└── 鼠标 / 触摸统一事件层
8.2 本周认知升级
| 之前(学本周前) | 现在(学完本周后) |
|---|---|
| Canvas 是"高级 div",画画靠猜 | Canvas 是状态机 + 像素缓冲区,一切尽在掌握 |
| 高清屏上模糊是"浏览器问题" | DPR 适配三行代码,原理门儿清 |
| 橡皮擦 = 画白色 | 橡皮擦 = destination-out 合成,真擦除 |
| 动画 = setInterval | 动画 = rAF + 时间差,帧率无关 |
| 画完就完了,改不了 | 状态驱动:画面是状态的投影,随时可撤销可重放 |
8.3 下周预告(第 6 周:Canvas 变换与像素)
画板里"跟随鼠标旋转的笔刷"、“整体缩放的画布"靠的就是变换矩阵(translate/rotate/scale 与 save/restore);“图片滤镜"靠的是 **ImageData 像素级操作”。下周从"画什么"进阶到"怎么变换着画"与"直接操纵向素”——工业组态的图元旋转、热成像滤镜,全在这一周。
本周毕业标准:画板作品至少使用 6 项功能完成一幅作品并导出 PNG,代码能脱离文档独立复写核心模块(笔画三阶段 + 撤销)。