JavaScript 中 call、apply、bind 的区别与实现
call、apply、bind 都是 JavaScript 中用于改变函数执行时 this 指向的方法,但它们在用法和返回值上有重要区别。
JavaScript 中 call、apply、bind 的区别与实现
问题解析
call、apply、bind 都是 JavaScript 中用于改变函数执行时 this 指向的方法,但它们在用法和返回值上有重要区别。
核心概念
三者的共同点
- 指定调用的
thisArg:普通函数按自身严格模式规则处理它;箭头函数沿用词法this,绑定函数再次绑定也不能替换原this - 继承自
Function.prototype:所有函数都可以调用这些方法 - 第一个参数都是
thisArg:用于指定函数执行时的this值
三者的区别
| 特性 | call |
apply |
bind |
|---|---|---|---|
| 调用时机 | 立即执行 | 立即执行 | 返回新函数,不立即执行 |
| 参数传递 | 逐个传递 | 数组传递 | 逐个传递(柯里化支持) |
| 返回值 | 函数执行结果 | 函数执行结果 | 绑定 this 后的新函数 |
| 使用场景 | 知道参数个数时 | 参数是数组/类数组时 | 需要延迟执行或柯里化时 |
基本用法
call 方法
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`)
}
const person = { name: 'Alice' }
// 第一个参数是 this 指向,后面参数逐个传递
greet.call(person, 'Hello', '!') // "Hello, Alice!"
apply 方法
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`)
}
const person = { name: 'Bob' }
// 第一个参数是 this 指向,第二个参数是数组
const args = ['Hi', '?']
greet.apply(person, args) // "Hi, Bob?"
bind 方法
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`)
}
const person = { name: 'Charlie' }
// 返回一个新函数,this 被永久绑定
const greetCharlie = greet.bind(person)
greetCharlie('Hey', '.') // "Hey, Charlie."
// bind 支持柯里化(预设参数)
const sayHelloToCharlie = greet.bind(person, 'Hello')
sayHelloToCharlie('!') // "Hello, Charlie!"
使用场景对比
场景 1:借用方法
// 借用数组方法操作类数组
function sum() {
// arguments 是类数组,没有 forEach 方法
const args = Array.prototype.slice.call(arguments)
// 或: const args = Array.from(arguments)
return args.reduce((a, b) => a + b, 0)
}
sum(1, 2, 3, 4) // 10
场景 2:数组方法的最大值
const numbers = [5, 2, 8, 1, 9]
// apply 适合参数是数组的场景
const max = Math.max.apply(null, numbers)
// ES6 之后更推荐: Math.max(...numbers)
console.log(max) // 9
场景 3:回调函数保持 this
const user = {
name: 'David',
sayHi: function() {
console.log(`Hi, I'm ${this.name}`)
},
sayHiLater: function() {
// 使用 bind 确保回调中的 this 指向正确
setTimeout(this.sayHi.bind(this), 1000)
// 或者用箭头函数(ES6)
// setTimeout(() => this.sayHi(), 1000)
}
}
user.sayHiLater() // 1秒后: "Hi, I'm David"
场景 4:函数柯里化
function multiply(a, b, c) {
return a * b * c
}
// bind 的柯里化特性
const multiplyByTwo = multiply.bind(null, 2)
const multiplyByTwoAndThree = multiplyByTwo.bind(null, 3)
multiplyByTwoAndThree(4) // 2 * 3 * 4 = 24
手写实现
手写 call
把函数临时挂到接收者上,再用方法调用建立 this,即可实现一个不依赖 Reflect 的受限版本。适用范围是可扩展的普通接收者;null/undefined 按非严格函数的常见规则映射到当前 globalThis,原始值装箱。原生调用对严格函数会保留原始 thisArg,这一点临时挂载法无法完整复现。以下原型扩展仅用于讲解,生产代码不建议修改内置原型。
Function.prototype.myCall = function(thisArg, ...args) {
if (typeof this !== 'function') {
throw new TypeError('myCall must be called on a function')
}
const receiver = thisArg == null ? globalThis : Object(thisArg)
const key = Symbol('call target')
Object.defineProperty(receiver, key, { value: this, configurable: true })
try {
return receiver[key](...args)
} finally {
delete receiver[key]
}
}
// 测试
function greet(greeting) {
console.log(`${greeting}, ${this.name}!`)
}
const person = { name: 'Alice' }
greet.myCall(person, 'Hello') // "Hello, Alice!"
手写 apply
apply 接受数组或类数组对象,不要求实现 Symbol.iterator;Set 虽然可迭代,却没有 length,作为参数列表时会得到零个参数。省略参数或传 null/undefined 表示空列表,字符串等原始值不能直接充当列表。
Function.prototype.myApply = function(thisArg, argsArray) {
if (typeof this !== 'function') {
throw new TypeError('myApply must be called on a function')
}
const args = []
if (argsArray != null) {
if (typeof argsArray !== 'object' && typeof argsArray !== 'function') {
throw new TypeError('arguments list must be an object')
}
// 一元 + 使用 ToNumber;BigInt/Symbol 长度会报错,不能用 Number 放宽它。
const numericLength = +argsArray.length
const length = Number.isNaN(numericLength) || numericLength <= 0
? 0
: Math.min(Math.floor(numericLength), Number.MAX_SAFE_INTEGER)
for (let index = 0; index < length; index += 1) args.push(argsArray[index])
}
const receiver = thisArg == null ? globalThis : Object(thisArg)
const key = Symbol('apply target')
Object.defineProperty(receiver, key, { value: this, configurable: true })
try {
return receiver[key](...args)
} finally {
delete receiver[key]
}
}
// 测试
function sum(a, b, c) {
return a + b + c
}
sum.myApply(null, [1, 2, 3]) // 6
sum.myApply(null) // NaN (a,b,c 都是 undefined)
手写 bind
bind 的重点是保存目标函数和预置参数,普通调用使用绑定接收者,构造调用使用新实例。下面复用原生 apply 完成普通调用,并用 new 完成构造调用,不依赖 Reflect。
Function.prototype.myBind = function(thisArg, ...presetArgs) {
// 1. 判断调用者是否为函数
if (typeof this !== 'function') {
throw new TypeError('myBind must be called on a function')
}
// 2. 保存原始函数的引用
const originalFn = this
// 3. 返回一个绑定函数
function boundFn(...laterArgs) {
const finalArgs = [...presetArgs, ...laterArgs]
// new.target 区分构造调用,不能用可伪造的 instanceof 判断。
if (new.target) {
// 用 new 继续传递构造语义,嵌套绑定时不会把实例 this 丢给内层绑定。
return new originalFn(...finalArgs)
}
// 直接取 Function.prototype.apply,避免目标函数自定义 apply 属性干扰调用。
return Function.prototype.apply.call(originalFn, thisArg, finalArgs)
}
// 4. new originalFn 创建的是目标实例;instanceof 也交给目标函数判断。
Object.defineProperty(boundFn, Symbol.hasInstance, {
value(instance) {
return instance instanceof originalFn
}
})
// 5. 扣除已预置的参数数量
Object.defineProperty(boundFn, 'length', {
value: Math.max(0, originalFn.length - presetArgs.length),
writable: false,
configurable: true
})
return boundFn
}
// 测试基础用法
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`)
}
const person = { name: 'Bob' }
const greetBob = greet.myBind(person, 'Hello')
greetBob('!') // "Hello, Bob!"
// 测试 new 调用
function Person(name, age) {
this.name = name
this.age = age
}
const CreatePerson = Person.myBind(null, 'Alice')
const alice = new CreatePerson(25)
console.log(alice) // Person { name: 'Alice', age: 25 }
console.log(alice instanceof Person) // true
console.log(alice instanceof CreatePerson) // true
// 再次绑定会追加预置参数,但不会替换第一次绑定的 this。
const CreateAlice25 = CreatePerson.myBind({ ignored: true }, 25)
console.log(new CreateAlice25().age) // 25
bind 模拟的边界
上述版本覆盖预置参数、嵌套绑定、普通函数和 class 的构造,以及构造函数显式返回对象/函数或 null 的区别。普通调用复用原生 apply,因此保留严格函数的 thisArg;构造调用交给 new originalFn,由引擎检查目标是否可构造。箭头函数和 Generator 都会抛出 TypeError,而没有自有 prototype 的原生绑定构造器仍能构造。不能用“是否有 prototype”判断能否 new。
new originalFn 使用目标函数的原型创建实例,再让 Symbol.hasInstance 把 instanceof boundFn 委托给目标函数。这样既支持嵌套绑定,也能跟随目标函数之后对 prototype 的替换。构造函数如果显式返回其他对象,返回值不一定是目标函数或绑定函数的实例,这与原生构造规则一致。
这仍是受限的教学包装,并非原生 bind 的完整实现:
- 包装函数自带一个
prototype,但它不参与最终目标实例的原型链;修改它不会改变new boundFn()的结果。原生绑定函数通常没有自有prototype。 - 本例的包装函数自身可构造,只有进入构造分支后才拒绝不可构造的目标;原生绑定函数的可构造性直接取决于目标。
- 直接
new boundFn()时,目标观察到的new.target是目标本身,与常规原生绑定调用一致;通过子类或其他newTarget构造时,本例仍强制执行new originalFn,不会保留子类原型或转发new.target,因此不支持作为继承基类。 name、函数对象自身的原型链和属性描述符未完整模拟;继承或借用这里的Symbol.hasInstance方法,也不等同于引擎内部的绑定函数规则。内建构造器、Proxy 和跨 realm 等场景应使用原生bind。
myCall/myApply 的 Symbol 避免覆盖同名属性,Object.defineProperty 避免普通对象继承的 setter,finally 保证目标函数抛错时尝试删除属性;冻结/不可扩展对象会在添加属性时报错。目标函数若主动冻结接收者,或 Proxy 拦截定义/读取/删除,仍可能导致清理失败或额外副作用。严格函数的 null、原始值、跨 realm 的全局绑定也不能靠临时挂载完全模拟。极大的参数列表还受引擎参数数量和内存限制。
精确调用的反射封装
需要保留严格 this、支持冻结接收者或真实构造语义时,可以使用内建反射能力。以下名字明确区分“语义封装”和上面的手写练习,不能用反射封装代替手写原理:
function reflectCall(fn, thisArg, ...args) {
return Reflect.apply(fn, thisArg, args)
}
function reflectApply(fn, thisArg, argsArray) {
return Reflect.apply(fn, thisArg, argsArray ?? [])
}
function reflectConstruct(Constructor, ...args) {
return Reflect.construct(Constructor, args)
}
new 的返回值边界
对普通可构造函数,new 会创建原型关联、以新对象调用函数,再处理返回值。返回非 null 对象或函数时使用该返回值,返回 null、数值等原始值时仍使用新实例。不能只写 typeof result === 'object',否则会错误返回 null 并漏掉函数。
function ReturnsNull() { this.ready = true; return null }
function ReturnsFunction() { return function result() {} }
console.log(new ReturnsNull().ready) // true
console.log(typeof new ReturnsFunction()) // 'function'
Reflect.construct 执行真正的构造语义,可处理 class,并拒绝箭头函数。Object.create(Constructor.prototype) 加 apply 的教学模拟没有这些保证,也不能完整模拟内建对象和 new.target。
关键要点总结
1. thisArg 的处理规则
function test() {
console.log(this)
}
// 非严格模式下:
test.call(null) // window / global
test.call(undefined) // window / global
test.call(123) // Number(123) 对象
test.call('abc') // String('abc') 对象
test.call(true) // Boolean(true) 对象
// 严格性取决于被调用函数的定义,不能在调用前临时切换。
function strictTest() {
'use strict'
return this
}
strictTest.call(null) // null
strictTest.call(123) // 123(原始值,不装箱)
2. 使用 Symbol 的原因
临时挂载法使用 Symbol 是为了避免覆盖同名属性;执行和删除用 try/finally 保证异常路径也尝试清理。Symbol 不能解决冻结对象、Proxy 副作用或严格模式语义,手写版本必须同时说明这些边界。
3. bind 的特殊性
- 柯里化:
bind可以预设部分参数,返回的函数可以接收剩余参数 - 构造函数处理:当
bind返回的函数作为构造函数使用时,this应该指向新创建的实例,而不是绑定的thisArg - 原型链:需要正确处理原型链,确保
instanceof判断正确
常见面试题
**Q:call 和 apply 的区别是什么?什么时候用哪个?
- 两者都立即执行函数并改变
this指向(深入阅读:三者的区别) call参数逐个传递,apply参数以数组形式传递- 参数确定时用
call,参数以数组形式存在时用apply(ES6 后可用call+ 展开运算符替代)
Q:bind 和 call/apply 的最大区别是什么?
bind返回一个新函数,不会立即执行(深入阅读:三者的区别、3.bind的特殊性)bind支持柯里化,可以分多次传入参数bind绑定是永久的,返回的函数this无法改变(除了用new调用)
Q:如何手写一个 call 方法?
先限定普通可扩展接收者,再用 Symbol 临时属性挂载函数、通过方法调用传参、在 finally 中删除并返回结果。随后解释严格 this、冻结对象和 Proxy 的差异;反射封装是精确调用的另一种工具。(深入阅读:手写 call)
Q:为什么 bind 返回的函数用 new 调用时,this 会指向实例而不是绑定的对象?
这是 JavaScript 的规范要求。当 bind 返回的函数作为构造函数使用时,绑定的 thisArg 被忽略,this 指向新创建的实例。这保证了继承链的正确性。(深入阅读:3. bind 的特殊性)