通俗易懂之人人都会手写call/apply/bind函数

面试时,为了能够进一步试探你对 this 相关概念理解和掌握的深度, 面试官会考察你 call、apply 和 bind 的实现机制,甚至可能会要求你手写代码。但很多时候,如果你以前并不了解这三个函数的实现机制,光靠死记硬背那是不行的,毕竟人是容易健忘的动物。
因此,针对 call、 apply 和 bind,我们不仅要会用、会辨析,更要对其原理知根知底。接下来,我们将这三个方法的实现一步步带领大家解析,由浅入深,这样方能信手拈来、百战不殆。

在这之前,我们先要知道call、apply 和 bind 是干嘛的?如何使用?它们之间有哪些区别?
这里我给大家画了一张思维导图:

结合这张图来说明,相信大伙会清楚得多:

  • call、apply 和 bind,都是用来改变函数的 this 指向的。

  • call、apply 和 bind 之间的区别比较大,前者在改变 this 指向的同时,也会把目标函数给执行掉;后者则只负责改造 this,不作任何执行操作。

  • call、bind 和 apply 之间的区别,则体现在对入参的要求上。前者只需要将目标函数的入参逐个传入即可,后者则希望入参以数组形式被传入。

好了,在了解三者的作用和区别后,让我们来模拟实现一个 call/apply/bind 方法,三者在实现层面上非常相似,我们以 call 方法的实现为例,带大家深入理解一下这类方法的模拟思路:

call 方法的模拟

实现之前,先来看一个 call 的调用示范:

var student = {
  name: 'xiaoming'
}

function showName() {
  console.log(this.name);
}

showName.call(student); // xiaoming

结合 call 方法的特性,我们首先至少能想到以下两点:

  • call 是可以被所有的函数继承的,因为 JS 中每个函数的原型都指向Function.prototype对象(JS基于原型链的继承),所以 call 方法应该被定义在 Function.prototype 上;
  • call 方法做了两件事:
    • 改变 this 的指向,将 this 绑定到第一个入参指定的的对象上去;
    • 根据输入的参数,执行函数。

结合这两点,我们一步一步来实现 call 方法。首先,改变 this 的指向

showName 在调用 call 方法后,表现得就像是 student 这个对象的一个方法一样。

所以我们最直接的一个联想是,如果能把 showName 直接塞进 student 对象里就好了,像这样:

var student = {
  name: 'xiaoming',
  showName: function() {
    console.log(this.name);
  }
}

student.showName(); // xiaoming

遵循以上思路,我们来模拟一下 call 方法(注意看注释):

Function.prototype.myCall = function(context) {
    // step1: 把函数挂到目标对象上(这里的 this 就是我们要改造的的那个函数)
    context.fn = this;
    // step2: 执行 fn 函数
    context.fn();
}

这样我们基本实现了刚才的联想,简单测试下我们的 myCall 函数:

showName.myCall(student); // xiaoming

但是这样做有一个问题,用户在传入 student 这个对象的时候,想做的仅仅是让 call 把 showName 里的 this 指向 student,而不是想给 student 对象新增一个额外的 fn 方法。所以我们在执行完 fn 之后,还要记得把它给删掉:

Function.prototype.myCall = function(context) {
    // step1: 把函数挂到目标对象上(这里的 this 就是我们要改造的的那个函数)
    context.fn = this;
    // step2: 执行 fn 函数
    context.fn();
    // step3: 删除 step1 中挂到目标对象上的 fn 函数,此举非“过河拆桥”,而是把目标对象”完璧归赵”
    delete context.fn;
}

在这个例子中,myCall 的执行结果结果与 call 无差,我们已经实现了 “改变 this 的指向” 这个功能点。现在我们的 myCall 函数还需要具备读取函数入参的能力,类比于 call 的这种调用形式:

var student = {
  name: 'xiaoming'
}

function showAge(age) {
  console.log(`My name is ${this.name}, I am ${age} years old.`)
}

showAge.call(student, 18) // My name is xiaoming, I am 18 years old.

读取函数入参,具体来说其实是读取 call 方法的第二个到最后一个入参。要做到这一点,我们可以借助数组的扩展符(注意阅读注释,如果对 ‘…’ 这个符号感到陌生,需要补习一下 ES6 中扩展运算符相关的知识):

// '...'这个扩展运算符可以帮助我们把一系列的入参变为数组
function readArr(...args) {
    console.log(args)
}

readArr(1,2,3) // [1,2,3]

然后把这个逻辑用到我们的 myCall 函数里:

Function.prototype.myCall = function(context, ...args) {
    ...
    console.log('入参是', args)
}

就能通过 args 这个数组拿到我们想要的入参了。把 args 数组代表的目标入参重新展开,传入目标方法里,就大功告成了:

Function.prototype.myCall = function(context, ...args) {
    // step1: 把函数挂到目标对象上(这里的 this 就是我们要改造的的那个函数)
    context.fn = this;
    // step2: 执行 fn 函数
    context.fn(...args);
    // step3: 删除 step1 中挂到目标对象上的 fn 函数,此举非“过河拆桥”,而是把目标对象”完璧归赵”
    delete context.fn;
}

接下来测试下我们的 myCall 函数:

var student = {
  name: 'xiaoming'
}

function showAge(age) {
  console.log(`My name is ${this.name}, I am ${age} years old.`)
}

showAge.myCall(student, 18) // My name is xiaoming, I am 18 years old.

以上,我们就成功模拟了一个 call 方法出来。
但还没完,考虑到函数的健壮性,我们还需要处理一些 边界 问题:

比如第一个参数传了 null 怎么办?是不是可以默认给它指到 window 去?函数如果是有返回值的话怎么办?是不是新开一个 result 变量存储一下这个值,最后 return 出来就可以了?等等…

让我们来完善一下这个 myCall 函数:

Function.proptotype.myCall = function(context, ...args){
    if(typeof this !== 'function'){
        throw new TypeError(`${this} is not a function`);
    }
    context = context || window;
    context.fn = this;
    const result = context.fn(...args);
    delete context.fn;
    return result;
}

最后总结一下:

  • 首先 context 为 this 需要指向的目标对象,如果不传的话默认上下文为 window,…args 是要被改造函数的传入参数,通过 ‘...’ 扩展运算符可以帮助我们把一系列的入参变为数组;
  • 然后给 context 挂载一个 fn 函数属性,并将 this 赋值给它 (这里的 this 就是我们要改造的那个函数);
  • 接下来执行 fn 函数,把 args 数组代表的传入参数重新展开,传入 其中,将执行结果赋值给 result ;
    这里需要注意的是: 因为 context 对象上本身是没有 fn 这个函数的,只是在传递作用域调用时,做一个临时属性,所以执行 fn 函数后就把它删除了。
  • 最后把执行结果返回出去即可。

基于对 call 方法的理解,写出一个 apply 方法(更改读取参数的形式) 和 bind 方法(延迟目标函数执行的时机)不是什么难事,只需要大家在上面这段代码的基础上作改造即可。(前提是你对 apply 方法和 bind 方法的特性和用法要心知肚明~)。

apply 方法的模拟

apply 的实现与 call 非常类似,区别在于对参数的处理,在这就不做一一分析了。

Function.prototype.myApply = function(context, args){
    if(typeof this !== 'function'){
        throw new TypeError(`${this} is not a function`);
    }
    if(args && !(args instanceof Array)){
        throw new TypeError('TypeError: CreateListFromArrayLike called on non-object');
    }
    context = context || window;
    context.fn = this;
    const result = context.fn(args ? [...args] : '');
    delete context.fn;
    return result;
}

bind 方法的模拟
bind 的实现对比其他两个函数略微地复杂了一点,因为 bind 需要返回一个函数,需要判断一些边界问题,以下是 bind 的实现:

Function.prototype.myBind = function (context, ...args) {
  if (typeof this !== 'function') {
    throw new TypeError(`${this} is not a function`);
  }
  const _this = this;
  // 返回一个函数
  return function F() {
    // 因为返回了一个函数,我们可以 new F(),所以需要判断
    if (this instanceof F) {
      return new _this(...args, ...arguments);
    }
    return _this.apply(context, args.concat(...arguments));
  }
}

分析:

  • 前几步和之前的实现差不多,就不再赘述。
  • bind 返回了一个函数,对于函数来说有两种方式调用,一种是 直接调用,一种是通过 new 的方式,我们先来说直接调用的方式:
    • 对于 直接调用 来说,这里选择了 apply 的方式实现,但是对于参数需要注意以下情况:因为 bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以我们需要将两边的参数拼接起来,于是就有了这样的实现 args.concat(…arguments);
    • 最后来说通过 new 的方式,对于这种情况来说,不会被任何方式改变 this,所以对于这种情况我们需要忽略返回函数中的 this。

面试中,要是你除了能讲解出以上的实现机制并手写出来,那面试官基本就露出认可的笑容了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值