requestAnimationFrame 与 JS 事件循环:宏任务执行顺序分析

一、先理清核心概念

在讲解执行顺序前,先明确几个关键概念:

  1. 宏任务(Macrotask) :常见的有 setTimeoutsetInterval、I/O 操作、script 整体代码、UI 渲染(注意:渲染是独立阶段,不是宏任务,但和 rAF 强相关)。
  2. 微任务(Microtask)Promise.then/catch/finallyqueueMicrotaskMutationObserver 等,会在宏任务执行完后、渲染 / 下一个宏任务前立即执行。
  3. requestAnimationFrame :不属于宏任务 / 微任务,是浏览器专门为动画设计的 API,会在浏览器重绘(渲染)之前执行,执行时机在微任务之后、宏任务之前(下一轮)。

二、事件循环的执行流程

一个完整的事件循环周期执行顺序:

markdown 复制代码
1. 执行当前宏任务(如 script 主代码)
2. 执行所有微任务(微任务队列清空)
3. 执行 requestAnimationFrame 回调
4. 浏览器进行 UI 渲染(重绘/回流)
5. 取出下一个宏任务执行,重复上述流程

三、代码分析

代码执行优先级:同步代码 > 微任务 > rAF(当前帧) > 普通宏任务(setTimeout) > rAF(下一帧) > 后续普通宏任务。

场景 1:基础顺序(script + 微任务 + rAF + 宏任务)

javascript 复制代码
// 1. 同步代码(属于第一个宏任务:script 整体)
console.log('同步代码执行');

// 微任务
Promise.resolve().then(() => {
  console.log('微任务执行');
});

// requestAnimationFrame
requestAnimationFrame(() => {
  console.log('requestAnimationFrame 执行');
});

// 宏任务(setTimeout 是宏任务)
setTimeout(() => {
  console.log('setTimeout 宏任务执行');
}, 0);

// 执行结果顺序大部分情况下是这样的:
// 同步代码执行
// 微任务执行
// requestAnimationFrame 执行
// setTimeout 宏任务执行

代码解释1

  • 第一步:执行同步代码,打印「同步代码执行」;
  • 第二步:微任务队列有 Promise.then,执行并打印「微任务执行」;
  • 第三步:浏览器准备渲染前,执行 rAF 回调,打印「requestAnimationFrame 执行」;
  • 第四步:浏览器完成渲染后,取出下一个宏任务(setTimeout)执行,打印「setTimeout 宏任务执行」。

代码解释2

  • 正常浏览器环境(60Hz 屏幕,无阻塞) :输出顺序是按上方写的先后顺序执行的:

    arduino 复制代码
    同步代码执行
    微任务执行
    requestAnimationFrame 执行
    setTimeout 宏任务执行

    原因:浏览器每 16.7ms 刷新一次,requestAnimationFrame 会在下一次重绘前 执行,而 setTimeout 即使设为 0,也会有 4ms 左右的最小延迟(浏览器限制),所以 requestAnimationFrame 先执行。

  • 极端情况(主线程阻塞 / 浏览器刷新延迟) :可能出现顺序互换:

    arduino 复制代码
    同步代码执行
    微任务执行
    setTimeout 宏任务执行
    requestAnimationFrame 执行

    原因 :如果主线程处理完微任务后,requestAnimationFrame 的回调还没到执行时机(比如浏览器还没到重绘节点),但 setTimeout 的最小延迟已到,就会先执行 setTimeout

总结

  1. 固定顺序:同步代码 → 微任务,这两步是绝对固定的,不受任何因素影响。

  2. 不固定顺序requestAnimationFramesetTimeout 的执行先后不绝对,前者优先级更高但依赖渲染时机,后者受最小延迟限制,多数场景下前者先执行,但不能当作 "绝对结论"。

  3. 核心原则:requestAnimationFrame 属于 "渲染相关回调",优先级高于普通宏任务(如 setTimeout),但并非 ECMAScript 标准定义的 "微任务 / 宏任务" 范畴,而是浏览器的扩展机制,因此执行时机存在微小不确定性。

场景 2:嵌套场景(rAF 内嵌套微任务 / 宏任务)

javascript 复制代码
console.log('同步代码');

// 第一个 rAF
requestAnimationFrame(() => {
  console.log('rAF 1 执行');
  
  // rAF 内的微任务
  Promise.resolve().then(() => {
    console.log('rAF 1 内的微任务');
  });
  
  // rAF 内的宏任务
  setTimeout(() => {
    console.log('rAF 1 内的 setTimeout');
  }, 0);
  
  // rAF 内嵌套 rAF
  requestAnimationFrame(() => {
    console.log('rAF 2 执行');
  });
});

// 外层微任务
Promise.resolve().then(() => {
  console.log('外层微任务');
});

// 外层宏任务
setTimeout(() => {
  console.log('外层 setTimeout');
}, 0);

// 执行结果顺序:
// 同步代码
// 外层微任务
// rAF 1 执行
// rAF 1 内的微任务
// 外层 setTimeout
// (浏览器下一次渲染前)
// rAF 2 执行
// rAF 1 内的 setTimeout

代码解释

  1. 先执行同步代码 → 外层微任务;
  2. 执行 rAF 1 → 立即执行 rAF 1 内的微任务(微任务会在当前阶段清空);
  3. 浏览器渲染后,执行下一轮宏任务:外层 setTimeout;
  4. 下一次事件循环的渲染阶段,执行嵌套的 rAF 2;
  5. 最后执行 rAF 1 内的 setTimeout(下下轮宏任务)。

场景 3:rAF 与多个宏任务对比

javascript 复制代码
// 宏任务1:setTimeout 0
setTimeout(() => {
  console.log('setTimeout 1');
}, 0);

// rAF
requestAnimationFrame(() => {
  console.log('rAF 执行');
});

// 宏任务2:setTimeout 0
setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

// 执行结果顺序:
// rAF 执行
// setTimeout 1
// setTimeout 2

结论:即使多个宏任务排在前面,rAF 依然会在「微任务后、渲染前」优先执行,然后才执行所有待处理的宏任务。

四、实际应用

rAF 的这个执行特性,常用来做高性能动画(比如 DOM 动画),因为它能保证在渲染前执行,避免「布局抖动」:

ini 复制代码
// 用 rAF 实现平滑移动动画
const box = document.getElementById('box');
let left = 0;

function moveBox() {
  left += 1;
  box.style.left = `${left}px`;
  
  // 动画未结束则继续调用 rAF
  if (left < 300) {
    requestAnimationFrame(moveBox);
  }
}

// 启动动画
requestAnimationFrame(moveBox);

这个代码的优势:rAF 会和浏览器的刷新频率(通常 60Hz,每 16.7ms 一次)同步,不会像 setTimeout 那样可能出现丢帧,因为 setTimeout 是宏任务,执行时机不固定,可能错过渲染时机。

总结

  1. 核心执行顺序:同步代码 → 所有微任务 → requestAnimationFrame → 浏览器渲染 → 下一轮宏任务(setTimeout/setInterval 等)。
  2. rAF 本质:不属于宏 / 微任务,是浏览器渲染阶段的「专属回调」,优先级高于下一轮宏任务。
  3. 实战价值:rAF 适合做 UI 动画,能保证动画流畅;宏任务(setTimeout)适合非渲染相关的异步操作,避免阻塞渲染。

相比传统的计时器防抖与节流

实战代码:rAF 实现节流(最常用)

rAF 做节流的核心优势:和浏览器渲染同步,不会出现「执行次数超过渲染帧」的无效执行,尤其适合 resizescrollmousemove 这类和 UI 相关的高频事件。

基础版 rAF 节流

javascript 复制代码
function rafThrottle(callback) {
  let isPending = false; // 标记是否已有待执行的回调
  return function(...args) {
    if (isPending) return; // 已有待执行任务,直接返回
    
    isPending = true;
    // 绑定 this 指向,传递参数
    const context = this;
    requestAnimationFrame(() => {
      callback.apply(context, args); // 执行回调
      isPending = false; // 执行完成后重置标记
    });
  };
}

// 测试:监听滚动事件
window.addEventListener('scroll', rafThrottle(function(e) {
  console.log('滚动节流执行', window.scrollY);
}));

代码解释

  1. isPending 标记是否有 rAF 回调待执行,避免同一帧内多次触发;
  2. 每次触发事件时,若没有待执行任务,就通过 rAF 注册回调;
  3. rAF 会在下一次渲染前执行回调,执行完后重置标记,确保每帧只执行一次。

对比传统 setTimeout 节流

ini 复制代码
// 传统 setTimeout 节流(对比用)
function timeoutThrottle(callback, delay = 16.7) {
  let timer = null;
  return function(...args) {
    if (timer) return;
    timer = setTimeout(() => {
      callback.apply(this, args);
      timer = null;
    }, delay);
  };
}

rAF 节流的优势:

  • 执行时机和浏览器渲染帧完全同步,不会出现「回调执行了但渲染没跟上」的无效操作
  • 无需手动设置延迟(如 16.7ms),自动适配浏览器刷新率(60Hz/144Hz 都能兼容)

实战代码:rAF 实现防抖

rAF 实现防抖需要结合「延迟 + 取消 rAF」的逻辑,核心是「触发事件后,只保留最后一次 rAF 回调」。

javascript 复制代码
function rafDebounce(callback) {
  let rafId = null; // 保存 rAF 的 ID,用于取消
  return function(...args) {
    const context = this;
    // 若已有待执行的 rAF,先取消
    if (rafId) {
      cancelAnimationFrame(rafId);
    }
    // 重新注册 rAF,延迟到下一帧执行
    rafId = requestAnimationFrame(() => {
      callback.apply(context, args);
      rafId = null; // 执行后清空 ID
    });
  };
}

// 测试:监听输入框输入
const input = document.getElementById('input');
input.addEventListener('input', rafDebounce(function(e) {
  console.log('输入防抖执行', e.target.value);
}));

代码解释

  1. 每次触发事件时,先通过 cancelAnimationFrame 取消上一次未执行的 rAF 回调;
  2. 重新注册新的 rAF 回调,确保只有「最后一次触发」的回调会执行;
  3. 防抖的延迟本质是「一帧的时间(16.7ms)」,若需要更长延迟,可结合 setTimeout

带自定义延迟的 rAF 防抖

ini 复制代码
function rafDebounceWithDelay(callback, delay = 300) {
  let rafId = null;
  let timer = null;
  return function(...args) {
    const context = this;
    // 取消之前的定时器和 rAF
    if (timer) clearTimeout(timer);
    if (rafId) cancelAnimationFrame(rafId);
    
    // 先延迟,再用 rAF 执行(保证渲染前执行)
    timer = setTimeout(() => {
      rafId = requestAnimationFrame(() => {
        callback.apply(context, args);
        rafId = null;
        timer = null;
      });
    }, delay);
  };
}

四、适用场景 vs 不适用场景

场景 是否适合用 rAF 做防抖 / 节流 原因
scroll/resize 事件 ✅ 非常适合 和 UI 渲染强相关,rAF 保证每帧只执行一次
mousemove/mouseover 事件 ✅ 适合 高频触发,rAF 减少无效执行,提升性能
输入框 input/change 事件 ✅ 适合(防抖) 保证输入完成后,在渲染前执行回调(如搜索联想)
网络请求(如按钮点击提交) ❌ 不适合 网络请求和 UI 渲染无关,用传统 setTimeout 防抖更合适
后端数据处理(无 UI 交互) ❌ 不适合 rAF 是浏览器 API,Node.js 环境不支持,且无渲染需求

总结

  1. rAF 适合做防抖 / 节流,尤其在「和 UI 交互相关的高频事件」(scroll/resize/mousemove)场景下,性能优于传统 setTimeout;
  2. rAF 节流 :核心是「每帧只执行一次」,利用 isPending 标记避免重复执行;
  3. rAF 防抖:核心是「取消上一次 rAF,保留最后一次」,可结合 setTimeout 实现自定义延迟;
  4. 非 UI 相关的防抖 / 节流(如网络请求),优先用传统 setTimeout,避免依赖浏览器渲染机制。
相关推荐
步步为营DotNet1 小时前
深度解析C# 11的Required成员:编译期验证保障数据完整性
java·前端·c#
han_1 小时前
开发提效利器 - 用好Snippets
前端·javascript·visual studio code
mCell2 小时前
为什么在 Agent 时代,我选择了 Bun?
javascript·agent·bun
J船长2 小时前
Firebase CLI 一直关联失败
前端
wuli_滔滔2 小时前
DevUI云控制台实战:多云管理平台前端架构解密
前端·架构·devui·matechat
深耕AI3 小时前
【wordpress系列教程】02 Blocksy主题
运维·服务器·前端
谎言西西里3 小时前
掌握原型链,写出不翻车的 JS 继承
javascript
老王熬夜敲代码3 小时前
C++中的thread
c++·笔记·面试