1. 程式人生 > >面試官問:能否模擬實現JS的call和apply方法

面試官問:能否模擬實現JS的call和apply方法

之前寫過兩篇《面試官問:能否模擬實現JSnew操作符》《面試官問:能否模擬實現JSbind方法》

其中模擬bind方法時是使用的callapply修改this指向。但面試官可能問:能否不用callapply來實現呢。意思也就是需要模擬實現callapply的了。

附上之前寫文章寫過的一段話:已經有很多模擬實現callapply的文章,為什麼自己還要寫一遍呢。學習就好比是座大山,人們沿著不同的路登山,分享著自己看到的風景。你不一定能看到別人看到的風景,體會到別人的心情。只有自己去登山,才能看到不一樣的風景,體會才更加深刻。

先通過MDN認識下callapply

MDN 文件:Function.prototype.call()
語法

fun.call(thisArg, arg1, arg2, ...)
複製程式碼

thisArg
fun函式執行時指定的this值。需要注意的是,指定的this值並不一定是該函式執行時真正的this值,如果這個函式處於非嚴格模式下,則指定為nullundefinedthis值會自動指向全域性物件(瀏覽器中就是window物件),同時值為原始值(數字,字串,布林值)的this會指向該原始值的自動包裝物件。
arg1, arg2, ...
指定的引數列表
返回值
返回值是你呼叫的方法的返回值,若該方法沒有返回值,則返回undefined


MDN 文件:Function.prototype.apply()

func.apply(thisArg, [argsArray])
複製程式碼

thisArg
可選的。在 func 函式執行時使用的 this 值。請注意,this可能不是該方法看到的實際值:如果這個函式處於非嚴格模式下,則指定為 nullundefined 時會自動替換為指向全域性物件,原始值會被包裝。
argsArray
可選的。一個數組或者類陣列物件,其中的陣列元素將作為單獨的引數傳給 func 函式。如果該引數的值為 nullundefined,則表示不需要傳入任何引數。從ECMAScript 5 開始可以使用類陣列物件。
返回值


呼叫有指定this值和引數的函式的結果。 直接先看例子1

callapply 的異同

相同點:
1、callapply的第一個引數thisArg,都是func執行時指定的this。而且,this可能不是該方法看到的實際值:如果這個函式處於非嚴格模式下,則指定為 nullundefined 時會自動替換為指向全域性物件,原始值會被包裝。
2、都可以只傳遞一個引數。
不同點:apply只接收兩個引數,第二個引數可以是陣列也可以是類陣列,其實也可以是物件,後續的引數忽略不計。call接收第二個及以後一系列的引數。
看兩個簡單例子1和2**:

// 例子1:瀏覽器環境 非嚴格模式下
var doSth = function(a, b){
    console.log(this);
    console.log([a, b]);
}
doSth.apply(null, [1, 2]); // this是window  // [1, 2]
doSth.apply(0, [1, 2]); // this 是 Number(0) // [1, 2]
doSth.apply(true); // this 是 Boolean(true) // [undefined, undefined]
doSth.call(undefined, 1, 2); // this 是 window // [1, 2]
doSth.call('0', 1, {a: 1}); // this 是 String('0') // [1, {a: 1}]
複製程式碼
// 例子2:瀏覽器環境 嚴格模式下
'use strict';
var doSth2 = function(a, b){
    console.log(this);
    console.log([a, b]);
}
doSth2.call(0, 1, 2); // this 是 0 // [1, 2]
doSth2.apply('1'); // this 是 '1' // [undefined, undefined]
doSth2.apply(null, [1, 2]); // this 是 null // [1, 2]
複製程式碼

typeof7種類型(undefined number string boolean symbol object function),筆者都驗證了一遍:更加驗證了相同點第一點,嚴格模式下,函式的this值就是callapply的第一個引數thisArg,非嚴格模式下,thisArg值被指定為 nullundefinedthis值會自動替換為指向全域性物件,原始值則會被自動包裝,也就是new Object()

重新認識了callapply會發現:它們作用都是一樣的,改變函式裡的this指向為第一個引數thisArg,如果明確有多少引數,那可以用call,不明確則可以使用apply。也就是說完全可以不使用call,而使用apply代替。
也就是說,我們只需要模擬實現applycall可以根據引數個數都放在一個數組中,給到apply即可。

模擬實現 apply

既然準備模擬實現apply,那先得看看ES5規範。ES5規範 英文版ES5規範 中文版apply的規範下一個就是call的規範,可以點選開啟新標籤頁去檢視,這裡摘抄一部分。

Function.prototype.apply (thisArg, argArray)
當以 thisArgargArray 為引數在一個 func 物件上呼叫 apply 方法,採用如下步驟:

1.如果 IsCallable(func)false, 則丟擲一個 TypeError 異常。
2.如果 argArraynullundefined, 則返回提供 thisArg 作為 this 值並以空引數列表呼叫 func[[Call]] 內部方法的結果。
3.返回提供 thisArg 作為 this 值並以空引數列表呼叫 func[[Call]] 內部方法的結果。
4.如果 Type(argArray) 不是 Object, 則丟擲一個 TypeError 異常。
5~8 略
9.提供 thisArg 作為 this 值並以 argList 作為引數列表,呼叫 func[[Call]] 內部方法,返回結果。
apply 方法的 length 屬性是 2

在外面傳入的 thisArg 值會修改併成為 this 值。thisArgundefinednull 時它會被替換成全域性物件,所有其他值會被應用 ToObject 並將結果作為 this 值,這是第三版引入的更改。

結合上文和規範,如何將函式裡的this指向第一個引數thisArg呢,這是一個問題。 這時候請出例子3

// 瀏覽器環境 非嚴格模式下
var doSth = function(a, b){
    console.log(this);
    console.log(this.name);
    console.log([a, b]);
}
var student = {
    name: '軒轅Rowboat',
    doSth: doSth,
};
student.doSth(1, 2); // this === student // true // '軒轅Rowboat' // [1, 2]
doSth.apply(student, [1, 2]); // this === student // true // '軒轅Rowboat' // [1, 2]
複製程式碼

可以得出結論1:在物件student上加一個函式doSth,再執行這個函式,這個函式裡的this就指向了這個物件。那也就是可以在thisArg上新增呼叫函式,執行後刪除這個函式即可。 知道這些後,我們試著容易實現第一版本:

// 瀏覽器環境 非嚴格模式
function getGlobalObject(){
    return this;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){ // `apply` 方法的 `length` 屬性是 `2`。
    // 1.如果 `IsCallable(func)` 是 `false`, 則丟擲一個 `TypeError` 異常。
    if(typeof this !== 'function'){
        throw new TypeError(this + ' is not a function');
    }

    // 2.如果 argArray 是 null 或 undefined, 則
    // 返回提供 thisArg 作為 this 值並以空引數列表呼叫 func 的 [[Call]] 內部方法的結果。
    if(typeof argsArray === 'undefined' || argsArray === null){
        argsArray = [];
    }
    
    // 3.如果 Type(argArray) 不是 Object, 則丟擲一個 TypeError 異常 .
    if(argsArray !== new Object(argsArray)){
        throw new TypeError('CreateListFromArrayLike called on non-object');
    }

    if(typeof thisArg === 'undefined' || thisArg === null){
        // 在外面傳入的 thisArg 值會修改併成為 this 值。
        // ES3: thisArg 是 undefined 或 null 時它會被替換成全域性物件 瀏覽器裡是window
        thisArg = getGlobalObject();
    }

    // ES3: 所有其他值會被應用 ToObject 並將結果作為 this 值,這是第三版引入的更改。
    thisArg = new Object(thisArg);
    var __fn = '__fn';
    thisArg[__fn] = this;
    // 9.提供 thisArg 作為 this 值並以 argList 作為引數列表,呼叫 func 的 [[Call]] 內部方法,返回結果
    var result = thisArg[__fn](...argsArray);
    delete thisArg[__fn];
    return result;
};
複製程式碼

實現第一版後,很容易找出兩個問題:

  • 1.__fn 同名覆蓋問題,thisArg物件上有__fn,那就被覆蓋瞭然後被刪除了。

針對問題1 解決方案一:採用ES6 Sybmol() 獨一無二的。可以本來就是模擬ES3的方法。如果面試官不允許用呢。 解決方案二:自己用Math.random()模擬實現獨一無二的key。面試時可以直接用生成時間戳即可。

// 生成UUID 通用唯一識別碼
// 大概生成 這樣一串 '18efca2d-6e25-42bf-a636-30b8f9f2de09'
function generateUUID(){
    var i, random;
    var uuid = '';
    for (i = 0; i < 32; i++) {
        random = Math.random() * 16 | 0;
        if (i === 8 || i === 12 || i === 16 || i === 20) {
            uuid += '-';
        }
        uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random))
            .toString(16);
    }
    return uuid;
}
// 簡單實現
// '__' + new Date().getTime();
複製程式碼

如果這個key萬一這物件中還是有,為了保險起見,可以做一次快取操作。比如如下程式碼:

var student = {
    name: '軒轅Rowboat',
    doSth: 'doSth',
};
var originalVal = student.doSth;
var hasOriginalVal = student.hasOwnProperty('doSth');
student.doSth = function(){};
delete student.doSth;
// 如果沒有,`originalVal`則為undefined,直接賦值新增了一個undefined,這是不對的,所以需判斷一下。
if(hasOriginalVal){
    student.doSth = originalVal;
}
console.log('student:', student); // { name: '軒轅Rowboat', doSth: 'doSth' }
複製程式碼
  • 2.使用了ES6擴充套件符...
    解決方案一:採用eval來執行函式。

eval把字串解析成程式碼執行。
MDN 文件:eval
語法

eval(string)
複製程式碼

引數
string
表示JavaScript表示式,語句或一系列語句的字串。表示式可以包含變數以及已存在物件的屬性。
返回值
執行指定程式碼之後的返回值。如果返回值為空,返回undefined
解決方案二:但萬一面試官不允許用eval呢,畢竟eval是魔鬼。可以採用new Function()來生成執行函式。 MDN 文件:Function
語法

new Function ([arg1[, arg2[, ...argN]],] functionBody)
複製程式碼

引數
arg1, arg2, ... argN
被函式使用的引數的名稱必須是合法命名的。引數名稱是一個有效的JavaScript識別符號的字串,或者一個用逗號分隔的有效字串的列表;例如“×”“theValue”,或“A,B”
functionBody
一個含有包括函式定義的JavaScript語句的字串。
接下來看兩個例子:

簡單例子:
var sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6));
複製程式碼
// 稍微複雜點的例子:
var student = {
    name: '軒轅Rowboat',
    doSth: function(argsArray){
        console.log(argsArray);
        console.log(this.name);
    }
};
// var result = student.doSth(['Rowboat', 18]);
// 用new Function()生成函式並執行返回結果
var result = new Function('return arguments[0][arguments[1]](arguments[2][0], arguments[2][1])')(student, 'doSth', ['Rowboat', 18]);
// 個數不定
// 所以可以寫一個函式生成函式程式碼:
function generateFunctionCode(argsArrayLength){
    var code = 'return arguments[0][arguments[1]](';
    for(var i = 0; i < argsLength; i++){
        if(i > 0){
            code += ',';
        }
        code += 'arguments[2][' + i + ']';
    }
    code += ')';
    // return arguments[0][arguments[1]](arg1, arg2, arg3...)
    return code;
}
複製程式碼

你可能不知道在ES3、ES5undefined 是能修改的

可能大部分人不知道。ES5中雖然在全域性作用域下不能修改,但在區域性作用域中也是能修改的,不信可以複製以下測試程式碼在控制檯執行下。雖然一般情況下是不會的去修改它。

function test(){
    var undefined = 3;
    console.log(undefined); // chrome下也是 3
}
test();
複製程式碼

所以判斷一個變數a是不是undefined,更嚴謹的方案是typeof a === 'undefined'或者a === void 0; 這裡面用的是voidvoid的作用是計算表示式,始終返回undefined,也可以這樣寫void(0)。 更多可以檢視韓子遲的這篇文章:為什麼用「void 0」代替「undefined」 解決了這幾個問題,比較容易實現如下程式碼。

使用 new Function() 模擬實現的apply

// 瀏覽器環境 非嚴格模式
function getGlobalObject(){
    return this;
}
function generateFunctionCode(argsArrayLength){
    var code = 'return arguments[0][arguments[1]](';
    for(var i = 0; i < argsArrayLength; i++){
        if(i > 0){
            code += ',';
        }
        code += 'arguments[2][' + i + ']';
    }
    code += ')';
    // return arguments[0][arguments[1]](arg1, arg2, arg3...)
    return code;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){ // `apply` 方法的 `length` 屬性是 `2`。
    // 1.如果 `IsCallable(func)` 是 `false`, 則丟擲一個 `TypeError` 異常。
    if(typeof this !== 'function'){
        throw new TypeError(this + ' is not a function');
    }
    // 2.如果 argArray 是 null 或 undefined, 則
    // 返回提供 thisArg 作為 this 值並以空引數列表呼叫 func 的 [[Call]] 內部方法的結果。
    if(typeof argsArray === 'undefined' || argsArray === null){
        argsArray = [];
    }
    // 3.如果 Type(argArray) 不是 Object, 則丟擲一個 TypeError 異常 .
    if(argsArray !== new Object(argsArray)){
        throw new TypeError('CreateListFromArrayLike called on non-object');
    }
    if(typeof thisArg === 'undefined' || thisArg === null){
        // 在外面傳入的 thisArg 值會修改併成為 this 值。
        // ES3: thisArg 是 undefined 或 null 時它會被替換成全域性物件 瀏覽器裡是window
        thisArg = getGlobalObject();
    }
    // ES3: 所有其他值會被應用 ToObject 並將結果作為 this 值,這是第三版引入的更改。
    thisArg = new Object(thisArg);
    var __fn = '__' + new Date().getTime();
    // 萬一還是有 先儲存一份,刪除後,再恢復該值
    var originalVal = thisArg[__fn];
    // 是否有原始值
    var hasOriginalVal = thisArg.hasOwnProperty(__fn);
    thisArg[__fn] = this;
    // 9.提供 `thisArg` 作為 `this` 值並以 `argList` 作為引數列表,呼叫 `func` 的 `[[Call]]` 內部方法,返回結果。
    // ES6版
    // var result = thisArg[__fn](...args);
    var code = generateFunctionCode(argsArray.length);
    var result = (new Function(code))(thisArg, __fn, argsArray);
    delete thisArg[__fn];
    if(hasOriginalVal){
        thisArg[__fn] = originalVal;
    }
    return result;
};
複製程式碼

利用模擬實現的apply模擬實現call

Function.prototype.callFn = function call(thisArg){
    var argsArray = [];
    var argumentsLength = arguments.length;
    for(var i = 0; i < argumentsLength - 1; i++){
        // argsArray.push(arguments[i + 1]);
        argsArray[i] = arguments[i + 1];
    }
    console.log('argsArray:', argsArray);
    return this.applyFn(thisArg, argsArray);
}
// 測試例子
var doSth = function (name, age){
    var type = Object.prototype.toString.call(this);
    console.log(typeof doSth);
    console.log(this === firstArg);
    console.log('type:', type);
    console.log('this:', this);
    console.log('args:', [name, age], arguments);
    return 'this--';
};

var name = 'window';

var student = {
    name: '軒轅Rowboat',
    age: 18,
    doSth: 'doSth',
    __fn: 'doSth',
};
var firstArg = student;
var result = doSth.applyFn(firstArg, [1, {name: 'Rowboat'}]);
var result2 = doSth.callFn(firstArg, 1, {name: 'Rowboat'});
console.log('result:', result);
console.log('result2:', result2);
複製程式碼

細心的你會發現註釋了這一句argsArray.push(arguments[i + 1]);,事實上push方法,內部也有一層迴圈。所以理論上不使用push效能會更好些。面試官也可能根據這點來問時間複雜度和空間複雜度的問題。

// 看看V8引擎中的具體實現:
function ArrayPush() {
    var n = TO_UINT32( this.length );    // 被push的物件的length
    var m = %_ArgumentsLength();     // push的引數個數
    for (var i = 0; i < m; i++) {
        this[ i + n ] = %_Arguments( i );   // 複製元素     (1)
    }
    this.length = n + m;      // 修正length屬性的值    (2)
    return this.length;
};
複製程式碼

行文至此,就基本結束了,你可能還發現就是寫的非嚴格模式下,thisArg原始值會包裝成物件,新增函式並執行,再刪除。而嚴格模式下還是原始值這個沒有實現,而且萬一這個物件是凍結物件呢,Object.freeze({}),是無法在這個物件上新增屬性的。所以這個方法只能算是非嚴格模式下的簡版實現。最後來總結一下。

總結

通過MDN認識callapply,閱讀ES5規範,到模擬實現apply,再實現call
就是使用在物件上新增呼叫apply的函式執行,這時的呼叫函式的this就指向了這個thisArg,再返回結果。引出了ES6 SymbolES6的擴充套件符...evalnew Function(),嚴格模式等。
事實上,現實業務場景不需要去模擬實現callapply,畢竟是ES3就提供的方法。但面試官可以通過這個面試題考察候選人很多基礎知識。如:callapply的使用。ES6 SymbolES6的擴充套件符...evalnew Function(),嚴格模式,甚至時間複雜度和空間複雜度等。
讀者發現有不妥或可改善之處,歡迎指出。另外覺得寫得不錯,可以點個贊,也是對筆者的一種支援。

// 最終版版 刪除註釋版,詳細註釋看文章
// 瀏覽器環境 非嚴格模式
function getGlobalObject(){
    return this;
}
function generateFunctionCode(argsArrayLength){
    var code = 'return arguments[0][arguments[1]](';
    for(var i = 0; i < argsArrayLength; i++){
        if(i > 0){
            code += ',';
        }
        code += 'arguments[2][' + i + ']';
    }
    code += ')';
    return code;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){
    if(typeof this !== 'function'){
        throw new TypeError(this + ' is not a function');
    }
    if(typeof argsArray === 'undefined' || argsArray === null){
        argsArray = [];
    }
    if(argsArray !== new Object(argsArray)){
        throw new TypeError('CreateListFromArrayLike called on non-object');
    }
    if(typeof thisArg === 'undefined' || thisArg === null){
        thisArg = getGlobalObject();
    }
    thisArg = new Object(thisArg);
    var __fn = '__' + new Date().getTime();
    var originalVal = thisArg[__fn];
    var hasOriginalVal = thisArg.hasOwnProperty(__fn);
    thisArg[__fn] = this;
    var code = generateFunctionCode(argsArray.length);
    var result = (new Function(code))(thisArg, __fn, argsArray);
    delete thisArg[__fn];
    if(hasOriginalVal){
        thisArg[__fn] = originalVal;
    }
    return result;
};
Function.prototype.callFn = function call(thisArg){
    var argsArray = [];
    var argumentsLength = arguments.length;
    for(var i = 0; i < argumentsLength - 1; i++){
        argsArray[i] = arguments[i + 1];
    }
    return this.applyFn(thisArg, argsArray);
}
複製程式碼

擴充套件閱讀

《JavaScript設計模式與開發實踐》- 第二章 第 2 章 this、call和apply
JS魔法堂:再次認識Function.prototype.call
不用call和apply方法模擬實現ES5的bind方法
JavaScript深入之call和apply的模擬實現

關於

作者:常以軒轅Rowboat為名混跡於江湖。前端路上 | PPT愛好者 | 所知甚少,唯善學。
個人部落格
segmentfault個人主頁
掘金個人主頁
知乎
github