Web特效07—Canvas 2D深挖:一支“画笔“到底是怎么在网页上画画的

0 阅读23分钟

Web特效07—Canvas 2D深挖:一支"画笔"到底是怎么在网页上画画的

上一篇(06)我们深挖了 WebGL,弄清楚了顶点着色器和片元着色器到底在干什么。这一篇把视角切回 Canvas 2D,从"拿笔画画"这个最朴素的类比讲起:canvas.getContext('2d') 这一行代码,到底给了你一支什么样的"笔"?答案是——一支"画完即定型、不会回头"的笔。你调用 beginPathlineTofill 这些方法时,就像真的落笔、走线、上色,每一步都是"现在、马上"发生的,颜料一干,画布上只剩一张改不回去的位图,不会保留"这是一条线"这样的结构信息。这和 WebGL/WebGPU"先整理好所有点位数据,再一次性交给 GPU 统一渲染"的思路完全不同,更像是"先画施工图纸再统一开工" vs "想到哪画到哪"。这篇文章会顺着"画笔"这个类比讲透三件事:这支笔带着哪些"状态"(fillStyle、lineWidth、globalAlpha、transform 这些属性怎么影响接下来的每一笔)、"即时模式(immediate mode)"绘图到底是什么意思、以及这种"画完即定型"的机制决定了 Canvas 2D 天生适合画什么、不适合画什么。

请添加图片描述

一、getContext('2d'):你拿到的到底是一支什么样的笔

1. 先问一个最朴素的问题:一支笔自己记着什么

你在纸上画画的时候,手里那支笔其实知道不少事情:它知道自己现在蘸的是什么颜色,知道笔尖有多粗,知道你现在下笔的力度大不大。这些信息不是纸给的,也不是墨水给的——是笔自己记着的。你换一支笔,这些设定就全变了;你不换笔,只是换个姿势继续画,笔还记得刚才的颜色和粗细。 请添加图片描述 这个"笔自己记着状态"的直觉,是理解 Canvas 2D 的第一把钥匙。因为 canvas.getContext('2d') 这一行代码,给你的正是这样一支"记性很好"的笔。

2. canvas.getContext('2d') 一行代码,到底返回了什么

<canvas> 标签本身只是一块"空白画布"——一块占了页面位置的矩形区域,什么都不知道,也不会画画。真正让它能画画的,是你调用 getContext('2d') 之后拿到的这个东西:一个 CanvasRenderingContext2D 对象。

这个对象不是"数据",而是一支"已经准备好可以直接用"的笔。你接下来调用的 fillRectstrokeStylebeginPath 这些方法,都是在问这支笔要东西、或者告诉这支笔改变设定——本质上和你伸手去拿一支笔、拧开笔帽、开始画画,是同一件事。

3. 和 WebGL context 对比:一个空入口 vs 一支装好的笔

06 里我们深挖过 WebGL,你应该还记得 canvas.getContext('webgl') 拿到的 context,其实相当"空"——它只是一个让你和 GPU 渲染管线打交道的入口。顶点数据怎么组织、着色器程序怎么写、渲染管线怎么跑,这些活儿全部要你自己动手搭起来,WebGL context 本身不替你做任何"画"的工作。

Canvas 2D 的 context 完全是另一种思路。你调用 getContext('2d') 的那一刻,拿到手的对象里已经装好了一整套"画笔的记忆"——当前用什么颜色填充(fillStyle)、线有多粗(lineWidth)、整体透明度是多少(globalAlpha)、坐标要不要做变换(transform)……这些状态早就准备好了,你不需要自己搭建任何底层结构,拿起来就能画。

换句话说:WebGL context 是一张"空的工作台",东西要你自己摆上去;Canvas 2D context 是一支"已经蘸好墨的笔",你拿起来就能直接画。

4. 这才是"高层接口"真正的意思

05 里说过一句话:"Canvas2D 是高层'画笔式'接口"。看完前面三节,这句话现在应该具体了——"高层"不是一个抽象的形容词,它对应的是一个实实在在的事实:浏览器在你调用 getContext('2d') 的那一刻,已经替你把"怎么画"这件事处理好了,你只需要告诉它"画什么颜色的什么形状",剩下的光栅化、填色这些底层工作,不需要你操心。

这也是为什么后面我们会花一整节专门讲这支笔身上到底挂着哪些"状态"——因为理解了这些状态,你才能理解 Canvas 2D 接下来每一笔画出来的东西,到底是怎么被决定的。下一节我们就把这支笔身上的"记忆"一项一项拆开看。

二、状态机式绘图:这支笔自己会"记住"什么

1. 类比一下:这支笔更像一台"调音台",不是一次性用品

想象你面前有一台调音台,上面一排旋钮——音量、混响、高低音。你转一下"混响"旋钮,它就停在那个位置,除非你再去动它,不然接下来放的每一首歌都会带着这个混响效果,不用你每次都重新调一遍。

Canvas 2D 的 context 就是这样一台"调音台"。fillStylelineWidthglobalAlpha……这些不是你每次画图时临时传进去的参数,而是一个个"旋钮",你拧一次,它就停在那,后面所有的绘制调用都会读这个旋钮当前的位置——直到你再去拧它。这种"状态被记住、后续操作都读取当前状态"的模式,专业说法叫状态机(state machine)

2. 这支笔身上到底挂着哪些"旋钮"

常用的几个状态,按用途分一下:

状态属性控制什么默认值
fillStyle填充用的颜色/渐变/图案#000000(黑)
strokeStyle描边用的颜色/渐变/图案#000000(黑)
lineWidth描边线条的粗细1
globalAlpha整体透明度(0~1)1
lineCap / lineJoin线条端点/拐角的样式butt / miter
font文字的字号、字体10px sans-serif
transform当前的坐标变换矩阵单位矩阵(不变换)

这些属性不需要你每次绘制都重新指定,拧一次,它会一直生效到你改它为止。

3. 状态是"累积"的,不是"一次性"的

来看一段代码,感受一下这种"记住"的效果:

const ctx = canvas.getContext('2d');

ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 50, 50);   // 红色方块

ctx.fillRect(70, 10, 50, 50);   // 还是红色——因为 fillStyle 没变

ctx.fillStyle = 'blue';
ctx.fillRect(130, 10, 50, 50);  // 现在才变蓝

第二次 fillRect 没有重新设置颜色,但它画出来还是红色的——因为 fillStyle 这个旋钮此时就停在"红"这个位置上,fillRect 只是去读了一下当前状态,自己并不知道"红色"是什么时候设的。这正是状态机式绘图和"每次调用都要把所有参数传全"的接口最大的不同。

4. save() / restore():把当前这一整套旋钮位置存起来,再一键调回去

如果你想"临时"改一组状态,画完之后再恢复原样,一个个把旋钮转回去很麻烦。Canvas 2D 提供了 save()restore():save() 把当前这一整套状态(不是某一个属性,是全部)复制一份,压进一个栈里;restore() 把栈顶这份状态弹出来,直接覆盖回当前状态——相当于一键"撤销到刚才存档的那个时间点"。

ctx.fillStyle = 'black';
ctx.save();          // 存档①:黑色

ctx.fillStyle = 'blue';
ctx.save();          // 存档②:蓝色

ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 50, 50);   // 红色方块

ctx.restore();        // 弹回存档②:回到蓝色
ctx.restore();        // 弹回存档①:回到黑色

请添加图片描述

这一套"压栈—弹栈"的机制,让你可以放心地在某一小段绘制里"随便改状态",画完调用 restore(),后面的代码不会受到任何影响——这是状态机模型能保持代码整洁的关键。

下一节我们就要谈谈,这套"记住状态、调用就画"的模式,和它落到画布上的那一刻会发生什么——也就是"即时模式(immediate mode)"到底是什么意思。

三、即时模式(Immediate Mode):画完这一笔,就再也改不了了

1. 什么叫“即时模式”:命令一执行,结果就落到画布上

Canvas 2D 最核心的特点之一,就是它属于**即时模式(Immediate Mode)**绘制。这个名字听起来有点抽象,其实可以把它理解成在纸上作画:你拿笔画下一条线,纸上立刻就留下了痕迹;之后就算你忘了这条线从哪里开始画、用了什么颜色,纸本身也不会替你保存这些信息。

Canvas 也是同样的逻辑。调用绘制方法后,浏览器会立刻把结果写进 Canvas 的像素位图中:

ctx.fillStyle = "#1b9b9c";
ctx.fillRect(40, 40, 160, 100);

执行完这两行,Canvas 上的对应区域已经被填成青绿色。此时它保存的是一块块像素的颜色结果,而不是“这里有一个宽 160、高 100 的矩形对象”。

Canvas 记住的是画完后的像素,不是你当初画了什么图形。

这也是为什么我们平时会说:Canvas 是一块画布,不是一组图形元素的集合。

2. 为什么不能像修改 DOM 一样,直接改掉已经画好的图形

假设网页上有一个普通的 DOM 元素:

<div class="box"></div>

你可以随时通过 CSS 或 JavaScript 找到它、修改它:

const box = document.querySelector(".box");

box.style.background = "#ef4444";
box.style.width = "220px";

因为浏览器知道这个 div 是一个独立对象:它有自己的标签、样式、尺寸和位置。SVG 里的 <rect><circle> 也是类似的道理,浏览器会保留每个图形节点。

但 Canvas 不会帮你保留这些对象。比如下面这个圆:

ctx.beginPath();
ctx.arc(150, 100, 50, 0, Math.PI * 2);
ctx.fillStyle = "#f59e0b";
ctx.fill();

fill() 执行后,画布里只剩下一片由橙色像素组成的圆形区域。Canvas 不会提供类似下面这样的代码:

// Canvas 里不存在这种写法
circle.fillStyle = "#ef4444";
circle.x = 220;

因为 circle 这个对象根本没有被 Canvas 留下来。它已经“消失”在位图里了。

绘制后的状态DOM / SVGCanvas 2D
是否保留图形对象保留不保留
能否定位某个图形可以直接找到节点不能直接找到
修改颜色或位置修改对象属性清除后重新绘制
最终保存的主要内容节点结构与样式像素颜色

3. 想“修改”画面,实际做法是:擦掉,再画一遍

既然 Canvas 不保存圆、矩形、线条这些对象,那动画是怎么实现的?答案是:每一帧都先清掉旧画面,根据最新数据把整张画布重新画出来。

例如让一个小球向右移动:

const ball = {
  x: 40,
  y: 100,
  radius: 20,
  speed: 2,
  color: "#1b9b9c"
};

function draw() {
  // 1. 擦除上一帧留下的像素
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 2. 更新数据,而不是修改画布里的旧圆
  ball.x += ball.speed;

  // 3. 根据最新数据重新绘制一个圆
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = ball.color;
  ctx.fill();

  requestAnimationFrame(draw);
}

draw();

从视觉上看,小球像是在画布上“移动”;但实际发生的是:旧位置的像素被清除,新位置重新画出一个圆。由于屏幕刷新很快,用户看到的就是连续动画。

flowchart LR
    A[保存当前图形数据] --> B[清除上一帧像素]
    B --> C[更新位置、速度等数据]
    C --> D[按最新数据重新绘制]
    D --> E[浏览器显示新的一帧]
    E --> B

所以,Canvas 动画真正需要维护的不是“画布里的对象”,而是 JavaScript 中的数据对象。例如上面的 ball.xball.yball.speed。数据是你自己保存的,Canvas 只是根据这些数据不断输出新的像素结果。

在 Canvas 中,状态存在 JavaScript 数据里;画布只负责呈现这一刻的结果。

理解了即时模式,后面很多现象就都顺理成章了:为什么动画总要先 clearRect(),为什么画布尺寸变化后内容会消失,以及为什么 Canvas 适合粒子、游戏和动态特效。下一节我们继续拆开看:一条路径是怎样被浏览器转换成一堆像素的。

四、路径(Path)与位图:从“一条线”到“一堆像素”发生了什么

1. lineTo() 不是立刻画线,而是在记录一条“行走路线”

第一次接触 Canvas 路径时,很多人都会有一个疑问:我明明调用了 lineTo(),为什么画布上却什么也没有出现?原因是 lineTo() 的职责不是直接涂像素,而是把“笔接下来要怎么走”记录到一份路径(Path)里。

可以把路径理解成画画前打的一份草稿:从哪里起笔,往哪里走,转弯还是画圆。这些方法都在描述路线:

ctx.beginPath();
ctx.moveTo(40, 80);     // 把起笔位置放到 (40, 80)
ctx.lineTo(220, 80);    // 记录:从起点连到这里
ctx.lineTo(130, 180);   // 记录:再连到这里

执行完以后,Canvas 已经知道要画一个三角形轮廓,但画布上仍然没有任何变化。因为此时浏览器只拿到“路线”,还没有收到“把这条路线真正画出来”的命令。

路径描述的是图形怎么走;它本身还不是画布上的颜色。

真正让路径落到画布上的,是 stroke()fill()

ctx.strokeStyle = "#1b9b9c";
ctx.lineWidth = 8;
ctx.stroke();

stroke() 会沿着路径描边;fill() 则会填满路径围成的区域。到这一步,Canvas 才开始计算并写入像素。

2. 从路径到位图:浏览器到底做了哪些事

路径本质上是几何描述。比如“从 A 点连到 B 点”“以某点为圆心画一个半径为 50 的圆弧”,这些都是连续、平滑的数学概念;但 Canvas 画布是一张由有限像素组成的位图,最终只能存下每个像素的颜色和透明度。

所以在调用 stroke()fill() 后,浏览器需要经历一次“把几何图形翻译成像素”的过程,这个过程叫作光栅化(Rasterization)

flowchart LR
    A[路径指令<br/>moveTo / lineTo / arc] --> B[当前路径<br/>记录几何路线]
    B --> C[读取绘图状态<br/>颜色、线宽、透明度、变换]
    C --> D[光栅化<br/>计算哪些像素被覆盖]
    D --> E[写入 Canvas 位图<br/>RGBA 像素数据]

例如,浏览器在画一条斜线时,要判断哪些像素完全被线覆盖,哪些像素只擦到边缘;边缘像素通常会写入较低的透明度,让线条看起来更平滑。这就是我们常说的抗锯齿效果。

最终,Canvas 里保存的并不是“从 (40, 80)(220, 80) 的线”,而是一大批像素:每一个像素都有自己的红、绿、蓝和透明度值,也就是 RGBA 数据。

阶段Canvas 内部主要保存什么能否直接看见
调用 moveTo()lineTo()路径的几何路线不能
调用 stroke()fill()计算后写入的像素
绘制完成后位图中的 RGBA 像素结果

这也是为什么一条已经画完的线,并不会以“线对象”的形式留在 Canvas 里。它已经被转换成位图的一部分了。

3. 为什么 beginPath() 很重要:Canvas 会记住上一条路线

Canvas 默认维护着一份“当前路径”。如果你没有调用 beginPath(),后续的 moveTo()lineTo()arc() 会继续往这份路径里添加内容。

ctx.beginPath();
ctx.arc(80, 80, 40, 0, Math.PI * 2);
ctx.stroke();

// 没有 beginPath(),下面这条线会和上面的圆处于同一条路径中
ctx.moveTo(150, 80);
ctx.lineTo(260, 80);
ctx.stroke();

上面的第二次 stroke() 不仅会描出新线,也可能把之前路径中的圆再描一遍。虽然像素颜色相同的时候不一定明显,但当你改变线宽、颜色或透明度时,问题就会暴露出来。

正确的习惯是:每准备绘制一个独立图形,就先调用一次 beginPath()

// 第一个图形:圆
ctx.beginPath();
ctx.arc(80, 80, 40, 0, Math.PI * 2);
ctx.fillStyle = "#f59e0b";
ctx.fill();

// 第二个图形:线
ctx.beginPath();
ctx.moveTo(150, 80);
ctx.lineTo(260, 80);
ctx.strokeStyle = "#1b9b9c";
ctx.lineWidth = 6;
ctx.stroke();

如果一条路径需要反复使用,可以通过 Path2D 把它单独保存下来:

const triangle = new Path2D();

triangle.moveTo(120, 30);
triangle.lineTo(220, 180);
triangle.lineTo(20, 180);
triangle.closePath();

ctx.fillStyle = "#fde68a";
ctx.fill(triangle);

ctx.strokeStyle = "#b45309";
ctx.lineWidth = 5;
ctx.stroke(triangle);

Path2D 保存的是图形路线,方便你多次描边或填充;但它仍然不是画布中的可编辑图形对象。每次调用 fill()stroke(),浏览器依然会把路径重新光栅化,写入 Canvas 的位图。

路径是临时的几何描述,位图才是 Canvas 最终留下的结果。

理解“路径先行、像素落地”这件事后,就能更清楚地看见 Canvas 2D 的接口特点:它已经替我们隐藏了大量图形学细节。下一节我们把它和 WebGL 放在一起比较,看看为什么 Canvas 2D 更像一支高级画笔,而 WebGL 更像直接走进了 GPU 的渲染流水线。

五、和 WebGL 反着来:为什么说 Canvas 2D 是“高层画笔式”接口

1. Canvas 2D:你说“画什么”,它负责“怎么画”

前面我们已经知道,Canvas 最终留下的是一张位图。但在日常开发中,我们不需要亲自计算“这条斜线要覆盖哪几个像素”“圆边缘该用多少半透明像素抗锯齿”。这些底层工作,Canvas 2D 已经替我们封装好了。

你只需要像拿着一支画笔一样,告诉它要画什么、画在哪里、用什么样式:

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

ctx.beginPath();
ctx.arc(160, 100, 60, 0, Math.PI * 2);

ctx.fillStyle = "#1b9b9c";
ctx.fill();

这段代码表达的意思很直接:在 (160, 100) 画一个半径为 60 的圆,并填充成青绿色。至于圆边缘怎样计算、路径怎样光栅化、结果怎样写进画布,Canvas 2D 都会在背后完成。

Canvas 2D 是“描述结果”的接口:开发者说出想画的东西,浏览器负责把它绘制出来。

因此,Canvas 2D 提供的 API 很接近日常绘画:fillRect() 画矩形、arc() 画圆弧、fillText() 写文字、drawImage() 贴图片。你不需要先理解 GPU 的工作过程,也可以很快完成二维图形和动画。

2. WebGL:除了说“画什么”,还要准备“怎么让 GPU 画”

WebGL 的目标同样是把图形显示成像素,但它把控制权交得更深。它不会直接提供 arc()fillRect() 这种“画圆”“画矩形”的高级方法。

例如,在 Canvas 2D 中画一个矩形只需要一行:

ctx.fillStyle = "#ef4444";
ctx.fillRect(40, 40, 160, 100);

而在 WebGL 中,一个矩形通常需要先拆成两个三角形;然后准备顶点坐标、创建缓冲区、编写着色器程序,最后再向 GPU 发出绘制命令。因为 GPU 擅长并行处理三角形,复杂图形通常也是由大量三角形拼出来的。

两者的工作流程可以这样理解:

flowchart TB
    subgraph A[Canvas 2D:高层画笔]
        A1[调用 arc / fillRect / drawImage]
        A2[浏览器处理路径、样式与光栅化]
        A3[写入 Canvas 位图]
        A1 --> A2 --> A3
    end

    subgraph B[WebGL:GPU 图形接口]
        B1[准备顶点、纹理与缓冲区]
        B2[编写并运行着色器]
        B3[GPU 图形渲染流水线]
        B4[写入 Canvas 位图]
        B1 --> B2 --> B3 --> B4
    end

这里的着色器(Shader),可以先把它理解成运行在 GPU 上的小程序:它决定顶点最终出现在什么位置,也决定每个像素显示成什么颜色。WebGL 给了开发者更大的自由,也意味着需要承担更多细节。

对比维度Canvas 2DWebGL
直接操作的内容路径、文字、图片、颜色顶点、纹理、缓冲区、着色器
开发者关注点“我要画一个什么东西”“GPU 应该如何生成这个图形”
API 抽象层级
上手难度较低较高
常见图形单位路径、矩形、圆形、图片三角形、顶点、片元

所以说 Canvas 2D 和 WebGL “反着来”,并不是真的它们做了相反的事,而是它们把复杂度放在了不同地方:Canvas 2D 把复杂度藏在浏览器内部;WebGL 则把更多控制权交给开发者。

3. Canvas 2D 不是“弱版 WebGL”,而是解决的问题不同

很多人刚接触 WebGL 时,会觉得它更底层、更接近 GPU,因此 Canvas 2D 就像“落后的方案”。这个理解并不准确。

Canvas 2D 的价值恰恰在于:大量普通二维需求,根本不值得从顶点、着色器开始搭建。比如粒子飘落、进度环、签名板、图表、弹幕、简单小游戏、图片编辑器中的基础标注,用 Canvas 2D 往往能更快完成。

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = ball.color;
  ctx.fill();

  ball.x += ball.vx;
  ball.y += ball.vy;

  requestAnimationFrame(draw);
}

上面这种“清空画布 → 更新数据 → 重新绘制”的动画写法,就是 Canvas 2D 最常见的工作方式。它简单、直观,也足以覆盖很多网页特效场景。

当然,当你需要处理海量粒子、复杂三维模型、实时光影、后处理滤镜,或者希望把大量并行计算稳定交给 GPU 时,WebGL 会更合适。

Canvas 2D 擅长让你快速画出二维效果;WebGL 擅长让你精细控制 GPU 渲染过程。

理解这一点后,Canvas 2D 的定位就很清楚了:它不是要取代 WebGL,而是提供了一套更接近日常绘画思维的二维接口。最后一节,我们就具体聊聊这支“画笔”最适合画什么,又在什么场景下应该换一套工具。

六、这支笔适合画什么、不适合画什么

1. Canvas 2D 最适合:持续变化、整体重绘的二维画面

Canvas 2D 的本质是一张位图画布:开发者维护数据,浏览器根据数据不断把新像素画上去。这个工作方式尤其适合“画面一直在变化,但不需要逐个编辑图形对象”的场景。

例如下面这些效果,都很适合优先考虑 Canvas 2D:

场景为什么适合 Canvas 2D
粒子特效、飘雪、星空、烟花每一帧都要更新大量小元素的位置
小游戏游戏循环本来就是更新数据后重新绘制画面
数据可视化、实时曲线图表数据持续变化,整体重绘逻辑清晰
签名板、涂鸦板、画笔工具用户操作本身就是不断留下像素轨迹
图片裁剪、滤镜、像素处理Canvas 可以直接读取和修改像素数据
视频帧加工、截图合成可以把图片或视频帧绘制到同一张画布中处理

以粒子特效为例,页面上可能同时有几百个小圆点不断移动。如果每一个粒子都是一个 DOM 元素,浏览器需要维护几百个节点、样式和布局关系;而 Canvas 只需要在每一帧清空画布,再循环画出所有粒子即可。

function drawParticles() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  particles.forEach((particle) => {
    particle.x += particle.vx;
    particle.y += particle.vy;

    ctx.beginPath();
    ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
    ctx.fillStyle = particle.color;
    ctx.fill();
  });

  requestAnimationFrame(drawParticles);
}

这里没有“移动某个已经存在的圆形元素”。每一帧只是根据 particles 数组里的最新数据,重新得到一张新的画面。这正好契合 Canvas 的即时模式。

如果你的核心需求是“高频重绘一张二维画面”,Canvas 2D 往往是自然的选择。

2. Canvas 2D 不擅长:大量独立对象的编辑、交互与语义表达

Canvas 的短板,也正来自它不保留图形对象。画布上出现十个按钮、十张卡片、十段文字后,Canvas 并不知道它们分别是什么;对它来说,这些都只是同一张位图上的不同像素。

因此,下面这些需求通常不适合只用 Canvas 2D 硬做:

需求更合适的方案原因
普通表单、按钮、后台界面HTML + CSS原生控件、布局和交互成本更低
可访问性要求高的内容HTML / SVG文字语义、键盘操作和读屏支持更完整
大量可单独编辑的图形节点SVG每个图形都能作为独立 DOM 节点存在
复杂三维场景与光影WebGL / WebGPU更适合直接利用 GPU 渲染能力
超大规模粒子或复杂实时特效WebGL / WebGPU更容易把大规模并行计算交给 GPU

比如你在 Canvas 上画了三个“按钮”,想让用户点击其中一个,就不能像 HTML 一样直接监听某个元素的点击事件:

button.addEventListener("click", handleClick);

因为 Canvas 里没有 button 这个对象。你需要自己保存每个按钮的位置和尺寸,再根据鼠标坐标手动判断点击落在哪个区域:

canvas.addEventListener("click", (event) => {
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  const isInButton =
    x >= button.x &&
    x <= button.x + button.width &&
    y >= button.y &&
    y <= button.y + button.height;

  if (isInButton) {
    handleClick();
  }
});

这不是 Canvas 做不到,而是每增加一个可交互图形,你都要自己补上命中检测、悬停状态、焦点管理、键盘操作等逻辑。对于普通网页 UI,这样做通常得不偿失。

Canvas 可以画出“看起来像按钮”的像素,但它不会自动得到一个真正可访问、可聚焦、可点击的按钮。

3. 最终怎么选:先看你需要“画面”,还是需要“对象”

在 Canvas、SVG、DOM 和 WebGL 之间选择时,最实用的判断方法不是先问“哪个性能更高”,而是先问:我需要维护的是一张不断变化的画面,还是一组可以单独操作的对象?

flowchart TD
    A[准备做网页图形效果] --> B{需要独立编辑、选中或无障碍访问吗?}
    B -- 是 --> C[优先 HTML / SVG]
    B -- 否 --> D{画面是否高频变化?}
    D -- 是 --> E{是否有复杂 3D 或海量并行计算?}
    E -- 否 --> F[优先 Canvas 2D]
    E -- 是 --> G[考虑 WebGL / WebGPU]
    D -- 否 --> H[按布局与交互需求选择 HTML / SVG / Canvas]

可以把它们简单记成四种不同工具:

  • HTML:适合搭网页结构和普通交互。
  • SVG:适合保留为独立对象的矢量图形。
  • Canvas 2D:适合不断变化的二维像素画面。
  • WebGL / WebGPU:适合高性能图形、复杂特效和三维渲染。

实际项目里,它们也不一定只能四选一。很常见的组合是:页面文字、按钮和控制面板用 HTML;图标和可编辑矢量图用 SVG;背景粒子、小游戏区域或动态图表用 Canvas;只有性能和图形复杂度确实上来时,再引入 WebGL。

回到本文一开始的问题:Canvas 2D 这支“画笔”到底是怎么在网页上画画的?答案是,JavaScript 先通过 Canvas API 描述路径、样式和图片;浏览器再把这些绘制指令立即转换为位图像素。它不会替你保存一个个图形对象,却因此很适合把大量动态数据快速变成一帧帧画面。

理解了即时模式、路径、位图和绘制状态后,Canvas 2D 就不再是一堆零散 API,而是一套完整的二维绘制思维:数据决定画面,命令写入像素,下一帧再重新绘制。

十一、关于八荒启

八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。

我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。

八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈

如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。

  • 官方网站:bahuangqi.com
  • 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。