Web特效020---让 Web 特效真正动起来:时间循环应该怎么接
一个静态图形只需要绘制一次,但网页特效要持续运动,就必须让浏览器不断重复执行一套有节奏的计算流程。时间循环就是连接"上一帧状态"和"下一帧画面"的核心机制:浏览器通过 requestAnimationFrame() 提供绘制时机,JavaScript 根据两帧之间的时间间隔计算 deltaTime,更新粒子的位置、速度、透明度和生命周期,再把最新状态交给 Canvas 或 WebGL 绘制。如果忽略时间单位、直接按帧数移动对象,特效就会因为设备刷新率不同而出现快慢不一致;如果不限制异常的时间间隔,页面切后台后重新激活还可能让粒子瞬间跳出画面。本文将从最简单的循环开始,逐步解释时间戳、上一帧时间、时间步长、更新函数和渲染函数之间的关系,并说明固定时间步、最大时间步和暂停恢复等常见处理方式。理解时间循环之后,你会知道动画为什么应该"按时间推进"而不是"按帧数推进",也能更容易定位运动抖动、速度异常、状态累积和不同设备表现不一致等问题。

一、为什么静态图形不会自己动:动画循环解决了什么问题
在 Canvas 上画出一个圆并不等于创建了一个会自动移动的对象。绘制 API 只负责把当前参数转换成当前画面,浏览器不会因为你修改了某个 JavaScript 变量,就自动重新绘制 Canvas。
要让图形动起来,必须不断改变状态,并在状态改变之后重新生成画面。这个"反复更新状态、重新绘制画面"的过程,就是动画循环。
1. 一次绘制只能得到一张当前画面
下面的代码只会画出一个静态圆:
js
const canvas = document.querySelector('#effectCanvas');
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(100, 100, 20, 0, Math.PI * 2);
ctx.fillStyle = '#2fc4df';
ctx.fill();
Canvas 记录的是已经绘制进去的像素,而不是一个会持续更新的圆形对象。之后即使写出:
js
let x = 100;
x = 200;
画布上的圆也不会自动从 100 移动到 200。因为变量变化只发生在 JavaScript 内存中,Canvas 画面仍然保持之前的像素结果。
2. 动画其实是很多张连续画面
要让圆从左向右移动,可以不断改变 x,再清除旧画面并重新绘制:
js
let x = 100;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(x, 100, 20, 0, Math.PI * 2);
ctx.fillStyle = '#2fc4df';
ctx.fill();
x += 2;
requestAnimationFrame(draw);
}
draw();
每一次 draw() 都只产生一张静态画面,但浏览器连续展示这些位置略有不同的画面,人眼就会把它们理解成运动:
text
第 1 帧:x = 100
第 2 帧:x = 102
第 3 帧:x = 104
第 4 帧:x = 106
运动感并不是 Canvas 内部自动保存了一个"正在移动的圆",而是连续帧之间的差异被人眼连接了起来。
3. 动画循环至少要完成两件事
一个最小动画循环包含两个核心动作:
text
更新状态:计算下一帧的位置、速度、透明度等
绘制画面:根据最新状态重新生成 Canvas 内容
可以把它拆成两个函数:
js
const state = {
x: 100,
y: 100,
speed: 120,
};
function update(state, deltaTime) {
state.x += state.speed * deltaTime;
}
function render(ctx, state) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.beginPath();
ctx.arc(state.x, state.y, 20, 0, Math.PI * 2);
ctx.fillStyle = '#2fc4df';
ctx.fill();
}
更新函数关心规则,绘制函数关心画面。这样做比把所有逻辑都塞进一个 draw() 函数更容易扩展,也更容易判断问题到底出在运动计算还是绘制过程。
4. 没有循环,交互也无法持续反馈
鼠标跟随、拖拽和粒子效果同样需要动画循环。鼠标事件只在输入发生时提供新的坐标,循环则负责持续读取状态并把变化显示出来:
js
canvas.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect();
state.pointer.x = event.clientX - rect.left;
state.pointer.y = event.clientY - rect.top;
});
function update(state, deltaTime) {
state.glow.x += (state.pointer.x - state.glow.x) * 0.12;
state.glow.y += (state.pointer.y - state.glow.y) * 0.12;
}
如果没有循环,鼠标事件只能修改内存中的坐标;有了循环,光晕才会在每一帧向新的目标位置靠近,形成平滑跟随。
同样地,粒子不会因为拥有 vx、vy 字段就自己运动。只有在每一帧执行位置更新,再重新绘制,速度才会转化为可见的位移。
5. 动画循环是渲染系统和时间的连接点
从结构上看,动画循环连接了三个部分:
text
时间:现在距离上一帧过去了多久
↓
状态:对象下一刻应该变成什么样
↓
画面:把最新状态绘制到 Canvas 或 WebGL
浏览器负责提供绘制机会,JavaScript 负责更新状态和提交绘制命令,Canvas 或 WebGL 负责把命令变成画面。
静态绘制只回答"现在画什么",动画循环还要回答"下一帧什么时候画、状态怎样变化"。
下一节会具体介绍 requestAnimationFrame(),看看浏览器怎样安排下一帧,以及为什么它比普通定时器更适合网页动画。
二、requestAnimationFrame():浏览器怎样安排下一帧
动画循环需要一个稳定的"下一帧通知"。浏览器提供的 requestAnimationFrame() 就是专门为视觉更新准备的调度 API。它不会直接替你移动对象,也不会自动绘制画面,而是在浏览器准备进行下一次屏幕刷新前调用指定函数。
开发者只需要在回调函数中更新状态、绘制画面,然后再次请求下一帧。
1. 最小调用方式是什么
最简单的循环可以这样写:
js
function frame(time) {
update(time);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
这里有两个关键点:第一次调用负责启动循环,回调内部的第二次调用负责请求下一帧。如果忘记在回调里再次调用,动画只会执行一次。
也可以把请求返回的编号保存下来:
js
let animationId = 0;
function frame(time) {
update(time);
render();
animationId = requestAnimationFrame(frame);
}
animationId = requestAnimationFrame(frame);
这个编号可以交给 cancelAnimationFrame(),用于停止当前循环。
2. 回调参数里的时间戳有什么用
requestAnimationFrame() 会把一个时间戳传给回调:
js
function frame(time) {
console.log(time);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
这个时间通常表示页面时间线上的某个时间点,单位是毫秒。它可以用来计算两帧之间的时间间隔:
js
let lastTime = 0;
function frame(time) {
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
update(deltaTime);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
第一次执行时,lastTime 还没有真正记录上一帧时间,因此通常需要单独处理第一次回调:
js
let lastTime = null;
function frame(time) {
if (lastTime === null) {
lastTime = time;
}
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
update(deltaTime);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
3. 为什么不用 setInterval() 驱动画面
也可以使用定时器:
js
setInterval(() => {
update();
render();
}, 16);
但 setInterval() 只是按照设定时间尝试执行,并不知道浏览器什么时候真正准备好绘制下一帧。它可能在页面不可见时继续触发,也可能因为主线程繁忙而堆积回调。
requestAnimationFrame() 更适合动画,主要是因为:
| 特点 | 作用 |
|---|---|
| 接近浏览器绘制时机 | 减少计算和显示之间的浪费 |
| 页面隐藏时通常会降频或暂停 | 降低后台资源消耗 |
| 每次只请求下一帧 | 不容易积累大量未执行回调 |
| 提供时间戳 | 方便计算真实时间间隔 |
它并不保证每次回调间隔永远固定,也不保证所有设备都是 60 FPS。动画逻辑仍然应该使用时间间隔,而不是假设每帧固定经过 16 毫秒。
4. 一次只请求一帧,避免重复启动循环
如果不小心从多个地方启动循环,可能会出现两个甚至多个动画循环同时修改同一份状态:
js
requestAnimationFrame(frame);
requestAnimationFrame(frame);
这样会让更新函数执行多次,粒子速度异常,绘制次数增加,甚至造成多个循环永远无法停止。
可以通过状态标记避免重复启动:
js
let animationId = 0;
let running = false;
function start() {
if (running) return;
running = true;
animationId = requestAnimationFrame(frame);
}
function frame(time) {
if (!running) return;
update(time);
render();
animationId = requestAnimationFrame(frame);
}
function stop() {
running = false;
cancelAnimationFrame(animationId);
}
初始化、页面切换或组件重新挂载时,尤其要注意不要重复调用 start()。在单页应用中,如果旧组件没有执行 stop(),还可能出现已经看不见的 Canvas 仍然在后台更新。
5. 浏览器安排时机,但不替你管理生命周期
一个完整的循环通常由这些角色组成:
text
requestAnimationFrame:请求浏览器在合适时机回调
回调时间戳:告诉程序当前时间
update:根据时间推进状态
render:把状态绘制成当前画面
cancelAnimationFrame:停止之后的调度
浏览器只负责调度回调。窗口是否应该继续运行、组件销毁时是否停止、Canvas 隐藏时是否暂停、状态是否保留,都需要由业务代码决定。
requestAnimationFrame()是"下一帧提醒",不是完整的动画引擎。
掌握了请求、更新时间和停止循环的方式后,还要解决速度稳定问题:不同设备的帧率并不相同,不能把"每调用一次回调"当成固定时间。下一节会专门解释 deltaTime 为什么是时间循环中的关键。
三、deltaTime 到底是什么:别让帧率决定动画速度
上一节已经让浏览器在每次刷新前调用动画函数,但不同设备的刷新率并不相同。60 Hz 屏幕可能每隔约 16.7 毫秒回调一次,120 Hz 屏幕可能每隔约 8.3 毫秒回调一次,页面卡顿时,间隔还会更长。
如果动画只按照"回调了几次"推进,帧率越高,物体移动得越快;帧率越低,物体移动得越慢。deltaTime 就是用来把帧数转换成真实经过的时间。
1. deltaTime 表示两帧之间经过了多久
它的计算公式很简单:
text
deltaTime = 当前帧时间 − 上一帧时间
如果时间戳单位是毫秒,而运动速度使用"每秒多少像素",就需要除以 1000:
js
let lastTime = null;
function frame(time) {
if (lastTime === null) {
lastTime = time;
}
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
update(deltaTime);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
例如,两帧之间相隔 16 毫秒,deltaTime 就是 0.016 秒;如果相隔 33 毫秒,deltaTime 就是 0.033 秒。
2. 直接按帧移动,速度会随刷新率改变
下面这段代码让对象每次回调向右移动 2 个单位:
js
particle.x += 2;
60 FPS 时,一秒大约执行 60 次,移动 120 个单位;120 FPS 时,一秒执行约 120 次,移动 240 个单位。代码看起来没有变化,但实际速度已经翻倍。
使用 deltaTime 后,速度改成"每秒移动多少":
js
const speed = 120;
particle.x += speed * deltaTime;
60 FPS 时,单帧移动约 2 个单位;120 FPS 时,单帧移动约 1 个单位。虽然每帧的移动量不同,但一秒内的总移动距离会更加接近。
同样的规则也适用于速度、重力、淡出和旋转:
js
particle.vy += gravity * deltaTime;
particle.rotation += rotationSpeed * deltaTime;
particle.alpha -= fadeSpeed * deltaTime;
只要变量的单位明确,动画就不应该依赖某个固定帧率。
3. 时间单位必须保持一致
时间错误是动画速度异常的常见原因。下面这些写法的单位不同:
| 表达式 | 时间单位 | 适合什么 |
|---|---|---|
(time - lastTime) |
毫秒 | 直接比较浏览器时间戳 |
(time - lastTime) / 1000 |
秒 | 配合每秒速度和加速度 |
time * 0.001 |
秒 | 把绝对时间输入动画公式 |
例如,速度写成每秒 100 像素,就应该这样更新:
js
const speed = 100; // pixels per second
const seconds = (time - lastTime) / 1000;
particle.x += speed * seconds;
如果忘记除以 1000,就会把 16 毫秒当成 16 秒,物体会瞬间移动很远。反过来,如果速度本来是"每毫秒多少单位",却又除以 1000,动画就会慢得几乎看不见。
写代码时,最好在变量名或注释中明确单位,例如 speedPerSecond、deltaSeconds 和 timeMs,不要让毫秒和秒在同一个模块里无提示地混用。
4. 为什么要限制最大 deltaTime
页面切到后台、浏览器窗口被遮挡或主线程长时间阻塞后,下一次回调的时间间隔可能非常大:
js
const deltaTime = (time - lastTime) / 1000;
如果页面暂停了 5 秒,下一帧可能得到 deltaTime = 5。粒子会在一次更新中移动几百甚至几千个单位,直接穿过边界或飞出画面。
通常可以设置一个最大值:
js
const rawDeltaTime = (time - lastTime) / 1000;
const deltaTime = Math.min(rawDeltaTime, 0.05);
这表示单次更新最多按照 50 毫秒计算。它并不是让时间凭空消失,而是防止一次异常停顿对模拟状态造成过大的冲击。
另一种处理方式是在页面重新可见时重置时间基准:
js
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
lastTime = null;
}
});
这样恢复后的第一帧只负责重新建立时间参考,不会把后台停留时间全部传给物理状态。
5. deltaTime 让不同效果共享同一时间基础
有了统一的时间步长,多个对象和效果就可以使用同一套时间单位:
js
function update(state, deltaTime, time) {
for (const particle of state.particles) {
particle.x += particle.vx * deltaTime;
particle.y += particle.vy * deltaTime;
particle.alpha -= particle.fadeSpeed * deltaTime;
}
state.glow.radius = 80 + Math.sin(time * 0.002) * 12;
state.rotation += state.rotationSpeed * deltaTime;
}
粒子运动使用的是时间间隔,光晕大小使用的是绝对时间,旋转使用的也是每秒速度。不同公式虽然用途不同,但都建立在明确的时间单位上。
deltaTime不是"这一帧移动多少",而是"这一帧真实经过了多少时间";移动多少要由速度和它相乘得到。
只要把动画参数统一成每秒速度、每秒加速度和每秒变化率,帧率就不再直接决定动画快慢。下一节会把更新时间、绘制画面和状态管理放在一起,明确一帧应该按照什么顺序完成。
四、更新和渲染如何分工:一帧画面应该按什么顺序完成
时间循环真正稳定之后,还需要明确每一帧内部的执行顺序。输入、状态更新、对象清理和绘制如果混在一起,代码很容易出现重复更新、绘制读取旧数据或状态被意外修改的问题。
一帧画面通常可以拆成四个阶段:读取输入、更新状态、整理对象、绘制画面。
1. 一帧的基本顺序是什么
一个清晰的动画循环可以概括成:
text
读取时间和输入
↓
更新所有对象的状态
↓
删除、创建或重置对象
↓
清理画面并绘制最新状态
↓
请求下一帧
代码结构可以这样写:
js
function frame(time) {
const deltaTime = getDeltaTime(time);
readInput(state);
update(state, deltaTime, time);
cleanup(state);
render(ctx, state);
requestAnimationFrame(frame);
}
这个顺序不是唯一写法,但每个阶段的职责应该清楚。尤其要避免在 render() 中修改速度、年龄和位置,否则一次绘制调用就会改变下一次更新的输入。
2. 输入阶段只记录用户当前状态
鼠标事件通常不会直接移动图形,而是修改状态中的输入数据:
js
canvas.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect();
state.pointer.x = event.clientX - rect.left;
state.pointer.y = event.clientY - rect.top;
state.pointer.active = true;
});
canvas.addEventListener('pointerleave', () => {
state.pointer.active = false;
});
事件发生的时间和动画帧的时间并不一定完全一致。把输入先保存下来,更新函数在下一帧统一读取,可以让运动逻辑不依赖事件触发频率。
例如,光晕跟随鼠标时,更新函数负责决定它靠近目标的速度:
js
function updatePointerEffect(state, deltaTime) {
if (!state.pointer.active) return;
const follow = Math.min(deltaTime * 8, 1);
state.glow.x += (state.pointer.x - state.glow.x) * follow;
state.glow.y += (state.pointer.y - state.glow.y) * follow;
}
3. 更新阶段只改变状态
更新阶段负责所有会影响下一帧的变化:
js
function updateParticles(state, deltaTime) {
for (const particle of state.particles) {
particle.vy += state.gravity * deltaTime;
particle.x += particle.vx * deltaTime;
particle.y += particle.vy * deltaTime;
particle.age += deltaTime;
const progress = particle.age / particle.lifetime;
particle.alpha = Math.max(0, 1 - progress);
}
}
更新函数可以按照固定顺序处理不同规则:
text
先处理外力和加速度
再处理速度
再处理位置
最后处理生命周期和外观
例如,先更新速度再更新位置,重力才能影响本帧的运动;如果先移动位置再增加重力,结果会产生轻微的积分差异。不同项目可以选择不同积分方式,但必须保持规则稳定。
4. 清理阶段负责对象的出生和死亡
粒子生命周期结束后,不应该继续把它交给绘制函数:
js
function cleanupParticles(state) {
for (let i = state.particles.length - 1; i >= 0; i--) {
const particle = state.particles[i];
const outside = particle.x < -50 ||
particle.x > state.width + 50 ||
particle.y < -50 ||
particle.y > state.height + 50;
if (particle.age >= particle.lifetime || outside) {
state.particles.splice(i, 1);
}
}
}
清理完成后,可以根据数量补充新对象:
js
function spawnParticles(state, random) {
while (state.particles.length < state.targetCount) {
state.particles.push(createParticle(random));
}
}
把对象的创建和销毁集中到清理阶段,比在绘制循环中边画边删除更容易理解,也不容易因为数组长度变化跳过对象。
5. 渲染阶段只读取最终状态
当状态已经完成更新和清理后,绘制函数才开始生成当前画面:
js
function render(ctx, state) {
ctx.clearRect(0, 0, state.width, state.height);
ctx.fillStyle = state.background;
ctx.fillRect(0, 0, state.width, state.height);
for (const particle of state.particles) {
ctx.save();
ctx.globalAlpha = particle.alpha;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
绘制阶段读取的是"本帧最终状态"。它不应该反过来修改粒子的 x、y、age 或 velocity,否则会把显示过程和模拟过程耦合在一起。
6. 一帧完整执行顺序示例
把这些阶段连接起来:
js
let lastTime = null;
function frame(time) {
if (lastTime === null) lastTime = time;
const deltaTime = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
readInput(state);
updatePointerEffect(state, deltaTime);
updateParticles(state, deltaTime);
cleanupParticles(state);
spawnParticles(state, Math.random);
render(ctx, state);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
这条顺序可以扩展到更复杂的特效。加入碰撞时放在更新阶段,加入对象池时放在清理和创建阶段,加入多层光效时放在渲染阶段。每个功能都能找到自己的位置,而不是所有代码都挤进一个巨大回调函数。
更新决定下一刻世界是什么样,渲染负责把这一刻的世界显示出来。
只要坚持这种分工,时间循环就不只是"让代码一直运行",而会变成一个有明确节奏的系统。下一节会讨论页面卡顿、切换后台和暂停恢复等异常情况,看看时间循环为什么不能只考虑正常运行。
五、页面卡顿、切后台与暂停恢复:时间循环最容易踩的坑
正常运行时,requestAnimationFrame() 的回调间隔通常比较稳定,但真实网页还会遇到页面切后台、浏览器节流、主线程阻塞、窗口隐藏和组件重复挂载等情况。
如果时间循环只按照理想的 60 FPS 编写,一旦运行环境变化,粒子可能突然跳动、速度失控,甚至出现多个循环同时更新同一份状态。
1. 页面卡顿会制造异常大的 deltaTime
假设页面因为主线程繁忙暂停了 2 秒,下一次回调可能得到一个很大的时间间隔:
js
const deltaTime = (time - lastTime) / 1000;
如果粒子速度是每秒 100 像素,那么一次更新可能直接移动 200 像素。对于带重力、碰撞或生命周期的系统,这种跳跃会造成穿透边界、错过碰撞点或瞬间消失。
最简单的保护方式是限制最大时间步:
js
const rawDeltaTime = (time - lastTime) / 1000;
const deltaTime = Math.min(rawDeltaTime, 0.05);
这样一次更新最多模拟 50 毫秒。它可以避免异常停顿把状态推得太远,但也意味着暂停期间的那段时间不会完整地被模拟。
2. 切后台时,应该暂停还是继续追赶
对于背景装饰和普通交互,页面不可见时通常没有必要继续运行完整动画。可以监听 visibilitychange:
js
let paused = false;
document.addEventListener('visibilitychange', () => {
paused = document.hidden;
});
更新函数中可以直接跳过状态推进:
js
function frame(time) {
if (!paused) {
const deltaTime = getDeltaTime(time);
update(state, deltaTime, time);
render(ctx, state);
}
requestAnimationFrame(frame);
}
另一种策略是页面恢复时重置 lastTime:
js
let lastTime = null;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
lastTime = null;
}
});
这样恢复后的第一帧不会把后台停留时间全部传给粒子,而是从恢复时刻重新开始计算。
3. 暂停和停止不是同一件事
暂停通常意味着保留当前状态,之后还可以继续;停止则可能意味着取消动画调度并释放资源。
可以把循环控制状态单独保存:
js
let running = false;
let animationId = 0;
function start() {
if (running) return;
running = true;
animationId = requestAnimationFrame(frame);
}
function pause() {
running = false;
cancelAnimationFrame(animationId);
}
function frame(time) {
if (!running) return;
updateAndRender(time);
animationId = requestAnimationFrame(frame);
}
暂停时不需要清空 state,恢复后可以从原来的位置继续。若要彻底销毁特效,还应该移除事件监听器、释放 WebGL 资源,并清理不再使用的对象。
4. 避免同一个特效启动多个循环
在组件重复挂载、窗口切换或重新初始化时,如果多次调用启动函数,可能会产生多个循环:
js
start();
start();
两个循环可能同时修改位置、年龄和透明度,结果会让动画速度变成预期的两倍,绘制也会重复执行。
除了 running 标记,还可以让启动函数只返回一个统一的控制对象:
js
function createAnimation(state, ctx) {
let running = false;
let animationId = 0;
let lastTime = null;
function frame(time) {
if (!running) return;
if (lastTime === null) lastTime = time;
const deltaTime = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
update(state, deltaTime, time);
render(ctx, state);
animationId = requestAnimationFrame(frame);
}
return {
start() {
if (running) return;
running = true;
lastTime = null;
animationId = requestAnimationFrame(frame);
},
stop() {
running = false;
cancelAnimationFrame(animationId);
},
};
}
调用方只使用 start() 和 stop(),不会直接在多个地方各自请求动画帧。
5. 固定时间步适合更严格的物理模拟
对于简单视觉特效,限制 deltaTime 通常已经足够;如果涉及碰撞、弹簧或更严格的物理规则,可以使用固定时间步:
js
const fixedStep = 1 / 60;
let accumulator = 0;
let lastTime = null;
function frame(time) {
if (lastTime === null) lastTime = time;
const elapsed = Math.min((time - lastTime) / 1000, 0.1);
lastTime = time;
accumulator += elapsed;
while (accumulator >= fixedStep) {
update(state, fixedStep, time);
accumulator -= fixedStep;
}
render(ctx, state);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
固定时间步让更新逻辑每次都使用相同的时间间隔,结果更容易复现。渲染仍然可以按照显示器的刷新节奏进行,但模拟层不再直接受回调间隔影响。
不过,固定步长会在一次回调中执行多次更新,因此不能无限制地追赶积累时间。真实项目中通常仍然需要设置最大累计时间,避免设备长时间卡顿后产生大量计算。
6. 用状态监控发现时间循环异常
调试时间循环时,可以暂时记录几个关键数据:
js
function debugFrame(time, deltaTime, state) {
console.table({
time,
deltaTime,
particleCount: state.particles.length,
firstX: state.particles[0]?.x,
});
}
如果 deltaTime 偶尔突然变大,说明页面发生了卡顿或切换;如果粒子数量持续增长,说明清理逻辑没有生效;如果同一时间每帧更新两次,可能是动画循环被重复启动。
| 现象 | 常见原因 |
|---|---|
| 恢复页面后粒子飞出画面 | 没有限制或重置 deltaTime |
| 动画速度逐渐变快 | 启动了多个循环 |
| 页面隐藏时仍然耗资源 | 没有暂停或降低更新频率 |
| 碰撞结果不稳定 | 更新步长变化过大 |
| 停止后仍在绘制 | 没有取消帧请求或旧循环仍在运行 |
时间循环不仅要考虑"正常的一帧",还要考虑这帧迟到、暂停、重复启动和恢复的情况。
处理好这些异常后,动画系统才具备真正的运行稳定性。下一节会把时间获取、状态更新、暂停控制和绘制整合成一个可以直接复用的完整骨架。
六、把时间循环接进特效:一个可复用的完整动画骨架
前面几节分别讲了动画循环的组成部分,现在把它们整合成一个可以直接扩展的最小骨架。这个骨架不绑定具体的粒子算法,后续可以替换状态结构、更新规则和绘制方式。
它包含五个职责:初始化 Canvas、创建状态、计算时间、更新状态和绘制画面。
1. 先准备一个可运行的初始化函数
初始化函数负责找到 Canvas、获取上下文、同步尺寸,并返回后续模块需要的对象:
js
function initCanvas(canvas) {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取 Canvas 2D 上下文');
}
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return {
ctx,
width: rect.width,
height: rect.height,
};
}
这里的 ctx.setTransform() 让后续绘制继续使用 CSS 尺寸对应的坐标,同时让内部缓冲区拥有更高像素密度。
2. 创建一份最小状态
状态对象保存画布尺寸、粒子和全局控制参数:
js
function createState(width, height) {
return {
width,
height,
background: '#0d1424',
gravity: 80,
particles: [
{
x: width * 0.5,
y: height * 0.5,
vx: 80,
vy: -30,
radius: 8,
alpha: 1,
age: 0,
lifetime: 4,
color: '#2fc4df',
},
],
};
}
实际项目中可以再增加鼠标位置、噪声采样器、对象池、渐变参数和多个绘制层,但建议先保持状态结构清楚。
3. 更新函数只推进状态
更新函数使用 deltaTime 修改位置、速度和生命周期:
js
function update(state, deltaTime) {
for (let i = state.particles.length - 1; i >= 0; i--) {
const particle = state.particles[i];
particle.vy += state.gravity * deltaTime;
particle.x += particle.vx * deltaTime;
particle.y += particle.vy * deltaTime;
particle.age += deltaTime;
const progress = particle.age / particle.lifetime;
particle.alpha = Math.max(0, 1 - progress);
if (progress >= 1) {
state.particles.splice(i, 1);
}
}
}
这里没有任何 Canvas 绘制代码。即使暂时把 render() 删除,状态仍然可以在控制台中被观察和测试。
4. 绘制函数只读取最新状态
绘制函数负责清理旧画面、绘制背景和遍历当前粒子:
js
function render(ctx, state) {
ctx.clearRect(0, 0, state.width, state.height);
ctx.fillStyle = state.background;
ctx.fillRect(0, 0, state.width, state.height);
for (const particle of state.particles) {
ctx.save();
ctx.globalAlpha = particle.alpha;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(
particle.x,
particle.y,
particle.radius,
0,
Math.PI * 2,
);
ctx.fill();
ctx.restore();
}
}
如果粒子需要拖尾、发光或纹理,只需要扩展绘制函数,不需要改变时间循环的结构。
5. 用控制器管理启动、暂停和停止
把动画帧请求封装起来,可以避免同一个特效被重复启动:
js
function createAnimation(canvas) {
const { ctx, width, height } = initCanvas(canvas);
const state = createState(width, height);
let running = false;
let animationId = 0;
let lastTime = null;
function frame(time) {
if (!running) return;
if (lastTime === null) lastTime = time;
const rawDeltaTime = (time - lastTime) / 1000;
const deltaTime = Math.min(rawDeltaTime, 0.05);
lastTime = time;
update(state, deltaTime);
render(ctx, state);
animationId = requestAnimationFrame(frame);
}
return {
start() {
if (running) return;
running = true;
lastTime = null;
animationId = requestAnimationFrame(frame);
},
pause() {
running = false;
cancelAnimationFrame(animationId);
},
getState() {
return state;
},
};
}
调用方式:
js
const canvas = document.querySelector('#effectCanvas');
const animation = createAnimation(canvas);
animation.start();
页面隐藏或组件销毁时可以暂停:
js
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
animation.pause();
} else {
animation.start();
}
});
6. 尺寸变化和资源清理也要接入骨架
响应式页面中,窗口尺寸变化后应该重新设置 Canvas 内部尺寸,并同步状态边界:
js
window.addEventListener('resize', () => {
const { width, height } = initCanvas(canvas);
const state = animation.getState();
state.width = width;
state.height = height;
});
实际项目中,如果 initCanvas() 会重置变换状态或清空画面,需要确保所有绘制参数在尺寸变化后重新设置。
如果特效彻底销毁,还应该移除事件监听器、取消帧请求、释放 WebGL Buffer 和纹理,并清理不再使用的对象。暂停只是暂时停止更新,销毁则是释放整个运行环境。
7. 这个骨架怎样继续扩展
后续增加功能时,可以按职责放入对应位置:
| 新功能 | 放入位置 |
|---|---|
| 鼠标交互 | 事件监听器和 state.pointer |
| 粒子生成 | 初始化或对象补充逻辑 |
| 重力、噪声和碰撞 | update() |
| 光晕、拖尾和纹理 | render() |
| 暂停、恢复和销毁 | 动画控制器 |
| WebGL 绘制 | 替换上下文和渲染模块 |
不要因为效果变复杂,就把所有逻辑重新塞回 frame()。frame() 最好始终只负责协调:计算时间、调用更新、调用绘制、请求下一帧。
一个可复用的动画骨架,不是为了限制效果,而是为了让效果复杂之后仍然知道每段代码应该放在哪里。
从空白 Canvas 开始,经过上下文初始化、状态创建、时间推进、状态更新和画面绘制,一个完整的 Web 特效就拥有了稳定的运行链路。之后无论加入粒子、噪声、鼠标交互还是 WebGL,都可以在这条链路上逐步扩展,而不必重新发明动画循环。
七、关于八荒启
八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。
我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。
八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈
如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。
- 官方网站:https://bahuangqi.com
- 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。