乐趣区

唠一唠callapply和bind以及手动实现拒绝晦涩难懂

对我来说,博客首先是一种知识管理工具,其次才是传播工具。我的技术文章,主要用来整理我还不懂的知识。我只写那些我还没有完全掌握的东西,那些我精通的东西,往往没有动力写。炫耀从来不是我的动机,好奇才是。阮一峰


最近突然想在弄弄基础的东西了,就盯上了这个,callapplybind 的区别、原理到底是什么,怎么手动实现了;经过自己的收集总结了这篇文章;

文章分为理解和实现两部分,如果你理解这三个方法,可以直接跳到实现的部分;

理解 call、apply、bind

共同点和区别

在 javascript 中,call、apply、bind 都是 Function 对象自带的方法;
call、apply、bind 方法的的共同点和区别:

三者都是用来改变函数的 this 对象的指向的;

三者的第一个参数都是 this 要指向的对象,也就是上下文(函数的每次调用都会拥有一个特殊值 – 本次调用的上下文(context)– 这就是 this 的关键字的值);

三者都可以利用后续传参:

call:call([thisObj,arg1,arg2,…);

apply:apply(thisObj,[arg1,arg2,…]);

bind:bind(thisObj,arg1,arg2,…);

bind 是返回对应函数,便于稍后调用,apply、call 则是立即调用

call

定义:调用一个对象的调用一个对象的一个方法,以另一个对象替换当前对象。

说明:call 方法可以用来代替另一个对象调用一个方法。

thisObj 的取值有以下 4 种情况:

  • 1 不传,或者传 null,undefined,函数中的 this 指向 window 对象;
  • 2 传递另一个函数的函数名,函数中的 this 指向这个函数的引用;
  • 3 传递字符串、数值或布尔类型等基础类型,函数中的 this 指向其对应的包装对象,如 String、Number、Boolean;
  • 4 传递一个对象,函数中的 this 指向这个对象;

再来看一下 w3school 上的解释

是不是不太好理解!

代码试验一下可能会更加的直观:

function fn1() {console.log(this);   // 输出函数 fn1 中的 this 对象
}       

function fn2() {}       

let obj = {name:"call"};    // 定义对象 obj  

fn1.call();   //window
fn1.call(null);   //window
fn1.call(undefined);   //window
fn1.call(1);   //Number
fn1.call('');   //String
fn1.call(true);   //Boolean
fn1.call(fn2);   //function fn2(){}
fn1.call(c);   //Object

如果还不理解上面的,没关系,我们再来看一个栗子:

function class1(){this.name = function(){console.log("我是 class1 内的方法", this);
  }
}
function class2() {class1.call(this);
}

var f = new class2();
f.name();   // 调用的是 class1 内的方法,将 class1 的 name 方法交给 class2 使用, 在 class1 中输出 this, 可以看到指向的是 class2

函数 class1 调用 call 方法,并传入 this(this 为 class2 构造后的的对象),传入的 this 对象替换 class1 的 this 对象,并执行 class1 函数体实现了 class1 的上下文(确切地说算伪继承,原型链才算得上真继承)。也就是修改了 class1 内部的 this 指向,你看懂了吗?

再来看几个常用的栗子,加强一下印象。

function eat(x,y){console.log(x+y);
  console.log(this);
}
function drink(x,y){console.log(x-y);
  console.log(this);
}
eat.call(drink,3,2);

输出:5 
那么这个 this 呢?是 drink;

这个栗子中的意思就是用 eat 临时调用了 (或说实现了) 一下 drink 函数,eat.call(drink,3,2) == eat(3,2),所以运行结果为:console.log(5); 直白点就是用 drink,代替了 eat 中的 this,我们可以在 eat 中拿到 drink 的实例;

注意:js 中的函数其实是对象,函数名是对 Function 对象的引用。

看懂了吗?看看下边这段代码中输出的是什么?

function eat(x,y){console.log(x+y);
  const  func = this;
  const a = new func(x, y);
  console.log(a.names());
}
function drink(x,y){console.log(x-y);
  this.names = function () {console.log("你好");
  }
}
eat.call(drink,3,2); // 5 1 '你好'

继承(伪继承)

function Animal(name){   
  this.name=name;   
  this.showName=function(){console.log(this.name);   
  }   
}   
function Dog(name){Animal.call(this,name);   
}   
var dog=new Dog("Crazy dog");   
dog.showName(); // 'Crazy dog'

Animal.call(this) 的意思就是使用 Animal 对象代替 this 对象,那么 Dog 就能直接调用 Animal 的所有属性和方法。


apply

定义:应用某一对象的一个方法,用另一个对象替换当前对象。

说明:如果 argArray 不是一个有效的数组或者不是 arguments 对象,那么将导致一个 TypeError。

如果没有提供 argArray 和 thisObj 任何一个参数,那么 Global 对象将被用作 thisObj,并且无法被传递任何参数。

对于 apply、call 二者而言,作用完全一样,只是接受参数的方式不太一样。这里就不多做解释了;直接看 call 的就可以了;

call 需要把参数按顺序传递进去,而 apply 则是把参数放在数组里。

既然两者功能一样,那该用哪个呢?

在 JavaScript 中,某个函数的参数数量是不固定的,因此要说适用条件的话,当你的参数是明确知道数量时用 call;而不确定的时候用 apply,然后把参数 push 进数组传递进去。当参数数量不确定时,函数内部也可以通过 arguments 这个数组来遍历所有的参数。


bind

注意 :bind 是在 EcmaScript5 中扩展的方法(IE6,7,8 不支持),bind() 方法与 apply 和 call 很相似,也是可以改变函数体内 this 的指向,但是 bind 方法的返回值是 函数

MDN 的解释是 :bind() 方法会创建一个新函数,称为绑定函数,当调用这个绑定函数时,绑定函数会以创建它时传入 bind()方法的第一个参数作为 this,传入 bind()方法的第二个以及以后的参数加上绑定函数运行时本身的参数按照顺序作为原函数的参数来调用原函数。

也就是说,区别是,当你希望改变上下文环境之后并非立即执行,而是回调执行的时候,使用 bind() 方法。而 apply/call 则会立即执行函数。

var bar=function(){console.log(this.x);   
}
var foo={x:3}   
bar();  
bar.bind(foo)();
 /* 或 */
var func=bar.bind(foo);   
func();

输出:undefined
3

有个有趣的问题,如果连续 bind() 两次,亦或者是连续 bind() 三次那么输出的值是什么呢?像这样:

var bar = function(){console.log(this.x);
}
var foo = {x:3}
var sed = {x:4}
var func = bar.bind(foo).bind(sed);
func(); //?
 
var fiv = {x:5}
var func = bar.bind(foo).bind(sed).bind(fiv);
func(); //?

答案是,两次都仍将输出 3,而非期待中的 4 和 5。
原因是,在 Javascript 中,多次 bind() 是无效的。更深层次的原因,bind() 的实现,相当于使用函数在内部包了一个 call / apply,第二次 bind() 相当于再包住第一次 bind() , 故第二次以后的 bind 是无法生效的


手动实现

既然谈到实现其原理,那就最好不要在实现代码里使用到 call、aplly 了。不然实现也没有什么意义;

call(obj,arg,arg….)

目标函数的 this 指向传入的第一个对象,参数为不定长,且立即执行;

实现思路

  • 改变 this 指向:可以将目标函数作为这个对象的属性
  • 利用 arguments 类数组对象实现参数不定长
  • 不能增加对象的属性,所以在结尾需要 delete
Function.prototype.myCall = function (object, ...arg) {if (this === Function.prototype) {return undefined; // 用于防止 Function.prototype.myCall() 直接调用
    }
    let obj = Object(object) || window; // 加入这里没有参数,this 则要指向 window;
    obj.fn = this; // 将 this 的指向函数本身;obj.fn(...arg); // 对象上的方法,在调用时,this 是指向对象的。delete obj.fn; // 再删除 obj 的_fn_属性, 去除影响.
}

在验证下没什么问题(不要在细节):

这是 ES6 实现的,不使用 ES6 实现,相对就比较麻烦了,这里就顺便贴一下吧

Function.prototype.myCall = function(obj){let arg = [];
    for(let i = 1 ; i<arguments.length ; i++){arg.push( 'arguments[' + i + ']' ) ;
        // 这里要 push 这行字符串  而不是直接 push 值
        // 因为直接 push 值会导致一些问题
        // 例如: push 一个数组 [1,2,3]
        // 在下面???? eval 调用时, 进行字符串拼接,JS 为了将数组转换为字符串,// 会去调用数组的 toString()方法, 变为 '1,2,3' 就不是一个数组了,相当于是 3 个参数.
        // 而 push 这行字符串,eval 方法,运行代码会自动去 arguments 里获取值
    }
    obj._fn_ = this;
    eval('obj._fn_(' + arg + ')' ) // 字符串拼接,JS 会调用 arg 数组的 toString()方法,这样就传入了所有参数
    delete obj._fn_;
}

aplly(obj,[…arg])

其实知道 call 和 apply 之间的差别,就会发现,它们的实现原理只有一点点差别,那就是后面的参数不一样,apply 的第二个参数是一个数组,所以可以拿 call 的实现方法稍微改动一下就可以了,如下:

Function.prototype.myApply = function (object, arg) {let obj = Object(object) || window; // 如果没有传 this 参数,this 将指向 window
    obj.fn = this; // 获取函数本身,此时调用 call 方法的函数已经是传进来的对象的一个属性,也就是说函数的 this 已经指向传进来的对象
    获取第二个及后面的所有参数(arg 是一个数组)
    delete obj.fn(arg); // 这里不要将数组打散,而是将整个数组传进去
}

bind

bind 方法被调用的时候,会返回一个新的函数,这个新函数的 this 会指向 bind 的第一个参数,bind 方法的其余参数将作为新函数的参数。

为返回的新函数也可以使用 new 操作符,所以在新函数内部需要判断是否使用了 new 操作符, 需要注意的是怎么去判断是否使用了 new 操作符呢?在解决这个问题之前,我们先看使用 new 操作符时具体干了些什么,下面是 new 操作符的简单实现过程:

function newFun(constructor){
    // 第一步:创建一个空对象;let obj = {};
    // 第二步:将构造函数的 constructor 的原型对象赋值给 obj 原型;obj.__proto__ = constructor.prototype;
    // 第三步:将构造函数的 constructor 中的 this 指向 obj,并立即执行构造函数的操作;constructor.apply(obj);
    // 第四步:返回这个对象;}

new 操作符的一个过程相当于继承,新创建的构造函数的实例可以访问构造函数的原型链;

在 new 操作符实现过程的第三步中,会将构造函数 constructor 中的 this 指向 obj, 并立即执行构造函数内部的操作,那么,当在执行函数内部的操作时,如果不进行判断是否使用了 new,就会导致 ” 将构造函数 constructor 中的 this 指向 obj ” 这一过程失效;

Function.prototype.myBind = function (context, ...args1) {if (this === Function.prototype) {throw new TypeError('Error')
    }
    const _this = this
    return function F(...args2) {
        // 判断是否用于构造函数
        if (this instanceof F) {return new _this(...args1, ...args2)
        }
        return _this.apply(context, args1.concat(args2))
    }
}
退出移动版