Canvas 入门与坐标系 — 第一块画布,先解决"高分屏模糊"
今天是 Canvas 的第一天:认识
<canvas>元素、拿到画笔getContext("2d")、建立坐标系的直觉(y 轴向下!)。但最重要的一课藏在最后:为什么你画的线在 MacBook / 高分安卓机上发虚发模糊? 答案是 DPR(设备像素比)——90% 的 Canvas 新手第一天就撞上这堵墙,今天一次性拆掉它,并把"适配三行代码"沉淀成全周复用的工具函数。产出:一根带刻度的设备温度条。
目录
- 一、canvas 元素:一块需要"借笔"的画布
- 二、坐标系:y 向下的左手世界
- 三、画布尺寸的三个层次(新手第一天的大坑)
- 四、DPR:高分屏模糊的元凶与三行代码修复
- 五、矩形三件套:fillRect / strokeRect / clearRect
- 六、实战:设备温度条
- 七、类比记忆:投影仪与幕布
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、canvas 元素:一块需要"借笔"的画布
1.1 最小可运行示例
<!-- canvas 只是"幕布",本身没有画图能力 -->
<canvas id="board" width="400" height="300"></canvas>
const canvas = document.getElementById("board") as HTMLCanvasElement;
// 借笔:拿到 2D 渲染上下文(画笔 + 颜料盒 + 橡皮)
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("此环境不支持 2D 上下文");
// 第一笔:
ctx.fillStyle = "#00ff88"; // 选颜色(默认黑色)
ctx.fillRect(20, 20, 120, 80); // 画一个 120×80 的矩形
1.2 context 是什么
// getContext("2d") 返回 CanvasRenderingContext2D —— 所有 2D 绘图 API 都挂在它身上:
ctx.fillStyle / strokeStyle / lineWidth // 状态类(当前画笔的设置)
ctx.fillRect / strokeRect / clearRect // 矩形三件套
ctx.beginPath / moveTo / lineTo // 路径家族(Day 30 主角)
ctx.save() / restore() // 状态栈(第 6 周主角)
// ⚠ 上下文有且只有一个:
canvas.getContext("2d") === canvas.getContext("2d"); // true —— 同一块画布永远拿到同一支笔
// "webgl" 是另一支笔(阶段 3 Three.js 的世界,2D 与 WebGL 不能同时借)
二、坐标系:y 向下的左手世界
// Canvas 坐标系(和数学课的正好相反):
//
// (0,0) ───────→ x
// │
// │
// ↓ y
//
// 1. 原点在【左上角】
// 2. y 轴【向下】为正 —— (10, 50) 在 (10, 10) 的【下方】
// 3. 单位是 CSS 像素(逻辑像素,DPR 修正前的值)
// ⚠ 来自数学课的肌肉记忆会坑你:
// 想画"温度越高条越高"(向上生长),y 必须递减:
const barTop = 200 - temperature; // 温度 80 → 顶在 y=120
ctx.fillRect(40, barTop, 30, temperature);
// 验证坐标直觉的小实验(今天必须亲手跑一遍):
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 20, 20); // 左上角的小红块
ctx.fillStyle = "blue";
ctx.fillRect(380, 280, 20, 20); // 右下角的小蓝块(width=400/height=300 时)
三、画布尺寸的三个层次(新手第一天的大坑)
3.1 width/height 属性 ≠ CSS 宽高
<!-- ⚠ 两套尺寸,控制着完全不同的东西 -->
<!-- 画布分辨率(像素缓冲区大小):属性控制 -->
<canvas width="400" height="300"></canvas>
<!-- 显示尺寸(占页面多大):CSS 控制 -->
<canvas style="width: 800px; height: 600px;"></canvas>
// 分离的后果(经典翻车现场):
// 分辨率 400×300,CSS 拉伸到 800×600 —— 每个逻辑像素被拉成 2×2 的糊块
// 就像 300p 的视频投到 4K 屏幕:能看,全是马赛克
// canvas.width / canvas.height → 分辨率(像素缓冲区)
// canvas.clientWidth / clientHeight → 显示尺寸(CSS 布局后的结果)
3.2 三个层次总表
| 层次 | 控制者 | 读取方式 | 影响 |
|---|---|---|---|
| 画布分辨率 | width/height 属性 | canvas.width |
像素缓冲区大小,决定清晰度上限 |
| 显示尺寸 | CSS | canvas.clientWidth |
页面上占多大地方 |
| 设备像素比 | 硬件 | window.devicePixelRatio |
1 个 CSS 像素背后有几个物理像素 |
四、DPR:高分屏模糊的元凶与三行代码修复
4.1 DPR 是什么
// 普通屏幕:DPR = 1(1 个 CSS 像素 = 1 个物理像素)
// MacBook Retina / 高分手机:DPR = 2 或 3(1 个 CSS 像素 = 2×2 或 3×3 个物理像素)
window.devicePixelRatio; // 2(在 Retina 屏上)
// 浏览器的默认行为:
// 画布分辨率 400×300 → 它被当 400×300 个 CSS 像素渲染 →
// 在 DPR=2 的屏上实际需要 800×600 个物理像素 → 浏览器直接放大插值 → 发虚!
4.2 修复方案:分辨率放大 + 绘制坐标缩放
/**
* DPR 适配:高分屏清晰的原理
* 1. 画布分辨率 × DPR(缓冲区塞满物理像素)
* 2. ctx.scale(DPR)(绘制坐标仍按 CSS 像素写,不用改任何业务代码)
*/
function setupCanvas(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const dpr = window.devicePixelRatio || 1;
// ① 显示尺寸(CSS 布局决定)
const cssWidth = canvas.clientWidth;
const cssHeight = canvas.clientHeight;
// ② 分辨率 = 显示尺寸 × DPR(三行代码的核心)
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
// ③ 坐标系缩放:此后所有绘制仍用 CSS 像素思维
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("no 2d context");
ctx.scale(dpr, dpr);
return ctx;
}
// 验证:修复前 fillRect(0,0,20,20) 的边缘是灰的(插值模糊)
// 修复后边缘是锐利的 —— 肉眼可见的差别,今天就试
4.3 封装成全周复用的 boot 模块
// canvas-boot.ts —— 本周所有项目共享的启动器
export interface CanvasBoot {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
width: number; // CSS 像素宽(业务坐标用这个)
height: number;
onResize: (cb: () => void) => void;
}
/** 初始化画布:DPR 适配 + 尺寸读取 + resize 监听 */
export function bootCanvas(
canvas: HTMLCanvasElement,
onResize?: () => void
): CanvasBoot {
const ctx = setupCanvas(canvas);
let resizeCb = onResize;
// 窗口变化时重新适配(resize 后 scale 会重置,必须重跑 setup)
window.addEventListener("resize", () => {
setupCanvas(canvas);
resizeCb?.();
});
return {
canvas,
ctx,
width: canvas.clientWidth,
height: canvas.clientHeight,
onResize: (cb) => { resizeCb = cb; },
};
}
五、矩形三件套:fillRect / strokeRect / clearRect
// 矩形是唯三"不需要路径"就能画的形状(路径是明天的大课):
// ① fillRect:填充矩形(实心)
ctx.fillStyle = "#0a1628"; // 深色底(工业大屏的经典底色)
ctx.fillRect(0, 0, 400, 300);
// ② strokeRect:描边矩形(空心)
ctx.strokeStyle = "#00ff88"; // 主题绿
ctx.lineWidth = 2; // 线宽(居中于边线,见下)
ctx.strokeRect(10, 10, 100, 50);
// ③ clearRect:清空矩形区域(变回透明,露出下层)
ctx.clearRect(20, 20, 30, 30); // 在上面两个矩形上"抠"一个透明洞
// ⚠ lineWidth 的居中陷阱:
// 描边以路径为中心向两侧各扩 lineWidth/2
// 想画"贴着画布边缘的 1px 边框"?
ctx.strokeRect(0.5, 0.5, 399, 299); // 坐标 +0.5 才不会有一半被裁掉
六、实战:设备温度条
今天产出——一根带刻度、带当前温度的横条(智能工厂大屏里最朴素的组件):
// day29-temperature-bar.ts
import { bootCanvas } from "./canvas-boot.js";
const boot = bootCanvas(document.querySelector("#board")!);
interface TempBarOptions {
x: number; // 左上角 x
y: number; // 左上角 y
width: number; // 条宽
height: number; // 条高
value: number; // 当前值(0-100)
label: string; // 设备名
}
/** 绘制一根温度条:底槽 + 数值条 + 刻度 + 标签 */
function drawTempBar(ctx: CanvasRenderingContext2D, o: TempBarOptions): void {
// ===== 1. 底槽(深色圆角感:底色矩形) =====
ctx.fillStyle = "#1a2740";
ctx.fillRect(o.x, o.y, o.width, o.height);
// ===== 2. 数值条(宽度按 value 比例,颜色按温度分档) =====
const ratio = Math.min(Math.max(o.value / 100, 0), 1);
ctx.fillStyle = o.value > 80 ? "#ff4d4f" : o.value > 50 ? "#ffc53d" : "#00ff88";
ctx.fillRect(o.x, o.y, o.width * ratio, o.height);
// ===== 3. 刻度(每 10% 一根短竖线) =====
ctx.strokeStyle = "#3a4a6a";
ctx.lineWidth = 1;
for (let i = 1; i < 10; i++) {
const tickX = o.x + (o.width / 10) * i;
ctx.strokeRect(tickX, o.y + o.height - 6, 0.5, 6); // 用细矩形当刻度线
}
// ===== 4. 标签(文本是 Day 33 的正式课程,今天先用起来) =====
ctx.fillStyle = "#e6f1ff";
ctx.font = "14px sans-serif";
ctx.fillText(`${o.label}: ${o.value}℃`, o.x, o.y - 8);
}
// ===== 主绘制 =====
const { ctx, width, height } = boot;
ctx.fillStyle = "#0a1628";
ctx.fillRect(0, 0, width, height); // 大屏底色
drawTempBar(ctx, { x: 40, y: 60, width: width - 80, height: 18, value: 72, label: "CNC-001" });
drawTempBar(ctx, { x: 40, y: 120, width: width - 80, height: 18, value: 91, label: "CNC-002" });
drawTempBar(ctx, { x: 40, y: 180, width: width - 80, height: 18, value: 35, label: "AGV-003" });
今日实操:
1. 建项目 day29-temperature-bar/,落地 canvas-boot.ts + 上面全部代码
2. 体验模糊:注释掉 setupCanvas 的两行 DPR 代码 → 在高分屏看边缘发虚 → 恢复
3. 改参数肉眼验证:温度改 95(变红)、刻度加密、y 向下坐标直觉
4. 博客:《Canvas 第一天:我修好了人生第一块模糊画布》
七、类比记忆:投影仪与幕布
canvas 三层尺寸 = 投影系统
- width/height 属性 = 投影仪的物理分辨率(真正有多少个像素可投)
- CSS 宽高 = 幕布尺寸(画面最终显示多大)
- DPR = 幕布的精细度(同样大的幕布,贴了多少个微小格子)
模糊事故 = 480p 投影仪投 4K 幕布 —— 幕布再好,源不行
DPR 适配 = 换 4K 投影仪(分辨率拉满)+ 镜头同步缩放(scale),
让你继续用"米"(CSS 像素)报幕,机器自动换算成"毫米"(物理像素)
八、常见坑点与最佳实践
坑点 1:用 CSS 强设 canvas 的 width/height
/* ❌ 千万别这样: */
canvas { width: 400px; height: 300px; } /* 只改显示尺寸,分辨率还是默认 300×150 */
/* 结果:画布内部被拉伸变形(默认分辨率 300×150 比例都不同) */
/* ✅ CSS 只管显示尺寸,分辨率永远在 JS 里 × DPR 设置 */
坑点 2:resize 后忘记重新适配
症状:拉伸窗口后画面消失 / 位置错乱
原因:canvas.width 被重新赋值会【重置整个画布状态】(内容清空 + scale 归 1)
修复:bootCanvas 的 resize 监听里重跑 setup + 重绘(今天的封装已内置)
坑点 3:整数坐标画 1px 线是"2px 灰线"
// 1px 线居中在像素边界 → 跨两个物理像素 → 各占 50% → 视觉变灰变粗
ctx.strokeRect(10, 10, 100, 50); // 模糊的 2px
ctx.strokeRect(10.5, 10.5, 100, 50); // 锐利的 1px(DPR=1 时)
// DPR=2 时 0.5 偏移可省(半像素恰好落在物理像素边界)—— 亲手实验确认
坑点 4:TypeScript 里 context 判空被忽略
// getContext("2d") 的返回类型是 CanvasRenderingContext2D | null
const ctx = canvas.getContext("2d"); // strictNullChecks 下 ctx 可能为 null
ctx.fillRect(0, 0, 10, 10); // ❌ 编译错误(第 1 周的开关在护航)
// ✅ 类型收窄:
if (!ctx) throw new Error("no 2d context");
// 第 1 个月的 strict 配置模板直接复制到本月项目 —— 工程化的利息
九、自测挑战
挑战 1(基础):三层尺寸问答
a. canvas.width 和 canvas.clientWidth 分别读的是什么?
b. DPR=3 的手机上,想要 CSS 400×300 的清晰画布,分辨率要设多少?
c. CSS 把画布显示为 800×600、分辨率是 400×300,会发生什么?
挑战 2(进阶):不写 y-向下陷阱的条形图
// 画 5 根竖直的温度柱,温度越高柱子越高(向上生长)
// 用数组 [30, 55, 80, 45, 95] 驱动 —— 注意 y 轴方向的换算
挑战 3(实验):模糊对照实验
1. 关闭 DPR 适配画一根 1px 竖线 → 截图(发虚)
2. 开启适配重画 → 截图(锐利)
3. 两图并排放进博客 —— 这是你给同事讲 DPR 的最好素材
挑战 4(论文级):讲清 scale(dpr) 的必要性
向橡皮鸭解释:
1. 只把 canvas.width 设成 cssWidth×dpr,不加 ctx.scale 会怎样?
2. (提示:fillRect(0,0,100,100) 画出来有多大?业务代码会受到什么影响?)
十、总结与知识图谱
Canvas 入门(Day 29)
│
├── canvas 元素
│ ├── 幕布本体(无绘图能力)
│ └── getContext("2d") 借笔(全局唯一)
│
├── 坐标系
│ ├── 原点左上、y 向下(数学课反向)
│ └── 单位是 CSS 像素
│
├── 三层尺寸体系 ⭐
│ ├── width/height 属性 —— 分辨率(缓冲区)
│ ├── CSS 尺寸 —— 显示大小
│ └── DPR —— 物理像素密度
│
├── DPR 适配三步
│ ├── 分辨率 = CSS 尺寸 × DPR
│ ├── ctx.scale(dpr, dpr)
│ └── resize 重跑(width 赋值重置状态)
│
└── 矩形三件套
├── fillRect —— 实心
├── strokeRect —— 空心(lineWidth 居中陷阱:+0.5)
└── clearRect —— 抠透明洞
一句话总结:Canvas 第一天最值钱的认知是**“画布有三层尺寸”**——分辨率、显示尺寸、DPR 各司其职;把 分辨率×DPR + scale 这三行写进 bootCanvas,全周的清晰度就有了保障。
明日预告:Day 30 路径与线条——beginPath/moveTo/lineTo 是 Canvas 的骨架,贝塞尔曲线是它柔软的关节;产出第一张带平滑曲线的实时温度折线图。