JavaScript進階之模擬call,apply和bind

EdwardXuan發表於2019-02-25

原文:zhehuaxuan.github.io/2019/02/26/…
作者:zhehuaxuan

目的

在JavaScript中有三種方式來改變this的作用域callapplybind。它們在前端開發中很有用。比如:繼承,React的事件繫結等,本文先講用法,再講原理,最後自己模擬,旨在對這塊內容有系統性掌握。

Function.prototype.call()

MDN中對call()解釋如下:
call()允許為不同的物件分配和呼叫屬於一個物件的函式/方法
也就是說:一個函式,只要呼叫call()方法,就可以把物件以引數傳遞給函式。

如果還是不明白,不急!我們先來寫一個call()函式最簡單的用法:

function source(){
    console.log(this.name); //列印 xuan
}
let destination = {
    name:"xuan"
};
console.log(source.call(destination));
複製程式碼

上述程式碼會列印出destinationname屬性,也就是說source()函式通過呼叫call()source()函式中的this<=>destination對應起來。類似於實現destination.source()的效果

好,明白基本用法,再來看下面的例子:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
}
let destination = {
    name:"xuan"
};
console.log(source.call(destination,18,"male"));//call本身沒有返回任何值,故undefined
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind

我們可以看到call()支援傳參,而且是以arg1,arg2,...的形式傳入。我們看到最後還還輸出一個undefined,說明現在呼叫source.call(…args)沒有返回值。

我們現在給source函式新增返回值:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    //新增一個返回值物件
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
console.log(source.call(destination,18,"male"));
複製程式碼

列印結果:

JavaScript進階之模擬call,apply和bind

果不其然!call()函式的返回值就是source函式的返回值。 所以call()函式的作用總結如下:

  1. 改變this的指向
  2. 支援對函式傳參
  3. 呼叫call的函式返回什麼,call返回什麼。

模擬Function.prototype.call()

根據call()的作用,我們一步一步進行模擬。我們先把上面的部分程式碼摘抄下來:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    //新增一個返回值物件
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
複製程式碼

現在只要實現一個函式call1()並使用下面方式

console.log(source.call1(destination));
複製程式碼

如果得出的結果和call()函式一樣,那就沒問題了。

現在我們來模擬第一步:
改變this的指向

假設我們destination的結構是這樣的:

let destination = {
    name:"xuan",
    source:function(age,gender){
        console.log(this.name);
        console.log(age);
        console.log(gender);
        //新增一個返回值物件
        return {
            age:age,
            gender:gender,
            name:this.name
        }
	}
}
複製程式碼

我們執行destination.source(18,"male");就可以在source()函式中把正確的結果列印出來並且返回我們想要的值。
現在我們的目的就是:給destination物件新增一個source屬性,然後新增引數執行它
我們定義如下:

Function.prototype.call1 = function(ctx){
    ctx.fn = this;   //ctx為destination   this指向source   那麼就是destination.fn = source;
    ctx.fn(); // 執行函式
    delete ctx.fn;  //在刪除這個屬性
}
console.log(source.call1(destination,18,"male"));
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind

我們發現this的指向已經改變了,但是我們傳入的引數還沒有處理。
第二步:
支援對函式傳參
我們使用ES6語法修改如下:

Function.prototype.call1 =function(ctx,...args){
    ctx.fn = this;
    ctx.fn(...args);
    delete ctx.fn;
}
console.log(source.call1(destination,18,"male"));
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind
引數出現了。 第三步:
呼叫call的函式返回什麼,call返回什麼

我們再修改一下:

Function.prototype.call1 =function(ctx,...args){
    ctx.fn = this || window; //防止ctx為null的情況
    let res = ctx.fn(...args);
    delete ctx.fn;
    return res;
}
console.log(source.call1(destination,18,"male"));
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind

現在我們實現了call的效果!

模擬Function.prototype.apply()

apply()函式的作用和call()函式一樣,只是傳參的方式不一樣。apply的用法可以檢視MDN,MDN這麼說的:apply() 方法呼叫一個具有給定this值的函式,以及作為一個陣列(或類似陣列物件)提供的引數。

apply()函式的第二個引數是一個陣列,陣列是呼叫apply()的函式的引數。

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
console.log(source.apply(destination,[18,"male"]));
複製程式碼

JavaScript進階之模擬call,apply和bind

效果和call()是一樣的。既然只是傳參不一樣,我們把模擬call()函式的程式碼稍微改改:

Function.prototype.apply1 =function(ctx,args){
    ctx.fn = this || window;
    args = args || [];
    let res = ctx.fn(...args);
    delete ctx.fn;
    return res;
}
console.log(source.apply1(destination,[18,'male']));
複製程式碼

執行效果如下:

JavaScript進階之模擬call,apply和bind

apply()函式的模擬完成。

Function.prototype.bind()

bind()的作用,我們引用MDN
bind()方法會建立一個新函式。當這個新函式被呼叫時,bind() 的第一個引數將作為它執行時的 this物件,之後的一序列引數將會在傳遞的實參前傳入作為它的引數。
我們看下述程式碼:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
var res = source.bind(destination,18,"male");
console.log(res());
console.log("==========================")
var res1 = source.bind(destination,18);
console.log(res1("male"));
console.log("==========================")
var res2 = source.bind(destination);
console.log(res2(18,"male"));
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind

我們發現bind函式跟applycall有兩個區別:

1.bind返回的是函式,雖然也有call和apply的作用,但是需要在呼叫函式時生效

2.bind中也可以新增引數

注:bind還支援new語法,下面會展開。
我們先根據上述2點區別來模擬bind函式。

模擬Function.prototype.bind()

和模擬call一樣,現摘抄下面的程式碼:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    //新增一個返回值物件
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
複製程式碼

然後我們定義一個函式bind1,如果執行下面的程式碼能夠返回和bind函式一樣的值,就達到我們的目的。

var res = source.bind1(destination,18);
console.log(res("male"));
複製程式碼

首先我們定義一個bind1函式,因為返回值是一個函式,所以我們可以這麼寫:

Function.prototype.bind1 = function(ctx,...args){
    var that = this;//外層的this通過閉包傳入內部函式中
    return function(){
        //將外層函式的引數和內層函式的引數合併
        var all_args = [...args].concat([...arguments]);
        //apply改變ctx的指向
        return that.apply(ctx,all_args);
    }
}
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind

這裡我們利用閉包,把外層函式的ctx和引數args傳到內層函式,再將內外傳遞的引數合併,然後使用apply()call()函式,將其返回。

當我們呼叫res("male")時,因為外層ctxargs還是會存在記憶體當中,所以呼叫時,前面的ctx也就是sourceargs也就是18,再將傳入的"male"跟18合併[18,'male'],執行source.apply(destination,[18,'male']);返回函式結果即可。bind()的模擬完成!

但是bind除了上述用法,還可以有如下用法:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    //新增一個返回值物件
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
var res = source.bind(destination,18);
var person = new res("male");
console.log(person);
複製程式碼

列印效果如下:

JavaScript進階之模擬call,apply和bind
我們發現bind函式支援new關鍵字,呼叫的時候this的繫結失效了,那麼new之後,this指向哪裡呢?我們來試一下,程式碼如下:

function source(age,gender){
  console.log(this);
}
let destination = {
    name:"xuan"
};
var res = source.bind(destination,18);
console.log(new res("male"));
console.log(res("male"));
複製程式碼

JavaScript進階之模擬call,apply和bind

執行new的時候,我們發現雖然bind的第一個引數是destination,但是this是指向source的。

JavaScript進階之模擬call,apply和bind

如上所示,不用new的話,this指向destination

好,現在再來回顧一下我們的bind1實現:

Function.prototype.bind1 = function(ctx,...args){
    var that = this;
    return function(){
        //將外層函式的引數和內層函式的引數合併
        var all_args = [...args].concat([...arguments]);
        //因為ctx是外層的this指標,在外層我們使用一個變數that引用進來
        return that.apply(ctx,all_args);
    }
}
複製程式碼

如果我們使用:

var res = source.bind(destination,18);
console.log(new res("male"));
複製程式碼

如果執行上述程式碼,我們的ctx還是destination,也就是說這個時候下面的source函式中的ctx還是指向destination。而根據Function.prototype.bind的用法,這時this應該是指向source自身。

我們先把部分程式碼抄下來:

function source(age,gender){
    console.log(this.name);
    console.log(age);
    console.log(gender);
    //新增一個返回值物件
    return {
        age:age,
        gender:gender,
        name:this.name
    }
}
let destination = {
    name:"xuan"
};
複製程式碼

我們改一下bind1函式:

Function.prototype.bind1 = function (ctx, ...args) {
    var that = this;//that肯定是source
    //定義了一個函式
    let f = function () {
        //將外層函式的引數和內層函式的引數合併
        var all_args = [...args].concat([...arguments]);
        //因為ctx是外層的this指標,在外層我們使用一個變數that引用進來
        var real_ctx = this instanceof f ? this : ctx;
        return that.apply(real_ctx, all_args);
    }
    //函式的原型指向source的原型,這樣執行new f()的時候this就會通過原型鏈指向source
    f.prototype = this.prototype;
    //返回函式
    return f;
}
複製程式碼

我們執行

var res = source.bind1(destination,18);
console.log(new res("male"));
複製程式碼

效果如下:

JavaScript進階之模擬call,apply和bind

已經達到我們的效果!

現在分析一下上述實現的程式碼:

//呼叫var res = source.bind1(destination,18)時的程式碼分析
Function.prototype.bind1 = function (ctx, ...args) {
    var that = this;//that肯定是source
    //定義了一個函式
    let f = function () {
       ... //內部先不管
    }
    //函式的原型指向source的原型,這樣執行new f()的時候this就會指向一個新的物件,這個物件通過原型鏈指向source,這正是我們上面執行apply的時候需要傳入的引數
    f.prototype = this.prototype;
    //返回函式
    return f;
}
複製程式碼

f()函式的內部實現分析:

//new res("male")相當於執行new f("male");下面進行函式的執行態分析
let f = function () {
     console.log(this);//這個時候列印this就是一個_proto_指向f.prototype的物件,因為f.prototype==>source.prototype,所以this._proto_==>source.prototype
     //將外層函式的引數和內層函式的引數合併
     var all_args = [...args].concat([...arguments]);
     //正常不用new的時候this指向當前呼叫處的this指標(在全域性環境中執行,this就是window物件);使用new的話這個this物件的原型鏈上有一個型別是f的原型物件。
    //那麼判斷一下,如果this instanceof f,那麼real_ctx=this,否則real_ctx=ctx;
     var real_ctx = this instanceof f ? this : ctx;
    //現在把真正分配給source函式的物件傳入
     return that.apply(real_ctx, all_args);
}
複製程式碼

至此bind()函式的模擬實現完畢!如有不對之處,歡迎拍磚!您的寶貴意見是我寫作的動力,謝謝大家。

相關文章