JavaScript 的 call、apply 和 bind:如何控制函数调用
JavaScript 中,一个普通函数可以配合不同对象使用,也可以预先固定参数,交给其他代码稍后调用。call、apply 和 bind 提供了这些能力。
要理解它们,先从 this 与调用方式的关系看起。
一、this 为什么会随着调用方式改变?
看下面这个对象:
javascript
const user = {
name: '小明',
sayName() {
return this.name
}
}
console.log(user.sayName()) // 小明
调用表达式是 user.sayName(),因此这次调用中,sayName 内部的 this 指向 user。
但如果把方法取出来,再单独调用,结果就不同了:
javascript
'use strict'
const user = {
name: '小明',
sayName() {
return this.name
}
}
const sayName = user.sayName
sayName() // TypeError:this 是 undefined,无法读取 name
const sayName = user.sayName 只是把函数赋给另一个变量,没有同时绑定 user。严格模式下,普通函数直接调用时,this 是 undefined。
因此,函数来自哪个对象,并不能永久决定它的 this;调用方式才是关键。
如果希望调用时明确指定 this,就可以使用 call。
二、call:指定 this,并立即执行
call 的基本形式是:
javascript
fn.call(thisArg, arg1, arg2)
第一个参数指定函数内部的 this,后面的参数按顺序传给原函数。
例如:
javascript
function introduce(greeting, punctuation) {
return greeting + ',我是' + this.name + punctuation
}
const user = { name: '小明' }
console.log(introduce.call(user, '你好', '!'))
// 你好,我是小明!
这次调用中:
user成为函数内部的this。'你好'传给greeting。'!'传给punctuation。
user 不会占用函数的第一个参数位置,它是提供给 call 的调用上下文。
换一个对象,同一段函数逻辑就能生成不同结果:
javascript
introduce.call({ name: '小红' }, '早上好', '。')
// 早上好,我是小红。
这正是 call 的用途:复用已有函数,同时明确指定这次调用使用的对象。
三、apply:把数组中的元素作为参数传入
apply 同样可以指定 this 并立即执行函数,只是参数的组织方式不同:
javascript
fn.apply(thisArg, args)
当参数已经保存在数组中时,可以直接交给 apply:
javascript
const user = { name: '小明' }
const args = ['你好', '!']
console.log(introduce.apply(user, args))
// 你好,我是小明!
apply 会把数组元素按顺序作为实参传入:
javascript
args[0] → greeting
args[1] → punctuation
因此,下面两种写法表达了相同的调用:
javascript
introduce.call(user, '你好', '!')
introduce.apply(user, ['你好', '!'])
选择哪一种,主要看调用者手中的参数是分散的值,还是已经集中在数组中。
展开语法与 apply
现代 JavaScript 可以使用展开语法,把数组展开为多个实参:
javascript
const args = ['你好', '!']
introduce.call(user, ...args)
对于普通数组,这能表达与 apply 相同的效果。
如果函数不需要指定 this,写法可以更直接:
javascript
const numbers = [10, 20, 30]
Math.max(...numbers) // 30
对应的 apply 写法是:
javascript
Math.max.apply(null, numbers) // 30
展开语法覆盖了 apply 的许多常见用途,不过旧项目中仍然经常能看到 apply。
两者还有一个细节差异:apply 可以接收类数组对象,而展开语法要求被展开的值可迭代。普通数组同时满足这两个条件,因此一般数组示例看不出区别。
四、bind:先固定调用条件,返回一个新函数
call 和 apply 都会立即执行函数,bind 则返回一个新函数:
javascript
const boundFn = fn.bind(thisArg, arg1)
新函数会记住指定的 this,以及预先提供的参数。
继续使用 introduce:
javascript
const user = { name: '小明' }
const sayHello = introduce.bind(user, '你好')
执行这一行时,introduce 尚未运行。得到的 sayHello 已经固定了两个条件:
this使用user。- 第一个参数使用
'你好'。
调用时只需要提供剩下的参数:
javascript
sayHello('!') // 你好,我是小明!
sayHello('。') // 你好,我是小明。
这种先固定部分参数、再得到新函数的方式,叫作部分应用(partial application)。
即使函数不使用 this,也可以利用 bind 固定参数:
javascript
function multiply(a, b) {
return a * b
}
const double = multiply.bind(null, 2)
double(5) // 10
double(10) // 20
double 固定了第一个参数 2,调用者只需提供第二个参数。
这里传入 null,是因为 multiply 没有使用 this,所以这个值不会影响计算结果。
bind 后面的括号表示什么?
看下面这行代码:
javascript
multiply.bind(null, 2, 5)() // 10
它实际上包含两步:
javascript
const boundMultiply = multiply.bind(null, 2, 5)
const result = boundMultiply()
bind(...) 创建新函数,后面的 () 才执行它。如果没有最后这对括号,得到的只是函数,还没有计算结果。
五、为什么回调函数经常需要 bind?
对象方法作为回调传出去后,后续调用未必会以原对象作为 this。
例如:
javascript
const user = {
name: '小明',
sayName() {
console.log(this.name)
}
}
setTimeout(user.sayName.bind(user), 100)
// 稍后输出:小明
这里先通过 bind(user) 创建一个绑定了 user 的函数,再交给 setTimeout 稍后执行。
需要注意,固定 this 不等于复制对象数据。新函数记住的是对象引用,执行时仍会读取对象当时的属性:
javascript
const sayName = user.sayName.bind(user)
user.name = '小红'
sayName() // 小红
回调也可以通过箭头函数包装:
javascript
setTimeout(() => user.sayName(), 100)
稍后执行的是 user.sayName(),所以方法内部的 this 同样指向 user。
六、箭头函数的 this 不能重新绑定
前面的绑定示例使用的都是普通函数。
箭头函数的 this 来自定义时所在的外层作用域,不能通过 call、apply 或 bind 重新指定。
javascript
function createReader() {
return () => this.name
}
const reader = createReader.call({ name: '小明' })
reader.call({ name: '小红' }) // 小明
创建 reader 时,箭头函数捕获了外层函数中的 this。之后使用 call,也无法把它改成"小红"。
不过,bind 仍然可以固定箭头函数的参数:
javascript
const add = (a, b) => a + b
const addTen = add.bind(null, 10)
addTen(20) // 30
也就是说,绑定 this 与固定参数是两个独立作用 。箭头函数不接受新的 this,但仍然可以接收预先绑定的参数。
七、TypeScript 如何检查这些调用?
JavaScript 提供调用机制,TypeScript 则可以检查调用上下文和参数是否符合类型要求。
例如,显式声明 introduce 所需的 this 类型:
javascript
function introduce(
this: { name: string },
greeting: string,
punctuation: string
): string {
return greeting + ',我是' + this.name + punctuation
}
这里的 this 参数只用于类型检查,不是运行时的普通形参,也不会占用 greeting 的位置。
启用严格检查后:
javascript
introduce.call({ name: '小明' }, '你好', '!') // 正确
introduce.call({ name: 123 }, '你好', '!')
// 类型错误:name 应该是 string
使用 apply 时,可以用元组准确表达参数的位置和类型:
javascript
const args: [string, string] = ['你好', '!']
introduce.apply({ name: '小明' }, args)
[string, string] 表示两个位置都必须是字符串,与函数的两个普通参数对应。
这些调用的类型检查由 strictBindCallApply 控制,启用 strict 时也会启用它。类型检查帮助我们提前发现错误,JavaScript 的运行时调用规则仍然照常生效。
八、实际使用时如何选择?
| 场景 | 写法 |
|---|---|
| 普通函数调用 | fn(a, b) |
指定 this,逐个提供参数 |
fn.call(obj, a, b) |
指定 this,参数保存在数组或类数组中 |
fn.apply(obj, args) |
固定 this 或部分参数,得到供后续调用的新函数 |
fn.bind(obj, a) |
写代码时可以先判断:这次是否需要指定 this?参数是否已经集中在数组里?需要的是执行结果,还是一个可交给其他代码的函数?
这些需求确定后,对应的调用方式也就清楚了。