我第一次用Canvas画圆点是在做一个小工具的时候。需求特别简单:用户在页面上每点一下,就在对应位置留下一个圆点。当时我觉得半小时就能搞定,结果足足折腾了两天。一开始是画不出圆,后来是坐标对不上,再后来发现屏幕上的圆又虚又裂,最后还因为点太多页面卡成了PPT。每个问题单独拎出来都很小,但叠加在一起足以让一个前端新手对Canvas产生心理阴影。
这个标题里藏着两个关键信息:一个是"小白",一个是"避坑"。这篇我就把Canvas圆点绘制从头到尾完整走一遍——底层坐标与路径逻辑、最小可运行代码、鼠标点击交互的坐标换算、从静态圆点升级到粒子动效的常见姿势,以及我踩过最多次的高清屏模糊、路径累积、半透明叠色和性能爆炸。写完你会明白,Canvas画圆点其实就三句话:拿到画笔(ctx)、描述图形(arc)、把图形画出来(fill)。
1. 画圆点前必须先搞懂的坐标系与画笔逻辑
1.1 为什么Canvas的坐标系看起来是"倒"的
很多新手翻开Canvas文档,第一个看不懂的地方就是坐标系。数学课上老师讲的是笛卡尔坐标系:原点在左下角,x向右,y向上。Canvas完全是另一套——原点在画布左上角,x轴向右,y轴向下。
我早期也疑惑过,这不是故意跟人过不去吗?后来才明白,Canvas的坐标系跟屏幕的扫描方式是一致的:显示器第一行像素从左上角开始。把原点放在左上角,x和y就正好跟像素的行列号对上了,写起代码来反而不需要做多余的换算。图形学里的坐标变换,比如平移、旋转、缩放,也都是建立在这套坐标系上的。
真正坑人的地方其实不在这里,而在于"canvas元素显示的坐标"和"canvas内容坐标"不是一回事。你看到的是一个宽400px、高300px的矩形,但canvas内部可能设置了不同的实际像素尺寸。所有绘制都以内部坐标为基准,鼠标点击拿到的又是另一个坐标系。这几个坐标系只要混着用,画出来的圆点就一定会偏离你点中的位置。这个坑在第3章会详细展开,现在你只需要记住:画布上的坐标原点在左上角,x向右增长,y向下增长。
1.2 arc方法:画圆点唯一的正解
画圆点最标准、也最多人用的方法,是Canvas 2D API里的arc方法。完整签名长这样:
ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);参数的通俗解释:
- x和y:圆心的坐标,单位是canvas内部像素。
- radius:半径。如果写成0或者负数,不会画出可见图形,实际使用时确保大于0。
- startAngle和endAngle:起始角和结束角,单位是弧度,不是角度。
- anticlockwise:布尔值,是否逆时针,默认false(顺时针)。
新手最容易卡在角度上。心想:画个圆不就是360度吗?于是写了ctx.arc(50, 50, 20, 0, 360),结果画出来的不是圆,而是一条从圆心射出的奇怪线段。原因很简单:参数要求的是弧度制,一个完整圆是2π,也就是Math.PI * 2。如果你非要写度数,就得自己换算——角度 × Math.PI / 180。
用一个生活类比来理解arc:它就像一支圆规。startAngle是你落笔的起始位置,endAngle是收笔的位置,radius是圆规张开的跨度。起始角0、结束角2π,这支圆规正好转满一圈,就是一个完整的圆;如果只转半圈,画出来的就是半圆;结束角没到2π,你得到的就是一段弧线。圆点嘛,当然要满圈。
1.3 beginPath、fill与stroke:三步走的标准流程
拿到ctx之后,画一个圆点和画一条直线在流程上是完全一样的,必须走"新建路径 → 定义图形 → 填充或描边"这套流程。看一段最简单的完整代码:
const canvas = document.getElementById('dotBoard'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ff6600'; ctx.strokeStyle = '#333333'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(100, 100, 30, 0, Math.PI * 2); ctx.fill(); ctx.stroke();这段代码做了这么几件事:
- fillStyle设置填充色,strokeStyle设置描边色,lineWidth设置描边粗细。
- beginPath告诉Canvas:"我要开启一条全新的路径了",把当前路径重置掉。
- arc在路径上添加了一个圆形的轮廓。
- fill把圆内区域用填充色填满,stroke用描边色沿着圆的轮廓走一圈。
这里的beginPath被无数新手忽略过,包括我自己。Canvas的路径是"累积式"的,如果你不主动beginPath,后面画的图形会跟在上一段路径后面,看起来就是线条乱串、圆点和大铁环长在一起。简单说:每画一个新图形之前,都写一次beginPath,你就能避开这个经典问题。
还有一个细节:fill和stroke调用顺序也会影响效果。如果线宽比较粗,先stroke再fill,会让描边线条覆盖圆形内部颜色的一部分。通常的做法是先fill后stroke,填充色在下面、描边线在上面,视觉效果更干净。
2. 从零搭建一个能画圆点的小页面
2.1 HTML与script的摆放顺序
理论聊完,直接上手。一个能画圆点的最小页面,只需要一个canvas标签和一小段JavaScript。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Canvas圆点绘制入门</title> <style> canvas { display: block; margin: 40px auto; background: #f7f8fa; border: 1px dashed #ccc; } </style> </head> <body> <canvas id="dotBoard" width="400" height="300"></canvas> <script> const canvas = document.getElementById('dotBoard'); const ctx = canvas.getContext('2d'); ctx.beginPath(); ctx.arc(200, 150, 25, 0, Math.PI * 2); ctx.fillStyle = '#409EFF'; ctx.fill(); </script> </body> </html>如果你把这个页面直接双击打开,应该能看到浅灰背景上一个蓝色的圆点,圆心正好在画布正中间,也就是(200, 150)的位置。
有一个很小的细节值得注意:script标签放在了body最底部。这不是强迫症,而是DOM加载顺序问题。如果script放在head里,执行的时候document.getElementById('dotBoard')可能还找不到这个canvas元素,返回null,后面直接报错。新手常常在这里栽跟头。我会直接用window.onload或者DOMContentLoaded兜底,但最简单的方式就是把script放到body的末尾。这一点对任何直接操作DOM的原生JS代码都适用。
2.2 width和height属性与CSS尺寸:两个会打架的数值
canvas标签上的width和height,跟CSS里的width和height,是两套完全不同的东西。这句话值得用加粗标记:
- canvas标签的width和height属性,决定的是画布的物理分辨率,也就是"画布内部有多少像素",所有绘图操作都以它为准。
- CSS里的width和height,决定的是canvas在页面上被拉伸显示成多大。
如果把CSS宽度设成800px,而canvas标签上的width属性还是400,那么画布内部的400像素会被拉伸铺满800px的显示区域,所有内容看起来都放大了一倍,边缘也会变模糊。反过来,标签width设成800却用CSS显示为400,内容会被压缩,圆点直接变成扁点。
一个常见的需求是让画布自适应容器。如果懒得处理内部参数,最简单的做法是:不用CSS去改canvas尺寸,让标签属性直接决定大小,外面套一个div来负责布局宽度。等你后面需要适配高清屏,再引入devicePixelRatio方案,也就是第5.1节要讲的内容。现在先别给自己添负担。
2.3 fillStyle和状态管理:颜色为什么总是"串味"
fillStyle、strokeStyle一旦设置,会作为状态保留在ctx里。这意味着你画完一个蓝点,再画另一个点的时候如果忘记重新设置fillStyle,新圆点仍然是蓝色。
对于简单场景这没什么影响,但随着圆点增多,颜色管理就会变得混乱。我的习惯是:每次绘制都显式设置颜色,哪怕和上一次一样。多一行代码,换来的是别人,以及三个月后的自己,能一眼看懂这段代码在画什么颜色。变量存颜色也行:
const color = '#409EFF'; ctx.fillStyle = color;把"画布状态"和"绘制动作"分开理解也很重要。ctx上所有的set值,包括fillStyle、strokeStyle、lineWidth、globalAlpha等,都属于"当前状态";而beginPath、arc、fill属于"当前动作"。状态会被后续动作一直携带,所以做动画时如果不处理,颜色、透明度很容易互相污染。画圆点的过程,本质上是"状态 + 动作"的不断循环。
3. 鼠标点击画圆点:坐标换算才是真正的坎
3.1 为什么click事件里拿到的坐标总对不上
静态圆点会画了,接下来是交互:用户用鼠标在画布上点击,点哪儿哪儿出现一个圆点。这一步是新手从"会画"到"会用"的分水岭,因为坐标开始对不上了。
事件对象里有一堆坐标属性:clientX、clientY、offsetX、offsetY、pageX、pageY。它们的含义各不相同:
- clientX/clientY:相对于浏览器窗口可视区域的坐标。
- pageX/pageY:相对于整个文档页面(包含滚动)的坐标。
- offsetX/offsetY:相对于触发事件的目标元素的坐标,初看很适合我们。
很多教程直接说"用offsetX和offsetY就行",实测在简单场景里确实可以,但它有一个隐患:offsetX依赖事件目标,如果canvas内部没有子元素还好,一旦canvas被某些元素覆盖,或者事件触发在边界区域,数值就可能跟你预期不一致。更稳妥的做法,是手动把clientX/clientY换算成canvas内部坐标。既然后面要做拖拽、缩放这些复杂交互,不如从一开始就建立一套清晰可靠的换算逻辑。
3.2 一套稳妥的坐标换算公式
公式其实不复杂:用getBoundingClientRect拿到canvas在页面中的位置,然后用鼠标的client坐标减去画布左上角坐标。最关键的是别忘了处理画布内部尺寸和显示尺寸不一致的缩放因子。
canvas.addEventListener('click', (event) => { const rect = canvas.getBoundingClientRect(); // 画布内部像素 / 画布显示尺寸,得到缩放比例 const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const mouseX = (event.clientX - rect.left) * scaleX; const mouseY = (event.clientY - rect.top) * scaleY; ctx.beginPath(); ctx.arc(mouseX, mouseY, 12, 0, Math.PI * 2); ctx.fillStyle = '#409EFF'; ctx.fill(); });为什么必须乘这个scaleX/scaleY?因为canvas.width是内部像素数,而rect.width是页面上的显示尺寸。如果前面你没用CSS改过canvas大小,两者数值相等,这时候缩放因子是1,随便写。但只要画布被CSS缩放过了,不乘这个因子,你点右下角,圆点就会画到中间甚至画布外面去。这个"看着一样、实际不一样"的坐标差异,正是所有小白在点击交互上翻车的第一大原因。
3.3 交互中的一个高频翻车点:每次都把画布清空了
接着上面的示例继续写。很多新手做完坐标换算,兴冲冲地运行,发现每点一次,新的圆点确实是出来了,但之前的圆点全没了,画面像在重启一样。
原因往往是他们习惯性地在每次点击事件里写了clearRect:
ctx.clearRect(0, 0, canvas.width, canvas.height); // 每次点击都执行的话,旧点全被擦掉clearRect的作用是把指定矩形区域内的所有像素擦成透明。静态绘制时,它能让画布重新开始;但在点击追加圆点的场景里,它就是"橡皮擦",你擦一次,前面画的就没了。
正确的做法是:追加圆点的场景不要清屏,让它一直在原画布上叠加绘制。如果你确实需要"点击后先清空再画",比如下棋、涂鸦软件的"重置"功能,那就要依据确切的业务逻辑来定,不要把所有点击都当成"新画一页"。
这个区别背后其实是一个Canvas的两难:它是"一次性打印"还是"每帧渲染"。追加绘制是"打印式",每次操作留下永久痕迹;动画是"渲染式",每帧先清屏再重画。两种模式不能混着用,想清楚你要哪种,代码才不会写得七扭八歪。
4. 从圆点到粒子:动态绘制与动画基础
4.1 随机圆点:让画面自己生出来
静态点会画了,交互点也会画了,接下来让画面"活"起来。最简单的一步:生成一堆随机位置、随机大小、随机颜色的圆点。
function drawRandomDot() { const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; const r = Math.random() * 20 + 5; // 5~25之间的随机半径 const hue = Math.floor(Math.random() * 360); ctx.fillStyle = `hsl(${hue}, 70%, 60%)`; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } for (let i = 0; i < 100; i++) { drawRandomDot(); }用hsl取色比直接用十六进制色值更适合随机颜色的场景,因为它以"色相"为单位,随便转都能得到柔和协调的颜色。这里的每一点都要开一条新路径,重复100次,肉眼无感;但到几千个点的时候,beginPath和arc的开销就开始显现了,这个话题到性能一节再展开。
4.2 一个迷你粒子系统:让圆点按规律运动
随机画点只是"刷新",真正让人眼前一亮的是让圆点动起来。我们来写一个最简粒子系统:每个圆点是一个对象,记录位置、速度、半径和颜色;每帧更新位置,碰到画布边缘反弹;然后清屏、重画所有圆点。
const particles = []; function createParticle(x, y) { return { x, y, vx: (Math.random() - 0.5) * 2, vy: (Math.random() - 0.5) * 2, r: Math.random() * 8 + 2, color: `hsl(${Math.floor(Math.random() * 360)}, 80%, 60%)` }; } function update() { particles.forEach((p) => { p.x += p.vx; p.y += p.vy; if (p.x - p.r < 0 || p.x + p.r > canvas.width) p.vx *= -1; if (p.y - p.r < 0 || p.y + p.r > canvas.height) p.vy *= -1; }); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); particles.forEach((p) => { ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); }); } function tick() { update(); draw(); requestAnimationFrame(tick); } for (let i = 0; i < 80; i++) { particles.push(createParticle(canvas.width / 2, canvas.height / 2)); } tick();这段代码的逻辑主线很清晰:数据层(particles数组)和渲染层(draw函数)分离,动画循环用requestAnimationFrame驱动。初学者写动画最容易犯的错误是把所有逻辑塞在一个大循环里,一会儿改数组一会儿画图,最后代码完全没法扩展。把update和draw拆开,后面加碰撞、加鼠标交互都会轻松很多。
4.3 requestAnimationFrame为什么是动画的默认选择
setInterval也能做动画循环,很多新手第一个想到的就是它。但实际上,setInterval做动画有一个很难受的问题:它不跟屏幕刷新率对齐,如果回调执行时间超出了间隔时间,容易堆积回调,页面就会显得一卡一卡的。
requestAnimationFrame是浏览器专门为动画设计的循环机制:它由浏览器在每次屏幕刷新前调用,帧率通常和显示器刷新率同步,比如60Hz就每秒跑60次,而且页面切到后台时它会自动暂停,不会白白消耗CPU。做粒子效果、路径动画、拖拽预览,凡是需要连续绘制的场景,直接选它。
为什么这里反复强调"每帧清屏+重画"?因为Canvas本质上是一块即时绘制的画布,你画了什么就是什么,它不会自动把旧图形挪走。要让圆点动起来,唯一的办法是下一帧把整块画布擦干净,再按最新位置画一遍。这像翻页动画书:每一页都是完整的画面,翻快了就形成了运动。代价就是,画面上的元素越多、效果越复杂,每帧的工作量越大,性能问题就随之而来。
5. 避坑指南:我踩过的四个高频坑
前面四章已经把画圆点的完整链路走通了,但真正的经验沉淀都在这里。我用自己的踩坑经历换了四个高频问题的排查思路,一个个说清楚。
5.1 高清屏模糊:明明画得很圆,看起来却有锯齿
现象:在Retina这类高清屏上,画出来的圆点边缘发虚,像被水泡过一样;字体、线条同样如此,但圆点最明显。手机上也一样,原本清晰锐利的圆点糊成一片。
原因:设备通常有一个devicePixelRatio(设备像素比),它表示一个CSS像素对应多少个物理屏幕像素。常见的高清屏是2,也就是1个CSS像素被渲染成2×2的物理像素。而你创建canvas时,如果只按CSS像素设置内部分辨率,比如width=400,那它就用400个物理像素去填满一块需要800个物理像素的画布,多余的像素全靠浏览器插值,自然就糊了。
解决办法是让canvas内部的像素数跟上设备像素比,一般是这个套路:
const dpr = window.devicePixelRatio || 1; const cssWidth = 400; const cssHeight = 300; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.scale(dpr, dpr);这里ctx.scale(dpr, dpr)的作用是把坐标系整体放大一倍,让你的绘图代码仍然按照CSS像素去写,比如半径20就是20px,但实际渲染在物理像素上,清晰度就对了。这个方案看起来只是多几行代码,却能直接决定你的圆点是"锐利"还是"糊成一团",属于必踩必改的坑。
5.2 漏写beginPath:圆点之间长出了神秘的线
现象:连续画几个圆点,本该是几个独立的圆,结果画布上出现连接它们的折线,或者最后一个图形的边角跟前面某个点连在一起,面目全非。
原因:我在第1章反复强调过,Canvas的路径是累积的。每次arc调用只是在当前路径上追加一段子路径,你没有beginPath,所有图形就走同一条路径,fill和stroke会把这条路径一整个画出来。你以为是画了三个圆点,实际上是画了"三个圆点加三条连线"的复合图形。
处理方式很简单,加一行beginPath而已:
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();我有一次排查了很久才发现是这个问题,因为当你只画一个点的时候,路径累积的影响根本看不出来,一旦连续画多个点,问题才会暴露。所以排查建议是:看到"图形之间出现不应该存在的连线",第一反应就去看beginPath有没有写。
5.3 半透明颜色越叠越深
现象:用rgba(255, 0, 0, 0.1)画一个圆点比较多的时候,随着动画循环执行,圆点颜色越来越深,最后变成接近不透明的深红色,跟预设的半透明效果完全不一样。
原因:半透明像素本来就会叠加。每帧清屏后重画,每个圆点在同一位置被反复绘制,每次叠一层10%的红色,颜色当然越来越深。这不是bug,而是Canvas像素混合的正确表现,但如果你没有意识到"帧与帧之间画面在叠加",就会觉得颜色在"变脏"。
解决方案有三个方向:
- 如果背景明确是某种颜色,用背景色不透明地clearRect或fillRect先把整块画布刷干净,再开始画这一帧。
- 用
ctx.globalCompositeOperation = 'destination-out'配合全局透明度做额外的擦除,但这属于高阶技巧,先不深入。 - 如果只是静态画面叠加,干脆别清屏,让"半透明"表现出它该有的层次感,你会发现圆点叠影挺好看的。
这个问题往往和动画一起出现,排查思路要聚焦在"每帧是否真的清干净了",而不只是看fillStyle写了什么。
5.4 圆点数量多了页面卡顿
现象:粒子数量从几十加到一两千,页面帧率明显下降,拖动鼠标都掉帧,CPU风扇呼呼作响。
原因:每帧都在遍历所有圆点,每个圆点都执行beginPath、arc、fill三步。arc这种矢量路径计算比较重,大量小圆点一来,每帧的工作量就上去了。
我的实用优化方案,按实施成本从低到高:
- 限制粒子数量。当数组长度超过上限时,从头部覆盖旧粒子。看起来简单,但最有效。
- 静态图形用离屏canvas缓存。比如涂鸦应用里,画过的圆点不会变,没必要每帧重画,先把它们画到一个隐藏canvas上,以后每次直接drawImage这个隐藏canvas,把旧点"印"上去,只画新增部分。这一步性能提升非常明显。
- 减少循环里的重复操作。比如fillStyle颜色不变时,可以提到循环外;同一批同一颜色的点,还可以考虑先把所有路径加完再统一fill,减少状态切换。
有一个很反直觉的经验:有时候用fillRect画一个正方形的小块,视觉上能代替圆形圆点,性能却比arc高不少。如果追求粒子数量,可以先用方形过渡,等真正需要圆形时再换回arc。画圆点的世界不是非黑即白,性能焦虑时做点视觉妥协完全值得。
到这里,Canvas圆点绘制从坐标系、最小示例、鼠标交互到粒子动画和性能避坑,就完整走了一遍。最后分享一个我自己的小习惯:凡是涉及坐标或尺寸的地方,我会在页面上先画一个调试用的粗边框,把画布真实边界显示出来,然后点几个点看看是否偏移。这一步排查坐标问题非常快,能帮你在"懵圈"阶段少耗掉大把时间。