主题
Canvas 概览
<canvas> 是一块由 JavaScript 驱动的位图。它不像 SVG 那样维护一棵图形对象树,而是提供一组绘制命令:你调用一次,它就往位图的指定位置画一次。这种模型叫立即模式(immediate mode)——画完即忘,画布上只会留下像素结果。
这一页先把“立即模式”的含义、适用边界、<canvas> 元素自身的脾气和坐标系讲清楚,后面的每一页都建立在它们之上。
立即模式意味着什么
| 你可能想问的问题 | 立即模式下的答案 |
|---|---|
| “把刚才那个矩形改成红色” | 做不到。没有矩形对象,只有像素。改颜色后要重画那一帧 |
| “第 3 个圆现在在哪?” | 画布不知道。要自己把几何数据存在数组里(或用 isPointInPath() 反查) |
| “让按钮有个 hover 效果” | 自己算命中 → 改状态 → 重画。没有 CSS :hover 可用 |
| “局部更新一个元素” | 要么整帧重画,要么用 clearRect() 精确擦除后再补画 |
| “把图导出成 PNG” | 可以,canvas.toBlob() / toDataURL(),因为结果就是位图 |
一次典型的绘制循环只有四步:
js
function frame() {
ctx.clearRect(0, 0, view.width, view.height) // 1. 清空(否则上一帧会叠上来)
updateState(delta) // 2. 更新自己的数据模型
drawEverything(ctx) // 3. 按顺序重新提交所有绘制命令
requestAnimationFrame(frame) // 4. 下一帧
}1
2
3
4
5
6
2
3
4
5
6
这里的“顺序”就是图层顺序:后画的覆盖先画的。想在背景上画图案、图案上画角色、角色上画 HUD,就只能按这个顺序提交命令。
状态是记忆的,路径不是
fillStyle、lineWidth、font、当前变换矩阵这些是上下文状态,设置一次就一直生效到下次修改;而“当前路径”是一段会被 beginPath() 清空的临时数据。把这两件事混在一起,是初学者最常见的困惑来源,详见图形与路径。
一块画布的最小骨架
html
<canvas id="board" width="640" height="360"></canvas>1
js
const canvas = document.querySelector('#board')
const ctx = canvas.getContext('2d')
// 用一份“逻辑尺寸”作为坐标基准,避免到处硬编码数字
const view = { width: canvas.width, height: canvas.height }
function frame(time) {
ctx.clearRect(0, 0, view.width, view.height) // 1. 清空
ctx.fillStyle = '#7aa2ff'
ctx.fillRect(40, 40, 160, 90) // 2. 提交绘制命令
requestAnimationFrame(frame) // 3. 下一帧继续
}
requestAnimationFrame(frame)1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
本站的示例在这 12 行之上补了四件事:按 devicePixelRatio 放大后备存储、用 ResizeObserver 跟随容器宽度、用 rafLoop 封装帧循环、把参数面板叠在画布旁边。这些约定写在在线示例与代码约定里。
亲眼看看:顺序与清空
立即模式实验台用“上移 / 下移”改图层顺序看遮挡变化;取消“每帧清空”看移动方块的残影
examples/canvas/index-order.jsjs
/**
* 【Canvas 概览】立即模式实验台:顺序即图层,清空是必需的
* ------------------------------------------------------------------
* 两个只属于立即模式的现象:
* 1. 绘制顺序就是图层顺序 —— 调整顺序,遮挡关系立刻变化;
* 2. 每帧必须自己清空 —— 关掉 clearRect,移动的方块会拖出一条残影。
* 拖动“图层顺序”即可体会:画布不保存对象,只保存你上一次提交命令的结果。
*/
import { create2D, rafLoop, cleanupAll } from '../shared/runtime.js'
import { createPanel } from '../shared/ui.js'
export default function (container) {
const view = create2D(container, { width: 640, height: 340, background: '#0b1220' })
const { ctx } = view
let clearEachFrame = true
let selected = 2
// 每个图层就是一个绘制函数:谁先执行,谁就在下层
const layers = [
{
name: '底板矩形',
paint: () => {
ctx.fillStyle = '#1d2b47'
ctx.fillRect(view.width * 0.06, view.height * 0.14, view.width * 0.42, view.height * 0.68)
}
},
{
name: '橙色圆',
paint: () => {
ctx.beginPath()
ctx.arc(view.width * 0.42, view.height * 0.5, view.height * 0.26, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 165, 74, 0.95)'
ctx.fill()
}
},
{
name: '移动方块',
paint: (time) => {
const x = view.width * 0.2 + (Math.sin(time * 1.1) * 0.5 + 0.5) * view.width * 0.5
ctx.fillStyle = '#7aa2ff'
ctx.fillRect(x, view.height * 0.4, 64, 64)
}
},
{
name: '文字标签',
paint: () => {
ctx.fillStyle = '#e6edf7'
ctx.font = '600 20px system-ui, -apple-system, sans-serif'
ctx.fillText('立即模式:命令顺序 = 图层顺序', view.width * 0.06, view.height * 0.1)
}
}
]
function orderText() {
return layers.map((l, i) => `${i + 1}. ${l.name}`).join('\n')
}
function draw(time) {
const { width: w, height: h } = view
if (clearEachFrame) {
// 立即模式的第一步:清空。省略它,上一帧就变成了“背景”
ctx.clearRect(0, 0, w, h)
ctx.fillStyle = '#0b1220'
ctx.fillRect(0, 0, w, h)
}
for (const layer of layers) layer.paint(time)
readout.set(`绘制顺序(后画的在上层):\n${orderText()}`)
}
const loop = rafLoop((time) => draw(time))
function move(delta) {
const target = selected + delta
if (target < 0 || target >= layers.length) return
const [item] = layers.splice(selected, 1)
layers.splice(target, 0, item)
selected = target
picker.set(String(selected))
syncLabels()
}
const panel = createPanel(container, { title: '立即模式' })
const readout = panel.readout('—')
readout.el.style.whiteSpace = 'pre'
const picker = panel.select({
label: '选中的图层',
value: '2',
options: layers.map((l, i) => ({ label: l.name, value: String(i) })),
onChange: (v) => {
selected = Number(v)
}
})
/** 重排后同步下拉框里的图层名,保持“选项 i = layers[i]”。 */
function syncLabels() {
layers.forEach((layer, i) => {
if (picker.el.options[i]) picker.el.options[i].text = layer.name
})
}
panel.button({ label: '↑ 上移一层', onClick: () => move(-1) })
panel.button({ label: '↓ 下移一层', onClick: () => move(1) })
panel.checkbox({
label: '每帧清空(clearRect)',
value: true,
onChange: (v) => (clearEachFrame = v)
})
panel.note('取消勾选“每帧清空”,移动方块会拖出残影——这就是立即模式最典型的反例。')
return cleanupAll(loop, view, panel)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
这个示例只演示了两件事,却是立即模式最重要的两个推论:“图层”完全由命令顺序决定(画布不记录谁是谁),以及画布不会帮你维护场景(不清空就只是往旧像素上继续涂)。
什么时候用 Canvas 2D
| 适合 | 不适合 |
|---|---|
| 每帧都在变化的 2D 内容:图表、游戏、粒子、可视化 | 页面 UI:文字要可选中、可读屏、能被 CSS 主题影响时,用 HTML + CSS |
| 对象数量多(数千~数万)且不需要逐个绑定事件 | 图形少(几十个)但需要逐元素交互、属性动画时,用 SVG 更省事 |
| 图像处理:裁剪、缩放、滤镜、像素读写 | 需要无限缩放而不失真的矢量插画,用 SVG |
| 需要把结果导出成位图(截图、分享图、水印) | 需要 3D 或 GPU 并行计算,用 WebGL / WebGPU |
| 用一块画布承载“多图层合成”的绘制结果 | 需要浏览器原生滚动、文本选中、表单交互的场景 |
选型的完整对照表在六大图形技术选型里。
Canvas 2D 做不到的事
把边界记清楚,能省下大量“用错工具”的时间:
| 做不到 | 替代方案 |
|---|---|
| 查询画布上某个图形的位置、颜色、层级 | 自己保存几何数据;或用 getImageData() 读像素 |
| 给单个图形绑定事件、加 CSS 类、改单个属性 | 自己做命中检测(isPointInPath 或几何公式);交互元素用 DOM 叠加 |
| 让画布里的文字可选中、可复制、可被搜索 | 在画布外放真实 DOM 文本;图表配 aria-label / aria-describedby |
| 无损无限缩放(画布本质是位图) | 矢量内容用 SVG;或按需以更高 dpr 重新光栅化 |
| 局部重绘时自动保留其余像素之外的状态 | 每帧重新设置上下文状态(变换、样式、虚线都会在 resize 后复位) |
<canvas> 元素本身的三个事实
html
<canvas id="board" width="600" height="400" aria-label="2024 年销售额趋势图">
你的浏览器不支持 canvas,这里是替代文字。
</canvas>1
2
3
2
3
- 不设置
width/height属性时,画布是 300×150 的透明位图。 它本身没有内容、没有背景色,alpha 全为 0,因此你看到的往往是它后面 CSS 背景的颜色。“画布是白纸”只是错觉。 - DOM 里只有一个
<canvas>元素。 里面画的图形、文字都不是节点:document.querySelector找不到,Ctrl+F搜不到,选中、复制、打印都不行。它对外是一个可替换元素。 - 可访问性要自己补。 读屏软件无法读出画布里的文字。可用的手段是:给
<canvas>加aria-label/aria-describedby;把关键信息用视觉隐藏的 DOM 文本同步一份;或者干脆把标题、图例、数值用真实 DOM 画在画布外面——这也往往比在画布里画文字更清晰。
画布里画字 ≠ 页面里有文字
把标题、坐标轴标签画进画布,搜索引擎和读屏软件都拿不到。信息型图表要么在画布外补 DOM,要么用 aria-describedby 指向一段说明文字。
坐标系统
- 原点在左上角,
x向右增大,y向下增大。这与数学课本相反,但在屏幕坐标里很自然。 - 单位是 CSS 像素。本站的
create2D()已经把上下文按devicePixelRatio预缩放,所以直接用view.width/view.height书写坐标即可,不要自己乘 dpr。 - 角度单位是弧度,
0指向 3 点钟方向,顺时针为正(因为 y 轴向下)。 - 矩形用
(x, y, width, height)描述,其中x, y是左上角,不是中心。
(0,0) ─────────────────► +x
│ ┌───────────────┐
│ │ rect(x, y, w, h) │ arc(cx, cy, r, 0, Math.PI/2)
│ └───────────────┘ 从 3 点钟顺时针扫到 6 点钟
▼
+y1
2
3
4
5
6
2
3
4
5
6
用尺子说话
坐标系记不住时,就在画布上画一层网格:每 32px 一条线,再在角落写上当前尺寸。本站几乎所有示例都这么干,hello-canvas 的底部信息条也来自同一习惯。
本节地图
| 页面 | 回答的问题 |
|---|---|
| Canvas 概览 | 立即模式是什么、<canvas> 有什么脾气 |
| 上下文、尺寸与高清屏 | getContext 的参数、CSS 尺寸与后备存储、dpr、resize 清空 |
| 图形与路径 | 矩形族、路径 API、填充规则、Path2D |
| 颜色、渐变与阴影 | 颜色取值、三种渐变、描边细节、混合模式、阴影、图案 |
| 文本绘制与排版 | fillText / measureText / 对齐基线 / 手写换行 |
| 图像绘制与合成 | drawImage 三种签名、平滑与失真、离屏画布、画布污染 |
| 变换、裁剪与状态栈 | translate/rotate/scale、save/restore、clip |
| 动画与帧循环 | requestAnimationFrame、时间步长、插值与暂停 |
| 像素操作与 ImageData | getImageData / putImageData、逐像素滤镜 |
| 交互与命中检测 | 坐标换算、isPointInPath、拖拽与缩放 |
| 性能优化 | 状态切换、脏矩形、分层、离屏缓存 |
| OffscreenCanvas 与 Worker | 把绘制搬到工作线程 |
跑一个最小示例
下面这块画布做了四件事:清空背景、画一个矩形和一个圆、画两行文字、在底部显示尺寸信息。拖动滑块会触发整帧重画——这就是立即模式的更新方式。
最小绘制与画布信息拖动色相滑块观察整帧重绘;底部显示 CSS 尺寸与后备存储尺寸
examples/canvas/hello-canvas.jsjs
/**
* 【Canvas 概览】最小绘制与画布信息
* ------------------------------------------------------------------
* 演示三件事:
* 1. create2D 一步拿到“已按 devicePixelRatio 预缩放”的 2D 上下文,
* 所以下面所有坐标都是 CSS 像素,不需要自己乘 dpr;
* 2. 立即模式:画布不保存任何图形对象,每帧都得“清空 → 重画”;
* 3. 画布内画出来的文字只是像素:DOM 里没有节点,读屏软件读不到。
*/
import { create2D, cleanupAll } from '../shared/runtime.js'
import { createPanel } from '../shared/ui.js'
import { hsl } from '../shared/math.js'
export default function (container) {
const view = create2D(container, { width: 640, height: 320, background: '#0b1220' })
const { ctx } = view
let hue = 205
function draw() {
const { width: w, height: h } = view
// ① 立即模式没有“场景”可保留:先清空,再按顺序重新提交所有绘制命令
ctx.clearRect(0, 0, w, h)
ctx.fillStyle = '#0b1220'
ctx.fillRect(0, 0, w, h)
// ② 先画的在下层,后画的盖住它 —— 顺序就是图层顺序
ctx.fillStyle = hsl(hue, 0.7, 0.55)
ctx.fillRect(w * 0.06, 56, w * 0.3, 130)
ctx.beginPath()
ctx.arc(w * 0.42, 121, 64, 0, Math.PI * 2)
ctx.fillStyle = hsl(hue + 110, 0.62, 0.55)
ctx.fill()
// ③ 画布里的文字是像素,不是文本节点
ctx.fillStyle = '#e6edf7'
ctx.font = '700 24px system-ui, -apple-system, sans-serif'
ctx.fillText('Canvas 里的一切都是像素', w * 0.6, 96)
ctx.font = '14px ui-monospace, SFMono-Regular, monospace'
ctx.fillStyle = 'rgba(230, 237, 247, 0.62)'
ctx.fillText('这里没有 DOM 节点,', w * 0.6, 128)
ctx.fillText('读屏软件与 Ctrl+F 都看不到它。', w * 0.6, 150)
// ④ 尺寸信息:CSS 像素尺寸与后备存储(位图)尺寸是两套数字
ctx.fillStyle = 'rgba(255,255,255,0.12)'
ctx.fillRect(12, h - 44, w - 24, 32)
ctx.fillStyle = 'rgba(230, 237, 247, 0.78)'
ctx.font = '13px ui-monospace, SFMono-Regular, monospace'
const sizeNote = `canvas: ${w}×${h} CSS px · 后备 ${view.pixelWidth}×${view.pixelHeight}`
ctx.fillText(`${sizeNote} · ctx 已按 dpr 预缩放`, 24, h - 23)
readout.set(
[
`CSS 尺寸 ${w} × ${h} px`,
`后备存储 ${view.pixelWidth} × ${view.pixelHeight} px`,
`devicePixelRatio ${view.dpr}`,
`画布内文本 读屏不可见 · DOM 无节点 · 需 aria-label 或旁白 DOM 替代`
].join('\n')
)
}
// 面板先建好,draw() 里才能更新读数
const panel = createPanel(container, { title: '画布信息' })
const readout = panel.readout('—')
readout.el.style.whiteSpace = 'pre'
draw()
panel.slider({
label: '配色色相',
min: 0,
max: 360,
value: hue,
onInput: (v) => {
hue = v
draw()
}
})
panel.note('拖动“配色色相”滑块会整帧重画:这就是立即模式的更新方式。')
// 尺寸变化时后备存储会被重建(内容清空),静态画面要自己重绘一次
const offResize = view.onResize(draw)
return cleanupAll(view, panel, offResize)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
注意示例里的三处细节:
- 所有坐标都是 CSS 像素,因为
create2D()返回的ctx已经setTransform(dpr, 0, 0, dpr, 0, 0); - 面板里的读数显示“画布内文本读屏不可见”,提醒你可访问性要靠 DOM 补;
view.onResize(draw)注册了重绘:尺寸变化会重建后备存储并清空位图。
API 速查
| 成员 | 说明 |
|---|---|
canvas.getContext('2d', options) | 获取 2D 上下文;同一个画布只能取一种上下文类型 |
canvas.width / canvas.height | 后备存储(位图)尺寸,赋值会清空画布并重置上下文状态 |
ctx.clearRect(x, y, w, h) | 把矩形区域清成透明 |
ctx.fillRect / strokeRect | 直接画填充矩形 / 描边矩形 |
ctx.beginPath / moveTo / lineTo / arc / ellipse / bezierCurveTo | 构建当前路径 |
ctx.fill() / stroke() | 用当前样式填充 / 描边当前路径 |
ctx.fillText / strokeText / measureText | 文本绘制与度量 |
ctx.drawImage(source, ...) | 绘制图像、画布、ImageBitmap、视频帧 |
ctx.save() / restore() | 压栈 / 出栈上下文状态 |
canvas.toBlob / toDataURL | 导出位图(画布被跨域资源污染时会抛错) |
常见坑与排查
我画的东西“立刻消失了”
现象:在 for 循环外调用一次 fillRect,刷新后什么都没有。 原因:多数情况下是没看到——内容画在了画布外(坐标超出 width / height),或者画布尺寸是 300×150 而你按 640×360 在画。 解决:先画一个铺满的测试矩形 ctx.fillRect(0, 0, view.width, view.height) 确认坐标范围,再画内容。
说好的图形不显示,但代码没报错
现象:ctx.arc(...) 之后直接 ctx.fillStyle = 'red',什么都没出现。 原因:调用了路径 API 却没调用 fill() / stroke();或者上一次的路径没 beginPath(),导致新老路径一起被填充。 解决:记住分工——路径 API 只负责构建,fill() / stroke() 才真正绘制;每次开始新图形前先 beginPath()。
高分辨率屏上文字发虚
现象:Retina / 200% 缩放下所有文字和 1px 细线都发糊。 原因:只设置了 CSS 尺寸,没有放大后备存储。 解决:canvas.width = cssWidth * devicePixelRatio,再把上下文 setTransform(dpr, 0, 0, dpr, 0, 0)。本站 create2D() 已经封装好,见上下文、尺寸与高清屏。
排查顺序
先确认画布尺寸(canvas.width/height)→ 再确认坐标系(画测试矩形)→ 最后确认绘制命令顺序(是否 beginPath + fill)。 这三步能解决绝大多数“什么都看不到”的问题。
小结
- Canvas 2D 是立即模式 + CPU 光栅化:命令即绘制,结果只有像素。
- 它擅长“数量多、变化快、要导出位图”的 2D 内容;不适合承载 UI 文本与交互控件。
<canvas>默认是 300×150 的透明位图,DOM 里也看不到内容,可访问性要自己补。- 坐标系从左上角开始,
y轴向下,角度顺时针为正,单位是 CSS 像素。 - 它擅长“绘制”,不擅长“管理对象”:状态要自己维护,事件要自己命中,文本要自己排版。
- 建议的阅读顺序:先看上下文、尺寸与高清屏(把画布配好),再按“图形 → 样式 → 文本 → 图像”逐页推进。