【進階3-4期】深度解析bind原理、使用場景及模擬實現

木易楊說發表於2019-03-04

bind()

bind() 方法會建立一個新函式,當這個新函式被呼叫時,它的 this 值是傳遞給 bind() 的第一個引數,傳入bind方法的第二個以及以後的引數加上繫結函式執行時本身的引數按照順序作為原函式的引數來呼叫原函式。bind返回的繫結函式也能使用 new 操作符建立物件:這種行為就像把原函式當成構造器,提供的 this 值被忽略,同時呼叫時的引數被提供給模擬函式。(來自參考1)

語法:fun.bind(thisArg[, arg1[, arg2[, ...]]])

bind 方法與 call / apply 最大的不同就是前者返回一個繫結上下文的函式,而後兩者是直接執行了函式。

來個例子說明下

var value = 2;

var foo = {
    value: 1
};

function bar(name, age) {
    return {
		value: this.value,
		name: name,
		age: age
    }
};

bar.call(foo, "Jack", 20); // 直接執行了函式
// {value: 1, name: "Jack", age: 20}

var bindFoo1 = bar.bind(foo, "Jack", 20); // 返回一個函式
bindFoo1();
// {value: 1, name: "Jack", age: 20}

var bindFoo2 = bar.bind(foo, "Jack"); // 返回一個函式
bindFoo2(20);
// {value: 1, name: "Jack", age: 20}
複製程式碼

通過上述程式碼可以看出bind 有如下特性:

  • 1、可以指定this
  • 2、返回一個函式
  • 3、可以傳入引數
  • 4、柯里化

使用場景

1、業務場景

經常有如下的業務場景

var nickname = "Kitty";
function Person(name){
    this.nickname = name;
    this.distractedGreeting = function() {

        setTimeout(function(){
            console.log("Hello, my name is " + this.nickname);
        }, 500);
    }
}
 
var person = new Person('jawil');
person.distractedGreeting();
//Hello, my name is Kitty
複製程式碼

這裡輸出的nickname是全域性的,並不是我們建立 person 時傳入的引數,因為 setTimeout 在全域性環境中執行(不理解的檢視【進階3-1期】),所以 this 指向的是window

這邊把 setTimeout 換成非同步回撥也是一樣的,比如介面請求回撥。

解決方案有下面兩種。

解決方案1:快取 this

var nickname = "Kitty";
function Person(name){
    this.nickname = name;
    this.distractedGreeting = function() {
        
		var self = this; // added
        setTimeout(function(){
            console.log("Hello, my name is " + self.nickname); // changed
        }, 500);
    }
}
 
var person = new Person('jawil');
person.distractedGreeting();
// Hello, my name is jawil
複製程式碼

解決方案2:使用 bind

var nickname = "Kitty";
function Person(name){
    this.nickname = name;
    this.distractedGreeting = function() {

        setTimeout(function(){
            console.log("Hello, my name is " + this.nickname);
        }.bind(this), 500);
    }
}
 
var person = new Person('jawil');
person.distractedGreeting();
// Hello, my name is jawil
複製程式碼

完美!

2、驗證是否是陣列

【進階3-3期】介紹了 call 的使用場景,這裡重新回顧下。

function isArray(obj){ 
    return Object.prototype.toString.call(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 直接使用 toString()
[1, 2, 3].toString(); 	// "1,2,3"
"123".toString(); 		// "123"
123.toString(); 		// SyntaxError: Invalid or unexpected token
Number(123).toString(); // "123"
Object(123).toString(); // "123"
複製程式碼

可以通過toString() 來獲取每個物件的型別,但是不同物件的 toString()有不同的實現,所以通過 Object.prototype.toString() 來檢測,需要以 call() / apply() 的形式來呼叫,傳遞要檢查的物件作為第一個引數。

另一個驗證是否是陣列的方法,這個方案的優點是可以直接使用改造後的 toStr

var toStr = Function.prototype.call.bind(Object.prototype.toString);
function isArray(obj){ 
    return toStr(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 使用改造後的 toStr
toStr([1, 2, 3]); 	// "[object Array]"
toStr("123"); 		// "[object String]"
toStr(123); 		// "[object Number]"
toStr(Object(123)); // "[object Number]"
複製程式碼

上面方法首先使用 Function.prototype.call函式指定一個 this 值,然後 .bind 返回一個新的函式,始終將 Object.prototype.toString 設定為傳入引數。其實等價於 Object.prototype.toString.call()

這裡有一個前提toString()方法沒有被覆蓋

Object.prototype.toString = function() {
    return '';
}
isArray([1, 2, 3]);
// false
複製程式碼

3、柯里化(curry)

只傳遞給函式一部分引數來呼叫它,讓它返回一個函式去處理剩下的引數。

可以一次性地呼叫柯里化函式,也可以每次只傳一個引數分多次呼叫。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};

var increment = add(1);
var addTen = add(10);

increment(2);
// 3

addTen(2);
// 12

add(1)(2);
// 3
複製程式碼

這裡定義了一個 add 函式,它接受一個引數並返回一個新的函式。呼叫 add 之後,返回的函式就通過閉包的方式記住了 add 的第一個引數。所以說 bind 本身也是閉包的一種使用場景。

模擬實現

bind() 函式在 ES5 才被加入,所以並不是所有瀏覽器都支援,IE8及以下的版本中不被支援,如果需要相容可以使用 Polyfill 來實現。

首先我們來實現以下四點特性:

  • 1、可以指定this
  • 2、返回一個函式
  • 3、可以傳入引數
  • 4、柯里化

模擬實現第一步

對於第 1 點,使用 call / apply 指定 this

對於第 2 點,使用 return 返回一個函式。

結合前面 2 點,可以寫出第一版,程式碼如下:

// 第一版
Function.prototype.bind2 = function(context) {
    var self = this; // this 指向呼叫者
    return function () { // 實現第 2點
        return self.apply(context); // 實現第 1 點
    }
}
複製程式碼

測試一下

// 測試用例
var value = 2;
var foo = {
    value: 1
};

function bar() {
	return this.value;
}

var bindFoo = bar.bind2(foo);

bindFoo(); // 1
複製程式碼

模擬實現第二步

對於第 3 點,使用 arguments 獲取引數陣列並作為 self.apply() 的第二個引數。

對於第 4 點,獲取返回函式的引數,然後同第3點的引數合併成一個引數陣列,並作為 self.apply() 的第二個引數。

// 第二版
Function.prototype.bind2 = function (context) {

    var self = this;
    // 實現第3點,因為第1個引數是指定的this,所以只擷取第1個之後的引數
	// arr.slice(begin); 即 [begin, end]
    var args = Array.prototype.slice.call(arguments, 1); 

    return function () {
        // 實現第4點,這時的arguments是指bind返回的函式傳入的引數
        // 即 return function 的引數
        var bindArgs = Array.prototype.slice.call(arguments);
        return self.apply( context, args.concat(bindArgs) );
    }
}
複製程式碼

測試一下:

// 測試用例
var value = 2;

var foo = {
    value: 1
};

function bar(name, age) {
    return {
		value: this.value,
		name: name,
		age: age
    }
};

var bindFoo = bar.bind2(foo, "Jack");
bindFoo(20);
// {value: 1, name: "Jack", age: 20}
複製程式碼

模擬實現第三步

到現在已經完成大部分了,但是還有一個難點,bind 有以下一個特性

一個繫結函式也能使用new操作符建立物件:這種行為就像把原函式當成構造器,提供的 this 值被忽略,同時呼叫時的引數被提供給模擬函式。

來個例子說明下:

var value = 2;
var foo = {
    value: 1
};
function bar(name, age) {
    this.habit = 'shopping';
    console.log(this.value);
    console.log(name);
    console.log(age);
}
bar.prototype.friend = 'kevin';

var bindFoo = bar.bind(foo, 'Jack');
var obj = new bindFoo(20);
// undefined
// Jack
// 20

obj.habit;
// shopping

obj.friend;
// kevin
複製程式碼

上面例子中,執行結果this.value 輸出為 undefined,這不是全域性value 也不是foo物件中的value,這說明 bindthis 物件失效了,new 的實現中生成一個新的物件,這個時候的 this指向的是 obj。(【進階3-1期】有介紹new的實現原理,下一期也會重點介紹)

這裡可以通過修改返回函式的原型來實現,程式碼如下:

// 第三版
Function.prototype.bind2 = function (context) {
    var self = this;
    var args = Array.prototype.slice.call(arguments, 1);

    var fBound = function () {
        var bindArgs = Array.prototype.slice.call(arguments);
        
        // 註釋1
        return self.apply(
            this instanceof fBound ? this : context, 
            args.concat(bindArgs)
        );
    }
    // 註釋2
    fBound.prototype = this.prototype;
    return fBound;
}
複製程式碼
  • 註釋1:
    • 當作為建構函式時,this 指向例項,此時 this instanceof fBound 結果為 true,可以讓例項獲得來自繫結函式的值,即上例中例項會具有 habit 屬性。
    • 當作為普通函式時,this 指向 window,此時結果為 false,將繫結函式的 this 指向 context
  • 註釋2: 修改返回函式的 prototype 為繫結函式的 prototype,例項就可以繼承繫結函式的原型中的值,即上例中 obj 可以獲取到 bar 原型上的 friend

注意:這邊涉及到了原型、原型鏈和繼承的知識點,可以看下我之前的文章。

JavaScript常用八種繼承方案

模擬實現第四步

上面實現中 fBound.prototype = this.prototype有一個缺點,直接修改 fBound.prototype 的時候,也會直接修改 this.prototype

來個程式碼測試下:

// 測試用例
var value = 2;
var foo = {
    value: 1
};
function bar(name, age) {
    this.habit = 'shopping';
    console.log(this.value);
    console.log(name);
    console.log(age);
}
bar.prototype.friend = 'kevin';

var bindFoo = bar.bind2(foo, 'Jack'); // bind2
var obj = new bindFoo(20); // 返回正確
// undefined
// Jack
// 20

obj.habit; // 返回正確
// shopping

obj.friend; // 返回正確
// kevin

obj.__proto__.friend = "Kitty"; // 修改原型

bar.prototype.friend; // 返回錯誤,這裡被修改了
// Kitty
複製程式碼

解決方案是用一個空物件作為中介,把 fBound.prototype 賦值為空物件的例項(原型式繼承)。

var fNOP = function () {};			// 建立一個空物件
fNOP.prototype = this.prototype; 	// 空物件的原型指向繫結函式的原型
fBound.prototype = new fNOP();		// 空物件的例項賦值給 fBound.prototype
複製程式碼

這邊可以直接使用ES5的 Object.create()方法生成一個新物件

fBound.prototype = Object.create(this.prototype);
複製程式碼

不過 bindObject.create()都是ES5方法,部分IE瀏覽器(IE < 9)並不支援,Polyfill中不能用 Object.create()實現 bind,不過原理是一樣的。

第四版目前OK啦,程式碼如下:

// 第四版,已通過測試用例
Function.prototype.bind2 = function (context) {
    var self = this;
    var args = Array.prototype.slice.call(arguments, 1);

    var fNOP = function () {};

    var fBound = function () {
        var bindArgs = Array.prototype.slice.call(arguments);
        return self.apply(
            this instanceof fNOP ? this : context, 
            args.concat(bindArgs)
        );
    }

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();
    return fBound;
}
複製程式碼

模擬實現第五步

到這裡其實已經差不多了,但有一個問題是呼叫 bind 的不是函式,這時候需要丟擲異常。

if (typeof this !== "function") {
  throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
}
複製程式碼

所以完整版模擬實現程式碼如下:

// 第五版
Function.prototype.bind2 = function (context) {

    if (typeof this !== "function") {
      throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    }

    var self = this;
    var args = Array.prototype.slice.call(arguments, 1);

    var fNOP = function () {};

    var fBound = function () {
        var bindArgs = Array.prototype.slice.call(arguments);
        return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
    }

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();
    return fBound;
}
複製程式碼

【進階3-2期】思考題解

// 1、賦值語句是右執行的,此時會先執行右側的物件
var obj = {
    // 2、say 是立即執行函式
    say: function() {
        function _say() {
            // 5、輸出 window
            console.log(this);
        }
        // 3、編譯階段 obj 賦值為 undefined
        console.log(obj);
        // 4、obj是 undefined,bind 本身是 call實現,
        // 【進階3-3期】:call 接收 undefined 會繫結到 window。
        return _say.bind(obj);
    }(),
};
obj.say();
複製程式碼

【進階3-3期】思考題解

call 的模擬實現如下,那有沒有什麼問題呢?

Function.prototype.call = function (context) {
    context = context ? Object(context) : window; 
    context.fn = this;

    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }
    var result = eval('context.fn(' + args +')');

    delete context.fn;
    return result;
}
複製程式碼

當然是有問題的,其實這裡假設 context 物件本身沒有 fn 屬性,這樣肯定不行,我們必須保證 fn屬性的唯一性。

ES3下模擬實現

解決方法也很簡單,首先判斷 context中是否存在屬性 fn,如果存在那就隨機生成一個屬性fnxx,然後迴圈查詢 context物件中是否存在屬性 fnxx。如果不存在則返回最終值。

一種迴圈方案實現程式碼如下:

function fnFactory(context) {
	var unique_fn = "fn";
    while (context.hasOwnProperty(unique_fn)) {
    	unique_fn = "fn" + Math.random(); // 迴圈判斷並重新賦值
    }
    
    return unique_fn;
}
複製程式碼

一種遞迴方案實現程式碼如下:

function fnFactory(context) {
	var unique_fn = "fn" + Math.random();
    if(context.hasOwnProperty(unique_fn)) {
        // return arguments.callee(context); ES5 開始禁止使用
        return fnFactory(context); // 必須 return
    } else {
        return unique_fn;
    }
}
複製程式碼

模擬實現完整程式碼如下:

function fnFactory(context) {
	var unique_fn = "fn";
    while (context.hasOwnProperty(unique_fn)) {
    	unique_fn = "fn" + Math.random(); // 迴圈判斷並重新賦值
    }
    
    return unique_fn;
}

Function.prototype.call = function (context) {
    context = context ? Object(context) : window; 
    var fn = fnFactory(context); // added
    context[fn] = this; // changed

    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }
    var result = eval('context[fn](' + args +')'); // changed

    delete context[fn]; // changed
    return result;
}

// 測試用例在下面
複製程式碼

ES6下模擬實現

ES6有一個新的基本型別Symbol,表示獨一無二的值,用法如下。

const symbol1 = Symbol();
const symbol2 = Symbol(42);
const symbol3 = Symbol('foo');

console.log(typeof symbol1); // "symbol"
console.log(symbol3.toString()); // "Symbol(foo)"
console.log(Symbol('foo') === Symbol('foo')); // false
複製程式碼

不能使用 new 命令,因為這是基本型別的值,不然會報錯。

new Symbol();
// TypeError: Symbol is not a constructor
複製程式碼

模擬實現完整程式碼如下:

Function.prototype.call = function (context) {
  context = context ? Object(context) : window; 
  var fn = Symbol(); // added
  context[fn] = this; // changed

  let args = [...arguments].slice(1);
  let result = context[fn](...args); // changed

  delete context[fn]; // changed
  return result;
}
// 測試用例在下面
複製程式碼

測試用例在這裡:

// 測試用例
var value = 2;
var obj = {
    value: 1,
    fn: 123
}

function bar(name, age) {
    console.log(this.value);
    return {
        value: this.value,
        name: name,
        age: age
    }
}

bar.call(null); 
// 2

console.log(bar.call(obj, 'kevin', 18));
// 1
// {value: 1, name: "kevin", age: 18}

console.log(obj);
// {value: 1, fn: 123}
複製程式碼

擴充套件一下

有兩種方案可以判斷物件中是否存在某個屬性。

var obj = {
     a: 2
};
Object.prototype.b = function() {
    return "hello b";
}
複製程式碼
  • 1、in 操作符

in 操作符會檢查屬性是否存在物件及其 [[Prototype]] 原型鏈中。

("a" in obj);     // true
("b" in obj);     // true
複製程式碼
  • 2、Object.hasOwnProperty(...)方法

hasOwnProperty(...)只會檢查屬性是否存在物件中,不會向上檢查其原型鏈。

obj.hasOwnProperty("a");     //true
obj.hasOwnProperty("b");     //false
複製程式碼

注意以下幾點:

  • 1、看起來 in 操作符可以檢查容器內是否有某個值,實際上檢查的是某個屬性名是否存在。對於陣列來說,4 in [2, 4, 6] 結果返回 false,因為 [2, 4, 6] 這個陣列中包含的屬性名是0,1,2 ,沒有4
  • 2、所有普通物件都可以通過 Object.prototype 的委託來訪問 hasOwnProperty(...),但是對於一些特殊物件( Object.create(null) 建立)沒有連線到 Object.prototype,這種情況必須使用 Object.prototype.hasOwnProperty.call(obj, "a"),顯示繫結到 obj 上。又是一個 call 的用法

本期思考題

用 JS 實現一個無限累加的函式 add,示例如下:

add(1); // 1
add(1)(2);  // 3
add(1)(2)(3); // 6
add(1)(2)(3)(4); // 10 

// 以此類推
複製程式碼

參考

不用 call 和 apply 方法模擬實現 ES5 的 bind 方法

JavaScript 深入之 bind 的模擬實現

MDN 之 Function.prototype.bind()

MDN 之 Symbol

第 4 章: 柯里化(curry)

進階系列目錄

  • 【進階1期】 呼叫堆疊
  • 【進階2期】 作用域閉包
  • 【進階3期】 this全面解析
  • 【進階4期】 深淺拷貝原理
  • 【進階5期】 原型Prototype
  • 【進階6期】 高階函式
  • 【進階7期】 事件機制
  • 【進階8期】 Event Loop原理
  • 【進階9期】 Promise原理
  • 【進階10期】Async/Await原理
  • 【進階11期】防抖/節流原理
  • 【進階12期】模組化詳解
  • 【進階13期】ES6重難點
  • 【進階14期】計算機網路概述
  • 【進階15期】瀏覽器渲染原理
  • 【進階16期】webpack配置
  • 【進階17期】webpack原理
  • 【進階18期】前端監控
  • 【進階19期】跨域和安全
  • 【進階20期】效能優化
  • 【進階21期】VirtualDom原理
  • 【進階22期】Diff演算法
  • 【進階23期】MVVM雙向繫結
  • 【進階24期】Vuex原理
  • 【進階25期】Redux原理
  • 【進階26期】路由原理
  • 【進階27期】VueRouter原始碼解析
  • 【進階28期】ReactRouter原始碼解析

交流

進階系列文章彙總如下,內有優質前端資料,覺得不錯點個star。

github.com/yygmind/blo…

我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階3-4期】深度解析bind原理、使用場景及模擬實現

相關文章