# 剖析call、apply、bind

# call

TIP

实现方式可能不是最好的,欢迎指正

# 草稿版

# 原生call回顾

  • 在实现之前我们先来看一下原生的call方法是如何使用,这个很重要,如果对原生的call理解不够透彻,对后面我们要自己实现会有一点障碍
let husky = {
  name:'husky',
  print:function(age){
    console.log(this.name,age);
  }
}
let keji={
  name:'keji'
}

husky.print.call(hh,5) // 输出:keji 5
1
2
3
4
5
6
7
8
9
10
11
  • 可以看到这里输出的是keji 5而不是husky 5 是因为我们改变了this指向
  • OK 回顾之后,我们开始本次实验

# 草稿版开始

  • 先定义一个$call方法,用来模拟call方法
  • 接下来实现一个方便理解的版本,上代码,先看代码,在讲解
function $call() {
  // 第一行
  let target = arguments[0]

  let currentFn = Symbol('$call')

  target[currentFn] = this;

  let args = [...arguments].slice(1)

  return target[currentFn](...args)
}
1
2
3
4
5
6
7
8
9
10
11
12

# 开始讲解

# 第一行
  let target = arguments[0]
1
  • 第一行很简单,就是通过arguments的方式来拿参数,arguments是一个类数组类型,具体什么是类数组,先不过多讨论,在这里我们就把他当作数组
  • 此时第一个参数就是this改变指向后的对象,后面这里会有优化
  let currentFn = Symbol('$call')
1
# 第二行
  • 第二行代码,其实就是声明一个变量,一个不会重复的变量,作用就是保证不会影响其他对象的属性
  • 如果对Symbol还不了解的小伙伴可以先搜一下,再继续,保证后面逻辑的通畅性,还是要了解一下,不用深入了解特性就好
  target[currentFn] = this;
1
# 第三行
  • 第三行代码,可以看到第一行和第二行声明的变量在这里就用上了
  • 先说等号左边的代码target[currentFn]这种写法,就是给target一个currentFn属性很好理解
  • 接下来就是等号右边这个this是个啥东西,在还没有写这篇文章之前,每次看到这里,都很不理解,只好背下来,但是容易忘
  • 为了好理解这个this是什么,我们也不从理论上来理解,来个实验,但是最后还是要落实到理论上的
let test = {
  attr1:'属性一',
  call:function(){
    console.log(this);
  }
}
test.call() // {attr1: "属性一", call: ƒ}
1
2
3
4
5
6
7
  • 可以看到我们这里定义了一个test对象,给他定义了一个call方法,在call方法中来打印日志,打印这个this
  • 这个this输出的是{attr1: "属性一", call: ƒ},我们可以理解为,谁调用了这个call,这个this就是谁
  • 好按照这个方式理解,我们的第三行代码中等号右边的this其实就是print这个函数(函数也是对象)
  husky.print.call(hh,5)
1
  • 相信到这里,可能理解了这个this,还有其他???,为什么print函数会有call方法,这里先记下,在第五行之后讲
  • 一定要记得,其实对原型链掌握很好的小伙伴应该不会有这个疑问
# 第四行
let args = [...arguments].slice(1)
1
  • 第四行代码,也很好理解,对在$call中传递的参数,除了第一个参数(在之前第一行已经处理了),保存到变量args
  • [...arguments]的方式来处理参数,是因为arguments是类数组,之前提到过,他的原型中没有数组的很好用的工具方法
  • slice(1)很简单,就是截取数组,从索引1的位置一直截取到最后一个元素
# 第五行
  return target[currentFn](...args)
1
  • 这个就简单了,就是调用targetcurrentFn方法,并且传入参数
  • return 就相当于返回 target[currentFn]调用后的返回值
# 核心逻辑完成,接下来是组装环节
  • 在第三行代码留了一个?没有说,接下来解惑,解涉及到原型链
  • 我们先随便写一个函数,并打印它,看一下他的原型
  console.dir(function(){});
1
dock
  • 可以看到其实call方法是在该方法原型链上,所以它可以调用call方法,具体原型链分析事先了解,后续我也会单独开一篇文章讲一下
  • 那么在组装之前还要了解一下Function构造函数,我们再看一个示例
  console.dir(new Function());
1
dock
  • 根据这个示例,其实在声明function(){}这个函数的时候,就相当于执行了new Function()
  • OK根据这些特性,我们得到了思路,把$call方法挂载到Function构造函数的原型上即可
  Function.prototype.$call = $call
1
  • 此时我们自定义的$call方法就挂载到了Function的原型上了

# 优化版

# $call核心逻辑优化

  • 理解了之前的几行代码,我们还可以继续优化一下
  • 按照习惯,上示例
let husky = {
  attr:'husky',
  print:function(age){
    console.log(this.attr, age);
  }
}

husky.print.call('test',5) // undefined 5
husky.print.call(1, 5) // undefined 5
husky.print.call(true, 5) // undefined 5
husky.print.call(Symbol(), 5) // undefined 5
husky.print.call(null, 5) // 5
husky.print.call(undefined, 5) // 5
1
2
3
4
5
6
7
8
9
10
11
12
13
  • 可以看到当我们给call传递基本值类型的时候,原生的call方法,输出的是这些
  • 所以我们要对传入的参数做一下判断
function $call() {

  let arg_1 = arguments[0];

  let target = arg_1 ? Object(arg_1) : window;

  let other_args = [...arguments].slice(1);

  let currentFn = Symbol("$call");

  target[currentFn] = this;

  return target[currentFn](...other_args);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 根据上面原生call函数方法执行返回值,分析出,当传入的第一个参数值为undefinednull的时候,会把target赋值为window
  • 我们还要看一个实验,为什么要这么处理
function test(age) {
  console.log(this.name, age);
}
test('test') // 只输出:test 大家可以试一下
1
2
3
4
  • 此时这个this就相当于是window,window并没有定义name属性,看这个实验console.log(this.name, age)中的this.name并没有返回undefined

  • 根据这个特性,就可以解释,为什么当传入第一个参数是undefined或者null要赋值为window

# 原型挂载优化

  • $call方法增加属性配置,使其不可被遍历
Function.prototype.$call = $call
Object.defineProperty(Function.prototype,'$call',{
  enumerable: false
})
1
2
3
4

# apply

# 实现

  • 其实这个方法和我们自定义的$call唯一的区别就是传入的参数,原生的apply需要传入一个数组,所以我们也要传入一个数组
  • 区别二,就是需要对第二个参数进行判断是不是数组,如果不是抛出错误
 function $apply() {
  let arg_1 = arguments[0];
  
  let other_args = arguments[1] ? arguments[1] : []

  if (!Array.isArray(other_args) && other_args !== undefined)
    throw new Error("CreateListFromArrayLike called on non-object");

  let target = arg_1 ? Object(arg_1) : window;

  let currentFn =  Symbol("$apply");

  target[currentFn] = this;

  return target[currentFn](...other_args);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Function.prototype.$apply = $apply
Object.defineProperty(Function.prototype,'$apply',{
  enumerable: false
})
1
2
3
4
  • ok是不是很简单,大部分逻辑不同就是在参数上

# bind

# 原生bind回顾

  • 这个就和callapply不同了,这个方法是直接返回一个改变了this指向的函数,参数可以在改变this指向的是传递,也可以在执行这个改变了this指向的函数时候再传递参数
let husky = {
  name: "husky",
  print: function (...age) {
    console.log(this.name, ...age);
  },
};

let keji = {
  name: "keji",
};

let hh = husky.print.bind(husky,'test1')
hh('test1') // husky test1 test1
1
2
3
4
5
6
7
8
9
10
11
12
13
  • ok,我们回顾了基础用法之后,就可以来实现

# 代码实现

  • 首先我们先看代码,可以先看一下代码,带着问题,看解析,效果会更好
function $bind() {
  let target = arguments[0];

  let other_args = [...arguments].slice(1);

  let currentFn = Symbol("$bind");

  target[currentFn] = this
  
  return function(){
    let secondParams = [...arguments]
    let mergeParams = other_args.concat(secondParams)
    target[currentFn](...mergeParams)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

#### 前4行代码

  • 前4行代码,我们在$call中已经基本介绍了,这里就不重复讲解了

# 第5行到第9行

  • 可以看到,我们这里返回了一个匿名函数,因为原生的bind也是返回一个方法,所以很容易想到,返回值是一个函数
  • 接下来就要处理参数了,首先两处传参的位置,我们是很明确的,一次是bind方法中可以传入,另一次是调用返回的函数时候传入的参数,并且这两次传入参数都有效果,所以我们就要合并两次参数,第7行代码就是这个作用
  • 第8行代码,就很好理解了,改变this指向,传入参数,在自定义$call中也已经分析过了

TIP

OK 到这里已经全部实现完毕了,其实重点将$call的实现研究透彻,$bind和$apply就很简单了

# 参考资料

Last Updated: 1/23/2022, 10:16:22 AM