阅读视图

发现新文章,点击刷新页面。

隐形追踪者:当你删除了 Cookies,谁还在看着你?——揭秘浏览器指纹

韭菜们是否经历过这样的诡异时刻:你在某个购物网站搜索了一双球鞋,仅仅过了一分钟,当你打开新闻网站或社交媒体时,那双球鞋的广告就出现在了显眼的位置。

通常,我们会把这归咎于 Cookies。于是,聪明的韭菜打开了“无痕模式”,或者彻底清除了浏览器的缓存和 Cookies,认为这样就能隐身于互联网。

然而,广告依然如影随形。

这是因为,由于 “浏览器指纹”(Browser Fingerprinting) 技术的存在,你实际上一直在“裸奔”。

什么是浏览器指纹?

在现实生活中,指纹是我们独一无二的生理特征。而在互联网世界中,浏览器指纹是指当你访问一个网站时,你的浏览器不仅会请求网页内容,还会无意中暴露一系列关于你设备的软硬件配置信息。

这些信息单独看起来都很普通,比如:

  • 你的操作系统(Windows, macOS, Android...)
  • 屏幕分辨率(1920x1080...)
  • 浏览器版本(Chrome 120...)
  • 安装的字体列表
  • 时区和语言设置
  • 显卡型号和电池状态

神奇之处在于组合: 当把这几十甚至上百个特征组合在一起时,它们就形成了一个极高精度的“特征值”。据研究,对于绝大多数互联网用户来说,这个组合是全球唯一

它是如何工作的?

为了生成这个指纹,追踪者使用了一些非常巧妙的技术:

1. Canvas 指纹(画布指纹)

这是最著名的指纹技术。网站会命令你的浏览器在后台偷偷绘制一张复杂的隐藏图片(包含文字和图形)。

由于不同的操作系统、显卡驱动、字体渲染引擎处理图像的方式有微小的像素级差异,每台电脑画出来的图在哈希值上是完全不同的。

2. AudioContext 指纹(音频指纹)

原理类似 Canvas。网站会让浏览器生成一段人耳听不到的音频信号。不同的声卡和音频驱动处理信号的方式不同,生成的数字指纹也就不同

3. 字体枚举

你安装了 Photoshop?或者安装了一套冷门的编程字体?网站可以通过脚本检测你系统里安装了哪些字体。安装的字体越独特,你的指纹辨识度就越高

为什么它比 Cookies 更可怕?

特性 Cookies (传统的追踪) 浏览器指纹 (新型追踪)
存储位置 你的电脑硬盘里 不需要存储,实时计算
用户控制 你可以随时一键删除 你无法删除,它是你设备的属性
隐身模式 无效(隐身模式不读旧Cookies) 依然有效(隐身模式下设备配置不变)
持久性 易丢失 极难改变,甚至跨浏览器追踪

这就好比:

  • Cookies 就像是进门时发给你的一张胸牌,你把它扔了,保安就不认识你了
  • 浏览器指纹 就像是保安记住了你的身高、长相、穿衣风格和走路姿势。这和你戴不戴胸牌没有任何关系

主要用途

浏览器指纹技术在现代网络中有多种用途,主要可以分为追踪识别安全防护两大类:

追踪与用户画像

  • 跨网站追踪用户:广告网络会在不同站点嵌入脚本,通过指纹标记“同一访客”,进而在B站推送你在A站浏览过的商品或内容,实现“精准广告”。
  • 绘制用户画像:即使未登录,只要指纹相同,网站就能合并浏览记录、点击路径、停留时长等数据,推测兴趣偏好、消费水平,再反向优化推荐算法。
  • “无Cookie” 追踪:指纹在无痕/隐私模式下依旧存在,且无法像Cookie那样一键清空,因此被视为更顽固的追踪手段。

反欺诈与风控

  • 账号安全:银行、支付、社交平台把指纹作为“设备信任度”指标。若登录指纹突然大变(新系统、虚拟机、海外设备),可触发二次验证或冻结交易。
  • 薅羊毛/作弊识别:投票、抽奖、优惠券领取页面用指纹判断“是否同一设备反复参与”,防止批量注册、刷单。
  • 广告反欺诈:验证广告点击是否来自真实浏览器,而非自动化脚本或虚假流量农场。

多账号管理

  • 跨境电商/社媒运营:卖家或营销人员需要在一台电脑同时登录几十个Amazon、eBay、Facebook、TikTok账号。若用普通浏览器,平台会因指纹相同判定“关联店铺”并封号。指纹浏览器可为每个账号伪造独立的设备环境(分辨率、字体、Canvas、WebGL、MAC地址、IP等),实现“物理级隔离”。
  • 数据抓取与测试:爬虫或自动化测试脚本通过切换指纹模拟不同真实用户,降低被目标站点封锁的概率。

合规与隐私保护

  • 反指纹追踪:隐私插件或“高级指纹保护”功能会故意把Canvas、音频、WebGL结果做随机噪声,或统一返回常见值,削弱指纹的唯一性,减少被跨站跟踪。

JavaScript call、apply、bind 方法解析

JavaScript call、apply、bind 方法解析

在 JavaScript 中,callapplybind 都是用来**this** 改变函数执行时 指向 的核心方法,它们的核心目标一致,但使用方式、执行时机和传参形式有明显区别。

const dog = {
  name: "旺财",
  sayName() {
    console.log(this.name);
  },
  eat(food) {
    console.log(`${this.name} 在吃${food}`);
  },
  eats(food1, food2) {
    console.log(`${this.name} 在吃${food1}${food2}`);
  },
};

const cat = {
  name: "咪咪",
};
// call 会立即执行函数,并且改变 this 指向
dog.sayName.call(cat); // 输出 '咪咪'
dog.eat.call(cat, "🐟"); // 输出 '咪咪 在吃🐟'

dog.sayName.apply(cat); // 输出 '咪咪'

dog.eats.call(cat, "🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'

dog.eats.apply(cat, ["🐟", "🐔"]); // 输出 '咪咪 在吃🐟和🐔'

const boundEats = dog.eats.bind(cat);
boundEats("🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'

一、核心共性

三者的核心作用:this 手动指定函数执行时的 指向,突破函数默认的 this 绑定规则(比如对象方法的 this 原本指向对象本身,通过这三个方法可以强制指向其他对象)。

以示例中的 dog.sayName() 为例,默认执行时 this 指向 dog,但通过 call/apply/bind 可以让 this 指向 cat,从而输出 咪咪 而非 旺财

二、逐个解析

1. call

  • 执行时机立即执行 函数

  • 传参方式:第一个参数是 this 要指向的目标对象,后续参数逐个单独传递(逗号分隔)

  • 语法函数.call(thisArg, arg1, arg2, ...)

示例解析:
// this 指向 cat,无额外参数,立即执行 sayName
dog.sayName.call(cat); // 输出 '咪咪'

// this 指向 cat,额外参数 '🐟' 逐个传递,立即执行 eat
dog.eat.call(cat, "🐟"); // 输出 '咪咪 在吃🐟'

// 多参数场景:参数逐个传递,立即执行 eats
dog.eats.call(cat, "🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'

2. apply

  • 执行时机立即执行 函数(和 call 一致)

  • 传参方式:第一个参数是 this 要指向的目标对象,后续参数必须放在一个数组(或类数组)中传递

  • 语法函数.apply(thisArg, [arg1, arg2, ...])

示例解析:
// 无额外参数,数组可以为空(或不传),立即执行 sayName
dog.sayName.apply(cat); // 输出 '咪咪'

// 多参数场景:参数放在数组中传递,立即执行 eats
dog.eats.apply(cat, ["🐟", "🐔"]); // 输出 '咪咪 在吃🐟和🐔'

注意:apply 适合参数数量不固定、或参数已存在于数组中的场景(比如 Math.max.apply(null, [1,2,3]) 求数组最大值)。

3. bind

  • 执行时机不立即执行 函数,而是返回一个绑定了新 this 指向的新函数,后续需要手动调用这个新函数才会执行

  • 传参方式:第一个参数是 this 要指向的目标对象,后续参数可以提前绑定(柯里化),也可以在调用新函数时补充

  • 语法const 新函数 = 函数.bind(thisArg, arg1, arg2, ...); 新函数(剩余参数);

示例解析:
// 第一步:bind 不执行,仅绑定 this 为 cat,返回新函数 boundEats(原变量名 boundSayName 已修改)
const boundEats = dog.eats.bind(cat);

// 第二步:手动调用新函数,传递参数 '🐟' 和 '🐔',此时才执行 eats
boundEats("🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'
进阶用法:
// 提前绑定部分参数(柯里化),this 仍指向 cat
const boundEatWithFish = dog.eats.bind(cat, "🐟");
// 调用时补充剩余参数,同样输出目标结果
boundEatWithFish("🐔"); // 输出 '咪咪 在吃🐟和🐔'

三、核心区别总结

特性 call apply bind
执行时机 立即执行 立即执行 不立即执行,返回新函数
传参形式 逐个传递(逗号分隔) 数组/类数组传递 可提前绑定,也可调用时传
返回值 函数执行结果 函数执行结果 绑定 this 后的新函数

四、常见使用场景

  1. call:适用于参数数量明确、需要立即执行的场景(比如继承:Parent.call(this, arg1));

  2. apply:适用于参数是数组/类数组的场景(比如求数组最大值:Math.max.apply(null, arr));

  3. bind:适用于需要延迟执行、或需要重复使用绑定 this 后的函数的场景(比如事件回调、定时器:btn.onclick = fn.bind(obj))。

五、补充注意点

  • 如果第一个参数传 null/undefined,在非严格模式下,this 会指向全局对象(浏览器中是 window,Node 中是 global);严格模式下 thisnull/undefined

  • bind 返回的新函数不能通过 call/apply 再次修改 this 指向(bind 的绑定是永久的)。

彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析

彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析

在面试中,「手写 new 的实现」和「arguments 到底是个啥」几乎是中高级前端的必考题。
今天我们不背答案,而是把它们彻底拆开,看看 JavaScript 引擎在底层到底做了什么。

f7afe1f6c5914b92044e39cfb1e0cf81.jpg

一、new 运算符到底干了哪四件事?

当你写下这行代码时:

const p =new Person('柯基', 18);

JavaScript 引擎默默为你做了 4 件大事:

  1. 创建一个全新的空对象 {}
  2. 把这个空对象的 __proto__ 指向构造函数的 prototype
  3. 让构造函数的 this 指向这个新对象,并执行构造函数(传入参数)
  4. 自动返回这个对象(除非构造函数显式返回了一个对象)

这就是传说中的“new 的四步走”。

很多人背得滚瓜烂熟,但真正问他为什么 __proto__ 要指向 prototype?为什么不能直接 obj.prototype = Constructor.prototype?就懵了。

关键提醒(易错点!)

// 错误写法!千万别这样写!
obj.prototype = Constructor.prototype;

// 正确写法
obj.__proto__ = Constructor.prototype;

因为 prototype 是构造函数才有的属性,实例对象根本没有 prototype
所有对象都有 __proto__(非标准,已被 [[Prototype]] 内部槽替代,现代浏览器用 Object.getPrototypeOf),它是用来查找原型链的。

手撕一个完美版 new

function myNew(Constructor, ...args) {
    // 1. 创建一个空对象
    const obj = Object.create(Constructor.prototype);
    
    // 2 & 3. 执行构造函数,绑定 this,并传入参数
    const result = Constructor.apply(obj, args);
    
    // 4. 如果构造函数返回的是对象,则返回它,否则返回我们创建的 obj
    return result instanceof Object ? result : obj;
}

为什么这里用 Object.create(Constructor.prototype) 而不是 new Object() + 设置 __proto__

因为 Object.create(proto) 是最纯粹、最推荐的建立原型链的方式,比手动操作 __proto__ 更现代、更安全。

验证一下

function Dog(name, age) {
    this.name = name;
    this.age = age;
}
Dog.prototype.bark = function() {
    console.log(`${this.name} 汪汪汪!`);
};

const dog1 = new Dog('小黑', 2);
const dog2 = myNew(Dog, '大黄', 3);

dog1.bark(); // 小黑 汪汪汪!
dog2.bark(); // 大黄 汪汪汪!
console.log(dog2 instanceof Dog); // true
console.log(Object.getPrototypeOf(dog2) === Dog.prototype); // true

完美复刻!

二、arguments 是个什么鬼?

你可能写过无数次函数,却不知道 arguments 到底是个啥玩意儿。

function add(a, b, c) {
    console.log(arguments); 
    // Arguments(5) [1, 2, 3, 4, 5, callee: ƒ, Symbol(Symbol.iterator): ƒ]
}
add(1,2,3,4,5);

打印出来长得像数组,但其实不是!

类数组(Array-like)的三大特征

  1. length 属性
  2. 可以用数字索引访问 arguments[0]、arguments[1]...
  3. 不是真正的数组,没有 map、reduce、forEach 等方法

经典面试题:怎么把 arguments 变成真数组?

5 种方式,从老到新:

function test() {
    // 方式1:Array.prototype.slice.call(arguments)
    const arr1 = Array.prototype.slice.call(arguments);
    
    // 方式2:[...arguments] 展开运算符(最优雅)
    const arr2 = [...arguments];
    
    // 方式3:Array.from(arguments)
    const arr3 = Array.from(arguments);
    
    // 方式4:用 for 循环 push(性能最好,但写法古老)
    const arr4 = [];
    for(let i = 0; i < arguments.length; i++) {
        arr4.push(arguments[i]);
    }
    
    // 方式5:Function.prototype.apply 魔术(了解即可)
    const arr5 = Array.prototype.concat.apply([], arguments);
}

推荐顺序:[...arguments] > Array.from() > 手写 for 循环

arguments 和箭头函数的恩怨情仇(超级易错!)

const fn = () => {
    console.log(arguments); // ReferenceError!
};
fn(1,2,3);

箭头函数没有自己的 arguments!它会往上层作用域找。

这是因为箭头函数没有 [[Call]] 内部方法,所以也没有 arguments 对象。

arguments.callee 已经死了

以前可以这样写递归:

// 老黄历(严格模式下报错,已废弃)
function factorial(n) {
    if (n <= 1) return 1;
    return n * arguments.callee(n - 1);
}

现在请用命名函数表达式:

const factorial = function self(n) {
    if (n <= 1) return 1;
    return n * self(n - 1);
};

三、把所有知识点串起来:实现一个支持任意参数的 sum 函数

function sum() {
    // 方案1:用 reduce(推荐)
    return [...arguments].reduce((pre, cur) => pre + cur, 0);
    
    // 方案2:经典 for 循环(性能最好)
    // let total = 0;
    // for(let i = 0; i < arguments.length; i++) {
    //     total += arguments[i];
    // }
    // return total;
}

console.log(sum(1,2,3,4,5)); // 15
console.log(sum(10, 20));    // 30
console.log(sum());          // 0

四、总结:new 和 arguments 的灵魂考点

考点 正确答案 & 易错点提醒
new 做了哪几件事? 4 步:创建对象 → 链接原型 → 绑定 this → 返回对象
obj.proto 指向谁? Constructor.prototype(不是 Constructor 本身!)
手写 new 推荐方式 Object.create(Constructor.prototype) + apply
arguments 是数组吗? 不是!是类数组对象
如何转真数组? [...arguments] 最优雅
箭头函数有 arguments 吗? 没有!会抛错
arguments.callee 已废弃,严格模式下报错

fc962ce0cd306c49bc54248e80437e81.jpg

几个细节知识点

1.arguments 到底是什么类型的数据?

通过Object.prototype.toString.call 打印出 [object Arguments]

arguments 是一个 真正的普通对象(plain object),而不是数组! 它的内部类([[Class]])是 "Arguments",这是一个 ECMAScript 规范里专门为函数参数创建的特殊内置对象

为什么它长得像数组?

因为 JS 引擎在创建 arguments 对象时,特意给它加了这些“伪装属性”:

JavaScript

arguments.length = 参数个数
arguments[0], arguments[1]... = 对应的实参
arguments[Symbol.iterator] = Array.prototype[Symbol.iterator]  // 所以可以 for...of

这就是传说中的“类数组(array-like object)”。

2.apply 不仅可以接受数组,还可以接受类数组,底层逻辑是什么?

apply 的第二个参数只要求是一个 “Array-like 对象” 或 “类数组对象”,甚至可以是任何有 length 和数字索引的对象!

JavaScript

// 官方接受的类型统称为:arguments object 或 array-like object
func.apply(thisArg, argArray)
能传什么?疯狂测试!

JavaScript

function sum() {
    return [...arguments].reduce((a,b)=>a+b);
}

// 这些全都可以被 apply 正确处理!
sum.apply(null, [1,2,3,4,5]);                    // 真数组
sum.apply(null, arguments);                     // arguments 对象
sum.apply(null, {0:1, 1:2, 2:3, length: 3});     // 自定义类数组对象
sum.apply(null, "abc");                         // 字符串!也是类数组
sum.apply(null, new Set([1,2,3]));              // 不行!Set 没有 length 和索引
sum.apply(null, {length: 5});                    // 得到 [undefined×5]

所以只要满足:

  • 有 length 属性(可转为非负整数)
  • 有 0, 1, 2... 这些数字属性

就能被 apply 正确展开!

3.[].shift.call(arguments) 到底是什么鬼?为什么能取到构造函数?

这行代码堪称“手写 new 的经典黑魔法”:

JavaScript

function myNew() {
    var Constructor = [].shift.call(arguments);
    // 现在 Constructor 就是 Person,arguments 变成了剩余参数
}
myNew(Person, '张三', 18);
一步步拆解:

JavaScript

[].shift           // Array.prototype.shift 方法
.call(arguments)   // 把 arguments 当作 this 调用 shift

shift 的作用:删除并返回数组的第一个元素

因为 arguments 是类数组,所以 Array.prototype.shift 能作用于它!

执行过程:

JavaScript

// 初始
arguments = [Person函数, '张三', 18]

// [].shift.call(arguments) 执行后:
返回 Person 函数
arguments 变成 ['张三', 18]   // 原地被修改了!

归根结底:这利用了类数组能借用数组方法的特性

所以这行代码一箭三雕:

  1. 取出构造函数
  2. 把 arguments 变成真正的剩余参数数组
  3. 不需要写 arguments[0], arguments.slice(1) 这种丑代码

最后送你一份面试加分答案模板

面试官:请手写实现 new 运算符

function myNew(Constructor, ...args) {
    // 1. 用原型创建空对象(最推荐)
    const obj = Object.create(Constructor.prototype);
    
    // 2. 执行构造函数,绑定 this
    const result = Constructor.apply(obj, args);
    
    // 3. 返回值处理(常被忽略!)
    return typeof result === 'object' && result !== null ? result : obj;
}

面试官:那 arguments 呢?

// 快速转换为真数组
const realArray = [...arguments];
// 或者
const realArray = Array.from(arguments);

一句「Object.create 是建立原型链最纯粹的方式」就能让面试官眼前一亮。

搞懂了 new 和 arguments,你就已经站在了 JavaScript 底层机制的肩膀上。

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

一、先理清核心概念

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

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

二、事件循环的执行流程

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

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

三、代码分析

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

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

// 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 屏幕,无阻塞) :输出顺序是按上方写的先后顺序执行的:

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

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

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

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

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

总结

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

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

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

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

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 与多个宏任务对比

// 宏任务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 动画),因为它能保证在渲染前执行,避免「布局抖动」:

// 用 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 节流

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 节流

// 传统 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 回调」。

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 防抖

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,避免依赖浏览器渲染机制。
❌