跳到正文
前端知识库
前端三大件

JavaScript 中 call、apply、bind 的区别与实现

call、apply、bind 都是 JavaScript 中用于改变函数执行时 this 指向的方法,但它们在用法和返回值上有重要区别。

约 8 分钟

JavaScript 中 call、apply、bind 的区别与实现

问题解析

call、apply、bind 都是 JavaScript 中用于改变函数执行时 this 指向的方法,但它们在用法和返回值上有重要区别。


核心概念

三者的共同点

  1. 指定调用的 thisArg:普通函数按自身严格模式规则处理它;箭头函数沿用词法 this,绑定函数再次绑定也不能替换原 this
  2. 继承自 Function.prototype:所有函数都可以调用这些方法
  3. 第一个参数都是 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 的特殊性)