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;
- [ ] 移动端降低高度或改变密度,别让装饰动画吃掉滚动性能。
参考来源
- WHATWG, HTML Standard — The canvas element(定义、默认 300×150、数值按 CSS 像素解释)— https://html.spec.whatwg.org/multipage/canvas.html
- WHATWG, HTML Standard — Animation frames(rAF 调度语义)— https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html
- W3C, CSSOM View Module(
devicePixelRatio的规范性定义)— https://www.w3.org/TR/cssom-view-1/ - W3C, Page Visibility Level 2(
hidden状态定义)— https://www.w3.org/TR/page-visibility-2/ - W3C, Media Queries Level 5(
prefers-reduced-motion定义)— https://www.w3.org/TR/mediaqueries-5/ - MDN, Canvas API — https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- MDN, HTMLCanvasElement: getContext() — https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
- MDN, CanvasRenderingContext2D: drawImage() — https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
- MDN, CanvasRenderingContext2D: clearRect() — https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/clearRect
- MDN, CanvasRenderingContext2D: save() / restore() / transform() — https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save ・ https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/restore ・ https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/transform
- MDN, Window: requestAnimationFrame()(时间戳语义、后台标签页暂停的表述)— https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
- MDN, Window: cancelAnimationFrame() — https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelAnimationFrame
- MDN, Window: devicePixelRatio(HiDPI 适配示例)— https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio
- MDN, Page Visibility API(
visibilitychange触发场景)— https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API - MDN, prefers-reduced-motion — https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
- MDN, Animation performance and frame rate(60fps ≈ 16.7ms 帧预算)— https://developer.mozilla.org/en-US/docs/Web/Performance/Animation_performance_and_frame_rate
- MDN, CSS and JavaScript animation performance(rAF 与定时器对比)— https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/CSS_JavaScript_animation_performance
- MDN, Optimizing canvas(用 rAF、离屏预渲染)— https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
- MDN, Subresource Integrity — https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
- MDN, Content Security Policy (CSP)(
script-src)— https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP - jQuery API Documentation, .on() / .animate() / .ready() — https://api.jquery.com/on/ ・ https://api.jquery.com/animate/ ・ https://api.jquery.com/ready/