call & apply & bind 实现

77 阅读8分钟

本篇文章带你从零开始 由浅入深实现 call apply bind,从优先级分析和相互调用关系来解析其原理

前置知识: 了解this的绑定规则:箭头函数的this指向,new,显式绑定(call apply bind),隐式绑定,默认绑定。

这里简单介绍一下:

  1. 默认绑定:当函数直接调用时,this在非严格模式下,会默认绑定到window(浏览器环境)或global(nodeJS环境),
  2. 隐式绑定:当函数通过.运算符调用,如obj.foo(),那么函数foo的this会依据就近原则指向最近的obj
  3. 显示绑定:函数可以通过bind、apply、call显示绑定this;函数通过new创建的对象的this会绑定到对象本身
  4. 箭头函数:箭头函数中的this指向其父级作用域的this

不同的绑定方式也存在优先级,优先级高的无法被优先级低的进行修改,优先级排序如下:箭头函数 > new > bind > call&apply > 隐式绑定 > 默认绑定

call & apply

MDN概念:

  1. Function 实例的 call()  方法会以给定的 this 值和逐个提供的参数调用该函数。
  2. Function 实例的 apply()  方法会以给定的 this 值和作为数组(或类数组对象)提供的 arguments 调用该函数。

人话:call 和 apply的作用一致都是修改this,不过传递的参数存在区别。

  1. call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法
  2. apply() 方法在使用一个指定的 this 值和若干个指定的参数值组成的数组的前提下调用某个函数或方法

故下面我们以call的实现进行主要讲解

举个例子

var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call(foo); // 1

从例子中我们可以看出 call实际上做了两件事情:

  1. 改变this指向 指向foo
  2. 执行bar方法

这也正是我们实现call的关键点所在,实现的核心思想是:利用隐式绑定来实现显示绑定。同学可以先根据这一点先自己思考一下如何利用隐式绑定来实现this的更改效果,再看下面的实现代码。

实现代码

// 利用隐式绑定实现显式绑定
Function.prototype.myCall = function (that, ...rest) {
  that.fn = this // this 指向函数;that指向要绑定的对象;将函数添加到对象上再用对象来调用函数,实现隐式将this绑定到对象上
  that.fn(...rest)
}

代码解释:

  1. 首先要模拟原生call的使用方式 我们需要修改函数的原型 添加自己的myCall方法,这样即可通过bar.myCall()方式进行调用
  2. myCall接受的第一个参数是目标对象,并且支持传入调用的函数的参数,我们这里使用剩余参数的格式实现
  3. 其核心代码用人话来说就是在目标对象上添加自己的函数,再在目标对象上进行调用,这种调用就是隐式绑定,可以将函数的this绑定到目标对象上。对应到代码中 that就是目标对象,this指向被调用的函数(这也是隐式绑定,可以好好想想为什么),因此在that上添加this 并且调用this,就可以实现将函数的this指向目标对象的目的了

存在问题

  1. 修改了目标对象,添加了fn属性,这是不合理的副作用
  2. 缺少边界情况的处理,用户传入undefind和null怎么办

改进代码

// 利用隐式绑定实现显式绑定
Function.prototype.myCall = function (that, ...rest) {
  that = that || globalThis // 边界情况处理 处理用户传入undefined或者null的情况 默认绑定到全局this。注意在nodeJS中是globalThis 在浏览器环境中是window
  that.fn = this // this 指向函数;that指向要绑定的对象;将函数添加到对象上再用对象来调用函数,实现隐式将this绑定到对象上
  that.fn(...rest)
  delete that.fn
}

至此call的已基本实现,而apply就交给小伙伴自己去试试了~

bind

MDN概念:Function 实例的 bind()  方法创建一个新函数,当调用该新函数时,它会调用原始函数并将其 this 关键字设置为给定的值,同时,还可以传入一系列指定的参数,这些参数会插入到调用新函数时传入的参数的前面。

叽里咕噜一大堆总结成一句话:bind的作用同样是改变this指向,但是它不是立刻执行,而是返回一个改变了this指向+预设参数的函数

看个例子

var foo = {
  value: 'f',
};

function bar(param1, param2) {
  console.log(this.value + param + param2);
}

bar.bind(foo, "1")("2")  // f12
  1. bar.bind 接受的第一个参数也是目标this指向,但是它也支持传入函数的默认参数配置,比如代码中传入了"1",相当于之后在调用bar.bind 返回的函数时,param1会被默认置为"1"
  2. bar.bind 返回的是一个函数,因此代码中可以进一步调用函数 并且将"2"传递给param2

因此其任务和call近似,同样包括修改this指向,区别在于之前是直接调用 这边是返回一个函数,但实现思路大差不差 都是通过隐式绑定实现

实现代码

Function.prototype.myBind= function(that, ...rest){
    that = that || globalThis
    return (...args) => {
        that.fn = this;
        that.fn(...rest, ...args)
        delete that.fn
    }
}

代码解释

  1. 边界情况处理同上
  2. 修改this的方式同样是通过隐式绑定实现,但是有个细节点需要注意,返回的函数使用了箭头函数。这是为了确保函数内部的this才能够始终指向上层作用域的this 即指向函数;否则会变成默认绑定从而指向global。这是箭头函数的特性之一。

存在问题

这段代码无法处理使用bind之后使用返回的函数进行new操作的情况了,因为你无法new一个无法修改this的函数

举个例子:

Function.prototype.myBind = function(that, ...rest){
    that = that || globalThis
    return (...args) => {
        that.fn = this;
        that.fn(...rest, ...args)
        delete that.fn
    }
}

var foo = {
  value: 'f',
};

function bar(param1, param2) {
  console.log(this.value + param1 + param2);
}

const bindFunc = bar.myBind(foo, "1") 
const instance = new bindFunc("2") // error

修改方式

有同学会想了 既然是因为箭头函数无法进行new操作,那我们不用箭头函数不就好了?让我们来try try!

Function.prototype.myBind = function (that, ...rest){
	that = that || globalThis
	const curFn = this // 提前保存外层this指向的调用函数
	return function (...args) {
		that.fn = curFn; // 因为使用一般函数,其this是根据调用方动态变化的,所以我们需要手动获取上层的this 即curFn
		that.fn(...rest, ...args)
		delete that.fn
	}
}

var foo = {
  value: 'f',
};

function bar(param1, param2) {
  console.log(this.value + param1 + param2);
}

const bindFunc = bar.myBind(foo, "1") 
const instance = new bindFunc("2") // f12

似乎是修改成功了,但是隐隐约约中透露着一股不对劲。按理来说new出来的实例对象的指针应当是指向实例对象本身,但是我们的bind实现中并没有对这个处理,那如果构造函数中包含对实例对象的赋值 通过mybind指向的对象岂不是也会被修改?让我们来验证一下!

Function.prototype.myBind = function (that, ...rest) {
	that = that || globalThis
	const curFn = this // 提前保存外层this指向的调用函数
	return function (...args) {
		that.fn = curFn; // 因为使用一般函数,其this是根据调用方动态变化的,所以我们需要手动获取上层的this 即curFn
		that.fn(...rest, ...args)
		delete that.fn
	}
}

var foo = {
	value: 'f',
};

function bar(param1, param2) {
	this.value = "bar" // 在构造函数中进行赋值 添加实例的value
	console.log(this.value + param1 + param2);
}

const bindFunc = bar.myBind(foo, "1")
const instance = new bindFunc("2") // bar12 
console.log(foo) // { value: 'bar' }
console.log(instance) // {}

可以看到,在我们在实例中添加this.value = "bar"后,虽然构造函数的输出是正确的,但是其并没有在创建的实例中添加,反而修改了myBind绑定的foo对象

由于我们目前暂时不了解new的实现方式,所以我们根据已有代码进行推测,这可能是因为new操作的过程中可能存在一个调用构造函数的时机,但是我们myBind返回的构造函数中的this指向是绑定到myBind传入的参数 foo上,而没有绑定到实例对象上,故而修改了foo而没有修改实例。

这里推测暂时看不懂也没关系,等下面看完了new的实现再回头看就能串联起来了~

因此其可能的修改方式就是判断当前是不是在new一个实例,如果是就不修改this指向直接调用方法,如果是进行bind的逻辑处理。

但有同学可能又要一头雾水了,我怎么知道它是不是在new一个实例啊,这看起来也妹有区别啊?莫急,等我们先聊聊new的实现,理解其中原理,方可知晓真谛。

欲知后事如何,且听下回分解~