绕了很久的call,apply,bind

503 阅读6分钟

首先来看一道网上很经典的面试题。

问:以下代码会输出什么?

for(var i = 0;i < 5; i ++) {
    setTimeout(() => {
        console.log(i)
    }, i * 1000)
}

答案是 每隔一秒输出一个5。

那么就会有接下来的问题,怎么修改才能输出0 1 2 3 4这样的结果呢?

es6中的let

for(let i = 0;i < 5; i ++) {
    setTimeout(() => {
    	console.log(i)
    }, i * 1000)
}

let相比于var会多一个块级作用域的概念。

闭包的形式实现

for(var i = 0;i < 5; i ++) {
    (function(j) {
    	setTimeout(() => {
	    console.log(j)
    	}, j * 1000)
    })(i)
}

setTimeout的第三个参数

for(var i = 0;i < 5; i ++) {
    setTimeout((j) => {
    	console.log(j)
    }, i * 1000, i)
}

以上是一些常见的答案,当然,当我答出这些之后,很明显没有满足面试官的需求。于是,面试官问了句,还有别的方法吗,词穷之下我没有给出其他答案,他提示了我一下考虑使用bind。然而,我不会用啊T_T…

为此,我特地去查阅了一下bind的用法,发现很多文章是把bind,call,apply放在一起讲解。因为他们的作用很像,只是中间过程有点区别。

bind

首先看下bind在MDN上的定义。bind()方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

由此我们可以知道,通过bind绑定过后的结果是一个新的函数,类似于return function的感觉。同时我们可以知道,bind() 方法的主要作用就是改变函数的this指向(万恶的this,不得不说JavaScript的this确实有点混乱)。光说可能会有点难以理解,还是通过几个实例来看一下bind()方法吧。

一个🌰

function fn(a, b, c) {
    return a + b + c
}
var _fn = fn.bind(null, 10)
var res = _fn(20, 30)
console.log(res) // 60

此时打印出来的数值是60。fn函数本来需要三个参数,在调用bind之后将第一个参数也就是10传入到fn中,后面在调用_fn的时候传入后面的几个参数,这个时候如果调用 _fn(20,30,40) 的话,也不用担心,参数只会取到前两个。所以,bind除了第一个参数之外,剩下的都会顺序的传入到被绑定的函数体内。

new

在官方定义中特别的提到了new操作符,并说明了new的优先级要高于bind。 使用bind()返回的是一个function,那么就可以被new调用,根据规范可知,当使用new操作符调用的时候,bind的第一个参数无效。

另一个🌰

function Person(name, age) {
  this.name = name;
  this.age = age;
}

var _Person = Person.bind({});
var p = new _Person('hanzichi', 30); // Person {name: "hanzichi", age: 30}

上面例子中this指向并没有被改变。

同样的,我在查阅的过程中还发现了许多提到丢失this的可能,比如异步回调的过程中就很容易发生。

setTimeout&setInterval

var canvas = {
    render: function() {
    	this.draw();
    },
    draw: function() {
    	...
    }
}

setInterval(canvas.render, 2000)

上面代码中就会出现丢失this的问题,即this的指向会变成window。产生这个问题的原因就是由于setTimeout和setInterval的语法是 (fn, timeout)。第一个参数fn是一个传递过程,也就是说上面代码会变成下面这个样子

setInerval(fn = canvas.render, 2000)

所以this的指向就变成了window。这个时候我们就可以通过bind函数来强行改变this指向。

setInterval(canvas.render.bind(canvas), 2000)

call和apply

这两个方法放在一起说是因为在mdn的文档上就很明白的标注这,这两个语法和作用都类似,只有一个区别,就是call()接收的是参数列表,而apply()接受的是一个数组。

call和apply的作用

  1. 改变this指向
var obj = {
    name: 'ssss'
}

function fn() {
    console.log(this.name)
}

fn.call(obj)

call方法的第一个参数是函数上下文的对象,也就是改变this的关键所在。上面代码中将obj通过call传入了fn中,因此代码演变成了下面这样

function fn() {
    console.log(obj.name) // ssss
}
  1. 借用别的对象的方法
function Person1() {
    this.name = 'person1'
}

function Person2() {
    this.getName = function() {
    	console.log(this.name)
    }
    Person1.call(this)
}

var person = new Person2()
person.getName() // person1

或者是通过js获取到的domlist本身是一个类数组,是无法调用数组方法的,此时我们也可以通过call或者apply来实现。

var nodes = Array.prototype.slice.call(document.getElementsByTagName('*'))

判断变量是否是数组

function isArray(obj) {
    return Object.prototype.toString.call(obj) === '[object Array]'
}

接下来我们再通过一个经典的面试题来巩固我们刚才的知识。

定义一个log函数,用来代理console.log方法。

function log(msg) {
    console.log(msg)
}

log(1) // 1
log(1, 2) // 1

本质实现并不复杂,但是当传入多个或者不确定个参数的时候就会出现问题。因此我们需要对方法进行改造优化一下。

function log() {
    console.log.apply(console, arguments)
}

log(1) // 1
log(1, 2) // 1 2

现在需求又发生了变化,我们想要在每次输出之前加上一个 myApp 的前缀。

log('ssss') // myApp ssss

这个时候应该怎么优化呢?

function log() {
    Array.prototype.unshift.call(arguments, 'myApp ')
    console.log.apply(console, arguments)
}

通过call让类数组得以调用数组原型上的unshift方法,最后将结果打印出来。

bind和call、apply的区别

call和apply会立刻执行,而bind方法返回一个函数,需要再次调用函数才会执行。

最后,各个大厂都会要求的造火箭环节。手写bind,call,apply三个函数。

手写call

首先再来看下call的作用,然后我们根据他实现的效果来实现需求。

var obj = {
	name: 'sss',
	getName: function() {
		console.log(this)
		console.log(this.name)
	},
}

obj.getName() // sss
var obj1 = {
	name: 'www'
}

obj.getName.call(obj1) // www

通过上面的知识我们知道call的过程是改变了this的指向。所以有以下实现

Function.prototype.myCall = function(context) {
    // this是函数内部的this指向
    // context是传入的新的调用者 在context上扩展一个新的属性指向this
    context.getName = this
    context.getName()
}

好的,初步的实现已经完成了,但是考虑到各种情况的出现,我们需要对这个方法进行优化兼容。

参考call的做法

  1. 当没有context的时候,我们需要给出一个默认值,也就是window。
  2. 接下来,context.getName这个属性是很容易冲突的,那么如何可以保证新申请的属性绝对不冲突呢?这里我们就可以联想到es6新出的symbol属性。
  3. 还有就是call是支持更多参数的传入的,所以我们可以考虑将参数带入到新的方法当中。

所以,优化过后的代码就会是这样。

Function.prototype.myCall = function(context, ...args) {
    context = context || window
    let symbol = Symbol()
    context[symbol] = this
    let fn = context[symbol](...args)
    delete context[symbol]
    return fn
}

此时一个call函数就实现完全啦。 apply方法和call方法只是对于参数不同而已,可以自己实现一下。

bind实现 bind方法与call和apply差别还是比较大的。因此实现起来也会有些不同。

Function.prototype.myBind = function(context) {
    context = context || window
    const _this = this
    funtion fn(args) {
    	_this.apply(this, args)
    }
    return fn
}

好的,关于这三个方法的理解到这里就说完啦,欢迎大家来评论指正~