给博客底部加一个「小鱼跳跃」canvas特效

2026-06-18 前端

2026-10-03 更新:本文改用 WHATWG HTML 规范、W3C 规范、MDN 官方文档作为依据重写,删去原先的本机实测内容与本地化叙事;文中的实现骨架可直接复制,每条结论都标注了出处。

页面底部一条细长的区域,几条小鱼按随机节奏跃出水面——这类装饰性动画用 <canvas> 实现最省事,但要写得"不卡、不费电、不影响可访问性",有几条规范化做法值得照抄。本文把机制讲清楚,再给一份可以直接用的实现骨架。

一、先理解 canvas 是什么

WHATWG 的 HTML 规范把 <canvas> 定义为"分辨率相关的位图画布",供脚本实时渲染图形、游戏画面、艺术或其他视觉图像。三个容易忽略的默认值/概念:

  • 默认尺寸:不显式指定时,width 属性默认 300、height 默认 150;
  • 属性值按 CSS 像素解释:规范明确 width/height 的数值以 CSS 像素为单位;
  • 位图尺寸与显示尺寸是两套东西:CSS 里写 width:100% 只是把它拉伸显示,并不会改变位图的分辨率——高分屏上画面发虚就是这么来的。
<!-- 容器交给 CSS 决定尺寸,画布由脚本创建并按 devicePixelRatio 设置位图 -->
<link rel="stylesheet" href="/assets/fish/fish.css">
<div id="fish-strip" class="fish-strip" aria-hidden="true"></div>
<script src="/assets/fish/fish.js" defer></script>

aria-hidden="true" 是因为这块内容纯装饰,对屏幕阅读器没有意义。

二、用 requestAnimationFrame 驱动,不要用 setInterval

HTML 规范的 Animation frames 一节定义了 requestAnimationFrame 的调度:以时间戳 now 运行动画帧回调。MDN 补充了两个关键细节:

  • 回调会收到一个 timestamp 参数,类型是 DOMHighResTimeStamp,表示上一帧渲染结束的时间;
  • 回调频率通常与显示器刷新率一致(常见 60Hz,也有 75/120/144Hz),所以推进动画要用时间差,而不是"每帧固定位移"。

后一条是跨屏一致性的关键:在 144Hz 屏上"每帧 +2px"会变成两倍速。MDN 的 canvas 优化指南也明确建议:动画应使用 window.requestAnimationFrame() 而不是 setInterval(),并且 rAF 回调由浏览器在每帧重绘前调用,比固定延时的定时器更高效。

function frame(now) {
  if (!last) last = now;
  var dt = Math.min((now - last) / 1000, 0.05);  // 上限 50ms,避免切回前台时的“时间跳跃”
  last = now;
  update(dt);                       // 用 dt 推进物理/位置
  ctx.clearRect(0, 0, w, h);        // 每帧先擦干净
  draw(ctx);
  rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);

三、高分屏适配:把位图放大,再归一化坐标系

devicePixelRatio 在 W3C 的 CSSOM View 规范里有规范性定义(由"determine the device pixel ratio algorithm"决定返回值);MDN 的说明是它返回物理像素与 CSS 像素之比——值 1 对应经典 96 DPI 屏,值 2 是典型的 HiDPI/Retina 屏。

MDN 给出的官方做法正好是三步:CSS 尺寸按显示大小设定,canvas.width/height 按比例放大成内存像素,然后用 ctx.scale()(或 setTransform)把绘制坐标系归一化回 CSS 像素。

function resize() {
  var dpr = window.devicePixelRatio || 1;
  w = host.clientWidth;
  h = host.clientHeight;
  canvas.style.width  = w + 'px';      // 显示尺寸(CSS 像素)
  canvas.style.height = h + 'px';
  canvas.width  = Math.floor(w * dpr); // 位图尺寸(物理像素)
  canvas.height = Math.floor(h * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);  // 之后仍按 CSS 像素坐标绘制
}
window.addEventListener('resize', resize);
resize();

顺手清掉一个误解:clearRect() 的语义是"把矩形区域的像素置为透明黑"(MDN),不是"填白"——所以它会正确清除上一帧,也不会把背景涂成白色。

四、页面不可见时暂停:这是规范给的正当用法

  • W3C 的 Page Visibility 规范定义了文档可见性状态,其中 hidden 表示"文档在任何屏幕上都不被呈现";
  • MDN 说明当用户最小化窗口、切换到其他标签页,或文档被其他窗口完全遮挡时,API 会派发 visibilitychange 事件,而这个 API 的用途之一就是节省资源、避免在文档不可见时做无用功。
document.addEventListener('visibilitychange', function () {
  if (document.hidden) {
    cancelAnimationFrame(rafId);          // 隐藏:停
    rafId = 0;
  } else if (!rafId) {
    last = 0;                             // 回来时重置时间基准
    rafId = requestAnimationFrame(frame);
  }
});

⚠️ 一个措辞上的注意:MDN 说 requestAnimationFrame 在多数浏览器里后台标签页会被暂停——这是浏览器行为,不是规范强制条款。所以写"后台一定会暂停"是不严谨的,显式监听 visibilitychange 才是可控做法。

五、尊重"减少动态效果"

Media Queries Level 5 定义了 prefers-reduced-motion,用于探测用户是否请求系统减少非必要的动态效果;取值 no-preference / reduce。MDN 补充了原因:这类动画可能给有前庭运动敏感的用户带来不适。

做法是双层:CSS 层直接不显示,JS 层同步停掉循环。

/* fish.css —— 独立样式文件 */
.fish-strip { width: 100%; height: 150px; overflow: hidden; }
.fish-strip canvas { display: block; width: 100%; height: 100%; }

@media (max-width: 767px) {
  .fish-strip { height: 110px; }
}

@media (prefers-reduced-motion: reduce) {
  .fish-strip { display: none; }   /* 用户要求减少动态效果时不显示 */
}
var motion = window.matchMedia('(prefers-reduced-motion: reduce)');
var rafId = motion.matches ? 0 : requestAnimationFrame(frame);

motion.addEventListener('change', function (e) {
  if (e.matches) {
    cancelAnimationFrame(rafId); rafId = 0;
  } else if (!rafId) {
    last = 0; rafId = requestAnimationFrame(frame);
  }
});

六、绘制时用得到的几条官方语义

  • getContext('2d'):返回画布的绘制上下文,标识符不支持(或画布已设为其他上下文模式)时返回 null——所以取完上下文要判空,别直接 ctx.xxx;
  • save() / restore():save() 把画布当前完整状态压入状态栈,restore() 弹出栈顶恢复;成对使用,才能"只在局部变换";
  • transform():用参数描述的矩阵乘以当前变换矩阵,用于缩放、旋转、平移与斜切——鱼的旋转/翻转就靠它;
  • drawImage():有三种重载(目标点、目标框、源到目标框),精灵图动画用"源到目标框"那种把每一帧裁出来绘制。

性能上,MDN 的 canvas 优化指南给的建议很实在:把重复/相似的绘制(比如一条固定的小鱼形状)预渲染到离屏画布,再按需贴到主画布上,减少每帧的重复工序。参考帧预算:官方性能文档说明,60fps 下浏览器每帧只有约 16.7 毫秒用来执行脚本、重算样式与布局并重绘。

七、为什么建议自托管而不是引第三方 CDN

这不是"一定要"的规定,而是两个官方特性对应的风险控制:

  • CSP 管脚本来源:MDN 说明 script-src 设定允许加载 JavaScript 的来源。把脚本限定在自有源,等于用响应头固定了"页面能执行谁的代码"。
  • 第三方资源存在被篡改的风险:Subresource Integrity 的定义就是"让浏览器校验所获取的资源(例如来自 CDN 的资源)是否被意外篡改"——它的存在本身说明这个风险被官方认可。用外链就得连带维护 SRI 哈希与版本锁定,多数站点并不做。

所以自托管 + 文件名带版本号(升级换名字、不原地覆盖)是更省心的方案。这不是说 CDN 不能用,而是"用 CDN 却不做 SRI 与版本固定"才是问题。

八、与 jQuery 写法的对照

如果原实现基于 jQuery,两边的官方定位其实并不冲突,只是层次不同:.on() 是"为选中元素的一个或多个事件附加事件处理函数",.animate() 是"对一组 CSS 属性执行自定义动画",.ready() 是"指定一个在 DOM 完全加载后执行的函数"。而 canvas 动画是逐帧绘制位图,与"补间 CSS 属性"不是同一类问题——即使页面里还留着 jQuery,动画循环也该交给 requestAnimationFrame。

反过来说:如果站点本来就不需要 jQuery,为了一个特效引一个库(还要维护版本与 SRI)确实不划算。是否替换取决于你的现有依赖,不必为了"零依赖"而专门重写别的功能。

九、上线检查清单

  • [ ] canvas 位图尺寸 = CSS 尺寸 × devicePixelRatio,并用 setTransform 归一化坐标系;
  • [ ] 用 requestAnimationFrame,按回调的 timestamp 计算时间差,不写"每帧固定位移";
  • [ ] 监听 visibilitychange,隐藏时 cancelAnimationFrame、恢复时重置时间基准;
  • [ ] prefers-reduced-motion: reduce 时停掉动画(CSS+JS 两层);
  • [ ] 装饰性内容加 aria-hidden="true";
  • [ ] 脚本与样式都是外部文件(CSP 'self' 下内联会被拦);
  • [ ] 自托管 + 版本化文件名,或引外链时同时做 SRI;
  • [ ] 移动端降低高度或改变密度,别让装饰动画吃掉滚动性能。

参考来源