1. 程式人生 > >前端面試 js 你有多瞭解call,apply,bind?

前端面試 js 你有多瞭解call,apply,bind?

函式原型鏈中的 apply,call 和 bind 方法是 JavaScript 中相當重要的概念,與 this 關鍵字密切相關,相當一部分人對它們的理解還是比較淺顯,所謂js基礎紮實,繞不開這些基礎常用的API,這次讓我們來徹底掌握它們吧!

目錄

  1. call,apply,bind的基本介紹
  2. call/apply/bind的核心理念:借用方法
  3. call和apply的應用場景
  4. bind的應用場景
  5. 中高階面試題:手寫call/apply、bind

call,apply,bind的基本介紹

語法:

fun.call(thisArg, param1, param2, ...)
fun.apply(thisArg, [param1,param2,...])
fun.bind(thisArg, param1, param2, ...)

返回值:

call/apply:fun執行的結果
bind:返回fun的拷貝,並擁有指定的this值和初始引數

引數

thisArg(可選):

  1. funthis指向thisArg物件
  2. 非嚴格模式下:thisArg指定為null,undefined,fun中的this指向window物件.
  3. 嚴格模式下:funthisundefined
  4. 值為原始值(數字,字串,布林值)的this會指向該原始值的自動包裝物件,如 String、Number、Boolean

param1,param2(可選): 傳給fun的引數。

  1. 如果param不傳或為 null/undefined,則表示不需要傳入任何引數.
  2. apply第二個引數為陣列,陣列內的值為傳給fun的引數。

呼叫call/apply/bind的必須是個函式

call、apply和bind是掛在Function物件上的三個方法,只有函式才有這些方法。

只要是函式就可以,比如: Object.prototype.toString就是個函式,我們經常看到這樣的用法:Object.prototype.toString.call(data)

作用:

改變函式執行時的this指向,目前所有關於它們的運用,都是基於這一點來進行的。

如何不弄混call和aaply

弄混這兩個API的不在少數,不要小看這個問題,記住下面的這個方法就好了。

apply是以a

開頭,它傳給fun的引數是Array,也是以a開頭的。

區別:

call與apply的唯一區別

傳給fun的引數寫法不同:

  • apply是第2個引數,這個引數是一個數組:傳給fun引數都寫在陣列中。
  • call從第2~n的引數都是傳給fun的。

call/apply與bind的區別

執行:

  • call/apply改變了函式的this上下文後馬上執行該函式
  • bind則是返回改變了上下文後的函式,不執行該函式

返回值:

  • call/apply 返回fun的執行結果
  • bind返回fun的拷貝,並指定了fun的this指向,儲存了fun的引數。

返回值這段在下方bind應用中有詳細的示例解析。

call/apply/bind的核心理念:借用方法

看到一個非常棒的例子:

生活中:

平時沒時間做飯的我,週末想給孩子燉個醃篤鮮嚐嚐。但是沒有適合的鍋,而我又不想出去買。所以就問鄰居借了一個鍋來用,這樣既達到了目的,又節省了開支,一舉兩得。

程式中:

A物件有個方法,B物件因為某種原因也需要用到同樣的方法,那麼這時候我們是單獨為 B 物件擴充套件一個方法呢,還是借用一下 A 物件的方法呢?

當然是借用 A 物件的方法啦,既達到了目的,又節省了記憶體。

這就是call/apply/bind的核心理念:借用方法。

藉助已實現的方法,改變方法中資料的this指向,減少重複程式碼,節省記憶體。

call和apply的應用場景:

這些應用場景,多加體會就可以發現它們的理念都是:借用方法

  1. 判斷資料型別:

Object.prototype.toString用來判斷型別再合適不過,借用它我們幾乎可以判斷所有型別的資料:

function isType(data, type) {
    const typeObj = {
        '[object String]': 'string',
        '[object Number]': 'number',
        '[object Boolean]': 'boolean',
        '[object Null]': 'null',
        '[object Undefined]': 'undefined',
        '[object Object]': 'object',
        '[object Array]': 'array',
        '[object Function]': 'function',
        '[object Date]': 'date', // Object.prototype.toString.call(new Date())
        '[object RegExp]': 'regExp',
        '[object Map]': 'map',
        '[object Set]': 'set',
        '[object HTMLDivElement]': 'dom', // document.querySelector('#app')
        '[object WeakMap]': 'weakMap',
        '[object Window]': 'window',  // Object.prototype.toString.call(window)
        '[object Error]': 'error', // new Error('1')
        '[object Arguments]': 'arguments',
    }
    let name = Object.prototype.toString.call(data) // 借用Object.prototype.toString()獲取資料型別
    let typeName = typeObj[name] || '未知型別' // 匹配資料型別
    return typeName === type // 判斷該資料型別是否為傳入的型別
}
console.log(
    isType({}, 'object'), // true
    isType([], 'array'), // true
    isType(new Date(), 'object'), // false
    isType(new Date(), 'date'), // true
)
  1. 類陣列借用陣列的方法:

類陣列因為不是真正的陣列所有沒有陣列型別上自帶的種種方法,所以我們需要去借用陣列的方法。

比如借用陣列的push方法:

var arrayLike = {
  0: 'OB',
  1: 'Koro1',
  length: 2
}
Array.prototype.push.call(arrayLike, '新增元素1', '新增元素2');
console.log(arrayLike) // {"0":"OB","1":"Koro1","2":"新增元素1","3":"新增元素2","length":4}
  1. apply獲取陣列最大值最小值:

apply直接傳遞陣列做要呼叫方法的引數,也省一步展開陣列,比如使用Math.maxMath.min來獲取陣列的最大值/最小值:

const arr = [15, 6, 12, 13, 16];
const max = Math.max.apply(Math, arr); // 16
const min = Math.min.apply(Math, arr); // 6
  1. 繼承

ES5的繼承也都是通過借用父類的構造方法來實現父類方法/屬性的繼承:

// 父類
function supFather(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green']; // 複雜型別
}
supFather.prototype.sayName = function (age) {
    console.log(this.name, 'age');
};
// 子類
function sub(name, age) {
    // 借用父類的方法:修改它的this指向,賦值父類的建構函式裡面方法、屬性到子類上
    supFather.call(this, name);
    this.age = age;
}
// 重寫子類的prototype,修正constructor指向
function inheritPrototype(sonFn, fatherFn) {
    sonFn.prototype = Object.create(fatherFn.prototype); // 繼承父類的屬性以及方法
    sonFn.prototype.constructor = sonFn; // 修正constructor指向到繼承的那個函式上
}
inheritPrototype(sub, supFather);
sub.prototype.sayAge = function () {
    console.log(this.age, 'foo');
};
// 例項化子類,可以在例項上找到屬性、方法
const instance1 = new sub("OBKoro1", 24);
const instance2 = new sub("小明", 18);
instance1.colors.push('black')
console.log(instance1) // {"name":"OBKoro1","colors":["red","blue","green","black"],"age":24}
console.log(instance2) // {"name":"小明","colors":["red","blue","green"],"age":18} 

類似的應用場景還有很多,就不贅述了,關鍵在於它們借用方法的理念,不理解的話多看幾遍。

call、apply,該用哪個?、

call,apply的效果完全一樣,它們的區別也在於

  • 引數數量/順序確定就用call,引數數量/順序不確定的話就用apply。
  • 考慮可讀性:引數數量不多就用apply,引數數量比較多的話,把引數整合成陣列,使用apply。
  • 引數集合已經是一個數組的情況,用apply,比如上文的獲取陣列最大值/最小值。

引數數量/順序不確定的話就用apply,比如以下示例:

const obj = {
    age: 24,
    name: 'OBKoro1',
}
const obj2 = {
    age: 777
}
callObj(obj, handle)
callObj(obj2, handle)
// 根據某些條件來決定要傳遞引數的數量、以及順序
function callObj(thisAge, fn) {
    let params = []
    if (thisAge.name) {
        params.push(thisAge.name)
    }
    if (thisAge.age) {
        params.push(thisAge.age)
    }
    fn.apply(thisAge, params) // 數量和順序不確定 不能使用call
}
function handle(...params) {
    console.log('params', params) // do some thing
}

bind的應用場景:

1. 儲存函式引數:

首先來看下一道經典的面試題:

for (var i = 1; i <= 5; i++) {
   setTimeout(function test() {
        console.log(i) // 依次輸出:6 6 6 6 6
    }, i * 1000);
}

造成這個現象的原因是等到setTimeout非同步執行時,i已經變成6了。

關於js事件迴圈機制不理解的同學,可以看我這篇部落格:Js 的事件迴圈(Event Loop)機制以及例項講解

那麼如何使他輸出: 1,2,3,4,5呢?

方法有很多:

  • 閉包, 儲存變數
for (var i = 1; i <= 5; i++) {
    (function (i) {
        setTimeout(function () {
            console.log('閉包:', i); // 依次輸出:1 2 3 4 5
        }, i * 1000);
    }(i));
}

在這裡建立了一個閉包,每次迴圈都會把i的最新值傳進去,然後被閉包儲存起來。

  • bind
for (var i = 1; i <= 5; i++) {
    // 快取引數
    setTimeout(function (i) {
        console.log('bind', i) // 依次輸出:1 2 3 4 5
    }.bind(null, i), i * 1000);
}

實際上這裡也用了閉包,我們知道bind會返回一個函式,這個函式也是閉包。

它儲存了函式的this指向、初始引數,每次i的變更都會被bind的閉包存起來,所以輸出1-5。

具體細節,下面有個手寫bind方法,研究一下,就能搞懂了。

  • let

let宣告i也可以輸出1-5: 因為let是塊級作用域,所以每次都會建立一個新的變數,所以setTimeout每次讀的值都是不同的,詳解。

2. 回撥函式this丟失問題:

這是一個常見的問題,下面是我在開發VSCode外掛處理webview通訊時,遇到的真實問題,一開始以為VSCode的API哪裡出問題,除錯了一番才發現是this指向丟失的問題。

class Page {
    constructor(callBack) {
        this.className = 'Page'
        this.MessageCallBack = callBack // 
        this.MessageCallBack('發給註冊頁面的資訊') // 執行PageA的回撥函式
    }
}
class PageA {
    constructor() {
        this.className = 'PageA'
        this.pageClass = new Page(this.handleMessage) // 註冊頁面 傳遞迴調函式 問題在這裡
    }
    // 與頁面通訊回撥
    handleMessage(msg) {
        console.log('處理通訊', this.className, msg) //  'Page' this指向錯誤
    }
}
new PageA()

回撥函式this為何會丟失?

顯然宣告的時候不會出現問題,執行回撥函式的時候也不可能出現問題。

問題出在傳遞迴調函式的時候:

this.pageClass = new Page(this.handleMessage)

因為傳遞過去的this.handleMessage是一個函式記憶體地址,沒有上下文物件,也就是說該函式沒有繫結它的this指向。

那它的this指向於它所應用的繫結規則:

class Page {
    constructor(callBack) {
        this.className = 'Page'
        // callBack() // 直接執行的話 由於class 內部是嚴格模式,所以this 實際指向的是 undefined
        this.MessageCallBack = callBack // 回撥函式的this 隱式繫結到class page
        this.MessageCallBack('發給註冊頁面的資訊')
    }
}

既然知道問題了,那我們只要繫結回撥函式的this指向為PageA就解決問題了。

回撥函式this丟失的解決方案:

  1. bind繫結回撥函式的this指向:

這是典型bind的應用場景, 繫結this指向,用做回撥函式。

this.pageClass = new Page(this.handleMessage.bind(this)) // 繫結回撥函式的this指向

PS: 這也是為什麼reactrender函式在繫結回撥函式的時候,也要使用bind繫結一下this的指向,也是因為同樣的問題以及原理。

  1. 箭頭函式繫結this指向

箭頭函式的this指向定義的時候外層第一個普通函式的this,在這裡指的是class類:PageA

這塊內容,可以看下我之前寫的部落格:詳解箭頭函式和普通函式的區別以及箭頭函式的注意事項、不適用場景

this.pageClass = new Page(() => this.handleMessage()) // 箭頭函式繫結this指向

中高階面試題-手寫call/apply、bind:

在大廠的面試中,手寫實現call,apply,bind(特別是bind)一直是比較高頻的面試題,在這裡我們也一起來實現一下這幾個函式。

你能手寫實現一個call嗎?

思路

  1. 根據call的規則設定上下文物件,也就是this的指向。
  2. 通過設定context的屬性,將函式的this指向隱式繫結到context上
  3. 通過隱式繫結執行函式並傳遞引數。
  4. 刪除臨時屬性,返回函式執行結果
Function.prototype.myCall = function (context, ...arr) {
    if (context === null || context === undefined) {
       // 指定為 null 和 undefined 的 this 值會自動指向全域性物件(瀏覽器中為window)
        context = window 
    } else {
        context = Object(context) // 值為原始值(數字,字串,布林值)的 this 會指向該原始值的例項物件
    }
    context.testFn = this; // 函式的this指向隱式繫結到context上
    let result = context.testFn(...arr); // 通過隱式繫結執行函式並傳遞引數
    delete context.testFn; // 刪除上下文物件的屬性
    return result; // 返回函式執行結果
};

判斷函式的上下文物件:

很多人判斷函式上下文物件,只是簡單的以context是否為false來判斷,比如:

// 判斷函式上下文繫結到`window`不夠嚴謹
context = context ? Object(context) : window; 
context = context || window; 

經過測試,以下三種為false的情況,函式的上下文物件都會繫結到window上:

// 網上的其他繫結函式上下文物件的方案: context = context || window; 
function handle(...params) {
    this.test = 'handle'
    console.log('params', this, ...params) // do some thing
}
handle.elseCall('') // window
handle.elseCall(0) // window
handle.elseCall(false) // window

call則將函式的上下文物件會繫結到這些原始值的例項物件上:

所以正確的解決方案,應該是像我上面那麼做:

// 正確判斷函式上下文物件
    if (context === null || context === undefined) {
       // 指定為 null 和 undefined 的 this 值會自動指向全域性物件(瀏覽器中為window)
        context = window 
    } else {
        context = Object(context) // 值為原始值(數字,字串,布林值)的 this 會指向該原始值的例項物件
    }

你能手寫實現一個apply嗎?

思路:

  1. 傳遞給函式的引數處理,不太一樣,其他部分跟call一樣。
  2. apply接受第二個引數為類陣列物件, 這裡用了JavaScript權威指南中判斷是否為類陣列物件的方法。
Function.prototype.myApply = function (context) {
    if (context === null || context === undefined) {
        context = window // 指定為 null 和 undefined 的 this 值會自動指向全域性物件(瀏覽器中為window)
    } else {
        context = Object(context) // 值為原始值(數字,字串,布林值)的 this 會指向該原始值的例項物件
    }
    // JavaScript權威指南判斷是否為類陣列物件
    function isArrayLike(o) {
        if (o &&                                    // o不是null、undefined等
            typeof o === 'object' &&                // o是物件
            isFinite(o.length) &&                   // o.length是有限數值
            o.length >= 0 &&                        // o.length為非負值
            o.length === Math.floor(o.length) &&    // o.length是整數
            o.length < 4294967296)                  // o.length < 2^32
            return true
        else
            return false
    }
    context.testFn = this; // 隱式繫結this指向到context上
    const args = arguments[1]; // 獲取引數陣列
    let result
    // 處理傳進來的第二個引數
    if (args) {
        // 是否傳遞第二個引數
        if (!Array.isArray(args) && !isArrayLike(args)) {
            throw new TypeError('myApply 第二個引數不為陣列並且不為類陣列物件丟擲錯誤');
        } else {
            args = Array.from(args) // 轉為陣列
            result = context.testFn(...args); // 執行函式並展開陣列,傳遞函式引數
        }
    } else {
        result = context.testFn(); // 執行函式
    }
    delete context.testFn; // 刪除上下文物件的屬性
    return result; // 返回函式執行結果
};

你能手寫實現一個bind嗎?

劃重點:

手寫bind是大廠中的一個高頻的面試題,如果面試的中高階前端,只是能說出它們的區別,用法並不能脫穎而出,理解要有足夠的深度才能抱得offer歸!

思路

  1. 拷貝源函式:
    • 通過變數儲存源函式
    • 使用Object.create複製源函式的prototype給fToBind
  2. 返回拷貝的函式
  3. 呼叫拷貝的函式:
    • new呼叫判斷:通過instanceof判斷函式是否通過new呼叫,來決定繫結的context
    • 繫結this+傳遞引數
    • 返回源函式的執行結果
Function.prototype.myBind = function (objThis, ...params) {
    const thisFn = this; // 儲存源函式以及上方的params(函式引數)
    let fToBind = function () {
        const isNew = this instanceof fToBind // this是否是fToBind的例項 也就是返回的fToBind是否通過new呼叫
        const context = isNew ? this : Object(objThis) // new呼叫就繫結到this上,否則就繫結到傳入的objThis上
        return thisFn.apply(context, params); // 用apply呼叫源函式繫結this的指向並傳遞引數,返回執行結果
    };
    fToBind.prototype = Object.create(thisFn.prototype); // 複製源函式的prototype給fToBind
    return fToBind; // 返回拷貝的函式
};

小結

本來以為這篇會寫的很快,結果斷斷續續的寫了好幾天,終於把這三個API相關知識介紹清楚了,希望大家看完之後,面試的時候再遇到這個問題,就可以海陸空全方位的裝逼了^_^

覺得我的部落格對你有幫助的話,就給我點個Star吧!

前端進階積累、公眾號、GitHub、wx:OBkoro1、郵箱:[email protected]

以上2019/8