手写 call、apply、bind:彻底理解函数调用与 this 绑定

call、apply、bind 是 JavaScript 中高频出现却又容易被“会用但不理解”的三个方法。它们看起来只是改变 this 指向的小工具,实际上牵涉到函数调用机制、this 绑定规则、原型链、闭包、构造函数模拟以及 new 的优先级。本文将从原理出发,一步步手写实现,帮助你真正吃透函数调用与 this 绑定。

一、为什么需要 call、apply、bind?

在 JavaScript 中,this 的指向不是由函数定义的位置决定的,而是由函数调用方式决定的。常见规则可以概括为:

  • 普通函数调用:this 指向全局对象(非严格模式)或 undefined(严格模式)
  • 方法调用:this 指向调用该方法的对象
  • 构造函数调用:this 指向新创建的实例
  • 显式绑定:通过 call、apply、bind 指定 this

前三种是隐式规则,而 call、apply、bind 提供了显式控制 this 的能力。它们的核心区别是:

  • call:立即调用,参数逐个传入
  • apply:立即调用,参数以数组或类数组传入
  • bind:不立即调用,返回一个绑定了 this 和部分参数的新函数
function greet(city, country) {
  console.log(`${this.name} 来自 ${city}, ${country}`);
}

const person = { name: 'Alice' };

greet.call(person, '北京', '中国');
greet.apply(person, ['上海', '中国']);
const boundGreet = greet.bind(person, '广州');
boundGreet('中国');

二、手写 call

1. 基本思路

call 的本质是:让目标函数成为某个对象的临时方法,然后通过对象调用它。这样函数内部的 this 自然就指向了这个对象。

Function.prototype.myCall = function (context, ...args) {
  context = context == null ? globalThis : Object(context);

  const fnKey = Symbol('fn');
  context[fnKey] = this;

  const result = context[fnKey](...args);

  delete context[fnKey];

  return result;
};

2. 关键点解析

  • context == null 处理 null 和 undefined,非严格模式下应指向全局对象,这里用 globalThis 兼容不同环境
  • Object(context) 处理原始值,比如 myCall(1) 时,需要把数字包装成对象
  • 使用 Symbol 作为临时属性名,避免覆盖对象原有属性
  • 执行完成后必须 delete,保持对象干净
  • 返回函数执行结果,保持与原生 call 一致

3. 测试

function foo(a, b) {
  console.log(this.value, a, b);
}

const obj = { value: 42 };
foo.myCall(obj, 1, 2); // 42 1 2

三、手写 apply

apply 与 call 唯一区别是参数形式。实现时只需把剩余参数改为接收一个数组。

Function.prototype.myApply = function (context, args) {
  context = context == null ? globalThis : Object(context);

  const fnKey = Symbol('fn');
  context[fnKey] = this;

  const result = args ? context[fnKey](...args) : context[fnKey]();

  delete context[fnKey];

  return result;
};

注意 args 可能为 null 或 undefined,此时相当于无参数调用。另外,apply 的第二个参数也可以是类数组,...args 展开时要求其可迭代,若需兼容类数组,可使用 Array.from(args)。

四、手写 bind

bind 是最复杂的一个,因为它需要返回一个新函数,并且要处理多种调用场景。

1. 核心需求

  • 返回一个新函数
  • 新函数调用时,this 指向绑定的对象
  • 支持柯里化:绑定时的参数与调用时的参数合并
  • 支持 new 调用:当绑定后的函数被 new 调用时,绑定的 this 应被忽略,实例原型应指向原函数
Function.prototype.myBind = function (context, ...bindArgs) {
  const originalFn = this;

  function boundFn(...callArgs) {
    const isNew = this instanceof boundFn;
    const finalContext = isNew ? this : context;
    return originalFn.apply(finalContext, [...bindArgs, ...callArgs]);
  }

  if (originalFn.prototype) {
    boundFn.prototype = Object.create(originalFn.prototype);
  }

  return boundFn;
};

2. 为什么用 this instanceof boundFn 判断 new?

当使用 new boundFn() 时,boundFn 内部的 this 是新创建的实例,它继承自 boundFn.prototype,因此 this instanceof boundFn 为 true。此时应忽略绑定的 context,让 this 继续指向新实例,从而正确模拟构造函数行为。

3. 原型链处理

boundFn.prototype = Object.create(originalFn.prototype) 保证通过 new boundFn() 创建的实例,既能访问原函数原型上的方法,也能让 instanceof 判断保持合理。

4. 测试

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.say = function () {
  console.log(this.name, this.age);
};

const BoundPerson = Person.myBind(null, 'Bob');
const p = new BoundPerson(18);
p.say(); // Bob 18
console.log(p instanceof Person); // true

五、常见误区与边界情况

1. 箭头函数无法绑定 this

箭头函数没有自己的 this,它的 this 来自外层作用域。因此对箭头函数使用 call、apply、bind 不会改变 this,手写实现也无法绕过这一点。

2. 严格模式下的差异

在严格模式中,call(null) 会让 this 保持为 null,而不是全局对象。手写时如果追求与原生完全一致,需要判断函数是否处于严格模式,这通常通过 Function.prototype.toString 或 'use strict' 检测,实际项目中不必过度纠结。

3. bind 返回的函数作为构造函数时,bind 传入的参数仍然有效

注意上面的实现中,bindArgs 在 new 调用时依然会被传入,这与原生行为一致。

4. 性能与可读性

手写实现主要用于理解原理。生产环境应优先使用原生方法,它们经过引擎优化,性能更好,边界处理也更完善。

六、总结

通过手写 call、apply、bind,我们可以把零散的 this 知识串联成一条完整的链路:

  • call 和 apply 的本质是“临时方法借用”,利用对象调用改变 this
  • bind 的本质是“闭包 + apply + 构造函数模拟”,需要额外处理 new 优先级和原型链
  • this 绑定规则中,new 的优先级高于显式绑定,这也是 bind 实现中必须判断 instanceof 的原因

理解这些之后,再回头看 React 事件绑定、防抖节流、函数柯里化、继承实现等场景,你会发现它们背后都藏着 call、apply、bind 的影子。真正掌握它们,不只是会手写代码,更是对 JavaScript 函数调用机制的一次系统梳理。

未经允许不得转载:任鹏个人博客 » 手写 call、apply、bind:彻底理解函数调用与 this 绑定

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏