
1. 需求拆解先想清楚要画成什么样再动手写代码1.1 星空背景到底要解决什么问题Canvas 绘画动态星空背景这件事本质上是用浏览器的一块位图画布在每一帧里不断擦除、重绘模拟出肉眼在野外夜里抬头看到的那种星野效果——星星会呼吸式地明暗变化少数几颗会拖着尾巴划过去鼠标动一动整片星野还会微微跟着偏。我最早是用在一个数据大屏的底图上后来陆续又把它用在产品落地页首屏、活动页面、甚至网课录屏的背景。这个需求的真实痛点很明确纯色背景太干、静态图片一放大就糊、视频背景动辄几兆起步还容易在低端设备上卡成幻灯片。Canvas 走的是运行时求值这条路一百多 KB 的 JS 换一个无限分辨率的动态背景划算。适合谁看如果你已经能画个矩形、会写requestAnimationFrame那这篇能让你把星空真正做到能上线的程度如果你刚接触 Canvas也没关系我把每一行代码在干什么都拆开讲了照着敲一遍基本就懂了。下面所有代码都是可以独立跑通的不是伪代码。1.2 为什么不用 CSS 动画也不用 SVG这是我每次做这个效果都会被问的问题值得先讲清楚。用 CSS 画星星的做法通常是生成几百个绝对定位的div或者用box-shadow堆几百个光点然后靠keyframes做透明度动画。它的致命问题有三个第一DOM 节点数量一上去重排重绘的成本跟着指数级上升五百个星星节点在手机上就会明显掉帧第二box-shadow堆星星这种偏门技巧在星星数量超过一千时样式表的解析和合成开销会变得非常难看第三你没办法做随机的漂移轨迹CSS 动画的帧是规则化的看起来会有一股机械味。SVG 的思路是好的毕竟矢量图天然适配任意分辨率。但 SVG 里的每个圆形都是一个真实的 DOM 元素动画更新要走 SVG 的属性改写和 CSS 一样属于合成器之外的路径。而且 SVG 做粒子量的效果没有硬件加速的位图填充快。Canvas 的优势在于它是一块纯粹的位图所有绘制都在一张画布上完成浏览器不需要为每个星星维护一个布局盒。再配合requestAnimationFrame和 GPU 合成几百到几千个粒子的规模下帧率非常稳。唯一的代价是你必须自己管理所有状态包括坐标、生命周期、清除时机没有 DOM 帮你兜底。1.3 把一片星空拆成四个图层我踩过最深的坑就是一开始想做真实星空。结果写出来的东西要么像撒芝麻要么像电视雪花屏怎么调都不对。后来想明白了人眼看到的星空是有层次的近处的星大、亮、闪得快远处的星小、暗、几乎不动中间还有一层若隐若现的星云感。所以我在代码里的做法是分三层星星外加一个流星层图层占比半径范围(px)基础透明度闪烁速度视差系数远景层55%0.4 - 0.90.25 - 0.5慢0.25中景层30%0.8 - 1.50.4 - 0.75中0.6近景层15%1.4 - 2.40.6 - 1.0快1.0流星层事件驱动1 - 2 线宽动态一次性不用注意这个比例不是死的。数据大屏那种横屏大尺寸场景我会把远景层压到 60% 以上因为屏幕太大时近景星太多会显得脏而移动端小屏幕相反近景层可以适当提到 20%让小屏上也有视觉焦点。分层还有一个隐性好处视差。鼠标移动时远景层几乎不动近景层位移明显人眼立刻就解读出了空间深度。这个效果只靠单一图层是模拟不出来的。2. 核心原理拆解每一帧到底在算什么2.1 DPR 那层窗户纸不捅破永远画不清这是我见过最多人栽的地方。你在 CSS 里写了width: 100%; height: 100%然后canvas.width 800结果高分屏上星星边缘像被糊了一层毛玻璃。原因很简单canvas有两个尺寸一个是 CSS 尺寸style.width决定它在页面占多大地方另一个是画布的实际像素尺寸canvas.width决定它内部有多少个像素点去填充这块地方。在devicePixelRatio 2的屏幕上一个 CSS 像素要由 2×2 个物理像素来画。如果你没把canvas.width乘以 DPR浏览器就会把一张 800×600 的位图拉伸到 1600×1200 的物理区域等于强制放大自然糊。标准做法是三步先把 CSS 尺寸和实际像素尺寸分别设好然后用ctx.setTransform一次性把坐标系缩放回去const dpr Math.min(window.devicePixelRatio || 1, 2); canvas.width Math.round(cssW * dpr); canvas.height Math.round(cssH * dpr); canvas.style.width cssW px; canvas.style.height cssH px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);setTransform(dpr, 0, 0, dpr, 0, 0)这四个参数是矩阵的a b c d对应缩放和斜切最后两个是平移。这一句执行完之后你后面写的所有坐标都按 CSS 像素算不用到处乘 DPR代码干净得多。至于为什么用Math.min(dpr, 2)封顶——有些三倍屏手机 DPR 是 3甚至 4。像素总量是 DPR 的平方级增长4 倍屏意味着 16 倍像素要填而一百多像素的星星在这个密度下肉眼看不出差别。封顶在 2 是清晰度和性能之间我实测下来最舒服的位置。2.2 rAF 和时间步长速度不能跟着帧率跑requestAnimationFrame做的事情是告诉浏览器下一帧刷新之前请调用我。它的回调参数是一个高精度时间戳毫秒和performance.now()同一个时间基准。用它代替setInterval(fn, 16)的理由很实在setInterval不知道浏览器的刷新节奏容易和垂直同步错拍导致丢帧标签页切到后台时它还会继续跑白白耗电而 rAF 在页面不可见时会自动暂停。但光用 rAF 还不够必须处理时间步长。假设你的星星每帧移动 1 像素60Hz 屏幕上它一秒钟走 60 像素144Hz 屏幕上就走 144 像素——同一条代码在高刷屏上快了一倍多这是新手最容易忽略的坑。正确的写法是算出这一帧和上一帧的时间差dt所有和时间相关的量都乘上归一化系数let last performance.now(); function frame(now) { let dt now - last; last now; // 切后台回来时 dt 可能是几万毫秒直接夹住否则星星会瞬移出屏 if (dt 100) dt 16.7; const k dt / 16.7; // 60fps 下 k ≈ 1 // 移动量乘以 k star.x star.driftX * k; }这里dt 100的夹断非常关键。用户切到别的标签页十分钟再回来now - last可能是 600000 毫秒不夹的话你的星星会在一帧之内全部飞出边界视觉上就是闪没了再重新出现。2.3 一颗星星该存哪些字段数据结构设计得好后面调效果就是改数字的事设计得差改一个效果要动三处代码。我用的字段表是这样的字段类型作用调参手感x,yNumber当前坐标CS 像素飞行中会被漂移改写rNumber半径决定大小也决定精灵尺寸layerNumber所属图层索引决定视差和闪烁速度baseAlphaNumber基础亮度不要太接近 1否则没有闪的空间phaseNumber闪烁初相位0 ~ 2π 随机防止全体同步flickerNumber闪烁频率越大闪得越快driftX,driftYNumber每帧漂移量建议 ±0.02 以内超了就变流星雨colorString预生成的rgba字符串建星时生成渲染时不再拼字符串phase这个字段的意义很多人不理解。如果你让所有星星用同一个正弦函数同时变亮变暗整片星空会像呼吸灯一样整体闪烁非常假。给每颗星随机一个初始相位它们的明暗曲线就彼此错开了看起来才像各自独立的恒星。color预先转成字符串存起来是为了避免在每一帧的循环里做字符串拼接。几百次rgba(${r},${g},${b},${a})拼接在手机上会触发可观的 GC 压力这个细节后面还会展开。2.4 闪烁不是随机数是正弦波我试过用Math.random()直接控制透明度结果星星像电压不稳的灯抖得人眼睛疼。真实的星星闪烁是连续的、有节奏的用正弦函数最贴近const twinkle Math.sin(t * 0.001 * s.flicker s.phase); const alpha s.baseAlpha twinkle * s.baseAlpha * 0.55;拆开看t是累计毫秒数乘上0.001变成秒再乘flicker就是这个星自己的角频率。 s.phase是前面说的错峰。Math.sin的输出在 -1 到 1 之间乘上baseAlpha * 0.55意味着亮度在基础值上下浮动 55%。为什么是 0.55 而不是 1因为如果振幅太大最暗的时候星星会彻底消失再出现视觉上像在眨眼一屏几百颗一起眨眼就成了爆闪。0.55 这个值是我反复试下来的甜点暗的时候还能看见一点微光亮的时候也不会过曝。还有一个细节alpha要兜底一个最小值。如果是负的某些浏览器会直接不画某些会把rgba当无效值忽略行为不一致。我习惯在Math.max(alpha, 0.05)这一步就截住。2.5 发光感从哪来混合模式与色温纯白色的圆点堆在一起看起来是白点不是星。要做出光感两个手段。第一是globalCompositeOperation lighter。它让新绘制的内容和已有内容做加法混合而不是覆盖。好处是重叠处的亮度会累加星星密集区域自然形成微弱的星云感而且叠加时不会有生硬的边缘。代价是它只对亮色有用暗色叠加等于没效果所以用之前要确保背景是深色我一般用#05070f到#0b1226这种偏蓝的近黑。第二是色温。全白的星空是假的。我让大约 15% 的星星偏蓝HSL 色相 200-22010% 偏暖黄30-50剩下的用接近纯白的冷调。这点色彩差异在单颗星上看不出来但整屏扫过去就会有一种这是真实天区的错觉。提示如果用hsla要小心它在不同设备的色彩管理下渲染一致性不如rgba。我最终是在建星的时候用 HSL 算好再转成rgba字符串存起来的兼容性最稳。3. 动手实现完整代码逐段拆解3.1 画布初始化与尺寸自适应先把骨架搭起来。HTML 部分很简单一块铺满视口的 canvas背景色交给 CSS 去托管canvas idsky/canvashtml, body { margin: 0; height: 100%; background: radial-gradient(circle at 50% 40%, #0b1226 0%, #05070f 70%); } #sky { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }把背景渐变放在 CSS 上而不是每帧用 Canvas 画是一个很重要的优化决定。createRadialGradient每帧都要重新创建并填充整个屏幕这个开销在 4K 屏上非常可观。交给 CSS 之后Canvas 每帧只需要clearRect也就是清成完全透明底下的 CSS 背景自然透出来。初始化函数const canvas document.getElementById(sky); const ctx canvas.getContext(2d, { alpha: true }); let W 0, H 0, DPR 1; function resize() { DPR Math.min(window.devicePixelRatio || 1, 2); W canvas.clientWidth; H canvas.clientHeight; canvas.width Math.round(W * DPR); canvas.height Math.round(H * DPR); // 每次改 canvas.width 都会重置变换矩阵所以必须重新设置 ctx.setTransform(DPR, 0, 0, DPR, 0, 0); buildStars(); }这里有个新手常掉的坑canvas.width xxx这个赋值不只改尺寸它会把整个绘制上下文的状态全部重置回默认值包括变换矩阵、globalAlpha、fillStyle、混合模式。所以setTransform必须放在尺寸赋值之后放在前面等于白设。resize还要绑到window的resize事件上但一定要加防抖。窗口拖拽会产生几十上百次事件每次重建几百个星星对象不抖的话直接卡死。我用一个 150ms 的防抖let resizeTimer null; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(resize, 150); });3.2 星星生成分层、密度与分布密度不该写死成数字应该跟屏幕面积挂钩。同一份代码在手机和大屏上跑固定 400 颗星星在大屏上稀疏得像没画在小屏上密得发白。我的做法是用面积除以一个常量const LAYERS [ { ratio: 0.55, radius: [0.4, 0.9], alpha: [0.25, 0.50], speed: 0.45, parallax: 0.25 }, { ratio: 0.30, radius: [0.8, 1.5], alpha: [0.40, 0.75], speed: 0.75, parallax: 0.60 }, { ratio: 0.15, radius: [1.4, 2.4], alpha: [0.60, 1.00], speed: 1.10, parallax: 1.00 }, ]; const rand (min, max) min Math.random() * (max - min); let stars []; function buildStars() { stars []; // 面积决定总数量1440x900 的屏大约得到 144 颗实测偏稀所以用 5000 const total Math.min(Math.round((W * H) / 5000), 900); LAYERS.forEach((layer, layerIndex) { const count Math.round(total * layer.ratio); for (let i 0; i count; i) { const r rand(layer.radius[0], layer.radius[1]); const baseAlpha rand(layer.alpha[0], layer.alpha[1]); // 色温80% 冷白12% 偏蓝8% 偏暖 let hue 0; const dice Math.random(); if (dice 0.12) hue rand(205, 225); else if (dice 0.20) hue rand(30, 50); const color hue ? hsla(${hue.toFixed(0)}, 70%, 82%, 1) : rgba(255,255,255,1); stars.push({ x: Math.random() * W, y: Math.random() * H, r, layerIndex, baseAlpha, phase: Math.random() * Math.PI * 2, flicker: rand(0.6, 1.6) * layer.speed, driftX: rand(-0.02, 0.02) * layer.speed, driftY: rand(-0.01, 0.01) * layer.speed, color, }); } }); }Math.min(..., 900)是安全阀。如果是超宽屏比如 5120×1440面积算出来会有 1474 颗加上精灵绘制开销在低配机器上会掉帧。封顶之后只是密度略降视觉上完全可以接受。这里的color用的是hsla带固定亮度 82%、饱和度 70%透明度固定为 1。亮度不放进颜色里是因为透明度要在渲染时动态变化而改 alpha 我用globalAlpha来做比每帧改颜色字符串快得多。3.3 主循环渲染一帧的完整顺序整个渲染流程的顺序是有讲究的不能随便调。let rafId null; let lastTime performance.now(); let elapsed 0; const pointer { x: 0, y: 0, tx: 0, ty: 0 }; const meteors []; function frame(now) { let dt now - lastTime; lastTime now; if (dt 100) dt 16.7; elapsed dt; const k dt / 16.7; render(elapsed, k); rafId requestAnimationFrame(frame); } function render(t, k) { // 1. 清屏因为背景交给了 CSS这里清成透明即可 ctx.clearRect(0, 0, W, H); // 2. 指针位置做缓动避免鼠标一动星空就硬跳 pointer.x (pointer.tx - pointer.x) * 0.05 * k; pointer.y (pointer.ty - pointer.y) * 0.05 * k; // 3. 加法混合让重叠的星星亮度累加 ctx.globalCompositeOperation lighter; // 4. 逐颗绘制 for (let i 0; i stars.length; i) { const s stars[i]; const layer LAYERS[s.layerIndex]; // 闪烁 const twinkle Math.sin(t * 0.001 * s.flicker s.phase); const alpha Math.max(s.baseAlpha twinkle * s.baseAlpha * 0.55, 0.05); // 漂移 环绕回卷 s.x s.driftX * k; s.y s.driftY * k; if (s.x -6) s.x W 6; else if (s.x W 6) s.x -6; if (s.y -6) s.y H 6; else if (s.y H 6) s.y -6; // 视差偏移 const x s.x pointer.x * layer.parallax * 18; const y s.y pointer.y * layer.parallax * 12; // 绘制 ctx.globalAlpha alpha; ctx.fillStyle s.color; ctx.beginPath(); ctx.arc(x, y, s.r, 0, 6.2832); ctx.fill(); } ctx.globalAlpha 1; // 5. 流星画在最上层 updateMeteors(t, k); // 6. 恢复默认混合模式避免影响后续其他绘制 ctx.globalCompositeOperation source-over; }几个关键点逐个说明clearRect放在第一步是因为用加法混合的话上一帧的残留会一直累加下去几帧之内整屏就白成一片了。曾经有人问我为什么我的星空越跑越亮基本都是漏了清屏。漂移的回卷用了 ±6 像素的缓冲区而不是严格在 0 和 W 处回卷。因为星星有半径如果严格贴边回卷半径 2.4 的近景星会在边缘半个身子突然消失有个 6 像素的余量就完全看不出来了。pointer.x做 0.05 的缓动而不是直接跟随是为了让视差有跟手但有惯性的手感。* k保证这个缓动速度在不同刷新率下一致。ctx.globalAlpha在循环结束后必须复位成 1否则后面画流星的时候会带着最后一颗星星的透明度出现流星忽隐忽现的诡异现象。globalCompositeOperation也是同理如果页面里还有别的绘制逻辑共用这个 context用完必须还回去。3.4 流星概率触发和拖尾渐变流星是这套东西里最提气的部分但它恰恰是少即是多的典型。如果你每秒都放流星那它就不稀罕了。let meteorCooldown 2000; function updateMeteors(t, k) { // 冷却结束才有概率生成 meteorCooldown - k * 16.7; if (meteorCooldown 0) { if (Math.random() 0.02) { spawnMeteor(); meteorCooldown rand(2500, 8000); // 下次至少等 2.5 秒 } } for (let i meteors.length - 1; i 0; i--) { const m meteors[i]; m.x m.vx * k; m.y m.vy * k; m.life k * 16.7; if (m.life m.maxLife || m.x W 300 || m.y H 300) { meteors.splice(i, 1); continue; } drawMeteor(m); } } function spawnMeteor() { const angle Math.PI / 4 rand(-0.22, 0.22); // 45° 附近斜向右下 const speed rand(7, 12); meteors.push({ x: rand(-W * 0.1, W * 0.85), y: rand(-H * 0.35, H * 0.15), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, len: rand(90, 240), width: rand(0.8, 2), life: 0, maxLife: rand(700, 1400), }); } function drawMeteor(m) { const mag Math.hypot(m.vx, m.vy); const nx m.vx / mag, ny m.vy / mag; const tailX m.x - nx * m.len; const tailY m.y - ny * m.len; // 生命周期末段淡出避免啪一下消失 const fade m.life m.maxLife * 0.75 ? 1 - (m.life - m.maxLife * 0.75) / (m.maxLife * 0.25) : 1; const grad ctx.createLinearGradient(tailX, tailY, m.x, m.y); grad.addColorStop(0, rgba(180, 210, 255, 0)); grad.addColorStop(0.7, rgba(200, 225, 255, ${0.45 * fade})); grad.addColorStop(1, rgba(255, 255, 255, ${0.95 * fade})); ctx.strokeStyle grad; ctx.lineWidth m.width; ctx.lineCap round; ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(m.x, m.y); ctx.stroke(); }流星的效果好坏几乎全在渐变停止点的安排上。头段全透明让尾巴自然融进夜空0.7 处半透明形成尾巴有实体但发虚的感觉头部接近纯白模拟撞击大气层的高亮核心。如果三段都用同一个透明度看起来就是一根棍子非常硬。生成位置的 y 是负的也就是从屏幕上方外部飞进来这比凭空在屏幕中间出现要自然得多。angle加了一点随机扰动因为如果每颗都严格 45°几颗流星一起出现的时候会看起来像印刷网格。meteors.splice(i, 1)用的是倒序循环正序删除会导致索引错位漏掉元素这是数组遍历里的老规矩了。3.5 交互层视差、指针与降级视差的指针监听有一个必须注意的细节桌面端用pointermove就够了但pointermove会包含触摸拖动如果页面本身需要竖向滚动pointermove里再去做任何preventDefault都会让用户没法滑动。我的做法是只读取坐标绝不做preventDefault并把监听挂成passivefunction onPointerMove(e) { const rect canvas.getBoundingClientRect(); pointer.tx ((e.clientX - rect.left) / rect.width - 0.5) * 2; // -1 ~ 1 pointer.ty ((e.clientY - rect.top) / rect.height - 0.5) * 2; } window.addEventListener(pointermove, onPointerMove, { passive: true });用getBoundingClientRect而不是直接用e.clientX是因为 canvas 不一定在页面左上角尤其当它被放在某个容器里时。而且这里不需要节流——pointermove本身在合成器线程里是有合并的处理函数又只有两行算术开销可以忽略。降级方案也要准备好。如果设备开启了减少动态效果prefers-reduced-motion: reduce说明用户对动画敏感这时候应该把闪烁幅度降到极低甚至关掉只保留静态星野const reduceMotion window.matchMedia((prefers-reduced-motion: reduce)).matches;我在render里用一个motionScale变量统一控制等于 0 的时候就完全静止。这不是可选项很多无障碍规范里明确要求这么做而且对晕动症用户是真的有帮助。4. 性能调优从掉帧到稳跑满帧4.1 三个最要命的性能陷阱第一个陷阱是每帧创建渐变。createLinearGradient、createRadialGradient每次调用都会创建一个新的CanvasGradient对象带 GC 压力。星星如果用径向渐变做光晕一屏 500 颗、每秒 60 帧就是每秒 3 万次对象创建手机上直接卡死。解决办法是预渲染精灵图下面细讲。第二个陷阱是在循环里拼字符串。ctx.fillStyle rgba(255,255,255, alpha )这种写法每颗星每帧一次字符串拼接500 颗就是每秒 3 万次。正确的做法是颜色固定、透明度用globalAlpha控制fillStyle只在颜色真正变化时才赋值。我上面代码里s.color是建星时算好的常量就是这个原因。第三个陷阱是绘制调用次数。ctx.beginPath()ctx.arc()ctx.fill()是三次 API 调用在粒子多的时候非常可观。而且arc在半径小于 1px 的时候光栅化质量会明显下降画出来的圆点边缘发灰。改成fillRect快一些但形状是方块视觉上能看出来。4.2 精灵缓存把 arc 换成 drawImage最优解是预先把每种尺寸 颜色的星星画到离屏 canvas 上运行时只做drawImage。drawImage走的是位图搬运路径比路径光栅化快一个数量级而且天然带抗锯齿边缘。const spriteCache new Map(); function getStarSprite(r, color) { const key r.toFixed(1) | color; if (spriteCache.has(key)) return spriteCache.get(key); const glow r * 3; // 光晕外扩半径 const size Math.ceil(glow * 2); const c document.createElement(canvas); c.width c.height Math.ceil(size * DPR); const g c.getContext(2d); g.scale(DPR, DPR); const cx size / 2; const grd g.createRadialGradient(cx, cx, 0, cx, cx, glow); grd.addColorStop(0, color); grd.addColorStop(0.25, color); grd.addColorStop(1, rgba(255,255,255,0)); g.fillStyle grd; g.beginPath(); g.arc(cx, cx, glow, 0, 6.2832); g.fill(); spriteCache.set(key, { canvas: c, size }); return { canvas: c, size }; }绘制时const sp getStarSprite(s.r, s.color); ctx.globalAlpha alpha; ctx.drawImage(sp.canvas, x - sp.size / 2, y - sp.size / 2, sp.size, sp.size);注意size在这里是 CSS 像素单位而sp.canvas的内部像素是size * DPRdrawImage会自动按目标宽高缩放所以传sp.size是对的。spriteCache用 Map 缓存键是半径保留一位小数加颜色。因为半径是rand()生成的浮点数如果直接用原值做键几乎每颗星都是唯一的缓存命中率接近 0反而更慢。保留一位小数就把可能的键收敛到了几十种缓存命中率能到 95% 以上。注意精灵缓存要有上限。如果页面有主题切换或者颜色随机缓存会无限增长。我在spriteCache.size 200的时候直接clear()因为重建缓存的成本远低于内存泄漏。4.3 参数实测与调参对照表这些数字是我在不同设备上大致跑出来的感觉不是精确基准但相对关系有参考价值。星星总数绘制方式桌面端帧率中端手机帧率备注300arc6060毫无压力600arc6055手机开始有轻微抖动900arc5542手机明确掉帧600drawImage 精灵6060稳1200drawImage 精灵6058基本可用2000drawImage 精灵5845需要降 DPR结论很清楚能不能用精灵图是这套效果能否上生产的分水岭。常用参数的手感对照参数谨慎值舒适区间过头的表现密度除数120005000 - 7000小于 3000 会白茫茫一片闪烁振幅系数0.20.45 - 0.6大于 0.8 像爆闪灯漂移速度0.0050.01 - 0.02大于 0.05 变成流星雨视差系数00.2 - 1.0大于 2 会晕流星间隔150004000 - 8000小于 1500 就廉价了5. 踩坑实录这些问题我几乎每次都遇到5.1 高分屏糊成一片、1px 线发虚前面 DPR 那节讲的只是基础。真正容易出问题的是setTransform被意外重置。如果你在某个地方又给canvas.width或者canvas.height赋了一次值整个上下文状态就清了setTransform的缩放没了但你的 CSS 尺寸没变结果就是画面被放大糊掉。还有一个更隐蔽的ctx.clearRect(0, 0, W, H)里的W和H是 CSS 像素但清屏是作用在物理像素上的。有缩放变换在的时候clearRect也会被缩放所以它能正确清掉整个区域这点不用担心。真正要担心的是clearRect之前globalCompositeOperation是lighter——有些实现下清屏行为和混合模式有关保险做法是在清屏前把混合模式复位成source-over或者干脆用ctx.save()/ctx.restore()包住渲染过程。5.2 切后台回来星星瞬移或直接空白这件事我排查了半天。最初我以为是dt太大导致的加了夹断之后好了很多但偶尔还是会整片空白。真正的原因是有些浏览器在标签页不可见时不会调用 rAF但performance.now()一直在走。你切回来的时候第一帧的dt可能是几百秒虽然夹断了dt但如果你的漂移逻辑写成了根据累计时间重算位置那位置还是会一次性跳很远。我的加固方案是三步dt夹断到 100ms 以内监听visibilitychange隐藏时cancelAnimationFrame显示时重置lastTime performance.now()再重启document.addEventListener(visibilitychange, () { if (document.hidden) { if (rafId) { cancelAnimationFrame(rafId); rafId null; } } else { if (!rafId) { lastTime performance.now(); rafId requestAnimationFrame(frame); } } });if (!rafId)这个判断是防重复启动的。如果页面隐藏和显示的事件触发了两次或者你在别处也调了启动就会出现两个 rAF 链并行帧率翻倍星星飞得飞快。这是我在一个项目里真实遇到的问题排查了很久才发现是双循环。5.3 移动端卡顿与事件打架移动端的坑集中在三处。第一是 DPR 封顶。前面说封顶到 2其实在中低端安卓上封顶到 1.5 会更稳。我现在的写法是Math.min(dpr, W * H 800000 ? 1.5 : 2)大屏降一档。第二是pointermove和页面滚动的冲突。如果你的星空是固定背景用户在上面滑动页面时pointermove依然会触发触摸拖动也算 pointer 移动导致视差跟着滚动一起变看起来像画面在抖。解决办法是判断e.pointerType touch时直接忽略只在鼠标和触控笔上启用视差。第三是低电量模式。有些系统在低电量时会主动把刷新率压到 30Hz这时候你的dt会稳定在 33ms 左右k等于 2。如果所有逻辑都乘了k视觉速度是对的但如果有哪块逻辑忘了乘就会出现慢动作或者跳帧的割裂感。这也是我坚持把所有时间相关的量都乘k的原因。5.4 事件监听与内存泄漏单页应用里切路由的时候如果 canvas 被移除了但事件监听还在就会泄漏。必清的清单window上的resize、pointermovedocument上的visibilitychange正在跑的requestAnimationFrame的 id缓存的精灵 canvas虽然量不大但引用不释放就一直占着显存我一般封装成一个destroy()函数框架的卸载钩子里调一次简单粗暴但管用。5.5 问题速查表现象最可能的原因处理方式星星边缘模糊DPR 未处理或setTransform被重置检查canvas.width赋值后再设变换越跑越亮 / 整屏发白漏了clearRect加法混合累加每帧开头清屏星星同时闪烁没给phase随机初值每颗星独立随机相位手机掉帧严重用了arc逐颗绘制改精灵图 drawImage切后台回来星星消失dt过大导致坐标越界夹断dtvisibilitychange暂停星星飞得特别快重复启动了 rAF启动前判断rafId是否为 null视差点了没反应pointermove挂到了 canvas 上但被上层遮罩拦截改挂window流星像根棍子渐变的三个停止点透明度一样头尾拉开透明度差流星出现频率太高生成概率和冷却独立计算冷却期间不掷骰子首屏闪烁一下才出现尺寸初始化在 DOM 布局前跑了放在DOMContentLoaded或首帧 rAF 里最后补一条不在表里但我认为最重要的别在render里写console.log。我调视差参数的时候留下了一行日志忘了删在低端机上直接把帧率从 58 拉到了 40。canvas 这种高频循环任何一行多余的调用都会被放大六十倍。这套东西我断断续续改了一年多从最开始的三百行堆到一个文件里到现在拆成配置、生成、渲染、交互四块最大的体会是让星空好看的不是代码复杂度而是那几个小数——闪烁振幅 0.55、视差 18 和 12、流星间隔 4 到 8 秒。这些数字没有标准答案但方向很明确所有东西都要往慢一点、少一点、错开一点调一旦你快了、多了、齐了它立刻就从星空变成了屏保。