我先把最基础的版本跑通,再一层一层往里面加东西。这个“100个五角星随机撒屏”听起来像是给Canvas新手练手的小项目,但真做起来会发现,它把Canvas 2D绘图里最核心的几个知识点全串起来了:路径绘制、数学计算、随机算法、动画循环、性能优化,甚至还包括设备像素比这种平时根本不会注意的坑。
这篇文章我是按照自己的实操顺序来写的,从“五角星到底怎么画”到“100颗星同时动还不卡”,每一步都给了可以直接抄走的代码和参数。不管你是刚接触Canvas的小白,还是写过一些页面但没系统整理过Canvas API的开发者,这篇文章都能让你拿到一段能跑、能玩、能扩展的完整代码。我尽量把每个“为什么这么做”也讲清楚,毕竟只知道API不知道怎么用,换个需求照样卡住。
1. 需求拆解与整体设计思路
1.1 先把这个项目拆成四件事
“100个五角星随机撒屏”这句话拆开看,其实是四个独立的问题:
第一,五角星怎么画出来。五角星不是Canvas内置的图形,它得靠路径(Path)一笔一笔连出来。这里需要用到三角函数来算顶点坐标,五角星一共有10个顶点,5个在外圈、5个在内圈,内外圈交替连接,才能画出那个经典的星星轮廓。
第二,100个五角星怎么摆。要求是“随机撒屏”,那就要保证位置、大小、颜色、旋转角度都有随机性,否则100个星星跟阅兵一样整整齐齐排在屏幕上,就失去了“撒屏”的感觉。
第三,撒上去之后要不要动。如果只是静态画100个五角星,说实话难度只算入门的一半。真正好玩的是让它们动起来——旋转、闪烁、飘动、甚至跟随鼠标。这就涉及Canvas动画的核心:requestAnimationFrame。
第四,性能怎么办。100个五角星不算多,但如果每一帧都重新绘制且不加优化,在低端设备上照样会掉帧。怎么组织绘制逻辑、怎么减少不必要的计算,这个从一开始就要想清楚,不然后面加特效会卡得怀疑人生。
1.2 技术选型:为什么用原生Canvas
这个项目我全程用的原生Canvas 2D API,没有引入任何绘图库。我知道有Konva、Fabric、Paper.js这些成熟的Canvas库,画五角星这种事儿它们一行代码就能搞定,但我不建议小白一上来就用库。
原因很简单:库把底层逻辑封装好之后,你根本学不到Canvas本身的能力。就好比你学做饭,直接买料理包加热确实快,但火候、调味、食材处理的底层经验你是积累不到的。真到了需要定制复杂交互或者做性能优化的项目,最后还是要回到原生API上来解决问题。
原生Canvas 2D的核心API其实不多,常用的就这些:getContext('2d')拿画笔,beginPath()开新路径,moveTo()和lineTo()画线,fill()和stroke()填充描边,clearRect()清屏,再加一个requestAnimationFrame()做动画循环。全列出来不超过20个,挨个用一遍基本就记住了。
1.3 整体运行结构
这个项目的最终形态是一个单HTML文件,包含HTML结构、CSS样式和JavaScript代码。打开浏览器运行后,全屏画布上会散落100颗五角星,每颗星有自己的大小、颜色、透明度、旋转速度和飘动速度。它们会缓慢飘动并旋转,鼠标移动过去时,最近的星星会被吸引过来——这个交互是后加的加分项,后面我会展开讲。
2. 五角星的数学原理与绘制逻辑
2.1 五角星的10个顶点是怎么算出来的
五角星看着复杂,其实规律很简单:它就是在圆周上均匀取5个点作为外圈顶点,再取另外5个点作为内圈顶点,内外交替连线。
外圈5个点均匀分布在半径为R(外半径)的圆上,每两个点之间夹角是360°÷5 = 72°。内圈5个点均匀分布在半径为r(内半径)的圆上,但不跟外圈重合,而是偏移36°——这样内外顶点错开,连线后才有五角星的尖角效果。
看代码更直观:
function drawStar(ctx, cx, cy, outerR, innerR, rotation = 0) { const points = 5; // 五角星有5个外角 const stepAngle = Math.PI / points; // 36°,用于内外圈交替 ctx.beginPath(); for (let i = 0; i < points * 2; i++) { // 偶数下标用外半径,奇数下标用内半径 const radius = i % 2 === 0 ? outerR : innerR; const angle = rotation + i * stepAngle - Math.PI / 2; const x = cx + radius * Math.cos(angle); const y = cy + radius * Math.sin(angle); if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.closePath(); }循环10次,每次根据当前下标是偶数还是奇数决定用外半径还是内半径,然后算坐标用lineTo连线。第一次循环用moveTo移动到起点,最后closePath()闭合路径。这里减Math.PI / 2是为了让星星第一个尖角朝上,这是视觉上的小细节,不处理的话第一个角会朝右,看着很别扭。
2.2 弧度制的坑:新手最容易懵的地方
Canvas所有三角函数都用弧度,不是角度。360°等于2π弧度,1°等于π/180弧度。所以前面代码里72°要写成2 * Math.PI / 5,36°要写成Math.PI / 5。
我第一次写的时候直接填了数字72,结果画出来的图形完全对不上,后来才想起Math.cos()和Math.sin()接收的必须是弧度。这个坑几乎每一个Canvas新手都会踩,我建议直接把换算公式打在代码注释里,以免下次又忘。
2.3 参数化:让一个函数画出千变万化的星
上面代码里的drawStar函数我用cx、cy控制位置,用outerR控制整体大小,用innerR控制尖角的锐利程度,用rotation控制旋转角度。这样的设计是为了批量生成100颗星时不重复写绘制逻辑,只需要不断调用这个函数、每次传入不同的参数就行。
innerR和outerR的比例关系决定了星星长什么样。innerR / outerR越接近1,星星越圆润,看起来像胖乎乎的卡通星;比值越小,尖角越长越锐利。我做这个项目用的比例是0.4到0.5之间,效果比较标准,不会太夸张。
2.4 填充、描边与渐变的细节
绘制五角星除了路径本身,样式也很重要。最基础的是设置fillStyle填充颜色,再配合strokeStyle和lineWidth设置描边。纯色填充最省事,但做出来的星星视觉上会比较平。
我给星星加了两层效果,一层是线性渐变填充,一层是发光阴影,代码是这样的:
const gradient = ctx.createRadialGradient( star.x, star.y, star.innerR * 0.3, star.x, star.y, star.outerR ); gradient.addColorStop(0, star.color); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = gradient; ctx.shadowColor = star.color; ctx.shadowBlur = 15; ctx.fill();径向渐变让星星中心亮、边缘透明,看起来有发光效果;shadowBlur加一层光晕,投影仪感一下就出来了。需要提醒的是,shadowBlur比较消耗性能,100颗星星每帧都画的话,低端机可能会吃力。后面第四部分我会讲怎么在性能和视觉效果之间取舍。
3. 随机撒屏的核心算法
3.1 均匀随机还是权重随机
“随机撒屏”最简单的做法是对每颗星星的位置用Math.random()生成一个坐标,x范围取0到画布宽度,y范围取0到画布高度。这样写确实随机,但效果会显得非常“均匀”——每个区域的星星密度都差不多,看起来反而没什么设计感。
我用了另一种策略:让星星在中心区域聚集多一些,四周稀疏一些。这个效果用高斯随机或简单的“中心偏差”就能实现。我的做法粗糙但有效:x坐标加两次随机值再取平均,这样中心区域的概率自然更高。
function randomPos(max) { return (Math.random() + Math.random()) / 2 * max; }两次Math.random()取平均会产生类似钟形曲线的分布,中心和边缘的比例大概是2比1,视觉上比纯均匀分布舒服很多。
还有一个容易被忽略的边界问题:星星有半径,如果x坐标随机到0,星星的一半会在画布外面。所以生成坐标时要留出至少一个outerR的边距。
const margin = star.outerR; star.x = margin + (Math.random() + Math.random()) / 2 * (canvas.width - margin * 2); star.y = margin + (Math.random() + Math.random()) / 2 * (canvas.height - margin * 2);3.2 每颗星星的属性模型
100颗星星不能每帧都临时算一堆随机数,它们的属性必须在初始化时确定,之后每一帧只做增量更新。我用的星星对象是这样的:
{ x: 320, // 当前x位置 y: 240, // 当前y位置 vx: 0.3, // x方向飘动速度 vy: 0.2, // y方向飘动速度 outerR: 28, // 外半径 innerR: 12, // 内半径 rotation: 1.2, // 当前旋转角度(弧度) rotateSpeed: 0.02, // 每帧旋转增量 color: '#FFD700', // 主色调 alpha: 0.8, // 全局透明度 phase: 2.1, // 用于计算闪烁的相位值 pulseSpeed: 0.01 // 闪烁速度 }这些属性里,vx和vy用来做飘动,每帧更新x += vx、y += vy就行;rotateSpeed控制旋转快慢;phase和pulseSpeed配合Math.sin()实现透明度周期变化,模拟眨眼效果。
所有星星的随机范围我是这样定的:
| 属性 | 随机范围 | 设计考虑 |
|---|---|---|
| outerR | 15~45 | 大小差异明显,画面有层次 |
| rotateSpeed | -0.05~0.05 rad/帧 | 正负都有,顺时针逆时针混着转 |
| 透明度 | 0.4~1.0 | 太透明看不清,太有实感没灵气 |
| 颜色 | 从柔和色板里选 | 避免纯随机RGB产生刺眼荧光色 |
3.3 为什么要用对象数组而不是画死
100颗星的状态如果写死在代码里,确实也能画,但全屏的星星位置、大小都一样,跟墙纸似的,没有生命力。用对象数组的好处是:每颗星星的每个属性都可以单独变化,而且所有状态集中在一个数组里,更新、删除、排序都方便。
比如我想让特定坐标范围内的星星变大或者变亮,只需遍历数组判断条件再改属性,非常灵活。这种“数据与表现分离”的思路不仅是Canvas项目的基础,也是前端框架(Vue、React)的核心思想,早点建立这个意识对后面写复杂项目很有帮助。
3.4 颜色的坑:别直接用随机RGB
新手最容易踩的坑是直接用rgb(Math.random() * 255, Math.random() * 255, Math.random() * 255)生成颜色。理论上这样可以,实际上跑出来一片荧光色,因为高饱和度的红绿蓝随机组合会显得又脏又刺眼。
我用了两组颜色方案,一组是暖色调的星光色系(金色、橙色、淡粉、米白),一组是冷色调的夜色系(淡蓝、淡紫、冰绿、青白)。初始化时从色板里随机取一个,保证整体画面和谐,单颗星星又有区别。这个方案实测观感比纯随机好太多。
4. 完整代码实现与运行步骤
4.1 HTML结构和全屏画布
整个项目只需要一个HTML文件。结构里最核心的是<canvas>标签,但有几个细节直接决定体验:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>100颗五角星随机撒屏</title> <style> * { margin: 0; padding: 0; } html, body { width: 100%; height: 100%; overflow: hidden; } canvas { display: block; width: 100%; height: 100%; } </style> </head> <body> <canvas id="starCanvas"></canvas> <script> // JavaScript代码写在这里 </script> </body> </html>overflow: hidden去掉滚动条,display: block去掉canvas默认的内联元素间隙,width/height 100%保证铺满视口。这些CSS细节不注意的话,页面底部会出现一条白色空隙,或者出现莫名滚动条,属于那种“看着小但特别烦”的问题。
4.2 JavaScript初始化与星星批量生成
完整的初始化逻辑分三步:拿到画布和绘图上下文、按实际视口尺寸设置画布宽高、根据宽高批量创建星星。
const canvas = document.getElementById('starCanvas'); const ctx = canvas.getContext('2d'); // 关键:canvas的width和height属性决定实际像素数量 // 要与CSS尺寸区分开,否则画出来的图形会发虚 canvas.width = window.innerWidth; canvas.height = window.innerHeight; const STAR_COUNT = 100; const stars = []; const COLORS = [ '#FFD700', '#FFB347', '#FF6F61', '#F4E1FF', '#FFA0A0', '#5B9BD5', '#8FBC8F', '#E0FFFF' ]; function getRandomColor() { return COLORS[Math.floor(Math.random() * COLORS.length)]; } function createStar(width, height) { const outerR = 15 + Math.random() * 30; const innerR = outerR * (0.4 + Math.random() * 0.1); const margin = outerR; return { x: margin + (Math.random() + Math.random()) / 2 * (width - margin * 2), y: margin + (Math.random() + Math.random()) / 2 * (height - margin * 2), vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6, outerR, innerR, rotation: Math.random() * Math.PI * 2, rotateSpeed: (Math.random() - 0.5) * 0.04, color: getRandomColor(), alpha: 0.4 + Math.random() * 0.6, phase: Math.random() * Math.PI * 2, pulseSpeed: 0.005 + Math.random() * 0.015 }; } for (let i = 0; i < STAR_COUNT; i++) { stars.push(createStar(canvas.width, canvas.height)); }外半径范围我控制在15到45像素。太小的星画不出来细节,太大的星会遮挡视野。另外注意innerR是基于outerR动态计算的,这样每颗星的比例一致,不会出现某颗星的内外半径比例异常、形状别扭的情况。
4.3 动画循环:为什么用requestAnimationFrame
动画不能直接写个for循环一帧画完,因为画面需要持续刷新。有两个选择:setInterval和requestAnimationFrame。
// 不推荐的做法 setInterval(() => { drawFrame(); }, 16); // 约60fps // 推荐的做法 function animate() { drawFrame(); requestAnimationFrame(animate); } animate();setInterval的问题在于它是定时触发,不管浏览器是否处于当前标签页,也不管设备屏幕刷新率是多少。如果屏幕是120Hz,16毫秒的间隔会导致掉帧;如果切到后台标签页,它还在白白消耗CPU。requestAnimationFrame会跟随屏幕刷新率自动调整,页面不可见时自动暂停,省电又流畅。这是Canvas动画的基本功,一定要用后者。
4.4 绘制帧的完整流程
drawFrame每一帧做四件事:清屏、更新每颗星的状态、绘制全部星星、处理鼠标交互。
function drawFrame() { // 1. 清屏。不清理的话,上一帧的画会被覆盖叠加 ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 更新状态 for (let star of stars) { star.x += star.vx; star.y += star.vy; star.rotation += star.rotateSpeed; star.phase += star.pulseSpeed; // 边界反弹:超出屏幕边缘就把速度取反,星星不会飞走消失 if (star.x < star.outerR || star.x > canvas.width - star.outerR) star.vx *= -1; if (star.y < star.outerR || star.y > canvas.height - star.outerR) star.vy *= -1; } // 3. 绘制 for (let star of stars) { ctx.save(); ctx.translate(star.x, star.y); ctx.rotate(star.rotation); ctx.globalAlpha = star.alpha * (0.7 + 0.3 * Math.sin(star.phase)); drawStar(ctx, 0, 0, star.outerR, star.innerR); // 渐变填充 ctx.fillStyle = star.color; ctx.shadowColor = star.color; ctx.shadowBlur = 12; ctx.fill(); ctx.restore(); } requestAnimationFrame(drawFrame); }这里有个很重要的细节:绘制星星时用了ctx.translate(star.x, star.y)把原点移动到星星中心,然后坐标系中画星就能直接用(0, 0)作为圆心,rotate()旋转也会围绕这个新原点进行。画完后必须ctx.restore()把变换状态恢复,否则下一次循环会在错误的坐标系里继续画,画面会错乱得没法看。
清屏这步我用了clearRect,它把所有像素变成全透明黑色,视觉效果回到背景色。如果不想全清,想留一点残影的效果,可以改用半透明底色覆盖,这个技巧后面扩展部分会提。
4.5 加上鼠标交互
基础动画跑起来后,我加了一个鼠标吸附效果——鼠标靠近哪颗星,哪里就会被吸引过来。这需要监听mousemove事件,在动画循环里计算每颗星与鼠标的距离,距离小于阈值时施加一个向鼠标方向的力。
let mouse = { x: null, y: null }; canvas.addEventListener('mousemove', (e) => { mouse.x = e.clientX; mouse.y = e.clientY; });更新星星位置时增加一段:
if (mouse.x !== null) { const dx = mouse.x - star.x; const dy = mouse.y - star.y; const dist = Math.sqrt(dx * dx + dy * dy); const influence = 150; // 生效半径 if (dist < influence && dist > 0) { const force = (influence - dist) / influence; star.vx += (dx / dist) * force * 0.15; star.vy += (dy / dist) * force * 0.15; } }这样星星会越靠近鼠标越是加速飞过去,离开影响力范围后继续巡航飘动,整个画面一下子就有了灵魂。鼠标噪声在交互设计里是个常见套路,画布类的项目很合适,因为没有DOM节点可监听,全靠canvas事件配合数据驱动。
5. 实战中的坑与排查技巧
5.1 星星发虚发糊:devicePixelRatio的锅
如果你在普通笔记本上运行前面代码,大概率看不出问题,但在高分屏(比如Mac的Retina屏幕)上跑,星星边缘会明显发虚。原因是物理像素和CSS像素的差异:屏幕实际物理像素是CSS像素的2倍或3倍,而canvas的绘制尺寸只按CSS像素设置了,浏览器被迫放大渲染,自然就糊了。
解决方法是在初始化时按设备像素比缩放画布:
const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(window.innerWidth * dpr); canvas.height = Math.floor(window.innerHeight * dpr); ctx.scale(dpr, dpr); canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px';这样canvas的物理像素和屏幕一致,绘制又通过ctx.scale把坐标系统一回到CSS像素值,代码里的坐标不用改,渲染清晰度却上了一个档次。这个细节不处理,星星边缘的锯齿感会特别明显,整体观感直接降级。
5.2 动画越跑越快:requestAnimationFrame重复调用
我在调试时遇到过一个问题:星星的运动速度越来越快,画面越来越卡。排查半天发现原因是我既在drawFrame末尾调用了requestAnimationFrame(drawFrame),又在window的load事件里启动了另一个requestAnimationFrame(drawFrame),两个循环同时在跑,一帧画两次,update里的速度增量自然翻倍了。
解决方法是保证animate只被调用一次,并且建议加一个isRunning标志防止重复启动:
let isRunning = false; function animate() { if (isRunning) return; isRunning = true; function tick() { drawFrame(); requestAnimationFrame(tick); } requestAnimationFrame(tick); }5.3 星星全部堆在左上角:坐标和div混淆
另一个很典型的错误是初始化时直接用createStar(100, 100)这种固定值。如果画布尺寸是1920×1080,100颗星星全部聚集在左上角100×100的小区域里,看起来像一团糊掉的墨点。这个问题表面看是“忘了传canvas宽高”,实际上更深层的原因是混淆了DOM的clientWidth/clientHeight和canvas的width/height属性。
canvas的width和height属性才是真实的绘图尺寸,必须在设置时同步用到初始化函数里。我建议把它们都放到一个对象里管理,减少传错的可能。
5.4 性能优化:save/restore别滥用
ctx.save()和ctx.restore()是Canvas里的状态栈操作,它们保存和恢复的内容包括变换矩阵、样式、阴影等所有绘制状态。看起来很好用,但每次调用都有开销,100颗星星每帧各一次save/restore就是200次调用,在低端设备上会明显拖慢帧率。
优化方向有两个。一是减少save的必要性——如果只是设置globalAlpha、fillStyle这些属性,用完再改回来就行,不一定要用save。二是把固定不变的设置提升到循环外,比如阴影的shadowColor和shadowBlur如果所有星星都一样,那只需设置一次;需要动态变化的部分(旋转、坐标系)才单独处理。
我实测下来100颗星星每帧100次save/restore其实还好,但如果星星数量翻倍到500颗,或者每颗星还要绘制渐变、阴影和光晕,就必须做这层优化了。
5.5 渐变和阴影的性能取舍
我前面提到给星星做了径向渐变和阴影光晕,这两个效果都很好看,但也是性能消耗的“大头”。createRadialGradient()每次创建渐变对象都有成本,shadowBlur更是出了名的开销大。
实测结果是这样的:100颗星星、带渐变不带阴影,在普通笔记本上保持60帧没问题;加上阴影后帧率会掉到40~50帧,虽然视觉上不容易察觉,但开发者工具里的FPS监控是能看出来的。如果目标设备性能一般,建议把shadowBlur值减半,或者只在鼠标悬停时给个别星星加光晕,其余全部用渐变填充替代,画面差异不会太大。
6. 从五角星出发:Visual扩展的N种玩法
6.1 人物形象、文字3D效果与绘图引擎
“100个五角星”跑通之后,这个项目可以作为很多复杂Canvas效果的起点。热搜词里出现的“canvas小人形象”“canvas文字3d效果”其实都是同一套底子:用基础图形组合、变换矩阵、逐帧渲染。
比如做一个小人形象,可以画一个头(圆形)、身体(椭圆或矩形)、四肢(线段),把每一帧的状态当做一个“对象数组”来更新,方法和五角星几乎没有区别。文字3D效果则是在同一文字的不同位置绘制多个偏移的副本,用颜色深浅模拟厚度,本质也是把单一图形变成批量绘制。
如果你往后想做更大的Canvas项目,建议把这套星星逻辑抽成一个小的绘图引擎——结构上就是“对象数组 + 批量更新 + 批量渲染”,再封装一层addShape()和removeShape()的方法,以后换任何图形都只是换个draw()函数的问题。
6.2 五角星变粒子系统
五角星的属性模型本身就是一个“粒子”模型:有位置、速度、大小、透明度、生命周期。把颜色换成渐变,把形状从星星改成任意路径,把随机数从一次性生成改成持续向外抛出新粒子,这就成了粒子系统。
举个例子,鼠标每移动一段距离就生成一颗新的小星星,星星从鼠标位置出发,向外扩散、缩小、淡出,看起来就像撒了一捧星星碎片。代码改动量不大,新增一个出生逻辑,把过期的星星从数组里splice掉就行。颗粒感很强,效果一下子就从“静态屏保”变成了“交互特效”。
6.3 在Vue/React中使用这套逻辑
不少人问过我“canvas 2d怎么配合vue用”。我的意见是:用框架没问题,但别让框架插手Canvas内部的每一帧绘制。Canvas动画本质上是命令式的,每一帧都在做“更新数据+重绘”,这和Vue/React的声明式渲染模型是冲突的。强行用响应式数据去驱动canvas绘制,只会导致频繁的diff和虚拟DOM开销,反而不如原生写法流畅。
正确的姿势是:组件的mounted里创建Canvas上下文并启动动画循环,所有星星数据保存为普通数组(不放进reactive),动画循环自己同步更新。只有需要外部控制的状态(比如开关、速度、数量)才放到框架的数据里,通过watch去调整。这样既享受到框架的组件化优势,又不会损失Canvas的性能。
6.4 扩展思路:半透明残影和色彩叠加
最后分享一个视觉效果极好但代码量极小的技巧。前面代码用的清屏方式是clearRect全清,如果改成用半透明黑色矩形覆盖,就能产生残影拖尾的效果:
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; ctx.fillRect(0, 0, canvas.width, canvas.height);这一行的效果是:每一帧只把以前的画面淡掉10%,新帧的星星会带着之前的轨迹一起呈现,视觉上像流动的光带。五角星旋转的时候,残影会把旋转的弧线勾勒出来,尤其适合做成背景装饰。
我个人做这类Canvas项目最深的体会是:先把一个形状真正画好、画透,后面几倍的效果都建立在最初那几十行代码上。五角星这个项目看着不起眼,但该遇到的坑一个不落:坐标系变换、随机分布、性能取舍、动画循环,每个都是Canvas开发的“通用零件”。把它们吃透了,后面要画什么、要做什么交互,都只是换个形状往里套的事。