call、apply 与 bind

区分 call、apply 与 bind 的调用时机、实参形式和绑定行为,并正确处理回调标识、类数组对象与构造调用。

难度 进阶 时长 标准深度约 13分钟
版本 Node 24
what

call()apply() 立即调用函数,并显式提供 thisbind() 保存 this 和可选的前置实参,返回稍后调用的新函数。

trap

apply() 接受类数组对象,展开语法要求可迭代对象;每次 bind() 还会创建不同的函数标识,不能在注册与移除时各绑定一次。

fix

根据实参来源选择 call()apply()Reflect.apply();需要长期回调时只绑定一次并保存结果,同时单独测试普通调用与构造调用。

是什么,为什么存在

call()apply()bind() 都是 Function.prototype 上的方法,用来控制一个可调用值怎样接收 this 和实参。前两个方法立即执行目标函数,最后一个方法返回新函数。这是三者最先要分清的边界。

普通方法调用从点号或方括号左侧取得接收者,例如 account.close() 中的 thisaccount。函数被赋值、解构或作为回调传递后,只剩函数值,原对象不会自动跟随。 显式 this 绑定(explicit this binding) 让调用方在不修改对象的情况下指定接收者。

call() 适合实参数量和位置已经写在调用点的情况。apply() 适合实参已经装在数组或类数组对象中的情况。bind() 适合稍后调用、稳定回调标识,或用 偏应用(partial application) 预先填入前几个实参。

这三个方法不是 this 的全部规则。箭头函数没有自己的 this,类构造函数不能作为普通函数调用,而严格函数与非严格函数对 nullundefined 和原始值接收者的处理也不同。学习这些 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) 。传入 nullundefined 表示没有实参;其他非对象值会抛出 TypeError

调用 fn.bind(receiver, ...leadingArgs) 时,目标函数不会执行。运行时创建一个绑定函数,内部保存目标函数、receiver 和前置实参。以后调用绑定函数时,前置实参排在本次实参之前;再次对它调用 call()apply() 不能替换已保存的 this

每次 bind() 都创建新的函数对象,即使目标、接收者和实参完全相同。这个 回调标识(callback identity) 差异会影响事件移除、订阅取消、缓存键和去重。需要对应的注册与清理操作时,绑定结果本身就是必须保存的资源。

接收者与实参是两个维度

thisArg 不会成为第一个普通形参,普通实参也不会自动成为 thisbind(null, currency) 常用于只预置实参的函数;如果目标函数从不读取 thisnull 只是一个明确表示「不使用接收者」的占位值。

严格普通函数会原样收到 call()apply()bind() 提供的 thisArg。非严格普通函数会把 nullundefined 替换为 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() 则保存接收者以及前两个实参,等待最后一个实参。

three_modes.js
'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() 与展开语法的协议差异直接暴露出来。

argument_protocols.js
'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|mail

arrayLike 可供 apply() 按索引读取,却不能被展开,因此第二次调用在目标函数执行前抛出 TypeErrorSet 的情况正好相反:apply() 看不到 length,于是提供零个实参;展开语法则从迭代器取得两个值。

apply() 机械替换为展开语法前,应先确定调用方承诺的是数组、类数组对象还是可迭代对象。若 API 由自己设计,使用真正的数组通常能减少这种协议歧义。

保存一次绑定后的回调

订阅表按函数对象标识添加和移除监听器。Reporter 在构造时绑定一次,因此注册与移除能使用同一个引用;临时再绑定一次则无法匹配已有监听器。

stable_callback.js
'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' 实参。

bound_constructor.js
'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
false

new BoundSession('42') 忽略 fallback,却保留前置实参。instanceof 会通过绑定目标检查 Session.prototype。绑定函数没有自己的 prototype 属性,所以它能被 new 调用,却不能直接作为 extends 的基类。

这里用普通函数同时演示普通调用和构造调用,不代表一个公开 API 应同时支持两者。生产代码通常应以 class、命名工厂或明确文档固定一种调用形式。

陷阱

call()apply() 串联类构造

修复方法: 类继承使用 extendssuper()。元编程确实需要转发构造时使用 Reflect.construct(),并分别验证原型、返回对象和 new.target

apply() 输入一律当作可展开值

修复方法: 写清输入协议,并在边界转换成真正数组。实参数量可能很大时,不要把整个集合一次展开为函数实参;改用循环、reduce() 或专门接收集合的 API。

在注册与清理位置分别绑定

修复方法: 在所有者初始化时绑定一次,把结果保存到语义明确的字段,并在完整的「注册、触发、清理、再次触发」测试中复用这个引用。

认为再次绑定会替换 this

修复方法: 沿绑定链分别记录目标、首次绑定的接收者、每一批前置实参和调用时实参。需要另一个接收者时,从未绑定的原始函数创建新函数。

依赖非严格模式的全局替换

修复方法: 用严格语义测试,并显式传递业务对象。确实需要全局对象时直接写 globalThis,不要让 call(null)bind(null) 暗中决定它。

把简化实现当作原生替代品

修复方法: 面试练习必须明确列出没有实现的语义,生产代码使用原生方法或 Reflect.apply()。不要修改 Function.prototype 来发布自定义版本,也不要把通过几个样例等同于规范兼容。

深入 绑定函数不只是包装器

绑定函数不只是包装器

规范把绑定函数定义为特殊函数对象。它保存 [[BoundTargetFunction]][[BoundThis]][[BoundArguments]] 三项内部状态。普通调用先拼接已保存实参与本次实参,再以已保存接收者调用目标;这是可观察语义,不要求引擎真的创建一个源码可见的闭包。

再次绑定会以现有绑定函数为新目标。因此最外层的 thisArg 到达已有绑定层时被忽略,而每一层的实参仍按从内到外的顺序拼接。这个行为解释了为什么「不能重新绑定 this」与「可以继续偏应用实参」能够同时成立。

namelength 与自有属性

原生 bind() 会根据目标设置新函数的 namelength。名称以 "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 BoundSessionsession instanceof Session 都为 true 的原因。自定义 Symbol.hasInstance 或代理仍可能影响最终行为,因此不要把这个结果推广到任意包装器。

通用调用与 API 设计

call() 本身也是依赖接收者的方法:Function.prototype.callthis 是它要调用的函数。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 是对象;传入 nullundefined 或原始值都会抛出 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 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?