从getContext到requestAnimationFrame,手把手讲透Canvas2D绘图、状态管理与逐帧动画入门。
Canvas 2D 绘图入门:从画矩形到 requestAnimationFrame 动画
Canvas 2D 绘图入门:从画矩形到 requestAnimationFrame 动画
如果说 HTML 负责结构、CSS 负责样式,那么 Canvas 就是你手里那支可以随意挥洒的画笔。它和普通的 DOM 元素很不一样:普通的 div、img 每一个都是浏览器精心管理的一颗节点,你改它的宽度、颜色,浏览器会帮你重新布局和绘制;而 Canvas 提供的是一块"画布",你把一条条绘制指令喂给它,浏览器就在一个像素缓冲区里把结果画出来,画完就完,它不记得你画了什么。这种"命令式、即时模式"的绘图方式,让 Canvas 在做图表、小游戏、数据可视化、图像处理这类需要大量动态绘制的场景里,比操作成百上千个 DOM 节点要高效得多。
不过,也正因为 Canvas 是命令式的,它和很多前端同学习惯的"声明式"思维不太一样。你不能直接"选中刚才那个矩形把它变宽一点",只能每一帧重新把整个画面擦掉再画一遍。这篇文章就带你从最基础的画矩形开始,一步步走到用 requestAnimationFrame 做出流畅的逐帧动画,把 Canvas 2D 绘图这件事彻底讲透。如果你已经玩过 Three.js 零基础入门:四要素手搓一个会转的 3D 立方体,会发现 Three.js 是在 WebGL 之上封装的 3D 世界,而 Canvas 2D 关注的则是平面上的二维图形。两者"画布"的概念一脉相承,但 API 风格差别不小,本文聚焦更轻量、更底层的 2D 能力。
一、拿到画布:getContext('2d') 与坐标系
要使用 Canvas,第一步永远是在 HTML 里放一个 <canvas> 元素,然后通过 JavaScript 拿到它的"2D 上下文"(context)。这个上下文对象就是你后续所有绘图方法的入口,所有的 fillStyle、lineWidth、fillRect 都挂在它上面。
html<!-- 一个最基础的画布:width/height 是画布自身的像素尺寸 --><canvas id="board" width="600" height="400"></canvas><script> // 1. 先拿到 canvas 元素 const canvas = document.getElementById('board'); // 2. 调用 getContext('2d') 取得 2D 绘图上下文 const ctx = canvas.getContext('2d'); // 3. 设置填充色为番茄红,画一个实心矩形 ctx.fillStyle = '#e74c3c'; // 填充颜色 ctx.fillRect(50, 50, 200, 120); // 在 (50,50) 处画一个宽200高120的矩形</script>

这里有几个关键点必须说清楚。第一,canvas 的 width和height 属性决定的是"画布实际的像素分辨率",而你在 CSS 里给 canvas 设置的宽高,只是它在页面上显示的尺寸。如果两者不一致,浏览器会把画布像素拉伸到 CSS 尺寸,结果就是图形发虚、变模糊、边缘出现锯齿。所以最佳实践是用 JavaScript 把 canvas 的 width/height 属性设成你真正想要的像素大小,CSS 只负责布局位置,不要指望靠 CSS 来"放大"画布。
第二,Canvas 的坐标系原点在左上角,x 轴向右为正,y 轴向下为正。这和数学课本里常用的笛卡尔坐标系(y 轴向上)正好相反,初学时特别容易在画圆、做旋转的时候把方向搞反,记住这一点能替你省掉大量调试时间。当你用 rotate 旋转画面时,旋转方向也是顺时针为正,因为它本质是围绕着一个 y 轴向下的坐标系在转。
第三,现在的设备屏幕很多都是高分屏,比如常见的 devicePixelRatio 等于 2 或 3。在这种屏幕上,一个 CSS 像素其实由多个物理像素组成。如果你直接按 CSS 尺寸设 canvas 的 width/height,浏览器就会把较少的画布像素放大铺满,画面必然发糊。为了让画面在高清屏上保持锐利,你需要把画布的像素尺寸放大到 CSS尺寸 × devicePixelRatio,再用ctx.scale(dpr, dpr) 把坐标系缩回来,这样你后续写的代码仍然可以用 CSS 像素来思考,但底层渲染已经是用高清像素画的了。这是一个几乎所有生产级 Canvas 项目都会用到的套路。
还有一个新手容易忽略的点:getContext除了能传'2d',也能传'webgl' 拿到 WebGL 上下文,拿到的是完全不同的另一套绘图接口。本文我们只谈 2D。另外,同一个 canvas 重复调用 getContext('2d') 拿到的其实是同一个上下文对象,所以你不用担心"拿了两次会不会冲突",放心用就行。
顺便说一个选型问题:很多新手会纠结"画个图到底用 Canvas 还是 SVG"。简单记一个原则——SVG 适合图形数量不多、需要单独选中或点击、或者要随 CSS 缩放不失真的场景,因为每个图形都是真实存在的 DOM 节点;而 Canvas 适合图形数量巨大、需要逐帧动画、追求极致渲染性能的场景,比如成千上万个点的散点图、实时波形、小游戏。两者没有绝对优劣,看你画的图形"动得多不多、数量多不多"来选。
二、基础绘制:矩形、路径、圆弧与文本
拿到上下文之后,最常用的几类"图元"分别是矩形、路径(由线条组成的形状)、圆弧和文本。下面我们逐个来看,并且每个都配上可以照着跑的示例。
2.1 矩形三兄弟
Canvas 提供三个直接画矩形的方法:fillRect画实心矩形、strokeRect画描边矩形、clearRect 则是在指定区域"擦除"成透明。它们都不需要你手动 beginPath,调用即生效,是最省事的绘图命令。其中 clearRect 在做动画时极其重要,因为我们每一帧都要用它把上一帧擦掉。
2.2 路径:更自由的图形
当你要画的不是规整矩形,而是一条折线、一个多边形、甚至一个不规则形状时,就要用到路径。路径的标准写法是:先用beginPath() 告诉 Canvas"我要开始定义一条新路径了",然后用 moveTo把"画笔"移动到起点,lineTo连线到下一个点,必要时再closePath()把路径闭合,最后调用fill()填充或stroke() 描边。注意一个常见错误:忘了调用 beginPath,导致新旧路径连在一起;或者忘了在 fill 之前 closePath,导致填充区域和你想象的不一样。
下面这段代码用路径画了一条带节点的折线,并加上了详细的中文注释,你可以直接复制到页面里跑一遍:
javascriptconst ctx = document.getElementById('board').getContext('2d');// 定义一条折线:从左边出发,拐两个弯,最后到右边ctx.beginPath(); // 开始一条新路径(务必先 beginPath)ctx.moveTo(40, 80); // 把画笔移动到起点 (40,80)ctx.lineTo(180, 80); // 连一条线到第一个拐点ctx.lineTo(180, 220); // 向下拐ctx.lineTo(320, 220); // 向右延伸ctx.lineWidth = 4; // 线宽设为 4 像素ctx.strokeStyle = '#2980b9'; // 描边颜色设成蓝色ctx.stroke(); // 真正把这条线描出来// 在折线末端画一个实心圆点当作"节点"ctx.beginPath();ctx.arc(320, 220, 8, 0, Math.PI * 2); // 圆心(320,220),半径8,画一整圈ctx.fillStyle = '#e67e22';ctx.fill();
2.3 圆弧与曲线
arc(x, y, radius, startAngle, endAngle, anticlockwise) 用来画圆弧,角度用的是弧度制(Math.PI 对应 180 度),最后一个布尔参数控制是顺时针还是逆时针画。除此之外,Canvas 还支持 arcTo(常用于画圆角矩形)、二次贝塞尔quadraticCurveTo和三次贝塞尔bezierCurveTo,足以画出任何平滑曲线——这正是各种图表里平滑趋势线、流畅波浪效果的基础。比如要做一条平滑的曲线,就把多个控制点用 quadraticCurveTo 连起来,比用一堆直线段近似自然得多。
2.4 文本绘制
往画布上写文字用fillText(实心字)或strokeText(描边字,做出空心字效果),配合font、textAlign、textBaseline控制字体和对齐方式。这里有个细节:textAlign控制的是文字水平对齐的基准点(left/center/right),而textBaseline 控制的是垂直对齐的基准(top/middle/bottom 等),两者结合起来才能把文字精确放到你想要的位置。比如画一个居中的标题:
javascriptconst ctx = document.getElementById('board').getContext('2d');ctx.font = 'bold 28px"Microsoft YaHei", sans-serif'; // 字体与字号ctx.fillStyle = '#2c3e50';ctx.textAlign = 'center'; // 水平居中ctx.textBaseline = 'middle'; // 垂直居中ctx.fillText('前端哥 · Canvas 实战', 300, 60); // 在 (300,60) 处写字
这里还要特别提醒一个 Canvas 和 DOM 最大的区别:DOM 里每个元素都能直接绑定点击事件、用 CSS 控制 hover,而 Canvas 画出来的图形只是"像素",浏览器并不知道你画了什么形状,因此你没法直接给某个矩形单独绑定事件。如果你需要点击某个图形,得自己在点击事件里用坐标去判断(比如用 isPointInPath 或几何公式)算鼠标落在哪个图形上。这是很多人第一次做交互式图表时会卡住的地方,提前心里有数能少踩坑。
掌握了矩形、路径、圆弧、文本这四类基础图元,你已经能拼出绝大多数静态画面了。下一篇我们要让画面"有颜色、有层次",这就涉及到状态的管理。
三、颜色与渐变:fillStyle 不止能填纯色
前面我们一直用fillStyle和strokeStyle 来设置颜色,其实这两个属性非常灵活,不仅能接受颜色字符串(十六进制、rgb、rgba、颜色名都行),还能接受渐变对象,甚至能接受用 createPattern 生成的图案。
最常用的是线性渐变和径向渐变。线性渐变通过createLinearGradient(x0, y0, x1, y1)定义一条渐变轴线,然后往上面addColorStop(位置, 颜色) 添加色标,位置是 0 到 1 之间的小数;径向渐变 createRadialGradient则是从一个内圆到一个外圆做扩散,适合做高光、光晕这类效果。下面这个例子画了一个从左到右由蓝到紫的渐变矩形:
javascriptconst ctx = document.getElementById('board').getContext('2d');// 定义一条水平方向的线性渐变(从左 0 到右 300)const grad = ctx.createLinearGradient(0, 0, 300, 0);grad.addColorStop(0, '#3498db'); // 起点:蓝色grad.addColorStop(1, '#9b59b6'); // 终点:紫色ctx.fillStyle = grad; // 把渐变对象交给 fillStylectx.fillRect(20, 20, 300, 160); // 用渐变填充矩形
这里插一句题外话:当你的画布颜色需要跟随网站主题变化时,可以结合CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换 的思路——在 CSS 里定义主题色变量,再用 getComputedStyle(document.documentElement).getPropertyValue('--brand') 读出来,转成 Canvas 能用的颜色喂给 fillStyle。这样页面一换肤,画布配色也自动跟着换,完全不用改动绘图逻辑,主题切换和图表配色就能保持统一。
除了渐变,阴影也是提升质感的好工具。通过shadowColor、shadowBlur、shadowOffsetX、shadowOffsetY可以给图形加上投影,做出悬浮、发光等效果。不过要提醒一句:阴影的渲染开销不低,在动画里大量使用会让帧率明显下降,生产环境要权衡使用。
四、状态管理:save / restore 与变换
Canvas 的上下文是一个"有状态"的对象。你设置的 fillStyle、lineWidth、lineCap这些属性,会影响之后每一次绘制,直到你再次修改它们为止。这既是便利,也是坑:当你在一个复杂画面里画了几十个形状,每个形状的颜色、线宽都不一样,如果每次都手动改回去,代码会又臭又长,还容易改漏。
解决办法是save()和restore()。你可以把它们理解为一个"状态栈":save()把当前所有绘图状态(颜色、线宽、变换矩阵、阴影等)压栈保存,restore()则把最近一次保存的状态弹出来恢复。配合translate、rotate、scale这类变换,就能干净利落地做出旋转、镜像、局部坐标系等效果,而不必每次都手动还原。
javascriptconst ctx = document.getElementById('board').getContext('2d');// 一次性画三个依次旋转的方块,彼此互不干扰for (let i = 0; i < 3; i++) { ctx.save(); // 保存当前状态(含坐标系原点) ctx.translate(150 + i * 130, 200); // 把坐标原点移动到每个方块中心 ctx.rotate(i * Math.PI / 6); // 依次旋转 0°、30°、60° ctx.fillStyle = ['#e74c3c', '#27ae60', '#2980b9'][i]; ctx.fillRect(-40, -40, 80, 80); // 以新原点为中心画方块(所以坐标是 -40) ctx.restore(); // 恢复状态,下一次循环不受本次变换影响}
save和restore 成对出现非常重要——多 save 一次、少 restore 一次,都会导致状态栈失衡,后续绘制莫名其妙错位,而且这种 bug 很难一眼看出来。写动画时尤其要注意:每一帧都从干净的状态开始,要么在循环开头 restore 回初始状态,要么用 save/restore 把本帧的改动包起来。
关于变换还有个实用技巧:translate 之后原点变了,所以填充矩形时的坐标要按"相对新原点"来写,这也是上面代码里 fillRect 用 -40 而不是正坐标的原因。理解了这一点,你就能用"移动原点 + 画相对图形"的方式,轻松画出一圈均匀分布的刻度、放射状图表等对称结构。
五、让画面动起来:requestAnimationFrame 逐帧动画
静态图会画了,真正的乐趣在于"动"。在 Canvas 里做动画的核心套路只有一句话:在每一帧里,先把画布清空,再根据最新的状态重新画一遍。因为 Canvas 不保留你画过的"对象",所以动画本质上是一连串"擦掉重画"。
驱动逐帧刷新的最佳工具是浏览器原生的requestAnimationFrame(简称 rAF)。它的工作方式是你传给它一个回调函数,浏览器会在"下一次重绘之前"调用它——通常是每秒 60 次,和显示器的刷新率对齐。相比老旧的 setInterval,rAF 有三个压倒性优势:第一,它会自动在标签页不可见时暂停,省电也省 CPU;第二,它和屏幕刷新严格同步,画面不撕裂、不卡顿;第三,回调会收到一个高精度的时间戳,方便你做"基于时间"的动画,而不是"基于帧数"的动画(不同设备帧率不同,基于帧数会让动画速度不一致)。
经典的动画循环长这样:
javascriptconst canvas = document.getElementById('board');const ctx = canvas.getContext('2d');let angle = 0; // 当前旋转角度// 动画主循环:浏览器每次重绘前都会调用一次function loop(timestamp) { ctx.clearRect(0, 0, canvas.width, canvas.height); // 先清空整块画布 // 画一个随时间旋转的方块 ctx.save(); ctx.translate(300, 200); ctx.rotate(angle); ctx.fillStyle = '#e74c3c'; ctx.fillRect(-50, -50, 100, 100); ctx.restore(); angle += 0.02; // 角度每帧增加,产生旋转效果 requestAnimationFrame(loop); // 预约下一帧,形成持续循环}requestAnimationFrame(loop); // 启动循环
注意clearRect 这一步不能省——正因为 Canvas 是命令式、不会自动保留上一帧的"形状对象",你必须自己负责把旧的擦掉,否则所有帧会叠加在一起,画面糊成一团。如果你发现动画越跑越"脏",第一反应就应该是检查有没有漏掉清屏。
上面的例子里我们每帧给 angle 加一个固定值 0.02,属于一种"基于帧"的写法。它在 60fps 的屏幕上没问题,但万一用户设备卡到 30fps,动画整体就会明显变慢。更稳妥的做法是"基于时间":利用 rAF 回调拿到的时间戳,算出两帧之间真实过去了多少毫秒(也就是 delta time),再让角度按"每毫秒该转多少"来累加。这样无论帧率怎么波动,动画在真实世界里走过的总时长都是一致的,观感才稳定。
当你把"每帧重画"的思路用到数据上,就能做出最常见的图表动画:比如让一组柱子的高度从 0 逐渐长到目标值,或者让一条折线从左到右一点点画出来。这里的关键是把"目标值"拆成"当前值",每帧让当前值朝目标值逼近一点点(专业说法叫缓动 / easing,比如用 current += (target - current) * 0.1 这种指数逼近),配合 rAF 就得到了顺滑的生长动画。这也是关键词"图表动画"最典型的应用场景——数据大屏里那些柱子蹦起来、折线长出来的效果,底层都是这个套路。
另外,动画不一定要一进页面就跑。如果你有一长串 Canvas 动画散布在页面各处,可以用 Intersection Observer API 实战:高性能滚动触发动画与懒加载 的思路,只在画布滚动进入视口时才启动对应的 rAF 循环,离开视口就停掉。这样既保证观感,又避免一堆动画在后台空转浪费性能。判断"该不该继续 requestAnimationFrame"很简单:用一个布尔标志位控制,元素离开视口就把它置为 false,循环里检测到 false 就不再预约下一帧即可。
六、性能与进阶:离屏 Canvas 与那些坑
当你画的图形越来越多、动画越来越复杂,性能问题就会冒头。下面几个是实战中最常踩的点,提前知道能少走很多弯路。
离屏 Canvas(Offscreen Canvas)。 有些图形非常复杂(比如带大量细节的背景、一段带阴影的文字、一张处理过的图片),如果每帧都现场重画,开销巨大。这时可以先用一个"看不见"的 canvas 把它画一次,缓存成位图,之后每帧直接 drawImage贴上去。最简单的离屏做法是document.createElement('canvas')新建一个画布当缓冲区;现代浏览器还提供了专门的OffscreenCanvas,可以配合 Worker 把绘图计算挪到后台线程,主线程完全不被卡住,UI 依旧丝滑。
javascript// 1. 创建一个离屏画布作为"缓存层"const off = document.createElement('canvas');off.width = 200; off.height = 200;const octx = off.getContext('2d');// 2. 把复杂背景只画一次,存进离屏画布octx.fillStyle = '#34495e';octx.fillRect(0, 0, 200, 200);octx.font = '20px sans-serif';octx.fillStyle = '#ecf0f1';octx.fillText('缓存内容', 40, 100);// 3. 动画每帧直接把缓存"贴"到主画布,省去重复绘制function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(off, 100, 100); // 一次贴图,代价极低 requestAnimationFrame(animate);}animate();
减少状态切换。 Canvas 每次切换 fillStyle、改变线宽、切换变换,都有底层开销。把相同样式的图形批量画完,再统一换样式,比"画一个、改一下、再画一个"要快得多。画大量柱状图时尤其明显:先循环把所有柱子的位置算好,用同一种填充画完,再统一加描边,比逐根柱子反复切状态快一个量级。
别碰布局抖动。在动画循环里反复读取canvas.getBoundingClientRect()、修改会触发浏览器重排的属性,会让主线程频繁卡顿。把这类读取放到循环外,或者只在 resize 时更新一次缓存起来,循环里只读变量,不要读 DOM。
高清屏适配别忘了。再次强调devicePixelRatio:不处理的话,在高分屏上你的精致动画会集体变糊,前面第一节讲的那套放大策略一定要用上。
最后还有个容易被忽略的工程问题:rAF 循环一旦启动就会一直跑,如果你在单页应用里切走了页面、或者组件被卸载,却没有停止循环,它就会一直在后台空转、持续占用 CPU 和内存,甚至造成内存泄漏。正确做法是在合适的时机(比如组件销毁、离开视口、或者动画播完)主动把循环停掉——用一个变量记录 requestAnimationFrame 返回的 id,需要停止时调用 cancelAnimationFrame 即可。养成"启动即想好如何停止"的习惯,能帮你避开很多诡异的性能问题。
如果你的 Canvas 用到了需要离线也能显示的素材(比如缓存好的图标、背景图),可以结合 PWA 离线应用实战:Service Worker 缓存策略与 manifest 配置,把这些资源交给 Service Worker 缓存,用户断网也能看到完整画面。而当你想把"一个带动画的图表"做成可复用的 UI 部件时,不妨参考 Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析),把 canvas 封装成一个自定义元素,让绘图逻辑和页面其它部分彻底解耦,别人只要写一行标签就能用。
七、小结与下一步
走到这里,你已经掌握了 Canvas 2D 的完整知识骨架:用 getContext('2d') 拿到绘图入口,用矩形 / 路径 / 圆弧 / 文本画出静态图形,用 fillStyle、渐变和save/restore管理颜色与状态,最后用requestAnimationFrame 把"每帧清空重画"的循环跑起来,再配合离屏缓存和状态批处理把性能拉满。回顾一下核心要点:坐标系原点在左上角、y 轴向下;清空画布是动画的前提;rAF 永远优于 setInterval;save/restore 一定要成对;高清屏必须乘 devicePixelRatio。
接下来,建议你动手做三件小事巩固一下:第一,用路径画一个你自己的头像剪影,练熟 moveTo/lineTo/arc 的配合;第二,写一个从 0 长到满的柱状图动画,把"当前值逼近目标值"的缓动逻辑跑通;第三,尝试把动画封装进一个函数,配合视口检测只在可见时播放,体会性能优化的实际收益。把这套流程跑顺了,无论是数据大屏、小游戏还是交互式图表,你都能从容上手,Canvas 2D 这把画笔就算真正握在手上了。

发表评论发布评论后审核成功,即可显示在列表中...