唠一唠call、apply和bind以及手动实现(拒绝晦涩难懂)

1,726 阅读9分钟

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


最近突然想在弄弄基础的东西了,就盯上了这个,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))
    }
}