js 手動實現bind方法,超詳細思路分析!


壹 ❀ 引

js 實現call和apply方法 一文中,我們詳細分析並模擬實現了call/apply方法,由於篇幅問題,關於bind方法實現只能另起一篇。

在模擬bind之前,我們先了解bind的概念,這里引入MDN解釋:

bind() 方法創建一個新的函數,在 bind() 被調用時,這個新函數的 this 被指定為 bind() 的第一個參數,而其余參數將作為新函數的參數,供調用時使用。

說的通俗一點,bindapply/call一樣都能改變函數this指向,但bind並不會立即執行函數,而是返回一個綁定了this的新函數,你需要再次調用此函數才能達到最終執行。

我們來看一個簡單的例子:

var obj = {
    z: 1
};
var obj1 = {
    z: 2
};

function fn(x, y) {
    console.log(x + y + this.z);
};
// call與apply
fn.call(obj, 2, 3); //6
fn.apply(obj, [2, 3]); //6

var bound = fn.bind(obj, 2);
bound(3); //6
//嘗試修改bind返回函數的this
bound.call(obj1, 3); //6

可以到bind並不是立即執行,而是返回一個新函數,且新函數的this無法再次被修改,我們總結bind的特點:

  • 可以修改函數this指向。
  • bind返回一個綁定了this的新函數boundFcuntion,例子中我們用bound表示。
  • 支持函數柯里化,我們在返回bound函數時已傳遞了部分參數2,在調用時bound補全了剩余參數。
  • boundFunction的this無法再被修改,使用call、apply也不行。

考慮到有同學對於柯里化的陌生,這里簡單解釋,所謂函數柯里化其實就是在函數調用時只傳遞一部分參數進行調用,函數會返回一個新函數去處理剩下的參數,一個經典簡單的例子:

//函數柯里化
function fn(x, y) {
    return function (y) {
        console.log(x + y);
    };
};
var fn_ = fn(1);
fn_(1); //2

fn(1)(1) //2

不難發現函數柯里化使用了閉包,在執行內層函數時,它使用了外層函數的局部形參x,從而構成了閉包,扯遠了點。

我們來嘗試實現bind方法,先從簡單的改變this和返回函數開始。

貳 ❀ 實現bind

之前已經有了模擬call/apply的經驗,這里直接給出版本一:

Function.prototype.bind_ = function (obj) {
    var fn = this;
    return function () {
        fn.apply(obj);
    };
};

var obj = {
    z: 1
};

function fn() {
    console.log(this.z);
};

var bound = fn.bind_(obj);
bound(); //1

唯一需要留意的就是var fn = this這一行,如果不提前保存,在執行bound時內部this會指向window。

版本一以滿足了this修改與函數返回,馬上有同學就想到了,版本一不支持函數傳參,那么我們進行簡單修改讓其支持傳參:

Function.prototype.bind_ = function (obj) {
    //第0位是this,所以得從第一位開始裁剪
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    return function () {
        fn.apply(obj, args);
    };
};

完美了嗎?並不完美,別忘了我們前面說bind支持函數柯里化,在調用bind時可以先傳遞部分參數,在調用返回的bound時可以補全剩余參數,所以還得進一步處理,來看看bind_第二版:

Function.prototype.bind_ = function (obj) {
    //第0位是this,所以得從第一位開始裁剪
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    return function () {
        //二次調用我們也抓取arguments對象
        var params = Array.prototype.slice.call(arguments);
        //注意concat的順序
        fn.apply(obj, args.concat(params));
    };
};

var obj = {
    z: 1
};

function fn(x, y) {
    console.log(x + y + this.z);
};

var bound = fn.bind_(obj, 1);
bound(2); //4

看,改變this,返回函數,函數柯里化均已實現。這段代碼需要注意的是args.concat(params)的順序,args在前,因為只有這樣才能讓先傳遞的參數和fn的形參按順序對應。

至少走到這一步都挺順序,需要注意的是,bind方法還有一個少見的特性,這里引用MDN的描述

綁定函數也可以使用 new 運算符構造,它會表現為目標函數已經被構建完畢了似的。提供的 this 值會被忽略,但前置參數仍會提供給模擬函數。

說通俗點,通過bind返回的boundFunction函數也能通過new運算符構造,只是在構造過程中,boundFunction已經確定的this會被忽略,且返回的實例還是會繼承構造函數的構造器屬性與原型屬性,並且能正常接收參數。

有點繞口,我們來看個簡單的例子:

var z = 0;
var obj = {
    z: 1
};

function fn(x, y) {
    this.name = '聽風是風';
    console.log(this.z);
    console.log(x);
    console.log(y);
};
fn.prototype.age = 26;

var bound = fn.bind(obj, 2);
var person = new bound(3);//undefined 2 3

console.log(person.name);//聽風是風
console.log(person.age);//26

在此例子中,我們先是將函數fnthis指向了對象obj,從而得到了bound函數。緊接着使用new操作符構造了bound函數,得到了實例person。不難發現,除了先前綁定好的this丟失了(后面會解釋原因),構造器屬性this.name,以及原型屬性fn.prototype.age都有順利繼承,除此之外,兩個形參也成功傳遞進了函數。

難點來了,至少在ES6之前,JavaScript並沒有class類的概念,所謂構造函數其實只是對於類的模擬;而這就造成了一個問題,所有的構造函數除了可以使用new構造調用以外,它還能被普通調用,比如上面例子中的bound我們也可以普通調用:

bound(3); //1 2 3

有同學在這可能就有疑惑,bound()等同於window.bound(),此時this不是應該指向window從而輸出0嗎?我們在前面說bind屬於硬綁定,一次綁定終生受益,上面的調用本質上等同於:

window.fn.bind(obj, 2);

函數fn存在this默認綁定window與顯示綁定bind,而顯示綁定優先級高於默認綁定,所以this還是指向obj

當構造函數被new構造調用時,本質上構造函數中會創建一個實例對象,函數內部的this指向此實例,當執行到console.log(this.z)這一行時,this上並未被賦予屬性z,所以輸出undefined,這也解釋了為什么bound函數被new構造時會丟失原本綁定的this。

是不是覺得ES5構造函數特別混亂,不同調用方式函數內部this指向還不同,也正因如此在ES6中隆重推出了class類,凡是通過class創建的類均只能使用new調用,普通調用一律報錯處理:

class Fn {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    };
    sayName() {
        console.log(this.name);
    };
};
//只能new構造調用
const person = new Fn('聽風是風', 26);
person.sayName(); //聽風是風
const person1 = Fn(); //Class constructor Fn cannot be invoked without 'new'

扯遠了,讓我們回到上面的例子,說了這么多無非是為了強調一點,我們在模擬bind方法時,返回的bound函數在調用時得考慮new調用與普通調用,畢竟兩者this指向不同。

再說直白一點,如果是new調用,bound函數中的this指向實例自身,而如果是普通調用this指向obj,怎么區分呢?

不難,我們知道(強行讓你們知道)構造函數實例的constructor屬性永遠指向構造函數本身(這句話其實有歧義,具體我會在原型的文章中解釋),比如:

function Fn(){};
var o = new Fn();
console.log(o.constructor === Fn);//true

而構造函數在運行時,函數內部this指向實例,所以this的constructor也指向構造函數:

function Fn() {
    console.log(this.constructor === Fn); //true
};
var o = new Fn();
console.log(o.constructor === Fn); //true

所以我就用constructor屬性來判斷當前bound方法調用方式,畢竟只要是new調用,this.constructor === Fn一定為true。

讓我們簡單改寫bind_方法,為bound方法新增this判斷以及原型繼承:

Function.prototype.bind_ = function (obj) {
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    var bound = function () {
        var params = Array.prototype.slice.call(arguments);
        //通過constructor判斷調用方式,為true this指向實例,否則為obj
        fn.apply(this.constructor === fn ? this : obj, args.concat(params));
    };
    //原型鏈繼承
    bound.prototype = fn.prototype;
    return bound;
};

有同學就問了,難道不應該是this.constructor===bound嗎?並不是,雖然new的是bound方法,本質上執行的還是fn,畢竟bound自身並沒有構造器屬性,這點關系還是需要理清。

其次還有個缺陷。雖然構造函數產生的實例都是獨立的存在,實例繼承而來的構造器屬性隨便你怎么修改都不會影響構造函數本身:

function Fn() {
    this.name = '聽風是風';
    this.sayAge = function () {
        console.log(this.age);
    };
};
Fn.prototype.age = 26;

var o = new Fn();
o.sayAge(); //26
//我們改變實例繼承的構造器屬性,並不會影響構造函數本身
o.name = 'echo';
var o1 = new Fn();
console.log(o1.name) //聽風是風

但是如果我們直接修改實例原型,這就會對構造函數Fn產生影響,來看個例子:

function Fn() {
    this.name = '聽風是風';
    this.sayAge = function () {
        console.log(this.age);
    };
};
Fn.prototype.age = 26;

var o = new Fn();
o.sayAge(); //26
//修改實例的原型屬性,這會影響構造函數本身
o.__proto__.age = 18;
var o1 = new Fn();
console.log(o1.age) //18

不難理解,構造器屬性(this.name,this.sayAge)在創建實例時,我們可以抽象的理解成實例深拷貝了一份,這是屬於實例自身的屬性,后面再改都與構造函數不相關。而實例要用prototype屬性時都是順着原型鏈往上找,構造函數有便借給實例用了,一共就這一份,誰要是改了那就都得變。

我們可以輸出實例o,觀察它的屬性,可以看到age屬性確實是綁原型__proto__上(注意,prototype是函數特有,普通對象只有__proto__,兩者指向相同)。

怎么做才保險呢,這里就可以借助一個空白函數作為中介,直接看個例子:

function Fn() {
    this.name = '聽風是風';
    this.sayAge = function () {
        console.log(this.age);
    };
};
Fn.prototype.age = 26;
// 創建一個空白函數Fn1,單純的拷貝Fn的prototype
var Fn1 = function () {};
Fn1.prototype = Fn.prototype;
// 這里的Fn2對應我們的bound方法,將其原型指向Fn1創建的實例
var Fn2 = function () {};
Fn2.prototype = new Fn1();
var o = new Fn2();
console.log(o.age); //26
//嘗試修改
o.__proto__.age = 18;
var o1 = new Fn();
console.log(o1.age);//26

說到底,我們就是借用空白函數,讓Fn2的實例多了一層__proto__,達到修改原型不會影響Fn原型的目的,當然你如果通過__proto__.__proto__還是一樣能修改,差不多就是這個意思:

o.__proto__.__proto__.age = 18;
var o1 = new Fn();
console.log(o1.age);//18

所以綜上,我們再次修改bind_方法,拿出第四版:

Function.prototype.bind_ = function (obj) {
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    //創建中介函數
    var fn_ = function () {};
    var bound = function () {
        var params = Array.prototype.slice.call(arguments);
        //通過constructor判斷調用方式,為true this指向實例,否則為obj
        fn.apply(this.constructor === fn ? this : obj, args.concat(params));
        console.log(this);
    };
    fn_.prototype = fn.prototype;
    bound.prototype = new fn_();
    return bound;
};

最后,bind方法如果被非函數調用時會拋出錯誤,所以我們要在第一次執行bind_時做一次調用判斷,加個條件判斷,我們來一個完整的最終版:

Function.prototype.bind_ = function (obj) {
    if (typeof this !== "function") {
        throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    };
    var args = Array.prototype.slice.call(arguments, 1);
    var fn = this;
    //創建中介函數
    var fn_ = function () {};
    var bound = function () {
        var params = Array.prototype.slice.call(arguments);
        //通過constructor判斷調用方式,為true this指向實例,否則為obj
        fn.apply(this.constructor === fn ? this : obj, args.concat(params));
        console.log(this);
    };
    fn_.prototype = fn.prototype;
    bound.prototype = new fn_();
    return bound;
};

var z = 0;
var obj = {
    z: 1
};

function fn(x, y) {
    this.name = '聽風是風';
    console.log(this.z);
    console.log(x);
    console.log(y);
};
fn.prototype.age = 26;

var bound = fn.bind_(obj, 2);
var person = new bound(3); //undefined 2 3

console.log(person.name); //聽風是風
console.log(person.age); //26
person.__proto__.age = 18;
var person = new fn();
console.log(person.age); //26

看着有些長,不過我們順着思路一步步走過來其實不難理解。

好啦,關於bind方法的模擬實現就說到這里了,萬萬沒想到這篇實現居然用了我五個小時時間...

另外,如果大家對於new一個構造函數發生了什么存在疑惑,可以閱讀博主這篇文章:

new一個對象的過程,實現一個簡單的new方法

若對於上文中修改實例原型會影響原構造函數存在疑慮,可以閱讀博主這篇文章:

精讀JavaScript模式(八),JS類式繼承

那么就寫到這里了,巨累,准備睡覺。

叄 ❀ 參考

深度解析bind原理、使用場景及模擬實現

MDN Function.prototype.bind()

最詳盡的 JS 原型與原型鏈終極詳解,沒有「可能是」。(一)

JavaScript深入之bind的模擬實現


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM