iT邦幫忙

2022 iThome 鐵人賽

DAY 16
0
Modern Web

這些那些你可能不知道我不知道的Web技術細節系列 第 16

你可能不知道的Function.prototype.bind()

  • 分享至 

  • xImage
  •  

前言

Function有三種用法,除了一般呼叫方式外,還可以使用Function.prototype.call()Function.prototype.apply()方法。此外,Function還有一個很常見,偶爾會與後兩個用法混淆的方法--Function.prototype.bind()。沒錯,這節就是要來說說Function.prototype.bind()和另外兩者的差異,以及常見用法和你可能不知道的Function.prototype.bind()

<Fn>.call()/<Fn>.apply()<Fn>.bind()的差異

由於過去其實我是寫過bind()的相關內容的。所以我個人並不曾將三者搞混,蠻能區分用法上的不同的。不過在偶然幾次討論程式碼應該如何寫的過程中,發現偶爾會有人弄不清楚何時應該使用bind()?何時使用其他兩者?

回頭看我過去所寫的,也蜻蜓點水的點到過call()apply()。它們三者的參數形式確實有些像,特別是bind()call()都接受一個thisArg參數和多個參數展開。

所以Function.prototype.bind()有甚麼不同之處嗎?

Function.prototype.call()Function.prototype.apply()與一般函式呼叫寫在同一節裡,他們三著共同點是「會真的執行函示內容」。與他們不同的是Function.prototype.bind()並不會真的執行函式,它會返回一個新的函式。

function helloWorld() {
    console.log(`Hello World`)
}

helloWorld();       // 會印出 Hello World
helloWorld.call();  // 會印出 Hello World
helloWorld.apply(); // 會印出 Hello World

helloWorld.bind();  // 不會印出 Hello World。返回一個函式物件

新的函式物件與原本的可能沒有什麼差異:

var newFn1 = helloWorld.bind();
newFn1();  // 會印出 Hello World

雖然上面程式碼很像是直接賦值給變數,但還是有些差異。

var newFn2 = helloWorld;
newFn2 === helloWorld;   // true。直接賦值的話是同一個函式物件
newFn1 === helloWorld;   // false。使用bind()會產生一個新的函式物件,儘管它們用起來可能很像,但依然不同

直接賦值的話是同一個函式物件;相對來說,使用bind()會產生一個新的函式物件。儘管它們用起來可能很像,但依然不同。

常見用法

在JavaScript裡面this是一個特別的存在。它經常會有隱含綁定和隱含遺失的狀況。

因此,使用bind()更常見的狀況是,明確綁定this,然後將綁好this的新函式傳遞出去,這樣一來在使用新函式的時候就沒有隱含遺失的問題。

var bob = {
    name: "Bob",
    sayHello() {
        console.log(`Hello, ${this.name}`);
    },
    setName(newName) {
        this.name = newName;
    }
}

bob.sayHello(); // Hello, Bob
var bobSayHello = bob.sayHello.bind(bob);
var setBobName = bob.setName.bind(bob);
bobSayHello(); // Hello, Bob
setBobName("BBob");
bob.sayHello(); // Hello, BBob

在上例中,將bob的兩個方法--sayHello()setName()明確綁定bob,在設定給變數bobSayHellosetBobName。後續調用這兩個變數儲存的函式物件的時候,就會作用到bob上。當然有些時候你也可以使用箭頭表達式綁定this問題,不過使用箭頭表達式有時候並不明確,更多時候需要清楚知道自己綁定了甚麼東西。

有時候你會需要在一些callback執行的時候,去改變一些物件的屬性,特別是在用第三方框架的時候。JavaScript的callback寫法算是寫出了一種特色,在JavaScript特別常見的設計。有時候你根本不知道callback什麼時候、被誰給執行,這時候依賴可能變動的this並不是個好主意,這種情況就會先綁定this

閉包 & 柯里化

其實看到bind()和一部分用法,我更想來聊聊柯里化(curring),因為我認為那更有趣、更有意思、更少人懂。但基於寫作時間考量,我也只會提及一點點東西。還是先來說說更為常見的閉包吧。

閉包

閉包(Closure)是函式以及該函式被宣告時所在的作用域環境(lexical environment)的組合。^1

閉包是有一些變數在函式建立執行環境時跟著建立,但是在函式執行生命結束後,本應也跟著消滅的卻仍然活者,而且可能被某種方式存取的變數。

function makeAddFn(add) {
    return function (other) {
        return other + add;
    }
}

var add5 = makeAddFn(5);
console.log(add5(7)); // 12

上例中,當我們呼叫makeAddFn(5)的時候,參數變數add被建立並賦值為5,並這個函式的執行環境服務。這個函式執行時會再建立一個你名的函式物件,函式物件捕獲到這個參數變數。雖後函式物件作為返回值被保留了下來,準備做為add5變數的值,結束這個函式的執行環境。

作為返回值的函式物件捕獲到了參數變數add。因此原本add應該也要隨著執行環境結束而消失,卻因此同樣被保留了下來,成為閉包。當執行這個函式物件的時候,這個參數變數將再次被使用到。

閉包 & bind()

實際上bind()除了用於綁定this外,它還有多個參數。其中就有種用法很像是建立一個閉包環境:

function add(a, b) {
    return a + b;
}

var add5 = add.bind(null, 5);
console.log(add5(7)); // 12

上例中bind()並未綁定this,相對的他綁定了參數a。這樣的行為我們可以用閉包寫法來實現:

function myBind(a) {
    return function (b) {
        return add(a, b)
    }
}

var add5 = myBind(5);
console.log(add5(7)); // 12

是的,其實也就上一個閉包示例函示名稱換個名字而已

或許可以將myBind()寫的更為彈性一些,讓它可以綁定多數限定參數的函數:

function myBind(fn, ...args) {
    return function(...other_args) {
        return fn(...args, ...other_args)
    }
}

var add5 = myBind(add, 5);
console.log(add5(7)); // 12

這樣,我們就可以像使用bind()綁定多個參數值:

var add3_5 = add.bind(null, 3, 5);
console.log(add3_5); // 8

var add3_5 = myBind(add, 3, 5);
console.log(add3_5); // 8

那麼要怎麼像bind()綁定this呢?
這邊可以先暫停先自己想個十分鐘看看

var bob = {
    name: "Bob",
    sayHello() {
        console.log(`Hello, ${this.name}`);
    },
    setName(newName) {
        this.name = newName;
    }
}

this有些像部分程式語言裡有的動態作用域,但又有些差別。它看得是當函式執行時,函式是誰的方法?因此,為了綁定this關係,必須將函式物件作為一個方法執行。

function myBind(fn, thisArg) {
    if(thisArg !== null && typeof thisArg === "object") {
        return function(...other_args) {
            let sym = Symbol('fn');
            thisArg[sym] = fn;
            let result = thisArg[sym](...other_args);
            delete thisArg[sym];
            return result;
        }
    }
    return fn;
}

bob.sayHello(); // Hello, Bob
var bobSayHello = myBind(bob.sayHello, bob);
var setBobName = myBind(bob.setName, bob);
bobSayHello(); // Hello, Bob
setBobName("BBob");
bob.sayHello(); // Hello, BBob

這邊利用了Symbol來避免暫時的方法不會覆蓋掉其他屬性。不過Symbol是ES6(ES2015)之後才有的類型,因此有可能無法使用。不過其實有多種方式處理這個問題,像是先把會被覆蓋掉,但是呼叫時並不會使用到的屬性儲存下來,取得結果後再覆寫回去;或者也可以使用Function.prototype.call()Function.prototype.apply(),這兩個都支援thisArg參數。再不然作弊使用bind()也可以XD。

現在,我們可以結合兩個myBind()成一個更完整的函式:

function myBind(fn, thisArg, ...args) {
    if(fn instanceof Function === false)
        throw new Error(`fn must is a Function`);
    
    if(thisArg !== null && typeof thisArg === 'object'){
        let _fn = fn
        fn = function(..._args) {
            let sym = Symbol('fn');
            thisArg[sym] = _fn;
            let result = thisArg[sym](..._args);
            delete thisArg[sym];
            return result;
        }
    }
        
    return function(...other_args) {
        return fn(...args, ...other_args)
    }
}

不過再看一下MDN裡,針對bind()thisArg說明:

thisArg
The value to be passed as the this parameter to the target function func when the bound function is called. If the function is not in strict mode, null and undefined will be replaced with the global object, and primitive values will be converted to objects. The value is ignored if the bound function is constructed using the new operator.

其實還有一些部分是與描述行為不同的。針對描述可以知道:

  • 當在使用new關鍵字建立物件的時候,這個綁定值將會被忽略。
  • 如果不是嚴格模式(strict mode),若thisArgnullundefined則會用全域物件取代,也就是用globalThis取代。
  • 如果是原始型別(primitive types)。包含numberstringbooleanbigintsymbol)將會先被轉換成對應的物件類型。
  • 其餘物件類型才會作為this綁定

有了這些描述,就可以很容易的改寫myBind()

function myBind(fn, thisArg, ...args) {
    let _fn = fn;
    if(fn instanceof Function === false)
        throw new Error(`fn must is a Function`);
    
    if(thisArg === null || thisArg === undefined)
        thisArg = null; // 並沒有很簡單的方式判斷是不是嚴格模式
    else if (!(thisArg instanceof Object)) {
        thisArg = Object(thisArg);
    }
    
    if(thisArg instanceof Object){
        fn = function(..._args) {
            let sym = Symbol('fn');
            thisArg[sym] = _fn;
            let result = thisArg[sym](..._args);
            delete thisArg[sym];
            return result;
        }
    }
        
    return function(...other_args) {
        if(new.target) { // 當在使用`new`關鍵字建立物件的時候,忽略thisArg
            let result = new _fn(...args, ...other_args);
            return result;
        }
        return fn(...args, ...other_args)
    }
}

然後可以執行一些測試比較看看:

function showThis(a, b, c) {
  console.log('this', this);
  console.table({a, b, c});
}

function strictShowThis(a, b, c) {
  'use strict';
  console.log('this', this);
  console.table({a, b, c});
}

showThis.bind(null)(1,2,3);
myBind(showThis, null)(1,2,3);
strictShowThis.bind(null)(1,2,3);  // strict mode的this是null
myBind(strictShowThis, null)(1,2,3);


showThis.bind(undefined)(1,2,3);
myBind(showThis, undefined)(1,2,3);
strictShowThis.bind(undefined)(1,2,3);
myBind(strictShowThis, undefined)(1,2,3);

showThis.bind(1)(1,2,3);
myBind(showThis, 1)(1,2,3);
strictShowThis.bind(1)(1,2,3); // strict mode的this是1
myBind(strictShowThis, 1)(1,2,3);

showThis.bind(true)(1,2,3);
myBind(showThis, true)(1,2,3);
strictShowThis.bind(true)(1,2,3); // strict mode的this是true
myBind(strictShowThis, true)(1,2,3);


showThis.bind('true')(1,2,3);
myBind(showThis, 'true')(1,2,3);
strictShowThis.bind('true')(1,2,3); // strict mode的this是'true'
myBind(strictShowThis, 'true')(1,2,3);

showThis.bind(5n)(1,2,3);
myBind(showThis, 5n)(1,2,3);
strictShowThis.bind(5n)(1,2,3); // strict mode的this是1n
myBind(strictShowThis, 5n)(1,2,3);

obj = {a:1};

showThis.bind(obj, 1)(2,3);
myBind(showThis, obj, 1)(2,3);
strictShowThis.bind(obj, 1)(2,3);
myBind(strictShowThis, obj, 1)(2,3);


new (showThis.bind(obj))(1,2,3);
new (myBind(showThis, obj))(1,2,3);
new (strictShowThis.bind(obj))(1,2,3);
new (myBind(strictShowThis, obj))(1,2,3);

因為實現上避免使用.apply().call().bind(),有些情況還是難以實現。此外,雖然MDN並沒有提到,但是我嘗試在Mozilla Firefox Browser下執行,原生bind()在嚴格模式下,並沒有將原始型別轉換成物件類型。實際上也有可能很多地方實現上是存在問題的,如果你有能力看懂以下規格裡提到的的步驟,或許可以實現一個跟貼近規格的版本,反正我是看不太懂。

1. Let Target be the this value.
2. If IsCallable(Target) is false, throw a TypeError exception.
3. Let F be ? BoundFunctionCreate(Target, thisArg, args).
4. Let L be 0.
5. Let targetHasLength be ? HasOwnProperty(Target, "length").
6. If targetHasLength is true, then

    a. Let targetLen be ? Get(Target, "length").
    b. If Type(targetLen) is Number, then
        i. If targetLen is +∞?, set L to +∞.
        ii. Else if targetLen is -∞?, set L to 0.
        iii. Else,
            1. Let targetLenAsInt be ! ToIntegerOrInfinity(targetLen).
            2. Assert: targetLenAsInt is finite.
            3. Let argCount be the number of elements in args.
            4. Set L to max(targetLenAsInt - argCount, 0).

7. Perform SetFunctionLength(F, L).
8. Let targetName be ? Get(Target, "name").
9. If Type(targetName) is not String, set targetName to the empty String.
10. Perform SetFunctionName(F, targetName, "bound").
11. Return F.

雖然有時候規格可能也不定得非常明確,保留一些實作空間。如果你真的看懂了規格所說的,請一定要在下面留言讓我知道。

寫到這邊...自己有點意外閉包也寫了這麼多東西。
類似的,Python裡有Partial Function

柯里化

以上篇幅撰寫花費時間有點出乎我意料...柯里化有些並不是很好懂,這裡也就直接簡單說個一點就好。

用中文表示我覺得不是很容易,所以有些詞彙會使用英文。

curried function只的是僅僅接受一個參數,並返回一個函式物件或結果的函式。舉例來說下面的add()就可以算是一個curried function:

function add(a){
    return function(b) {
        return a + b;
    }
}

add(1)(2); // 3

add()原本的形式應該是更上面提到過的樣子:

function add(a, b) {
    return a + b;
}

這種形式的函式,稱之為 uncurried function。將 uncurried function 轉變為 curried function 的過程叫 curring ,其方法為curry。可以這麼寫下數學式:

在了解bind()用法以後,給我感覺很像是在調用curried function。

add.bind(null, 1).bind(null, 2)();
// or
add.bind(null, 1)(2);

雖然我原本是很想自己實現一個簡易的curry(),不過在寫下去我鐵人賽30天可能就完賽不了拉XD。有一些套件裡也提供了curry(),像是Lodash你可以這樣用:

// example from https://docs-lodash.com/v4/curry/

var abc = function(a, b, c) {
  return [a, b, c];
};
 
var curried = _.curry(abc);
 
curried(1)(2)(3);
// => [1, 2, 3]

結語

在寫這些內容的時候也不經讓我再思考一些未深入想過的細節,以及回憶過往所讀過的任何東西,包含近日閱讀完的「自己動手實現Lua」。

釐清了call()bind()的差異、bind()的常見用法和更多的使用方式。並介紹閉包和柯里化的概念。

你可能不知道Function的call()apply()bind()差不多多說完哩~

參考資料

本文同時發表於我的隨筆


上一篇
為什麼你需要知道Function的三種用法
下一篇
你可能不知道cookie是怎麼被製造出來的
系列文
這些那些你可能不知道我不知道的Web技術細節33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言