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道找错题