Web特效07—Canvas 2D深挖:一支"画笔"到底是怎么在网页上画画的
上一篇(06)我们深挖了 WebGL,弄清楚了顶点着色器和片元着色器到底在干什么。这一篇把视角切回 Canvas 2D,从"拿笔画画"这个最朴素的类比讲起:canvas.getContext('2d') 这一行代码,到底给了你一支什么样的"笔"?答案是——一支"画完即定型、不会回头"的笔。你调用 beginPath、lineTo、fill 这些方法时,就像真的落笔、走线、上色,每一步都是"现在、马上"发生的,颜料一干,画布上只剩一张改不回去的位图,不会保留"这是一条线"这样的结构信息。这和 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 对象。
这个对象不是"数据",而是一支"已经准备好可以直接用"的笔。你接下来调用的 fillRect、strokeStyle、beginPath 这些方法,都是在问这支笔要东西、或者告诉这支笔改变设定——本质上和你伸手去拿一支笔、拧开笔帽、开始画画,是同一件事。
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 就是这样一台"调音台"。fillStyle、lineWidth、globalAlpha……这些不是你每次画图时临时传进去的参数,而是一个个"旋钮",你拧一次,它就停在那,后面所有的绘制调用都会读这个旋钮当前的位置——直到你再去拧它。这种"状态被记住、后续操作都读取当前状态"的模式,专业说法叫状态机(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 / SVG | Canvas 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.x、ball.y、ball.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 2D | WebGL |
|---|---|---|
| 直接操作的内容 | 路径、文字、图片、颜色 | 顶点、纹理、缓冲区、着色器 |
| 开发者关注点 | “我要画一个什么东西” | “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 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。