【Canvas 2D】day29-canvas-intro

作者:mario 发布时间: 2026-09-01 阅读量:4 评论数:0

Canvas 入门与坐标系 — 第一块画布,先解决"高分屏模糊"

今天是 Canvas 的第一天:认识 <canvas> 元素、拿到画笔 getContext("2d")、建立坐标系的直觉(y 轴向下!)。但最重要的一课藏在最后:为什么你画的线在 MacBook / 高分安卓机上发虚发模糊? 答案是 DPR(设备像素比)——90% 的 Canvas 新手第一天就撞上这堵墙,今天一次性拆掉它,并把"适配三行代码"沉淀成全周复用的工具函数。产出:一根带刻度的设备温度条。


目录


一、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 的骨架,贝塞尔曲线是它柔软的关节;产出第一张带平滑曲线的实时温度折线图。

评论