# Web特效020—让 Web 特效真正动起来:时间循环应该怎么接

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;
}

如果没有循环,鼠标事件只能修改内存中的坐标;有了循环,光晕才会在每一帧向新的目标位置靠近,形成平滑跟随。

同样地,粒子不会因为拥有 vxvy 字段就自己运动。只有在每一帧执行位置更新,再重新绘制,速度才会转化为可见的位移。

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,动画就会慢得几乎看不见。

写代码时,最好在变量名或注释中明确单位,例如 speedPerSeconddeltaSecondstimeMs,不要让毫秒和秒在同一个模块里无提示地混用。

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();
  }
}

绘制阶段读取的是"本帧最终状态"。它不应该反过来修改粒子的 xyagevelocity,否则会把显示过程和模拟过程耦合在一起。

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 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。

相关推荐
动恰客流统计1 小时前
线下零售数字化浪潮下,客流统计的3个核心发展趋势
大数据·前端·人工智能
用户938515635071 小时前
从小米前端面试题,彻底搞懂闭包、作用域链与 useState 惰性初始化
前端·面试
粥里有勺糖2 小时前
视野修炼第133期 | Native 回春了?
前端·github·agent
aichitang20243 小时前
前端小skill
前端·人工智能·算法·ai·前端框架
前端·柱子3 小时前
q-floodfill白边锯齿?一招搞定抗锯齿边缘问题
前端·html·canva可画
葡萄城技术团队4 小时前
SpreadJS V19.2新特性揭秘:功能区键盘提示
前端
计算机魔术师4 小时前
AI 幻觉导致的错误情报险些引发美军拦截中国船只
前端
万物智能4 小时前
SARADC模数转换—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
志尊宝4 小时前
Vue3 零基础每日笔记(037):动手封装 useMouse 与 useWindowSize——事件监听的标准模板
前端·javascript·vue.js·笔记·html5