如何手写 call、apply 和 bind?
简化版
call、apply 都会立即调用函数并指定 this,区别是 call 接收参数列表,apply 接收数组或类数组;bind 不立即调用,而是返回绑定了 this 和前置参数的新函数。面试手写的重点是参数转发、返回值、异常清理,以及 bind 被 new 调用时忽略绑定的 this。
详细版
临时挂载函数能演示普通对象方法调用时的 this 绑定,但无法完整复刻严格模式、原始值接收者和引擎内部槽。更准确的教学实现可用 Reflect.apply 转发调用:
function myCall(fn, thisArg, ...args) {
return Reflect.apply(fn, thisArg, args)
}
function myApply(fn, thisArg, args) {
return Reflect.apply(fn, thisArg, args == null ? [] : Array.from(args))
}
bind 还要区分普通调用和构造调用。构造调用时应忽略绑定对象,并把前置参数放在运行时参数之前。手写版必须明确:函数的 name、length、内部槽和原生错误细节通常不能完全复刻。
完整版教学
一、先比较三者的调用合同
| API | 是否立即执行 | 参数形式 | 返回值 |
|---|---|---|---|
fn.call(ctx, a, b) | 是 | 逐个传入 | 原函数返回值 |
fn.apply(ctx, [a, b]) | 是 | 数组或类数组 | 原函数返回值 |
fn.bind(ctx, a) | 否 | 可预置部分参数 | 新的绑定函数 |
function greet(prefix, suffix) {
return `${prefix}${this.name}${suffix}`
}
const user = { name: 'Ada' }
greet.call(user, 'Hi ', '!')
greet.apply(user, ['Hi ', '!'])
greet.bind(user, 'Hi ')('!')
三次结果都是 Hi Ada!,但第三次创建了一个可复用函数。
“把函数挂到对象上再调用”只适合解释方法调用规则,不是完整 polyfill。原生调用能保留严格模式下精确的
thisArg,用户态临时挂载会先把原始值装箱,也无法挂到null上。
二、call 的教学版与异常安全
经典写法用唯一 Symbol 避免覆盖同名属性,并用 try/finally 保证被调函数抛错时也清理临时属性:
function educationalCall(fn, thisArg, ...args) {
if (typeof fn !== 'function') throw new TypeError('fn must be callable')
const receiver = thisArg == null ? globalThis : Object(thisArg)
const key = Symbol('temporary method')
receiver[key] = fn
try {
return receiver[key](...args)
} finally {
delete receiver[key]
}
}
这个版本能教学,但有明确差异:严格函数被原生 call(null) 调用时收到 null,这里却收到 globalThis;严格函数收到数字 1 时,原生保留数字,这里会收到包装对象。
若允许使用标准底层转发工具,Reflect.apply(fn, thisArg, args) 才能正确保留这些调用语义。
三、apply 不只是参数写成数组
规范上的 apply 第二个参数可以是数组或类数组;null、undefined 表示没有参数。可写成:
function educationalApply(fn, thisArg, args) {
if (typeof fn !== 'function') throw new TypeError('fn must be callable')
if (args == null) return Reflect.apply(fn, thisArg, [])
if (typeof args !== 'object' && typeof args !== 'function') {
throw new TypeError('args must be array-like')
}
return Reflect.apply(fn, thisArg, Array.from(args))
}
例如 {0: 'a', 1: 'b', length: 2} 能被 Array.from 转成两个参数。教学实现使用 Array.from 会先创建数组,原生内部过程及报错时机未必完全相同,因此仍不应称为字节级等价 polyfill。
四、bind 的普通调用与参数合并
普通调用时,后来怎样调用绑定函数都不能替换已绑定的 this;再次 bind 也只能继续追加参数:
function educationalBind(fn, thisArg, ...boundArgs) {
if (typeof fn !== 'function') throw new TypeError('fn must be callable')
function bound(...callArgs) {
const args = boundArgs.concat(callArgs)
if (new.target) {
return Reflect.construct(fn, args, new.target)
}
return Reflect.apply(fn, thisArg, args)
}
if (fn.prototype && typeof fn.prototype === 'object') {
bound.prototype = Object.create(fn.prototype, {
constructor: { value: bound, writable: true, configurable: true },
})
}
return bound
}
boundArgs 永远在 callArgs 前面。比如 add.bind(null, 1)(2) 最终调用 add(1, 2)。
五、为什么 new bound() 必须单独处理
原生绑定函数作为构造器调用时会忽略绑定的 this,但仍使用预置参数:
function Person(name, age) {
this.name = name
this.age = age
}
const BoundPerson = Person.bind({ ignored: true }, 'Ada')
const person = new BoundPerson(18)
person.name // 'Ada'
person.age // 18
person instanceof Person // true
Reflect.construct(fn, args, new.target) 使用真正的构造语义,能处理构造函数显式返回对象、类构造器和 new.target。若目标不可构造,例如箭头函数,构造时自然抛出 TypeError。
但原生绑定函数没有自己的 prototype 属性,本文为教学和 instanceof 直觉设置了原型,因此它与原生仍有差异,尤其不能据此模拟 class X extends boundFn 的全部行为。
六、严格模式和原生函数的不可模拟部分
| 场景 | 原生行为 | 临时挂载近似版 |
|---|---|---|
严格函数 + null | this === null | 被替换为全局对象 |
| 严格函数 + 数字 | 保留原始数字 | 数字被装箱 |
bind 的 name | 通常带 bound 前缀 | 不自动一致 |
bind 的 length | 扣除预置参数并截断到 0 | 不自动一致 |
引擎通过 [[Call]]、[[Construct]]、[[BoundTargetFunction]] 等内部机制实现这些能力,普通 JavaScript 无法创建完全相同的内部槽。面试时能准确指出不可模拟之处,比堆砌一个看似完整的 polyfill 更专业。
七、常见误区与追问
- 误区:给目标对象挂一个普通属性就安全。 可能覆盖已有属性,而且函数抛错后若没有
finally就会留下脏数据。 - 误区:
bind返回箭头函数最省事。 箭头函数不可构造,会直接丢失new bound()这一关键语义。 - 误区:
new bound()仍应使用绑定的对象。 构造调用会忽略绑定的this,只保留预置参数。 - 追问:
call和apply的性能谁更好? 现代引擎中不应凭接口形式下结论,应按参数来源和可读性选择并用基准测试验证热点。 - 追问:为什么要用
try/finally? 被调用函数可能抛异常,finally能保证临时 Symbol 属性仍被删除。 - 追问:对已经 bind 的函数再次 bind 会怎样? 新的
thisArg不生效,但新的预置参数会追加到已有参数之后。 - 追问:手写版为什么推荐
Reflect.apply? 它直接表达“以指定接收者和参数列表调用”,避免临时挂载造成的严格模式和装箱偏差。
八、加强记忆
- call:立即调用,参数逐个传。
- apply:立即调用,参数来自数组或类数组。
- bind:返回新函数,绑定接收者并预置参数。
- 构造:
new bound()忽略绑定对象,但保留预置参数。 - 清理:临时挂载必须用 Symbol 和
try/finally,且仍只是近似教学版。 - 边界:原生内部槽、严格
this、name、length等无法靠几行用户态代码完整复制。