call() 和 apply() 立即调用函数,并显式提供 this;bind() 保存 this 和可选的前置实参,返回稍后调用的新函数。
apply() 接受类数组对象,展开语法要求可迭代对象;每次 bind() 还会创建不同的函数标识,不能在注册与移除时各绑定一次。
根据实参来源选择 call()、apply() 或 Reflect.apply();需要长期回调时只绑定一次并保存结果,同时单独测试普通调用与构造调用。
是什么,为什么存在
call()、apply() 和 bind() 都是 Function.prototype 上的方法,用来控制一个可调用值怎样接收 this 和实参。前两个方法立即执行目标函数,最后一个方法返回新函数。这是三者最先要分清的边界。
普通方法调用从点号或方括号左侧取得接收者,例如 account.close() 中的 this 是 account。函数被赋值、解构或作为回调传递后,只剩函数值,原对象不会自动跟随。 显式 this 绑定(explicit this binding) 让调用方在不修改对象的情况下指定接收者。
call() 适合实参数量和位置已经写在调用点的情况。apply() 适合实参已经装在数组或类数组对象中的情况。bind() 适合稍后调用、稳定回调标识,或用 偏应用(partial application) 预先填入前几个实参。
这三个方法不是 this 的全部规则。箭头函数没有自己的 this,类构造函数不能作为普通函数调用,而严格函数与非严格函数对 null、undefined 和原始值接收者的处理也不同。学习这些 API 的重点是识别它们能控制什么,以及目标函数仍保留哪些语义。
| 方法 | 是否立即执行 | 实参形式 | 结果 |
|---|---|---|---|
fn.call(receiver, a, b) | 是 | 逐个实参 | 目标函数的返回值 |
fn.apply(receiver, args) | 是 | 数组或类数组对象 | 目标函数的返回值 |
fn.bind(receiver, a, b) | 否 | 可选前置实参 | 新的绑定函数 |
这三种操作都要求目标可调用。对数字或普通对象借用 Function.prototype.call 时,接收者规则还没机会生效,运行时就会因目标不可调用而失败。
它们都不会检查接收者是否拥有目标需要的字段。这项契约仍由目标函数负责,因此显式接收者可能在语法上合法,在业务语义上却错误。
工作原理
调用 fn.call(receiver, ...args) 时,call() 先确认 fn 可调用,再以 receiver 作为目标函数的 this,并按原顺序传入其余实参。call() 的返回值和异常都直接来自目标函数。它不会把目标函数永久附加到 receiver 上。
调用 fn.apply(receiver, args) 时,接收者规则与 call() 相同,但第二个参数先被转换为实参列表。这个值可以是数组,也可以是具有 length 和从 0 开始的索引属性的 类数组对象(array-like object) 。传入 null 或 undefined 表示没有实参;其他非对象值会抛出 TypeError。
调用 fn.bind(receiver, ...leadingArgs) 时,目标函数不会执行。运行时创建一个绑定函数,内部保存目标函数、receiver 和前置实参。以后调用绑定函数时,前置实参排在本次实参之前;再次对它调用 call() 或 apply() 不能替换已保存的 this。
每次 bind() 都创建新的函数对象,即使目标、接收者和实参完全相同。这个 回调标识(callback identity) 差异会影响事件移除、订阅取消、缓存键和去重。需要对应的注册与清理操作时,绑定结果本身就是必须保存的资源。
接收者与实参是两个维度
thisArg 不会成为第一个普通形参,普通实参也不会自动成为 this。bind(null, currency) 常用于只预置实参的函数;如果目标函数从不读取 this,null 只是一个明确表示「不使用接收者」的占位值。
严格普通函数会原样收到 call()、apply() 或 bind() 提供的 thisArg。非严格普通函数会把 null 和 undefined 替换为 globalThis,并包装原始值。业务代码不应依赖这种转换,因为模块和类方法使用严格语义。
箭头函数会忽略这些 API 提供的 thisArg,因为它从创建位置读取词法 this。这些方法仍能调用箭头函数并传递实参,bind() 也仍能预置实参。绑定不会把箭头函数变成可构造函数。
apply() 与展开语法
fn.apply(receiver, values) 和 fn.call(receiver, ...values) 在 values 为数组时常得到相同结果,但它们的输入协议不同。apply() 读取 length 与索引属性;展开语法读取 Symbol.iterator。一个值可以只满足其中一种协议。
Reflect.apply(fn, receiver, values) 把目标函数作为显式参数,而不是从 fn.apply 属性取得方法。它适合代理、包装器和通用调用工具,也能避开目标自身名为 apply 的属性。它和 Function.prototype.apply() 一样要求第三个参数是类数组对象,但不能省略该参数。
示例
下面四个示例依次展示立即调用与延迟调用、两种实参协议、稳定回调标识和绑定构造函数。所有输出都由本地 Node 24 运行对应文件得到。
同一个函数的三种调用方式
目标函数同时读取接收者和普通实参。call() 与 apply() 立即返回字符串,bind() 则保存接收者以及前两个实参,等待最后一个实参。
'use strict';
function price(currency, amount, fee) {
return `${this.region} ${currency}${amount + fee}`;
}
const store = { region: 'EU' };
console.log(price.call(store, 'EUR ', 20, 2));
console.log(price.apply(store, ['EUR ', 20, 2]));
const addFee = price.bind(store, 'EUR ', 20);
console.log(addFee(2));
console.log(addFee === price);EU EUR 22
EU EUR 22
EU EUR 22
false三个调用得到相同业务字符串,但过程不同。前两个表达式已经完成调用;addFee 是可复用的新函数,还需要接收 fee。最后一行证实它和 price 不是同一个函数对象。
bind() 只预置前置实参,不能留下中间空位。若接口需要先固定 amount、以后再提供 currency,应改写形参顺序,使用命名选项对象,或写一个明确的包装函数。
区分类数组对象与可迭代对象
自定义 arrayLike 有索引和 length,但没有迭代器;Set 有迭代器,但没有数组式 length。这两个值把 apply() 与展开语法的协议差异直接暴露出来。
'use strict';
function label(first, second) {
return `${this.prefix}:${first}|${second}`;
}
const context = { prefix: 'queue' };
const arrayLike = { 0: 'fast', 1: 'bulk', length: 2 };
console.log(label.apply(context, arrayLike));
try {
console.log(label.call(context, ...arrayLike));
} catch (error) {
console.log(error.name);
}
const iterable = new Set(['audit', 'mail']);
console.log(label.apply(context, iterable));
console.log(label.call(context, ...iterable));queue:fast|bulk
TypeError
queue:undefined|undefined
queue:audit|mailarrayLike 可供 apply() 按索引读取,却不能被展开,因此第二次调用在目标函数执行前抛出 TypeError。Set 的情况正好相反:apply() 看不到 length,于是提供零个实参;展开语法则从迭代器取得两个值。
把 apply() 机械替换为展开语法前,应先确定调用方承诺的是数组、类数组对象还是可迭代对象。若 API 由自己设计,使用真正的数组通常能减少这种协议歧义。
保存一次绑定后的回调
订阅表按函数对象标识添加和移除监听器。Reporter 在构造时绑定一次,因此注册与移除能使用同一个引用;临时再绑定一次则无法匹配已有监听器。
'use strict';
class TopicBus {
#listeners = new Set();
on(listener) {
this.#listeners.add(listener);
}
off(listener) {
this.#listeners.delete(listener);
}
emit(value) {
for (const listener of this.#listeners) listener(value);
}
get size() {
return this.#listeners.size;
}
}
class Reporter {
constructor(label) {
this.label = label;
this.onValue = this.onValue.bind(this);
}
onValue(value) {
console.log(`${this.label}:${value}`);
}
}
const bus = new TopicBus();
const reporter = new Reporter('R');
bus.on(reporter.onValue);
bus.emit('open');
bus.off(reporter.onValue);
console.log(bus.size);
bus.on(reporter.onValue);
bus.off(reporter.onValue.bind(reporter));
console.log(bus.size);R:open
0
1第一次 off() 删除了准确的引用,所以监听器数量归零。第二次 bind() 产生一个包装在已绑定函数外的新函数,接收者仍然是 reporter,但标识已经不同,因此原监听器留在集合中。
浏览器的 removeEventListener()、Node 的 EventEmitter.off() 以及许多订阅库都有相同要求。还要保存注册选项或取消函数等 API 特有信息;只保留目标方法通常不够。
用绑定函数进行构造
目标函数可构造时,它的绑定函数也可构造。普通调用使用保存的对象,new 调用创建自己的接收者,但两种调用都会收到预置的 'eu' 实参。
'use strict';
function Session(region, id) {
this.key = `${region}-${id}`;
}
const fallback = { key: 'unset' };
const BoundSession = Session.bind(fallback, 'eu');
BoundSession('ops');
const session = new BoundSession('42');
console.log(fallback.key);
console.log(session.key);
console.log(session instanceof Session);
console.log(session instanceof BoundSession);
console.log(BoundSession.name);
console.log(BoundSession.length);
console.log(Object.hasOwn(BoundSession, 'prototype'));eu-ops
eu-42
true
true
bound Session
1
falsenew BoundSession('42') 忽略 fallback,却保留前置实参。instanceof 会通过绑定目标检查 Session.prototype。绑定函数没有自己的 prototype 属性,所以它能被 new 调用,却不能直接作为 extends 的基类。
这里用普通函数同时演示普通调用和构造调用,不代表一个公开 API 应同时支持两者。生产代码通常应以 class、命名工厂或明确文档固定一种调用形式。
陷阱
用 call() 或 apply() 串联类构造
修复方法: 类继承使用 extends 和 super()。元编程确实需要转发构造时使用 Reflect.construct(),并分别验证原型、返回对象和 new.target。
把 apply() 输入一律当作可展开值
修复方法: 写清输入协议,并在边界转换成真正数组。实参数量可能很大时,不要把整个集合一次展开为函数实参;改用循环、reduce() 或专门接收集合的 API。
在注册与清理位置分别绑定
修复方法: 在所有者初始化时绑定一次,把结果保存到语义明确的字段,并在完整的「注册、触发、清理、再次触发」测试中复用这个引用。
认为再次绑定会替换 this
修复方法: 沿绑定链分别记录目标、首次绑定的接收者、每一批前置实参和调用时实参。需要另一个接收者时,从未绑定的原始函数创建新函数。
依赖非严格模式的全局替换
修复方法: 用严格语义测试,并显式传递业务对象。确实需要全局对象时直接写 globalThis,不要让 call(null) 或 bind(null) 暗中决定它。
把简化实现当作原生替代品
修复方法: 面试练习必须明确列出没有实现的语义,生产代码使用原生方法或 Reflect.apply()。不要修改 Function.prototype 来发布自定义版本,也不要把通过几个样例等同于规范兼容。
绑定函数不只是包装器
规范把绑定函数定义为特殊函数对象。它保存 [[BoundTargetFunction]]、[[BoundThis]] 和 [[BoundArguments]] 三项内部状态。普通调用先拼接已保存实参与本次实参,再以已保存接收者调用目标;这是可观察语义,不要求引擎真的创建一个源码可见的闭包。
再次绑定会以现有绑定函数为新目标。因此最外层的 thisArg 到达已有绑定层时被忽略,而每一层的实参仍按从内到外的顺序拼接。这个行为解释了为什么「不能重新绑定 this」与「可以继续偏应用实参」能够同时成立。
name、length 与自有属性
原生 bind() 会根据目标设置新函数的 name 与 length。名称以 "bound " 为前缀;长度通常是目标声明形参数量减去已绑定实参数量,并且最低为 0。这些值适合诊断,但默认参数、剩余参数和多层绑定会让它们不等于业务上的必需实参数量。
绑定函数沿用目标函数对象的内部原型链,却不会复制目标的自有静态属性。给构造函数设置 Session.kind = 'stateful' 后,Session.bind(...) 的结果不会自动拥有 kind。公共 API 若依赖函数对象属性,必须明确转发或避免隐藏在绑定结果后面。
绑定函数也没有普通可构造函数那样的自有 prototype 属性。若目标可构造,绑定函数仍有内部构造能力,new 会转发到目标;但 class Child extends BoundBase {} 会因为基类的 prototype 无效而抛出 TypeError。
构造与 instanceof
构造绑定函数时,新实例替代已保存的 this,已保存实参仍然位于最前。直接通过绑定函数构造时,目标观察到的 new.target 是原目标,绑定层对该值透明。若目标显式返回对象,该对象仍可替代自动创建的实例。
instanceof 以绑定函数为右操作数时,会转向其绑定目标并读取目标的 prototype。这就是示例中的 session instanceof BoundSession 和 session instanceof Session 都为 true 的原因。自定义 Symbol.hasInstance 或代理仍可能影响最终行为,因此不要把这个结果推广到任意包装器。
通用调用与 API 设计
call() 本身也是依赖接收者的方法:Function.prototype.call 的 this 是它要调用的函数。Function.prototype.call.bind(Array.prototype.slice) 利用两层绑定创建「未绑定方法」工具,让调用方写 slice(arrayLike)。这种技巧有时适合封装兼容接口,但名字必须说明接收者已经变成普通实参。
现代内置 API 经常提供更直接的替代形式,例如自有属性检查可使用 Object.hasOwn(object, key)。直接 API 能避免读者同时追踪两层 this。没有合适替代形式时,应把通用调用封装一次,并用普通调用和异常输入测试其契约。
Reflect.apply() 的边界
Reflect.apply(target, thisArgument, argumentsList) 适合目标函数本身可能定义 apply 属性的情况,也适合把调用作为数据处理的元编程代码。它首先显式检查 target 是否可调用,并要求 argumentsList 是对象;传入 null、undefined 或原始值都会抛出 TypeError。
与 target.apply(receiver, args) 相比,Reflect.apply() 不会读取可被覆盖的 target.apply。与 Function.prototype.apply.call(target, receiver, args) 相比,它更直接地表达目标、接收者和实参列表。但它不会验证领域参数,也不会让一次超大实参列表变得安全。
求值顺序与异常
在目标函数开始执行前,JavaScript 已经求值目标表达式、接收者表达式和实参表达式。apply() 与 Reflect.apply() 还要从类数组对象读取 length 和各个索引属性。getter、代理陷阱或迭代器因此可能在目标函数之前产生副作用或抛出异常。
稀疏类数组对象中的缺失索引会成为 undefined 实参,而不会被跳过。展开可迭代对象则由迭代器决定产生多少个值;它没有「索引缺失」的概念。审查调用包装器时,需要把实参收集阶段与目标执行阶段分开。
| 失败位置 | 目标是否开始执行 | 典型原因 |
|---|---|---|
取得或调用被覆盖的 target.apply | 否 | 属性不是函数或 getter 抛错 |
| 收集调用实参 | 否 | 非对象类数组输入、getter 抛错或迭代器失败 |
| 目标函数体 | 是 | 参数校验、业务错误或显式抛出 |
call()、apply()、bind() 和 Reflect.apply() 都不会吞掉目标抛出的异常。包装器若捕获异常,应根据自己的边界决定是否补充上下文并用 cause 保留原错误;不要把失败转换成看似成功的 undefined。
偏应用与接口形状
bind() 只能从左侧预置连续实参。它适合把 format(locale, options, value) 变成只接收 value 的函数,前提是形参顺序本来就支持这种使用方式。需要占位符、按名称填值或改变实参顺序时,明确的包装函数通常更容易维护。
偏应用和柯里化不是同义词。bind() 一次预置零个或多个实参,返回的函数在调用时可以接收其余全部实参;柯里化通常把多参数函数系统地转换为一连串调用。需要后者时,应把约定留给专门工具或相关主题。
标识、可达性与清理
绑定函数会保持目标函数、绑定接收者和所有前置实参可达。长期存在的事件源如果保存了绑定回调,也就可能延长整个服务实例、请求上下文或大型配置对象的生命周期。问题不在 bind() 本身,而在注册关系没有明确所有者和终止点。
清理应由建立注册关系的组件负责,并保留 API 要求的完整匹配信息。除了函数标识,某些 API 还要求事件名称、捕获选项或订阅令牌一致。测试应在清理后再次触发来源,确认没有调用,而不是依赖垃圾回收时机。
测试调用包装器
测试不能只断言返回值。还要让目标记录实际 this、实参数量和顺序,才能发现接收者与首个普通实参被混淆,或类数组对象被错误截断。
至少加入一次目标抛错和一次实参收集抛错。两者都应保留原异常,同时只有前者能观察到目标函数已经开始执行。
绑定回调需要生命周期测试。连续注册两次、触发一次、清理一次再触发,能够同时暴露重复工作和函数标识不匹配。
若绑定目标可以构造,再增加一组 new 调用。普通调用通过并不能证明绑定接收者、原型关系和显式对象返回在构造路径上正确。
延伸阅读
5个问题 · 1 道输出预测题 · 1 道找错题