JuniorTour / blog

not only front-end
8 stars 5 forks source link

原生JS模拟实现 call、apply 和 bind 方法 #2

Open JuniorTour opened 4 years ago

JuniorTour commented 4 years ago

.call.apply.bind 方法是函数对象的原型方法(Function.prototype),有「改变this指向的」特殊作用,初学 JavaScript 不太容易理解其机制,本文将通过逐步模拟实现这些方法,以加深对这些方法的理解。

一、Function.prototype.call()

1. 简介 call()

语法:func.call(thisArg[, arg1[, arg2[, ...]]])

简单来说,call方法的作用就是:在指定一个this值和若干个指定的参数值的前提下调用某个函数或方法

因为接下来介绍的函数方法和 this 指针都有很密切的关系,所以我首先向各位推荐一篇文章帮助理解 this 指针:《关于 this 你想知道的一切都在这里》

举个栗子来说明:

function foo () {
  console.log(this.val)
}

let obj = {
  val: 1
}

console.log('foo.call(obj) : ')
foo.call(obj) //1

2. 模拟实现:

我们模拟的步骤是:

  1. 将函数设为对象的方法。
  2. 执行该函数,
    • 注意三点:
      1. A. call 方法可带任意个参数。
      2. B. 参数可以为 nullundefined
      3. C. 并且函数可以有返回值。
  3. 删除该函数,返回返回值。
Function.prototype.myCall=function(context) {
    context=context||window     //B. 处理参数为 null 和 undefined 的情况
    context.fn=this             //1. 把调用 myCall 的函数设为对象的方法
    let targetArg=[]            //A. 注意 call 方法可带“任意个”参数

    //处理传入的参数:
    for (let i=1; i<arguments.length; i++) {
        targetArg.push('arguments['+i+']')
    }
    let result=eval('context.fn('+targetArg+')')   //2. 执行该函数;C.函数可以有返回值
    delete context.fn
    return result
}

1.为什么targetArg.push('arguments['+i+']')的值是字符串,而非targetArg.push(arguments[i])的值?

答:为了兼容字符串类型的值,

例如参数为myCall(obj, "me", 21)时,push(arguments[i])后的targetArg是:["me", 21],经过'context.fn('+targetArg+')'之后,会变成字符串:"context.fn(me,21)", 把me当成了一个变量,而非原本的字符串,但 me 作为一个变量,并未声明过,所以会报错、不可行。

targetArg.push('arguments['+i+']')的值是字符串,经过'+targetArg+'后,是"context.fn(arguments[1],arguments[2])",对字符串类型不会报错,可行!

2. 为什么用 eval() 这么 hack 的方式实现?还有别的实现方式吗?

首先,因为targetArg最终为「字符串」类型,所以需要通过eval转化为对应的变量

其次,如果不考虑兼容性,不需要 ES3 就支持的eval,也可以用 ES6 的 spread operator:... 替代eval,执行这一步,即:

Function.prototype.myCall=function(context, ...arg) {
// ...
let result = context.fn(...arguments)    // ES6

3. 嗅探兼容

这种给内置对象扩展方法的实现形式,通常称为Monkey patching(猴子补丁),可以做一步嗅探来保证兼容:

Function.prototype.myCall=Function.prototype.call || function(context) {/*...*/}
/*这一步的含义是:如果Function.prototype.call存在,就用原生的这个方法,否则用我们自己模拟实现的。*/

后续的几个模拟实现,都可以用嗅探兼容保证兼容性。

二、Function.prototype.apply()

1.简介 apply()

语法:func.apply(thisArg, [argsArray]) apply 和 call 的用法基本相同,两者的第一个参数都是 func 函数运行时的 this值。 唯一的区别在于,call 可以接受传入多个参数,而 bind 只能接受一个数组传入作为参数。

2. 模拟实现

Function.prototype.myApply = function(context, argArr) {
  context = context || window
  context.fn = this
  let result

  if (argArr) {
    let targetArg = []
    for (let i = 0; i < argArr.length; i++) {
      targetArg.push('argArr[' + i + ']')
    }
    result = eval('context.fn(' + targetArg + ')')
  } else {
    result = context.fn()
  }
  return result
}

三、Function.prototype.bind()

1. 简介 bind()

语法:func.bind(thisArg[, arg1[, arg2[, ...]]]) 这个方法比较特别,该方法会创建一个函数并返回,称之为绑定函数

绑定函数会以创建它时传入bind方法的第一个参数作为 this 值,第二个以及以后的参数,将当做这个新的绑定函数的初始预设参数

之后调用新绑定函数时,传递给绑定函数的其他参数会跟在预设参数之后传入。

看一个栗子就明白了:

  function bindFoo() {
    // 用 call 把 arguments 指定为 this 值用于 slice() 处理,
    // slice 方法可以把一个类数组(Array-like)对象/集合转换成一个数组。
    console.log('[].slice.call(arguments)  = ',[].slice.call(arguments))
  }
  bindFoo(1,2,3);  //[1,2,3]

  let bindedFoo=bindFoo.bind(undefined,'预先绑定的参数');
  bindedFoo();    //[“预先绑定的参数”]
  bindedFoo(1,2,3);   //[“预先绑定的参数”,1,2,3], '预先绑定的参数' 始终是第一个参数

2. 模拟实现

注意 bind() 的几个特点:

  1. 返回一个新函数,新函数会继承原来构造函数的原型。
  2. 可以传入参数,用作绑定的绑定预设参数。
  3. 有构造函数的效果:一个绑定函数也能使用 new 操作符创建对象。

要注意,MDN上对于这种用法有一个警告:“警告 :这部分演示了 JavaScript 的能力并且记录了 bind() 的超前用法。以下展示的方法并不是最佳的解决方案且可能不应该用在任何生产环境中。”

因为模拟构造函数的效果比较不容易理解,且不被推荐,所以在这里我们分别来实现。

不模拟构造函数:

Function.prototype.myBind = function (context) {
    if (typeof this !== 'function') {
      throw new Error('Function.prototype.bind - what is trying to be bound is not callable.')
    }

    let self = this
    // 获取传入的从下标为[1]开始的参数作为「预设参数」:
    let args = Array.prototype.slice.call(arguments, 1)

    return fBound = function () {
      // 预设参数之外传入的参数,即 bind() 返回的新函数调用时传入的『新增参数』
      let bindArgs = Array.prototype.slice.call(arguments)

      // 改变绑定函数中 this 的指向,并将「预设参数」和『新增参数』合并
      self.myApply(context, args.concat(bindArgs))
    }
}

模拟构造函数版:

Function.prototype.myBind = function(context) {
  if (typeof this !== 'function') {
    throw new Error('Function.prototype.bind - what is trying to be bound is not callable.')
  }

  let self = this
  let args = Array.prototype.slice.call(arguments, 1)

  let fBound = function() {
    let bindArgs = Array.prototype.slice.call(arguments)
    self.myApply(this instanceof self ? this : context, args.concat(bindArgs))
  }

  /*
  如果只是 fBound.prototype = this.prototype,
  当我们直接修改 fBound.prototype 的时候,
  也会一并顺带修改绑定函数的prototype,这不是我们想要的结果。
  这个时候,我们可以通过一个空函数来进行中转:
  */
  let fEmpty = function() {}
  fEmpty.prototype = self.prototype
  fBound.prototype = new fEmpty()

  return fBound
}

为什么「构造函数版」需要特殊处理?

  function bar(name, age) {
      this.habit = 'shopping';
      console.log(this.value);
      console.log(name);
      console.log(age);
  }

  bar.prototype.friend = 'kevin';
  var bindFoo = bar.myBind(foo, 'daisy');

  var obj = new bindFoo('18');

使用 new 时,绑定函数内的 this 值会被改成 fBound()(仍然是this指向的基本规则:this 永远指向最后调用它的那个对象),

而外层的 self = this ,self 则会指向调用 myBind() 的对象,即 bar

    let fEmpty = function() {}
    fEmpty.prototype = self.prototype
    fBound.prototype = new fEmpty()

通过这3行代码,使 fBound.prototype 指向了 self.prototype, 所以用this instanceof self 可以判断「绑定后函数」是否用作了构造函数。

这部分的内容已经逐步深入到 JavaScript 的内部实现原理之中,确实有些晦涩,可以通过亲手写代码并多多调试观察来加深理解。

参考资料:

JavaScript深入之call和apply的模拟实现JavaScript深入之bind的模拟实现可能遇到假的面试题:不用call和apply方法模拟实现ES5的bind方法