前端面試題:Call的用法及實現
大家好,我是前端西瓜哥。
我之前寫了一篇手寫 bind 的文章,里面直接使用了原生 call 方法。
有讀者說他面試的時候這個 call 也要求自己實現的。
那我們今天來手寫 call。apply 的實現也是一樣,只是調用形式有點區別。
call 的用法
我們先看看 Function.prototype.call() 的用法。
call() 可以修改函數調用時 this 的指向,其余參數則會作為原函數的參數。
call 接收的參數:
- 第一個參數 thisArg。代表 this 將會被指向的值。如果不是對象,也會通過 Object() 方法轉換為對象。如果是 null 或 undefined,this 則會指向全局對象(即 window 或 global),或在嚴格模式("use strict;")下,保持 undefined 或 null;
- 其余參數。第二個往后的參數則會傳入到原函數中。
例子:
function sum(num1, num2) {
return this.val + num1 + num2;
}
const obj = { val: 1 };
sum.call(obj, 2, 3); // 6
上面代碼中,this 指向了 obj。
Function.prototype.apply 也是類似,但它的參數是以數組的形式存在的。上面的 call 寫法等價于:
sum.call(obj, [2, 3]);
call 的實現
JS 函數中的 this 指向是在運行時決定的,里面的規則比較多,但其中有一條是:
如果是通過 obj.fn() 執行時,this 會指向前面的 obj 對象。
那我們只要將傳入對象和原方法進行拼接,拼成上面這個 對象.方法 的形式,執行時,this 就能乖乖指向我們傳入的 thisArg 了。
實現如下:
Function.prototype.myCall = function(thisArg, ...args) {
const context = Object(thisArg) || window;
// 構造唯一 key
const fn = Symbol();
// 組裝成"對象.方法"形式并調用,來改變 this
context[fn] = this;
const ret = context[fn](...args);
// 刪掉臨時加的 key,復原 thisArg
delete context[fn];
return ret;
}
這里我們用 Symbol() 創建了一個唯一的 key,是為了防止覆蓋掉 thisArg 原有的同名屬性。
執行完后,記得將這個 key 移除掉,防止污染 thisArg 對象。
如果面試官要你用 ES5 實現,那會復雜很多,我這里也給出實現吧。
在這之前,我們先來學點前置知識。
判斷是否為嚴格模式
var strict = (function() { return !this })();
利用了嚴格模式下,如果沒有指定 this(通過 bind、call、前面帶對象等方式),就會得到 undefined 的機制。如果是非嚴格模式,this 會拿到全局變量。
fn(...args) 的 ES5 實現
ES6 的擴展運算符 ... 能夠將數組 args,進行拆分按順序放到函數中。
const args = [4, 5, 6];
fn(...args);
// 等價于
fn(4, 5, 6);
那我們用 ES5,也能將數組拆分成一個參數塞到函數中嗎?
可以,但我們要用一點奇技淫巧:Function 方法。
Function 方法用得比較少。它可以在運行時創建一個函數,最后一個參數是函數體內容,前面的參數則是函數的參數。
const sum = new Function('a', 'b', 'return a + b');
sum(2, 6) // 8
fn(...args) 的 ES5 實現為:
function construct(fn, args) {
var list = [];
for (var i = 0; i < args.length; i++) {
list[i] = 'a[' + i + ']';
}
var f = new Function('fn', 'a', 'return fn(' + list.join(', ') + ')');
return f(fn, args);
}
Function 方法可以根據參數長度,動態生成 new Function('fn', 'a', 'return fn(a[0], a[1])') 形式的函數,來實現類似擴展運算符的效果。
還有種寫法是用 eval,也能根據字符串動態生成可執行代碼。
function construct(fn, a) {
var list = [];
for (var i = 0; i < a.length; i++) {
list[i] = 'a[' + i + ']';
}
return eval('fn(' + list.join(', ') + ')');
}
但這種封裝成一個函數的寫法,會有 this 隱式丟失問題。比如執行 construct(dog.bark, ['bark!']),執行時 this 將不再指向對象 dog。
關于 this 的指向問題還是比較復雜的,以后我會專門寫一篇文章來講解 this。
call 的 ES5 實現
Function.prototype.myCall = function(thisArg/*, ...args */) {
var context = Object(thisArg) || window;
context.fn = this;
// 偷懶用了 Array.prototype.slice + 原生 call
// 請讀者自行實現 slice
var a = Array.prototype.slice.call(arguments, 1);
var list = [];
for (var i = 0; i < a.length; i++) {
list[i] = 'a[' + i + ']';
}
var ret = eval('context.fn(' + list.join(',') + ')');
delete context.fn; // 復原
return ret;
}
為了不被干擾,上面的代碼實現 忽略掉了一些細節。
- 這里我沒有用前面實現的 construct 方法,因為會丟失 this,所以直接用了 eval。
- slice 請自行實現,不能用 Array.prototype.slice.call,因為用了原生的 call。
- 我們用了一個字符串 'fn'來臨時掛載函數,可能會和 thisArg 上的屬性名沖突,但 ES5 又不能用 Symbol,這種情況下。更好的做法是生成一個隨機的長字符串,用hasOwnProperty判斷對象是否存在該屬性,如果不存在就使用它。
- this 不可調用時(即不是函數時),要拋出錯誤。
另外我的實現,沒有考慮嚴格模式。嚴格模式下,如果 thisArg 是 undefined 或 null,直接執行原函數就行了,不需要拼裝成 obj.fn 形式。
結尾
手寫 call,核心在于通過另一種修改 this 指向的方式:obj.fn() 執行時 this 會指向 obj 對象。
手寫 apply 也是一樣的邏輯,還能少寫一個 slice 方法。