前端面试题 ---- 手撕JavaScript call apply bind 函数(超详细)

Posted ItDaChuang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端面试题 ---- 手撕JavaScript call apply bind 函数(超详细)相关的知识,希望对你有一定的参考价值。

手撕javascript call apply bind 函数

一、call apply bind 函数的基本用法

  首先这三个函数都是函数对象可用的三个方法,目的是为了改变调用者内部的 this 指向问题。下面简单介绍下他们的区别,详细内容可以看 其他文档

  例如我们定义如下 Person 函数:

function Person(){
	console.log(this.name)
}
Person();			// undefined
Person.call();		// undefined
Person.apply();		// undefined
let fun = Person.bind();
fun();				// undefined

  我们就可以利用 Person 这个函数对象去调用 call、apply、bind 这三个方法,这是为什么呢?其实这几个方法都在 Function 构造函数的原型对象上,而我们定义的 Person 构造函数就是 Function 构造函数的实例对象,Person 的原型 ( _proto_ ) 就是指向 Function 的原型对象的,所以可以通过原型链找到这三个方法。

  这三个函数都可以用于改变调用者内部的 this 指向的,第一个参数就是用于设置内部 this 所指的对象,之后可以有多个参数都将作为调用者的参数。

function Person(arg1,arg2){
	console.log(this.name+arg1+arg2);
}
let o = {
	name:"Dog"
}
Person("Arg1","Arg2");					// undefinedArg1Arg2
Person.call(o,"Arg1","Arg2");			// DogArg1Arg2
Person.apply(o,["Arg1","Arg2"]);		// DogArg1Arg2
let fun = Person.bind(o,"Arg1","Arg2");
fun();									// DogArg1Arg2

  从上述代码可以看出以下几点注意事项:

  call 和 apply 函数的调用会直接运行调用者函数,不同之处是他们传入参数的形式不一样,call 是将参数一个一个的传入apply 是将参数放入数组中传入

  bind 是暂时将调用者与要指定的 this 对象绑定在一起,但不执行调用者函数,可以暂时的保存这个绑定的函数,随后再执行。传参的形式与 call 一样。

  底层原理:

  为什么 Person.call(o) 就能将 Person 函数里的 this 指向 o 对象呢?其实这行代码相当于 o.Person()。即底层相当于在 o 对象中加入了一个 Person 方法。

function Person(){
	console.log(this.name);
}
let o = {
	name:"Dog"
}
Person.call(o);		// 相当于下面的代码
o.Person = function(){
	console.log(this.name);
};
o.Person();
/* 在调用 call 过程中 o 对象其实如下,之后 o 又恢复到原来的样子
o = {
	name:"Dog",
	Person:function(){
		console.log(this.name);
	}
}
*/

二、手写 myCall 方法

ES6 实现 myCall 方法

  • 确定函数位置

  因为我们要实现的 myCall 方法也是每个函数对象都能调用的方法,所以也要把这个方法写在 Function 的原型对象上。

Function.prototype.myCall = function(){
}
  • 确定函数参数

  在来考虑这个函数的参数,第一个参数是一个对象,用来修改调用者函数内部的 this 指向的对象,之后可以有多个参数,用于产给调用者函数作为参数的(这里可以用扩展操作符把第二个之后所有的参数都收集起来)。扩展操作符参考:10.6、参数扩展与收集

Function.prototype.myCall = function(obj,...args){
}
  • 确定函数内部的 this 指向

  这时,我们要明白一个事情就是在 myCall 内部的 this 指向什么?

  在除了箭头函数中的所有函数中 this 永远指向它的调用者。那么在我们后面要调用 myCall 的语句中例如: Person.myCall(o) 可以知道,调用 myCall 的调用者就是一个函数 Person ,所以 myCall 函数内部的 this 就是指向这个 Person 调用者函数

  • 为对象内部添加调用者函数

  接下来要做的就是将这个函数添加到传入的对象内部,让该对象拥有这个方法,可以调用。

Function.prototype.myCall = function(obj,...args){
    obj.fun = this;
}

  上述代码将调用 myCall 函数的调用者函数保存到了传入参数 obj 对象的内部 fun 属性(新建的属性)上,即现在的 obj 对象内部多了个 fun 方法,该方法名指向调用者函数。fun 这个属性名可以随便取,没有关系。

  • 调用外部的调用者函数

  obj 对象有了调用者函数后,因为 myCall 函数是要直接执行调用者函数的,所以在 myCall 内部直接执行该函数,这里需要将 myCall 第二个之后的参数作为调用者函数的参数传入,我们再次利用扩展操作符将 myCall 收集到的参数 args 数组展开。

Function.prototype.myCall = function(obj,...args){
    obj.fun = this;
    obj.fun(...args);
}
  • 删除内部的调用者函数,恢复原有结构

  执行完 fun 函数后,我们需要把这个属性给删除掉,不能改变了原 obj 对象的内部结构。

Function.prototype.myCall = function(obj,...args){
    obj.fun = this;
    obj.fun(...args);
    delete obj.fun;		// 删除添加的方法 fun 
}

  到这里,我们就基本实现了类似于 call 功能的 myCall 函数。不过还有一些细节地方需要处理。

  • 完善第一个参数问题

  问题一:就是当我们的第一个参数传入的是 null 时,我们自己写的 myCall 方法不能正常工作,而 call 可以。这时我们需要对参数 obj 进行一个处理。

Function.prototype.myCall = function(obj,...args){
    obj = obj || window;	// 当 obj 没传或传入 null 时,默认指向 window
    obj.fun = this;
    obj.fun(...args);
    delete obj.fun;		
}
  • 完善返回值问题

  问题二:还有一个问题就是调用者函数的返回值问题,如下第一段代码,这个时候我们上面所写的 myCall 方法不能很好的返回调用者函数的返回值。原 call 可以。

function Person(arg1,arg2){
	console.log(this.name+arg1+arg2);
    return 123;
}
let o = {
	name:"Dog"
}
Function.prototype.myCall = function(obj,...args){
    obj = obj || window;
    obj.fun = this;
    obj.fun(...args);
    delete obj.fun;		
}
let res1 = Person.myCall(o,"Arg1","Arg2");		// DogArg1Arg2
console.log(res1);    // undefined
let res2 = Person.call(o,"Arg1","Arg2");		// DogArg1Arg2
console.log(res2);    // 123
  • 完整版的 myCall 函数

  此时只需要将 myCall 内部调用外部调用者函数的结果保存并最终返回即可。如下第二段代码。

Function.prototype.myCall = function(obj,...args){
    obj = obj || window;
    obj.fun = this;
    let res = obj.fun(...args);		// 将调用者函数运行的结果保存
    delete obj.fun;
    return res;						// 将调用者函数运行的结果返回
}

  上述代码就是 ES6 中最终的手写 myCall 方法。

ES5 实现 myCall 方法

  用 ES5 实现 myCall 的主要问题是不能用 ES6 中的扩展语法实现参数的传递。这时主要就是去解决参数的传递问题。这时 myCall 的参数我们可以用内置的 arguments 来接收。但是 myCall 的第一个是不需要作为内部调用者函数执行的参数的。

Function.prototype.myCall = function(obj){
    obj = obj || window;
    obj.fun = this;
    // 将传入的第二个之后的参数要传入调用者函数,下行代码只是示例期望的样子,不符合语法的。
    let res = obj.fun(arguments[1],arguments[2],...);		
    delete obj.fun;
    return res;						// 将调用者函数运行的结果返回
}

  当不使用 ES6 的扩展语法时,我们可以采用 eval 函数(能够解析 JavaScript 代码)。

  我们需要将执行调用者函数的语句写成字符串的形式作为 eval 函数的参数传入如下示例,需要解决的问题是如何将 fun 的参数动态的生成如下的形式。

eval("obj.fun(arguments[1],arguments[2],...)");

  这里可以新建一个数组,用来保存 myCall 传入的第二个及之后的所有参数。如下代码:

Function.prototype.myCall = function(obj){
    obj = obj || window;
    obj.fun = this;
    const arrs = [];
    for(let i = 1;i<arguments.length;i++){
    	arrs.push(arguments[i]);
    }
}

   接下来需要将数组里的内容按, 分开插入eval 函数的相应位置即可。

  • 完整版的 myCall 函数
Function.prototype.myCall = function(obj){
    obj = obj || window;
    obj.fun = this;
    const arrs = [];
    for(let i = 1;i<arguments.length;i++){
    	arrs.push("arguments["+ i +"]");		
    }
    // 此时 arrs 就是:[arguments[1],arguments[2],...]
    eval("obj.fun("+ arrs +")");
    delete obj.fun;
    return res;						// 将调用者函数运行的结果返回
}

  这样我们 ES6 之前的手写 myCall 版本也就实现了。

  上述代码中要理解一个知识点就是。当把数组加一个空字符串打印时,就是把数组的所有元素以 , 逗号分割打印:

arr1 = [1,2,3,4];
console.log(arr1);		//  [1,2,3,4]
console.log(arr1+"");   // 1,2,3,4

三、手写 myApply 方法

  有了前面 myCall 方法的分析过程和 call 与 apply 函数的细微区别,我们可以很容易改造出自己的 myApply 方法。

  • ES6 之后版本
Function.prototype.myApply = function(obj,args){
    obj = obj || window;
    obj.fun = this;
    let res = null;
    if(!args){
    	res = obj.fun();
    }else{
    	res = obj.fun(...args);	
    }
    delete obj.fun;
    return res;						// 将调用者函数运行的结果返回
}
  • ES6 之前版本
Function.prototype.myCall = function(obj,args){
    obj = obj || window;
    obj.fun = this;
    const arrs = [];
    let res = null;
    if(!args){
    	res = obj.fun();
    }else{
    	for(let i = 0;i<args.length;i++){
    		arrs.push("args["+ i +"]");		
    	}
    	eval("obj.fun("+ arrs +")");
    }
    delete obj.fun;
    return res;						// 将调用者函数运行的结果返回
}

四、手写 myBind 方法

  bind 的不同之处是 bind 函数返回的是一个函数,而不执行调用者函数。第二是 bind 函数具有柯里化特性的即:参数可以在绑定 this 时传入一部分,在调用时再传入一部分。

function Person(a,b){
	console.log(this.name+a+b);
}
const o = {
	name:"Dog"
}
Person.bind(o,1)(2);    // Dog12

  上述代码中第七行的 Person.bind(o,1) 是一个函数,如果没有后面的括号2 (2) 就不会调用,有了括号才调用的,并且参数是在绑定 this 时传入了 a 为 1,调用时传入来了 b 为 2。

  在写自己的 myBind 方法时,内部的 this 绑定同 myCall 和 myApply 一样,主要解决返回值和柯里化特性。myBind 要返回一个函数,所以基本结构如下:

Function.prototype.myBind = function(){
	return function(){
	}
}

  上述代码中要分析清楚两个地方的 this 指向问题:

Function.prototype.myBind = function(){
    // 这里的 this 指向调用者函数
	return function(){
		// 这里的 this 指向 window
	}
}

  弄清楚上面的两个位置的 this 指向后,最终 myBind 函数是要返回调用者函数(可能修改内部的this)的。所以我们要在 return 的函数外部把外部的调用者函数保存起来(如下第 2 行)。然后在 return 返回的函数内部使用(这里就形成了闭包)。当然这里也可以 return 一个箭头函数,那么箭头函数里面的 this 就是外部环境中的 this 了,即调用者函数。

  绑定时的参数传入保存在数组 args1 中,如下第4行。调用时的参数保存在 args2 中,如下第 8 行。返回的函数中可以直接调用 apply 函数,并将外部传入的 obj 和所有的参数传入即可。

Function.prototype.myBind = function(obj){
    let that = this;		// 1.保存外部的调用者函数
    // 2. args1 以数组的形式保存绑定时传入的第二个之后的参数
    let args1 = Array.prototype.slice.call(arguments,1);	
    let args2 = null;	// 保存调用时传入的参数
	return function(){
        // 3. args2 以数组的形式保存调用时传入的参数
        args2 = Array.prototype.slice.call(arguments);
        // 4. 将 args1 和 args2 参数拼接后作为 apply 方法参数
		that.apply(obj,args1.concat(args2));
	}
}

以上是关于前端面试题 ---- 手撕JavaScript call apply bind 函数(超详细)的主要内容,如果未能解决你的问题,请参考以下文章

手撕前端面试题JavaScript

手撕前端面试题javascript

手撕前端javascript面试题---快速排序 | 全排列 | instanceof

JavaScript手撕前端面试题:事件委托 | 判断URL是否合法 | 全排列

JavaScript手撕前端面试题:手写new操作符 | 手写Object.freeze

JavaScript手撕前端面试题:手写Object.create | 手写Function.call | 手写Function.bind