js 實現call和apply方法,超詳細思路分析


壹 ❀ 引

我在 五種綁定策略徹底弄懂this 一文中,我們提到call,apply,bind屬於顯示綁定,這三個方法都能直接修改this指向。其中call與apply比較特殊,它們在修改this的同時還會直接執行方法,而bind只是返回一個修改完this的boundFunction並未執行,那么今天我們來講講如果通過JavaScript模擬實現call與apply方法。

貳 ❀ 關於call與apply1

貳 ✿ 壹 call與apply區別

除了都能改變this指向並執行函數,call與apply唯一區別在於參數不同,具體如下:

var fn = function (arg1, arg2) {
    // do something
};

fn.call(this, arg1, arg2); // 參數散列
fn.apply(this, [arg1, arg2]) // 參數使用數組包裹

call第一參數為this指向,后續散列參數均為函數調用所需形參,而在apply中這些參數被包裹在一個數組中。

貳 ✿ 貳 使用場景

call與apply在日常開發中非常實用,我們在此列舉幾個實用的例子。

檢驗數據類型:

function type(obj) {
    var regexp = /\s(\w+)\]/;
    var result =  regexp.exec(Object.prototype.toString.call(obj))[1];
    return result;
};

console.log(type([123]));//Array
console.log(type('123'));//String
console.log(type(123));//Number
console.log(type(null));//Null
console.log(type(undefined));//Undefined

數組取最大/小值:

var arr = [11, 1, 0, 2, 3, 5];
// 取最大
var max1 = Math.max.call(null, ...arr);
var max2 = Math.max.apply(null, arr);
// 取最小
var min1 = Math.min.call(null, ...arr);
var min2 = Math.min.apply(null, arr);

console.log(max1); //11
console.log(max2); //11
console.log(min1); //0
console.log(min2); //0

函數arguments類數組操作:

var fn = function () {
    var arr = Array.prototype.slice.call(arguments);
    console.log(arr); //[1, 2, 3, 4]
};
fn(1, 2, 3, 4);

關於這兩個方法實用簡單說到這里,畢竟本文的核心主旨是手動實現call與apply方法,我們接着說。

叄 ❀ 實現一個call方法

我們從一個簡單的例子解析call方法

var name = '時間跳躍';
var obj = {
    name: '聽風是風'
};

function fn() {
    console.log(this.name);
};
fn(); //時間跳躍
fn.call(obj); //聽風是風

在這個例子中,call方法主要做了兩件事:

  • 修改了this指向,比如fn()默認指向window,所以輸出時間跳躍
  • 執行了函數fn

叄 ✿ 壹 改變this並執行方法

先說第一步改變this怎么實現,其實很簡單,只要將方法fn添加成對象obj的屬性不就好了。所以我們可以這樣:

//模擬call方法
Function.prototype.call_ = function (obj) {
    obj.fn = this; // 此時this就是函數fn
    obj.fn(); // 執行fn
    delete obj.fn; //刪除fn
};
fn.call_(obj); // 聽風是風

注意,這里的call_是我們模擬的call方法,我們來解釋模擬方法中做了什么。

  • 我們通過Function.prototype.call_的形式綁定了call_方法,所以所有函數都可以直接訪問call_
  • fn.call_屬於this隱式綁定,所以在執行時call_時內部this指向fn,這里的obj.fn = this就是將方法fn賦予成了obj的一條屬性。
  • obj現在已經有了fn方法,執行obj.fn,因為隱式綁定的問題,fn內部的this指向obj,所以輸出了聽風是風
  • 最后通過delete刪除了obj上的fn方法,畢竟執行完不刪除會導致obj上的屬性越來越多。

叄 ✿ 貳 傳參

我們成功改變了this指向並執行了方法,但仍有一個問題待解決,call_無法接受參數。

其實也不難,我們知道函數有一個arguments屬性,代指函數接收的所有參數,它是一個類數組,比如下方例子:

Function.prototype.call_ = function (obj) {
    console.log(arguments);
};
fn.call_(obj, 1, 2, 3);// [{name:'聽風是風'},1,2,3...]

很明顯arguments第一位參數是我們需要讓this指向的對象,所以從下標1開始才是真正的函數參數,這里就得對arguments進行加工,將下標1之后的參數剪切出來。

有同學肯定就想到了arguments.splice,前面說了arguments並非數組,所以不支持Array方法。沒關系,不是還有Array.prototype.slice.call(arguments)嗎,轉一次數組再用。很遺憾,我們現在是在模擬call方法,也不行。那就用最保險的for循環吧,如下:

Function.prototype.call_ = function (obj) {
    var args = [];
    // 注意i從1開始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push(arguments[i]);
    }; 
    console.log(args);// [1, 2, 3]
};
fn.call_(obj, 1, 2, 3);

數組也不能直接作為參數傳遞給函數,有同學可能想到array.join字符拼接方法,這也存在一個問題,比如我們是希望傳遞參數1 2 3三個參數進去,但經過join方法拼接,它會變成一個參數"1,2,3",函數此時接受的就只有一個參數了。

所以這里我們不得不借用惡魔方法eval,看個簡單的例子:

var fn = function (a, b, c) {
    console.log(a + b + c);
};
var arr = [1, 2, 3];

fn(1, 2, 3);//6
eval("fn(" + arr + ")");//6

你一定有疑問,為什么這里數組arr都不分割一下,fn在執行時又如何分割數組呢?其實eval在執行時會將變量轉為字符串,這里隱性執行了arr.toString()。來看個有趣的對比:

console.log([1, 2, 3].toString()); //"1,2,3"
console.log([1, 2, 3].join(',')); //"1,2,3"

可以看出``eval幫我們做了數組處理,這里就不需要再使用join方法了,因此eval("fn(" + arr + ")")可以看成eval("fn(1,2,3)")`。

我們整理下上面的思路,改寫后的模擬方法就是這樣:

var name = '時間跳躍';
var obj = {
    name: '聽風是風'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.call_ = function (obj) {
    var args = [];
    // 注意i從1開始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push(arguments[i]);
    };
    obj.fn = this; // 此時this就是函數fn
    eval("obj.fn(" + args + ")"); // 執行fn
    delete obj.fn; //刪除fn
};
fn.call_(obj, "我的", "名字", "是");

可以了嗎?很遺憾,這段代碼會報錯。因為我們傳遞的后三個參數都是字符串。在args.push(arguments[i])這一步我們提前將字符串進行了解析,這就導致eval在執行時,表達式變成了eval("obj.fn(我的,名字,是)");設想一下我們普通調用函數的形式是這樣obj.fn("我的","名字","是"),所以對於eval而言就像傳遞了三個沒加引號的字符串,無法進行解析。

不信我們可以傳遞三個數字,比如:

fn.call_(obj, 1,2,3); // 6聽風是風

因為數字不管加不加引號,作為函數參數都是可解析的,而字符串不加引號,那就被認為是一個變量,而不存在我的這樣的變量,自然就報錯了。

怎么辦呢?其實我們可以在args.push(arguments[i])這里先不急着解析,改寫成這樣:

args.push("arguments[" + i + "]");

遍歷完成的數組args最終就是這個樣子["arguments[1]","arguments[2]","arguments[3]"],當執行eval時,arguments[1]此時確實是作為一個變量存在不會報錯,於是被eval解析成了一個真正的字符傳遞給了函數。

所以改寫后的call_應該是這樣:

var name = '時間跳躍';
var obj = {
    name: '聽風是風'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.call_ = function (obj) {
    var args = [];
    // 注意i從1開始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push("arguments[" + i + "]");
    };
    obj.fn = this; // 此時this就是函數fn
    eval("obj.fn(" + args + ")"); // 執行fn
    delete obj.fn; //刪除fn
};
fn.call_(obj, "我的", "名字", "是"); // 我的名字是聽風是風

叄 ✿ 叄 考慮特殊this指向

我們知道,當call第一個參數為undefined或者null時,this默認指向window,所以上面的方法還不夠完美,我們進行最后一次改寫,考慮傳遞參數是否是有效對象:

var name = '時間跳躍';
var obj = {
    name: '聽風是風'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.call_ = function (obj) {
    //判斷是否為null或者undefined,同時考慮傳遞參數不是對象情況
    obj = obj ? Object(obj) : window;
    var args = [];
    // 注意i從1開始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push("arguments[" + i + "]");
    };
    obj.fn = this; // 此時this就是函數fn
    eval("obj.fn(" + args + ")"); // 執行fn
    delete obj.fn; //刪除fn
};
fn.call_(obj, "我的", "名字", "是"); // 我的名字是聽風是風
fn.call_(null, "我的", "名字", "是"); // 我的名字是時間跳躍
fn.call_(undefined, "我的", "名字", "是"); // 我的名字是時間跳躍

那么到這里,對於call方法的模擬就完成了。

肆 ❀ 實現一個apply方法

apply方法因為接受的參數是一個數組,所以模擬起來就更簡單了,理解了call實現,我們就直接上代碼:

var name = '時間跳躍';
var obj = {
    name: '聽風是風'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模擬call方法
Function.prototype.apply_ = function (obj, arr) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    if (!arr) {
        obj.fn();
    } else {
        var args = [];
        // 注意這里的i從0開始
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push("arr[" + i + "]");
        };
        eval("obj.fn(" + args + ")"); // 執行fn
    };
    delete obj.fn; //刪除fn
};
fn.apply_(obj, ["我的", "名字", "是"]); // 我的名字是聽風是風
fn.apply_(null, ["我的", "名字", "是"]); // 我的名字是時間跳躍
fn.apply_(undefined, ["我的", "名字", "是"]); // 我的名字是時間跳躍

伍 ❀ 總

上述代碼總有些繁雜,我們來總結下這兩個方法:

// call模擬
Function.prototype.call_ = function (obj) {
    //判斷是否為null或者undefined,同時考慮傳遞參數不是對象情況
    obj = obj ? Object(obj) : window;
    var args = [];
    // 注意i從1開始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push("arguments[" + i + "]");
    };
    obj.fn = this; // 此時this就是函數fn
    var result = eval("obj.fn(" + args + ")"); // 執行fn
    delete obj.fn; //刪除fn
    return result;
};
// apply模擬
Function.prototype.apply_ = function (obj, arr) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    var result;
    if (!arr) {
        result = obj.fn();
    } else {
        var args = [];
        // 注意這里的i從0開始
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push("arr[" + i + "]");
        };
        result = eval("obj.fn(" + args + ")"); // 執行fn
    };
    delete obj.fn; //刪除fn
    return result;
};

如果允許使用ES6,使用拓展運算符會簡單很多,實現如下:

// ES6 call
Function.prototype.call_ = function (obj) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    // 利用拓展運算符直接將arguments轉為數組
    let args = [...arguments].slice(1);
    let result = obj.fn(...args);

    delete obj.fn
    return result;
};
// ES6 apply
Function.prototype.apply_ = function (obj, arr) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    let result;
    if (!arr) {
        result = obj.fn();
    } else {
        result = obj.fn(...arr);
    };

    delete obj.fn
    return result;
};

那么到這里,關於call與apply模擬實現全部結束。bind實現存在部分不同,我另起了一篇文章,詳情請見js 手動實現bind方法,超詳細思路分析!

這篇文章也是第一篇我使用markdown書寫的文章,為了統一樣式,我也專門修改了博客樣式。

參考

JavaScript深入之call和apply的模擬實現

深入淺出 妙用Javascript中apply、call、bind

深度解析 call 和 apply 原理、使用場景及實現


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM