
先讲个真实经历。有阵子我想给一个活动页面做星空氛围产品君丢过来一句话“撒点五角星上去要动的那种。”我心想五角星有啥难的循环五个点连起来不就成了结果画出来的东西连我自己都看不下去——有的像被踩过的塑料瓶盖有的像长歪了的海星旋转起来简直是在扭秧歌。后来我把这事彻底搞明白了五角星看着简单实际涉及两个圆的坐标换算、黄金分割比例、Canvas路径闭合、随机数分布边界、设备像素比……一整套东西。这篇文章就用“100个五角星随机撒屏”这个实战项目从数学原理讲到代码实现再把我调试过程中踩过的坑和优化思路完整摊开。零基础的读者照着敲也能跑出效果有基础的可以重点看第二章的几何推导和第五章的排坑记录。1. 为什么是Canvas四种“撒星星”方案的真实差距先给结论100个五角星这个数量级Canvas 2D是最合适的选择。不是说其他方案不行而是Canvas对上这个场景的性价比最高。尤其是标题里强调“随机撒屏”而不是“摆放五角星图标”这意味着星星会动、会闪、会转对渲染方案的动态性能有硬要求。1.1 方案对比DOM、SVG、Canvas 2D、WebGL很多新手看到“100个五角星”第一反应是往页面里塞100个div或者100个SVG图形。这两种思路在少量静态元素时没问题但一旦让它们动起来麻烦就来了。我把四个方案放在一起对比一下方案100个静态星星100个动态星星小白上手成本典型瓶颈DOM节点容易稍卡低主线程频繁更新样式帧率不稳SVG容易事件方便但运动开销大中每个图形都是独立DOM节点动画触发大量重排Canvas 2D容易流畅低数量到几千以后性能开始下降WebGL繁琐性能最强高需要写着色器、管理缓冲入门曲线陡表格里其实藏着一条主线越往下的方案离“直接操作像素”越近性能越高但代码复杂度也越高。DOM和SVG属于保留模式渲染——你创建了一个星星对象浏览器会一直记住它并在屏幕上管理它。好处是每个星星天然支持click、hover等事件坏处是浏览器要为这100个对象建立一棵庞大的“文档树”每次位置或角度的变化都要走一遍样式计算、布局、绘制的完整流程。100个节点还好如果哪天你想把数量翻到500页面会明显开始“喘”。WebGL是另一个世界。它直接调用GPU几万个粒子都能轻松跑但你需要理解顶点着色器、片元着色器、缓冲区管理。为了让100个五角星发光而去学这一整套性价比太低对小白尤其不友好。Canvas 2D落在这条线的中间偏左但它恰恰是“粒子类视觉项目”的甜点区。它背后是一块二维位图画布你用画笔往上涂涂完就完了浏览器不保留任何“星星对象”——所有状态都得你自己用JavaScript对象维护。这听起来麻烦实际上给了你最大的自由度星星怎么动、怎么排列、怎么消失完全由你说了算没有文档树在背后拖后腿。1.2 为什么100这个数量级正好落在Canvas舒适区我实测下来的体感是这样的Canvas 2D画10万个圆点会卡到怀疑人生画1万个圆点要开始认真优化画100个五角星则属于“闭着眼睛写也不会卡”的范围。因为100次beginPath、fill操作对现代浏览器来说连热身都算不上。那么为什么还要单独讨论这个数量级因为100这个数字很微妙。少于50个你其实用SVG甚至DOM也能做出流畅效果技术选型的差异不明显一旦超过100文档树方案开始出现偶发性的掉帧而Canvas依然稳如老狗。更重要的是100能覆盖绝大多数真实场景活动页星空背景、节日彩带、加载动画、简单的粒子模拟基本都在这个量级。选Canvas还有一个隐形好处你提前把“对象状态渲染循环”这套粒子系统的骨架搭好了。后面哪怕需求膨胀到“500颗流星”“1000片花瓣”你改的只是对象字段和绘制函数架构不用推倒重来。我做了几年前端最大的感受是——方案之间差的不是功能而是你为未来需求留了多少余地。Canvas在这里余地最大。2. 五角星画不像的根源两个圆、十个顶点和0.382的比例在动手写代码之前必须先解决一个灵魂问题为什么那么多人画出的五角星不像五角星因为大多数人的直觉错了。你以为五角星是“画五个点连起来”但五角星的轮廓根本不是五边形。你把一个标准五角星放在放大镜下看会发现它的边缘一共有10个转折点5个向外凸的尖角5个向内凹的谷点。如果你只画5个点得到的是一颗“五边形星”——圆鼓鼓的像被撑开的盾牌完全没有星星的精气神。2.1 五角星是两个同心圆的嵌套变形把五角星解剖一下你会发现它其实是两个圆的组合。外圆半径R决定星星整体的大小内圆半径r决定星星“肚子”凹进去的深度。外圆上均匀分布着5个外顶点两两相隔72度360度除以5。内圆上同样均匀分布着5个内谷点但它们的位置正好落在两个外顶点的正中间。也就是说内谷点相对外顶点偏移36度。外顶点、内谷点、外顶点、内谷点……依次用直线连起来就是五角星的轮廓。这段话值得多看两遍。它是五角星所有几何问题的地基外顶点作用在半径为R的圆上内谷点作用在半径为r的圆上两个圆的圆心相同。那这个r取多少合适如果你随手取一个值比如r R / 2画出来会是一颗“胖星星”——五个角变钝凹陷几乎看不出来更像一个齿轮。如果你让r小到R / 10星星会变成五根细长的尖刺中间几乎没有“身体”。这两个极端都不符合大家对五角星的日常认知。2.2 0.382这个比例是怎么来的标准五角星有一个被反复验证的黄金比例内谷点所在圆的半径约为外圆半径的0.382倍。也就是r R * (3 - √5) / 2 ≈ R * 0.381966这个数字看着眼熟吧它就是黄金比例φ的平方的倒数。为什么五角星会和黄金比例缠在一起因为标准五角星可以理解为正五边形的五条对角线互相切割后的结果而对角线的交点恰好把每条对角线按黄金分割切开。你不需要把这条几何链完整推一遍只需要记住结论0.382是最“有精神”的五角星比例每个尖角正好是36度整体轮廓最符合直觉。我在调试中试过各种数值经验是这样的想让星星更饱满圆润把比例调到0.45到0.5之间它会像一枚勋章想让星星更尖锐凌厉调成0.3左右它会像一块荆棘。小于0.3之后星星开始朝“暗器”的方向走超过0.5则像被压扁的五边形。实战里如果客户没有明确要求直接用0.382永远不会出错。2.3 从数学到canvas路径一段坐标代码有了上面的几何模型画五角星的代码就水到渠成了。核心公式就一个圆上角度对应的坐标是cos(angle) * radius和sin(angle) * radius。需要注意一个细节我习惯把第一个外顶点放在-90度方向也就是正上方。如果你从0度正右方开始画星星会整个歪过来最常见的表现是“尖角朝向45度斜向”看起来像被人拧了一把。function drawStar(ctx, x, y, outerR, innerR, rotation) { ctx.beginPath(); for (let i 0; i 5; i) { // 外顶点每72度一个起始位置在正上方(-90度) const outerAngle -Math.PI / 2 (i * 4 * Math.PI) / 5 rotation; const outerX x outerR * Math.cos(outerAngle); const outerY y outerR * Math.sin(outerAngle); ctx.lineTo(outerX, outerY); // 内谷点相对外顶点偏移36度即π/5 const innerAngle outerAngle Math.PI / 5; const innerX x innerR * Math.cos(innerAngle); const innerY y innerR * Math.sin(innerAngle); ctx.lineTo(innerX, innerY); } ctx.closePath(); }这里有个关键点innerAngle要基于outerAngle加Math.PI / 5而不是重新计算一个独立的起始角度。因为旋转参数rotation加在outerAngle上之后内谷点必须跟着一起转否则星星整体旋转时内外顶点会错位画出来就是一个“扭曲的螺旋星”。另外closePath()这句特别重要。五角星的路径是“外顶点-内谷点-外顶点”交替画出的画完最后一个内谷点后需要一条直线回到起点closePath()做的就是这件事。我遇到过好多次代码写对了但填充出来的星星缺了一个角排查到最后就是漏了这行。如果你之前用Python海龟绘图画过五角星一定体会过“填充不完整”的诡异现象——Canvas里对应的坑就是路径没闭合原理一模一样。3. 从画一个五角星到一百个代码逐段拆解几何问题解决了接下来把整个项目跑起来。我直接把完整代码放在3.4但在那之前先拆开讲清楚每一段的职责。3.1 画布初始化尺寸、设备像素比和resizecanvas元素有三个尺寸概念需要分清canvas.width是画布位图的物理宽度canvas.height是位图的物理高度而style.width和style.height是它在页面上显示的CSS尺寸。三者如果不对齐画面要么模糊要么变形。这里先给出一版稳妥的初始化const canvas document.getElementById(starCanvas); const ctx canvas.getContext(2d); function resizeCanvas() { const dpr window.devicePixelRatio || 1; const cssWidth window.innerWidth; const cssHeight window.innerHeight; // CSS尺寸决定画布在页面上占据多大空间 canvas.style.width cssWidth px; canvas.style.height cssHeight px; // 位图尺寸要按物理像素来高分屏下必须乘以DPR canvas.width Math.round(cssWidth * dpr); canvas.height Math.round(cssHeight * dpr); // 把坐标系放大DPR倍后续逻辑坐标直接用CSS像素即可 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resizeCanvas); resizeCanvas();这段代码是Canvas全屏项目的“标准开场白”。第2章强调过canvas.width和style.width的区别这里就是落地。不处理DPR的表现很典型在Retina屏上星星边缘全是毛刺像隔着一层起雾的玻璃处理之后线条立刻锐利清晰。3.2 Star对象5个字段和一组随机参数Canvas是立即模式它不保存星星对象所以每一位“五角星居民”都需要一个普通的JavaScript对象来记录自己的状态。我把这个对象命名为Star它包含的字段可以分成两大类描述“长什么样”的和描述“怎么动”的。字段含义生成方式x, y星星中心坐标随机位置画布范围内outerR外圆半径决定大小8到32像素之间随机innerR内圆半径谷点深度outerR × 0.382或按需要调整rotation当前旋转角度0到2π随机rotationSpeed旋转速度-0.03到0.03之间的随机值vx, vy漂移的速度分量-0.3到0.3之间随机baseOpacity基础不透明度0.4到1之间随机phase闪烁相位0到2π随机twinkleSpeed闪烁速度0.5到2之间随机hue色相0到360随机参数取值范围不是乱定的我下面讲动画章节时会展开说。现在先看代码实现class Star { constructor(options {}) { this.x options.x ?? Math.random() * window.innerWidth; this.y options.y ?? Math.random() * window.innerHeight; this.outerR options.outerR ?? 8 Math.random() * 24; this.innerR this.outerR * (options.innerRatio ?? 0.382); this.rotation Math.random() * Math.PI * 2; this.rotationSpeed (Math.random() - 0.5) * 0.03; this.vx (Math.random() - 0.5) * 0.6; this.vy (Math.random() - 0.5) * 0.6; this.baseOpacity 0.4 Math.random() * 0.6; this.phase Math.random() * Math.PI * 2; this.twinkleSpeed 0.5 Math.random() * 1.5; this.hue Math.random() * 360; // 颜色字符串只生成一次避免每帧重复拼接 this.color hsl(${this.hue}, 75%, 65%); } }注意到color字段了吗很多人会在绘制函数里写ctx.fillStyle hsl(${Math.random()*360}, 75%, 65%)这样每帧都在创建新字符串。100颗星每帧100次字符串拼接看起来不痛不痒但垃圾回收的压力是真实存在的。把颜色缓存到对象里是粒子系统的基本功。3.3 drawStar绘制函数一次绘制一个五角星绘制函数接收一个Star实例和一个时间值做三件事先保存画布状态再把坐标系平移到星星中心并旋转最后绘制路径填充颜色。function drawStar(star, time) { // 闪烁用正弦函数把透明度变成时间相关的波浪 const twinkle 0.6 0.4 * Math.sin(time / 1000 * star.twinkleSpeed star.phase); ctx.save(); ctx.translate(star.x, star.y); ctx.rotate(star.rotation); ctx.beginPath(); for (let i 0; i 5; i) { const outerAngle -Math.PI / 2 (i * 4 * Math.PI) / 5; ctx.lineTo(star.outerR * Math.cos(outerAngle), star.outerR * Math.sin(outerAngle)); const innerAngle outerAngle Math.PI / 5; ctx.lineTo(star.innerR * Math.cos(innerAngle), star.innerR * Math.sin(innerAngle)); } ctx.closePath(); ctx.globalAlpha star.baseOpacity * twinkle; ctx.fillStyle star.color; ctx.fill(); ctx.restore(); }这段代码里ctx.translate和ctx.rotate配合使用相当于我把“画笔”拿起来、挪到星星中心、旋转一个角度然后在这个局部坐标系里画五角星。这样x、y、rotation都不用参与顶点坐标的复杂运算代码可读性高得多。save和restore是成对出现的保证画完一颗星之后画布状态恢复原样下一颗星不会继承莫名其妙的旋转或透明度。3.4 主循环与完整代码动画主循环用的是requestAnimationFrame。浏览器会在每次刷新屏幕前调用我们传入的回调函数通常每秒60次。它比setInterval靠谱的地方在于标签页切到后台时浏览器会自动暂停动画不浪费资源。const stars Array.from({ length: 100 }, () new Star()); function update(time) { // 清空上一帧否则星星会叠出拖影 ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); for (const star of stars) { // 更新状态旋转、漂移 star.rotation star.rotationSpeed; star.x star.vx; star.y star.vy; // 边界反弹后面会细说 if (star.x star.outerR || star.x window.innerWidth - star.outerR) { star.vx * -1; star.x Math.max(star.outerR, Math.min(window.innerWidth - star.outerR, star.x)); } if (star.y star.outerR || star.y window.innerHeight - star.outerR) { star.vy * -1; star.y Math.max(star.outerR, Math.min(window.innerHeight - star.outerR, star.y)); } drawStar(star, time); } requestAnimationFrame(update); } requestAnimationFrame(update);整个项目只需要一个HTML文件就能跑一个canvas标签、一个背景色为深蓝的body、一个script脚本把上面的代码按顺序拼进去100颗五角星就会在屏幕上旋转、漂移、闪烁。没有框架、没有构建工具、没有依赖这大概就是Canvas对新手的最大友好度。不过这里有个取舍需要提一下旋转漂移的速度我直接写在代码里没有用deltaTime修正。这意味着在高刷新率屏幕比如120Hz上星星运动会比60Hz屏幕上快一倍。对要求不高的小项目来说无所谓如果严格追求跨设备一致性你可以在主循环里计算两帧间隔delta然后让rotation rotationSpeed * delta。这是进阶优化放到第4章末尾再说。4. 撒屏动画调优转速、闪烁、边界反弹与疏密控制代码跑起来之后你大概率会发现“能看但不够好看”。这很正常随机参数能保证多样性但不能保证美感。这一章讲怎么把“能看”变成“耐看”。4.1 三种基础动画旋转、闪烁、漂移旋转是最容易出效果的。我这里给每个星星一个随机转速正负都有方向有快有慢。如果所有星星都朝同一方向转屏幕会有一种“洗衣机甩干”的眩晕感。更自然的做法是大约三分之一的星星正转三分之一反转三分之一几乎静止。实现方式就是上面代码里的(Math.random() - 0.5) * 0.03——减0.5让结果均匀分布在-0.5到0.5之间再乘系数控制速度。闪烁的本质是让透明度随时间变化。我用的公式是sin(time * speed phase)输出的范围是-1到1通常要映射到0到1之间所以写成0.6 0.4 * sin(...)让透明度在0.2到1之间波动。phase参数保证所有星星不在同一时刻眨眼否则会整齐地一明一暗像安装了节拍器。漂移是高阶玩法。给星星一个速度向量之后它会像碎纸屑一样慢慢飘动。注意速度不能太大否则屏幕像在下冰雹也不能都在0附近否则和静态图没区别。我常用的范围是-0.3到0.3像素每帧折合每秒最多18像素属于“能感觉到在动但不抢注意力”的节奏。如果你想让某些星星飘得快一点可以特意让少量星星的速度落在0.6以上给画面增加一点动态层次。4.2 边界反弹别让半颗星挂在屏幕边如果你给星星加了漂移迟早会遇到“半颗星卡在屏幕边缘”的问题。原因很朴素边界判断用的是星星中心的坐标当中心即将超出屏幕时外半径outerR已经把一部分画到屏幕外面去了。所以边界阈值不是0而是star.outerRif (star.x star.outerR || star.x window.innerWidth - star.outerR) { star.vx * -1; star.x Math.max(star.outerR, Math.min(window.innerWidth - star.outerR, star.x)); }Math.max和Math.min套在一起目的是把坐标“夹紧”回合法区间。防止星星在高速移动时一帧之间冲过头导致它卡在边界外一直抖动。这种“先反转方向再拉回合法位置”的处理比单纯的反转速度更稳。同样的逻辑也适用于初始化坐标。如果你直接用Math.random() * width作为初始x那些出生在边缘附近、半径又比较大的星星会从一开始就缺个角。正确的随机位置生成要考虑半径余量x outerR Math.random() * (width - 2 * outerR)。我把这个坑放在后面第五章专门讲这里先记住结论。4.3 分布调优随机也可能“太均匀”很多人以为“随机撒屏”就是完全随机但真跑起来你会发现一个有趣的现象纯随机出来的100个点视觉上反而有一种均匀感像一张渔网均匀撒在屏幕上少了夜空那种天然的疏密层次。原因在于完全独立均匀的随机分布在样本量足够大时确实会趋向于均匀铺满。如果你想要的是“有的角落挤成一团有的区域空荡荡”的自然感需要引入一些额外手段。我推荐一种简单可控的方案分层随机也叫网格抖动。把屏幕想象成一张棋盘把100颗星分配到各个格子里每格先放一颗然后在格子内做小范围随机偏移。这样既能保证星星不会全部挤到某个角落又保留了随机分布的自然感。function generateStarsByGrid(count) { const cols Math.ceil(Math.sqrt(count * (window.innerWidth / window.innerHeight))); const rows Math.ceil(count / cols); const cellW window.innerWidth / cols; const cellH window.innerHeight / rows; const result []; for (let i 0; i count; i) { const star new Star(); const gx (i % cols) * cellW cellW / 2; const gy Math.floor(i / cols) * cellH cellH / 2; star.x gx (Math.random() - 0.5) * cellW * 0.6; star.y gy (Math.random() - 0.5) * cellH * 0.6; star.x Math.max(star.outerR, Math.min(window.innerWidth - star.outerR, star.x)); star.y Math.max(star.outerR, Math.min(window.innerHeight - star.outerR, star.y)); result.push(star); } return result; }(Math.random() - 0.5) * cellW * 0.6把星星限制在格子中心左右30%的范围内保证相邻格子不会串得太离谱。如果你想要星星之间有更多“空档”就把0.6调小想让分布更乱调大甚至直接去掉网格退回纯随机。这一格之差就是我说的“随机撒屏”和“均匀撒屏”的核心取舍。5. 实测踩坑记录模糊、变形、被裁切与掉帧排查如果说前四章是教程这一章就是事故报告。我把这几年做Canvas动画遇到的高频问题集中写出来每一个都是我或者我身边同事真正踩过的按现象、根因、解决方案三步来。5.1 星星发虚设备像素比DPR适配现象很典型星星在电脑浏览器上锐利清晰拿到手机上一看边缘全是毛刺像透过磨砂玻璃看东西。很多人第一反应是“Canvas抗锯齿没开”其实Canvas的2D绘图默认就有抗锯齿问题出在物理像素和CSS像素没有对齐。高分屏上一个CSS像素对应多个物理像素。如果你把canvas.width直接设置成CSS宽度画布只有一半甚至三分之一的物理像素可用。浏览器把这张小位图拉伸到大屏幕时每个像素都要被插值放大画面自然就糊了。解决方案已经在3.1节写过了这里再强调一次它的核心canvas.width cssWidth * window.devicePixelRatio然后调用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系放大。setTransform这行很多人不写于是后面所有坐标都要乘以dpr改起来极其痛苦。加上它后续代码就能一直用CSS像素思考问题。5.2 窗口缩放后变形与拉伸第二个高频坑出现在监听窗口resize事件的时候。有些同学只在resize里改了canvas.width和canvas.height没改CSS尺寸。结果一阵风把窗口拖大或者缩小星星全部被拉成了椭圆形形状歪歪扭扭。根因还是3.1节那个“三位一体”问题canvas.width是位图尺寸style.width是显示尺寸两者必须同步。更隐蔽的是如果DPR很高你只改位图尺寸而不改CSS尺寸位图像素和显示区域错位得更厉害。我的习惯是封装一个resizeCanvas函数里面四行代码成对出现style.width、style.height、canvas.width、canvas.height。resize事件触发时统一调用保证永远不出错。5.3 边缘被裁切忘记扣除外接半径这个坑很蠢但极其常见。我给星星设了漂移边界反弹也写了结果跑几分钟后还是发现有的星星半只挂在屏幕外。排查到最后发现我把边界判断的阈值写成了0而不是star.outerR。星星的中心还在屏幕内但它的外接圆半径已经让一部分身体超出了画面。正确的边界条件应该是“星星中心离边缘的距离不能小于外接半径”。同时初始化随机坐标时也要预留这个余量x star.outerR Math.random() * (width - 2 * star.outerR)。这样100颗星星无论出生还是漂移都不会出现“半颗星”的尴尬。5.4 明明只有100个对象为什么还是掉帧100颗五角星理论上闭眼跑都流畅但有人实测掉到了30帧。逐个排查最常见的原因有三个第一每帧重新生成颜色字符串。ctx.fillStyle hsl(${Math.random() * 360}, 75%, 65%)这种写法让每颗星每帧都在创建字符串100颗星一秒60帧就是6000次字符串分配。改法很简单在Star构造时生成this.color绘制时直接复用。第二开启了ctx.shadowBlur。阴影效果好看但开销非常大低端设备上100个带阴影的图形足以把帧率拉到不可用。我的建议是要么不开要么只给少数“最亮的星星”开。第6章会给出更便宜的发光替代方案。第三用了半透明填充清屏。如果你看到星星拖着残影多半是有人在update开头用ctx.fillStyle rgba(0, 0, 0, 0.1); ctx.fillRect(...)来制造拖影。这不是错误但如果不想要拖影就应该用clearRect老老实实清屏。拖影方案的代价是每帧多一次全屏填充加上半透明叠加重绘100颗星的时候帧率也会跟着抖。我把这四种常见问题的排查表列在一起方便以后当速查手册现象根因解决方案边缘发虚没适配DPRcanvas尺寸乘以devicePixelRatiosetTransform放大窗口缩放后变形位图尺寸和CSS显示尺寸不同步resize时同步更新style和canvas的width/height星星被裁切边界判断没算外接半径阈值用outerR初始化坐标留余量帧率掉到30反复生成颜色字符串、开shadowBlur、半透明全屏填充缓存颜色、限制阴影数量、用clearRect清屏6. 还能怎么玩交互点击、发光叠加与星座连线100颗五角星撒屏跑通之后这个项目只是起点。Canvas粒子系统的骨架已经搭好接下来加什么都只是往Star数组里塞新行为。6.1 点击生成新星与数量上限管理给Canvas加交互首先要解决坐标系转换问题。鼠标事件里的clientX和clientY是相对浏览器窗口的如果canvas在页面中不占满全屏或者页面有滚动条直接使用会错位。正确的写法是减去canvas元素自身的偏移canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const star new Star({ x: e.clientX - rect.left, y: e.clientY - rect.top, outerR: 10 Math.random() * 20 }); stars.push(star); // 控制总数量防止无限增长拖垮性能 if (stars.length 300) { stars.shift(); } });getBoundingClientRect返回的是canvas在页面上的实际位置这个操作本身不贵但注意不要在每帧动画里调用它只在事件回调里用就好。限制总数的shift()有一个妙用它把最老的星星移出数组新星星不断加入视觉上就像一连串新的五角星在屏幕中“生出来”适合做节日许愿、烟花类型的互动。你完全不用停下来优化性能因为300颗星的上限已经被明确框死了。6.2 发光效果shadowBlur和叠加合成想让星星发光第一个想到的是shadowBlur但我在5.4说了它太贵。这里给两个更实惠的方案。第一个是globalCompositeOperation把叠加模式改成lighter。它的原理是像素颜色相加重叠区域会越来越亮特别适合星空背景// 在绘制星星之前设置 ctx.globalCompositeOperation lighter; // ...绘制所有星星... // 画完后恢复 ctx.globalCompositeOperation source-over;实测下来lighter模式在暗色背景上的效果非常惊艳星星重叠的地方会产生自然的“辉光”而且开销远小于shadowBlur。缺点是完全重叠的两颗星星会亮到发白如果你的画面里允许星星大量堆叠需要对叠加后的亮度做过曝约束。第二个方案是给星星内部画一个径向渐变。用ctx.createRadialGradient在星星中心做一个从亮到暗的渐变再把它作为fillStyle填充。这样每一颗星自带光晕视觉柔和性能开销只有创建渐变的成本。我建议只在大星星上用渐变小星星直接用纯色填充即可层次感反而更好。6.3 星座连线与下一步想象最近很多星空类页面喜欢加一个“星座连线”特效星星之间距离小于某个阈值就画一条低透明度的线距离越近线越亮。这个效果在Canvas里实现起来出乎意料地简单只需要在绘制星星之前先遍历所有星星对const LINK_DISTANCE 130; function drawConstellationLines() { for (let i 0; i stars.length; i) { for (let j i 1; j stars.length; j) { const dx stars[i].x - stars[j].x; const dy stars[i].y - stars[j].y; const dist Math.hypot(dx, dy); if (dist LINK_DISTANCE) { ctx.beginPath(); ctx.moveTo(stars[i].x, stars[i].y); ctx.lineTo(stars[j].x, stars[j].y); ctx.strokeStyle rgba(255, 255, 255, ${0.3 * (1 - dist / LINK_DISTANCE)}); ctx.stroke(); } } } }代码里唯一需要留心的是复杂度。100颗星两两比较是4950次距离计算每帧跑完全没问题。但如果有一天你把数量加到1000两两比较就变成约50万次那就需要用空间网格优化了——这是“粒子系统从入门到进阶”的经典门槛。我个人的建议是星星少于300颗时这种暴力遍历完全可接受超过300再做优化不迟。再往下你可以把星星换成任意形状——五角星可以变成圆形光点、心形、雪花甚至一张张缩略图把漂移模式改成绕着中心点公转就是一个微缩星系给星星加上鼠标排斥力点击时四散开去就成了互动粒子场。Canvas最迷人的地方就在这里你维护的始终是一堆“有状态的对象”画面只是状态的可视化结果。改状态的逻辑就是改整个世界。最后说点个人体会。撒屏这个小项目看着简单但它是一个极好的Canvas入门练手几何计算、随机数使用、渲染循环、状态管理、性能优化全都在里面了。我强烈建议你把星星数量从10开始10、50、100、500、2000这样逐级加观察帧率和画面表现的变化——这个动作比任何教程都更能帮你建立“性能量级感”。等你把100颗星玩得滚瓜烂熟再回头看任何粒子系统都会发现内核全是同一个套路维护对象状态画出来更新状态再画出来。这套功夫今天就算是正式入门了。