手写【call,apply,bind】——多版本带你学会

309 阅读8分钟

image.png

1.call的重写

  • call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。
  • call() 允许为不同的对象分配和调用属于一个对象的函数/方法。
  • call() 提供新的 this 值给当前调用的函数/方法。你可以使用 call 来实现继承:写一个方法,然后让另外一个新的对象来继承它(而不是在新对象中再写一次这个方法)。

以上是MDN中文文档给出的定义,我们来逐句分析一下意思并提取一下重点。

  1. 使用一个指定的this?这个指定的this值就是我们手动选择的绑定对象,也就是传入call()方法的第一个参数。单独给出一个或多个参数来调用一个函数?这句话意思是,call()方法需要被一个函数来调用,并把除第一个外的剩余参数传入该函数。
  2. call() 允许为不同的对象分配和调用属于一个对象的函数/方法?这句话意思是当call方法被某个函数调用时,call可以指定任意的对象为该函数执行时的this指向。
  3. call() 提供新的 this 值给当前调用的函数/方法?新的this值就是上面我们说到的传入call()方法的第一个参数,也就是我们指定的任意一个对象。

现在我们可以总结到:call方法接收的所有参数中第一个参数一定是我们指定的绑定对象,也就是指定的this值,后面剩余的参数是传入借用的函数的参数。接下来让我们用代码来解释说的啥意思。

function foo(a, b) {
  console.log(this.name, a , b);
  return a+b
} 

const obj = {
  name: 'tony'
}

console.log(foo.call(obj,1,2))  
// tony 1 2
// 3

foo.call(obj, 1, 2)调用call方法首先传入想要绑定的对象(也就是this值)为obj,再将实参1,2传入foo()函数,这里的foo()就是我们借用的函数,而它的内部this指向就变成了我们指定的obj对象。可能看到这里,有的小伙伴还是有点晕,方便理解可以把context看作this值是完全没问题的。多的不说,让我们来重写call来加深理解。

重写之前我们必须要分析清楚该方法有什么功能,要注意什么细节。

  1. call()方法必须被函数/方法来调用
  2. call()方法接收一个或多个参数,且第一个参数为我们指定的this
  3. call()方法未传入参数时,指定的this值会绑定成全局对象(非严格模式下)
  4. call()方法借用的函数如果有返回值,则需要将结果返回,没有返回值则返回undefined

老版本实现call()方法

// 模拟symbol用法 添加在this值上的属性名不重复 实际上这里不能确保唯一性
function randomString() {
    return  Math.random().toString(16).substring(2,8) + new Date().getTime().toString(16)  
}
// 方法以函数的方式调用,所以需要放在Function的原型对象上,让函数通过原型链引用
Function.prototype.myCall = function(context) {
// 判断myCall方法是否以函数或方法的方式被调用
  if (typeof this !== 'function') {
    throw new Error('not a function')
  };
// 非严格模式下未传入this值则默认绑定到全局对象
  context = context || window;
  var symbolKey = randomString();
  var args = [];
// 将借用函数设置成context的方法
  context[symbolKey] = this;
// 收集参数
  for (var i = 1; i < arguments.length; i++) {
    args.push(arguments[i])
  };
// 用字符串拼接展开参数  这里直接用eval来运行了比较方便因为里面接收就是字符串
  var res = eval("context[symbolKey](" + args + ")");
// 添加了属性记得删除
  delete context[symbolKey];
// 返回值
  return res;
}
console.log(foo.myCall(obj,1,2))
// tony 1 2
// 3

上面代码可以粗略理解成

function myCall(context, ...args) {
    context.fn = foo;
    context.fn(...args);
    delete context.fn
}

context为我们指定的this值,借用的函数foo赋值给绑定对象context新添加的fn属性。而这也是为什么能指定函数的this指向的本质,其实就是让该函数作为指定对象的方法执行罢了

新版本实现call()方法

使用es6的Symbol()和扩展操作符...的语法更加简洁。

Function.prototype.myCall = function(context, ...args) {
  if (typeof this !== 'function') {
    throw new Error('not a function')
  };
  context = context || window;
  const symbolKey = Symbol();
  context[symbolKey] = this;
  const res = context[symbolKey](...args);
  delete context[symbolKey];
  return res;
}

2. apply的重写

  • apply() 方法调用一个具有给定 this 值的函数,以及以一个数组(或一个类数组对象)的形式提供的参数。
  • 虽然这个函数的语法与 call() 几乎相同,但根本区别在于,call() 接受一个参数列表,而 apply() 接受一个参数的单数组

根据上面给出的定义,不难发现确实与call()基本一致,本质区别就是传入参数的方式不同。

老版本实现apply()方法

function randomString() {
    return  Math.random().toString(16).substring(4,6) + new Date().getTime().toString(16)  
}

Function.prototype.myApply = function(context) {
  if (typeof this !== 'function') {
    throw new Error('not a function')
  };
  context = context || window;
  var symbolKey = randomString();
  var args = [];
  context[symbolKey] = this;
  // 这里需要把arguments的第二个元素拿出来 就是我们需要的参数数组
  for (var i = 0; i < arguments[1].length; i++) {
  // 拿到参数数组 循环存进args里
    args.push(arguments[1][i])
  };
  var res = eval("context[symbolKey](" + args + ")");
  delete context[symbolKey];
  return res;
}

console.log(foo.apply(obj, [1, 2]));
// tony 1 2
// 3
console.log(foo.myApply(obj, [1, 2]));
// tony 1 2
// 3

新版本实现apply()方法

Function.prototype.myApply = function(context, args) {
  if (typeof this !== 'function') {
    throw new Error('not a function')
  };
  context = context || window;
  const symbolKey = Symbol();
  context[symbolKey] = this;
  const res = context[symbolKey](...args);
  delete context[symbolKey];
  return res;
}

3. bind的重写

  • bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
  • 调用绑定函数时作为 this 参数传递给目标函数的值。如果使用new运算符构造绑定函数,则忽略该值。

bind方法是比较复杂的一个,但是可以总结重写bind()方法需要注意两个问题:

  1. 一个是偏函数的特性(即可以部分传参,然后返回一个接收剩余参数的新函数)
  2. 一个是原型的问题(当用new调用返回的新函数)

bind方法比较复杂的点在于它需要返回一个新函数,然后我们正常(不以new操作符方式)调用返回的新函数时this值仍然指向我们的指定对象。

官方的bind方法

function foo(a, b) {
  console.log(this.name, a , b);
  return a+b
} 

const obj = {
  name: 'tony'
}

const bar = foo.bind(obj, 1)
console.log(bar(2));
// tony 1 2
// 3
const p = new bar(2)
console.log(p);
// undefined 1 2
// foo {}

基于apply实现bind方法的重写

  // 可部分传参  
Function.prototype.myBind = function(context) {
  // 是否以函数方式调用myBind()方法 
    if (typeof this !== 'function') {
      throw new Error('not a function')
    }
  // 是否传入绑定this值 即绑定对象 
    context = context || window;
  // 存储借用函数
    const _this = this;
  // 收集第一次传入的参数
    const args = Array.prototype.slice.call(arguments,1)
  // 判断是否通过new来调用
    const bound = function () {};
    const res =  function() {
      return _this.apply(          // 将第一次收集的参数和剩余参数合并
        (this instanceof bound? this : context),args.concat(Array.prototype.slice.call(arguments)))
    }
    // 判断借用的函数是否有可枚举的prototype属性
    if (this.prototype) {
    // 把临时函数链接到this值的原型对象
        bound.prototype = this.prototype;
    }
    // 将返回的新函数的原型对象作为临时函数的实例对象 即新函数通过原型继承
    res.prototype = new bound();
    return res
}

const bar1 = foo.myBind(obj, 1)
console.log(bar1(2));
// tony 1 2
// 3
const p = new bar1(2)
console.log(p);
// undefined 1 2
// foo {}

显然功能我们也成功实现了,现在我们可以回顾整段代码的逻辑。首先我们写的方法是可以被任意函数调用,所以我们需要把方法定义在Function构造函数的原型对象上,这样函数就能通过原型链查询到共享的myBind方法

  1. myBind方法必须是通过函数或方法来调用,所以可以先做个判断。
  2. 如果myBind调用时未传入绑定对象,则默认绑定到全局对象上。
  3. 偏函数特性,所以在调用myBind方法时可以传部分参数,剩余参数则可以传入返回的新函数。(需要实现可以两次传参的功能)
  4. 如果通过new操作符来调用返回的新函数则忽略之前的绑定对象(这里需要操作原型)。
  5. 操作原型时,需要把返回的新函数的原型对象链接到借用函数的原型对象,实现继承,此时需要判断该借用函数的prototype是否可以枚举。(上面这种方法不能枚举的话拿不到它的原型对象)
  6. callapply重写时会删除新添加的属性,但是重写的bind删除不了,返回的新函数引用着外部的借用函数。
  7. 如果借用的函数有返回值也需要把结果返回。

基于call实现bind方法的重写

Function.prototype.myBind = function(context, ...args1) {
    if (typeof this !== 'function') {
      throw new Error('not a function')
    } 
    context = context || window;
    const _this = this;
    const bound = function () {};
    const res =  function(...args2) {
      return _this.call(
        (this instanceof bound? this : context), ...args1, ...args2)
    }
    if (this.prototype) {
        bound.prototype = this.prototype;
    }
    res.prototype = new bound();
    return res
}

原型继承问题

  1. 可以通过Object.create解决原型继承,就不需要借用临时函数的方式来写
Function.prototype.myBind = function(context, ...args1) {
  if (typeof this !== 'function') {
    throw new Error('not a function')
  }
  context = context || window;
  const args = [].slice.call(arguments,1)
  const _this = this
  const res = function(...args2) {
    return _this.call(
      (this.isPrototypeOf(_this) ? this : context),...args1, ...args2);
  }
  if (this.prototype) {
    res.prototype = Object.create(_this.prototype)
  }
  return res
}
  1. 为什么需要判断this.prototype是因为有的函数的prototype属性不可枚举所以直接使用instanceofObject.create()有时候会出错,因为这时候拿到的prototype值为undefined

重写bind方法的改进版本(解决了拿不到prototype的问题)

Function.prototype.myBind = function (context, ...args1) {
  if (typeof this !== 'function') {
    throw new Error('not a function')
  }
  context = context || window;
  const args = [].slice.call(arguments, 1)
  const _this = this
  const res = function (...args2) {
    return _this.call(
      (this.isPrototypeOf(_this) ? this : context), ...args1, ...args2);
  }
  res.prototype = Object.create(Object.getPrototypeOf(_this));
  return res;
}

实例测试

如果你看到这里,说明你已经对上面各种绑定方式有大概的理解了。接下来通过一波骚操作展示一下这些绑定的神奇用法。

官方的call,apply方法

Function.prototype.foo = function() {
        const fn = this;
        return function() {
          const _this = [].shift.call(arguments)
          return fn.apply(_this, arguments)
        }
}

const obj = {};
push = [].push.foo();
console.log(push(obj,'one', 'two')); // 2
console.log(obj); // { '0': 'one', '1': 'two', length: 2 }

我们重写的myCall,myApply方法也实现了

Function.prototype.foo = function() {
        const fn = this;
        return function() {
          const _this = [].shift.myCall(arguments)
          return fn.myApply(_this, arguments)
        }
}

const obj = {};
push = [].push.foo();
console.log(push(obj,'one', 'two')); // 2
console.log(obj); // { '0': 'one', '1': 'two', length: 2 }

如果通过bind方法则更简单

Function.prototype.foo = function (context) {
  const res = this.bind(context);
  return res;

}

const obj = {};
push = [].push.foo(obj);
console.log(push('1', '2'));
console.log(obj);
console.log(push('3', '4'));
console.log(obj);
console.log(push('5', '6'));
console.log(obj);

我们重写的myBind也完美实现了。

Function.prototype.foo = function (context) {
  const res = this.myBind(context);
  return res;

}

const obj = {};
push = [].push.foo(obj);
console.log(push('1', '2'));
console.log(obj);
console.log(push('3', '4'));
console.log(obj);
console.log(push('5', '6'));
console.log(obj);

结语

callapplybind三种绑定方式使用都很频繁,使用场景也很多,所以想掌握清楚还需要多多实战,多写多用。码字不易,喜欢的话不妨点个赞。

小鸡动画_15_爱给网_aigei_com.gif