【進階3-3期】深度解析 call 和 apply 原理、使用場景及實現

木易楊說發表於2018-12-04

之前文章詳細介紹了 this 的使用,不瞭解的檢視【進階3-1期】。

call() 和 apply()

call() 方法呼叫一個函式, 其具有一個指定的 this 值和分別地提供的引數(引數的列表)。

call()apply()的區別在於,call()方法接受的是若干個引數的列表,而apply()方法接受的是一個包含多個引數的陣列

舉個例子:

var func = function(arg1, arg2) {
     ...
};

func.call(this, arg1, arg2); // 使用 call,引數列表
func.apply(this, [arg1, arg2]) // 使用 apply,引數陣列
複製程式碼

使用場景

下面列舉一些常用用法:

1、合併兩個陣列

var vegetables = ['parsnip', 'potato'];
var moreVegs = ['celery', 'beetroot'];

// 將第二個陣列融合進第一個陣列
// 相當於 vegetables.push('celery', 'beetroot');
Array.prototype.push.apply(vegetables, moreVegs);
// 4

vegetables;
// ['parsnip', 'potato', 'celery', 'beetroot']
複製程式碼

當第二個陣列(如示例中的 moreVegs )太大時不要使用這個方法來合併陣列,因為一個函式能夠接受的引數個數是有限制的。不同的引擎有不同的限制,JS核心限制在 65535,有些引擎會丟擲異常,有些不丟擲異常但丟失多餘引數。

如何解決呢?方法就是將引數陣列切塊後迴圈傳入目標方法

function concatOfArray(arr1, arr2) {
    var QUANTUM = 32768;
    for (var i = 0, len = arr2.length; i < len; i += QUANTUM) {
        Array.prototype.push.apply(
            arr1, 
            arr2.slice(i, Math.min(i + QUANTUM, len) )
        );
    }
    return arr1;
}

// 驗證程式碼
var arr1 = [-3, -2, -1];
var arr2 = [];
for(var i = 0; i < 1000000; i++) {
    arr2.push(i);
}

Array.prototype.push.apply(arr1, arr2);
// Uncaught RangeError: Maximum call stack size exceeded

concatOfArray(arr1, arr2);
// (1000003) [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
複製程式碼

2、獲取陣列中的最大值和最小值

var numbers = [5, 458 , 120 , -215 ]; 
Math.max.apply(Math, numbers);   //458    
Math.max.call(Math, 5, 458 , 120 , -215); //458

// ES6
Math.max.call(Math, ...numbers); // 458
複製程式碼

為什麼要這麼用呢,因為陣列 numbers 本身沒有 max方法,但是 Math有呀,所以這裡就是藉助 call / apply 使用 Math.max 方法。

3、驗證是否是陣列

function isArray(obj){ 
    return Object.prototype.toString.call(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 直接使用 toString()
[1, 2, 3].toString(); 	// "1,2,3"
"123".toString(); 		// "123"
123.toString(); 		// SyntaxError: Invalid or unexpected token
Number(123).toString(); // "123"
Object(123).toString(); // "123"
複製程式碼

可以通過toString() 來獲取每個物件的型別,但是不同物件的 toString()有不同的實現,所以通過 Object.prototype.toString() 來檢測,需要以 call() / apply() 的形式來呼叫,傳遞要檢查的物件作為第一個引數。

另一個驗證是否是陣列的方法

var toStr = Function.prototype.call.bind(Object.prototype.toString);
function isArray(obj){ 
    return toStr(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 使用改造後的 toStr
toStr([1, 2, 3]); 	// "[object Array]"
toStr("123"); 		// "[object String]"
toStr(123); 		// "[object Number]"
toStr(Object(123)); // "[object Number]"
複製程式碼

上面方法首先使用 Function.prototype.call函式指定一個 this 值,然後 .bind 返回一個新的函式,始終將 Object.prototype.toString 設定為傳入引數。其實等價於 Object.prototype.toString.call()

這裡有一個前提toString()方法沒有被覆蓋

Object.prototype.toString = function() {
    return '';
}
isArray([1, 2, 3]);
// false
複製程式碼

4、類陣列物件(Array-like Object)使用陣列方法

var domNodes = document.getElementsByTagName("*");
domNodes.unshift("h1");
// TypeError: domNodes.unshift is not a function

var domNodeArrays = Array.prototype.slice.call(domNodes);
domNodeArrays.unshift("h1"); // 505 不同環境下資料不同
// (505) ["h1", html.gr__hujiang_com, head, meta, ...] 
複製程式碼

類陣列物件有下面兩個特性

  • 1、具有:指向物件元素的數字索引下標和 length 屬性
  • 2、不具有:比如 pushshiftforEach 以及 indexOf等陣列物件具有的方法

要說明的是,類陣列物件是一個物件。JS中存在一種名為類陣列的物件結構,比如 arguments 物件,還有DOM API 返回的 NodeList 物件都屬於類陣列物件,類陣列物件不能使用 push/pop/shift/unshift 等陣列方法,通過 Array.prototype.slice.call 轉換成真正的陣列,就可以使用 Array下所有方法。

類陣列物件轉陣列的其他方法:

// 上面程式碼等同於
var arr = [].slice.call(arguments);

ES6:
let arr = Array.from(arguments);
let arr = [...arguments];
複製程式碼

Array.from() 可以將兩類物件轉為真正的陣列:類陣列物件和可遍歷(iterable)物件(包括ES6新增的資料結構 Set 和 Map)。

PS擴充套件一:為什麼通過 Array.prototype.slice.call() 就可以把類陣列物件轉換成陣列?

其實很簡單,sliceArray-like 物件通過下標操作放進了新的 Array 裡面。

下面程式碼是 MDN 關於 slice 的Polyfill,連結 Array.prototype.slice()

Array.prototype.slice = function(begin, end) {
      end = (typeof end !== 'undefined') ? end : this.length;

      // For array like object we handle it ourselves.
      var i, cloned = [],
        size, len = this.length;

      // Handle negative value for "begin"
      var start = begin || 0;
      start = (start >= 0) ? start : Math.max(0, len + start);

      // Handle negative value for "end"
      var upTo = (typeof end == 'number') ? Math.min(end, len) : len;
      if (end < 0) {
        upTo = len + end;
      }

      // Actual expected size of the slice
      size = upTo - start;

      if (size > 0) {
        cloned = new Array(size);
        if (this.charAt) {
          for (i = 0; i < size; i++) {
            cloned[i] = this.charAt(start + i);
          }
        } else {
          for (i = 0; i < size; i++) {
            cloned[i] = this[start + i];
          }
        }
      }

      return cloned;
    };
  }
複製程式碼

PS擴充套件二:通過 Array.prototype.slice.call() 就足夠了嗎?存在什麼問題?

低版本IE下不支援通過Array.prototype.slice.call(args)將類陣列物件轉換成陣列,因為低版本IE(IE < 9)下的DOM物件是以 com 物件的形式實現的,js物件與 com 物件不能進行轉換。

相容寫法如下:

function toArray(nodes){
    try {
        // works in every browser except IE
        return Array.prototype.slice.call(nodes);
    } catch(err) {
        // Fails in IE < 9
        var arr = [],
            length = nodes.length;
        for(var i = 0; i < length; i++){
            // arr.push(nodes[i]); // 兩種都可以
            arr[i] = nodes[i];
        }
        return arr;
    }
}
複製程式碼

PS 擴充套件三:為什麼要有類陣列物件呢?或者說類陣列物件是為什麼解決什麼問題才出現的?

JavaScript型別化陣列是一種類似陣列的物件,並提供了一種用於訪問原始二進位制資料的機制。 Array儲存的物件能動態增多和減少,並且可以儲存任何JavaScript值。JavaScript引擎會做一些內部優化,以便對陣列的操作可以很快。然而,隨著Web應用程式變得越來越強大,尤其一些新增加的功能例如:音訊視訊編輯,訪問WebSockets的原始資料等,很明顯有些時候如果使用JavaScript程式碼可以快速方便地通過型別化陣列來操作原始的二進位制資料,這將會非常有幫助。

一句話就是,可以更快的操作複雜資料。

5、呼叫父建構函式實現繼承

function  SuperType(){
    this.color=["red", "green", "blue"];
}
function  SubType(){
    // 核心程式碼,繼承自SuperType
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.color.push("black");
console.log(instance1.color);
// ["red", "green", "blue", "black"]

var instance2 = new SubType();
console.log(instance2.color);
// ["red", "green", "blue"]
複製程式碼

在子建構函式中,通過呼叫父建構函式的call方法來實現繼承,於是SubType的每個例項都會將SuperType 中的屬性複製一份。

缺點:

  • 只能繼承父類的例項屬性和方法,不能繼承原型屬性/方法
  • 無法實現複用,每個子類都有父類例項函式的副本,影響效能

更多繼承方案檢視我之前的文章。JavaScript常用八種繼承方案

call的模擬實現

以下內容參考自 JavaScript深入之call和apply的模擬實現

先看下面一個簡單的例子

var value = 1;
var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call(foo); // 1
複製程式碼

通過上面的介紹我們知道,call()主要有以下兩點

  • 1、call()改變了this的指向
  • 2、函式 bar 執行了

模擬實現第一步

如果在呼叫call()的時候把函式 bar()新增到foo()物件中,即如下

var foo = {
    value: 1,
    bar: function() {
        console.log(this.value);
    }
};

foo.bar(); // 1
複製程式碼

這個改動就可以實現:改變了this的指向並且執行了函式bar

但是這樣寫是有副作用的,即給foo額外新增了一個屬性,怎麼解決呢?

解決方法很簡單,用 delete 刪掉就好了。

所以只要實現下面3步就可以模擬實現了。

  • 1、將函式設定為物件的屬性:foo.fn = bar
  • 2、執行函式:foo.fn()
  • 3、刪除函式:delete foo.fn

程式碼實現如下:

// 第一版
Function.prototype.call2 = function(context) {
    // 首先要獲取呼叫call的函式,用this可以獲取
    context.fn = this; 		// foo.fn = bar
    context.fn();			// foo.fn()
    delete context.fn;		// delete foo.fn
}

// 測試一下
var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call2(foo); // 1
複製程式碼

完美!

模擬實現第二步

第一版有一個問題,那就是函式 bar 不能接收引數,所以我們可以從 arguments中獲取引數,取出第二個到最後一個引數放到陣列中,為什麼要拋棄第一個引數呢,因為第一個引數是 this

類陣列物件轉成陣列的方法上面已經介紹過了,但是這邊使用ES3的方案來做。

var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']');
}
複製程式碼

引數陣列搞定了,接下來要做的就是執行函式 context.fn()

context.fn( args.join(',') ); // 這樣不行
複製程式碼

上面直接呼叫肯定不行,args.join(',')會返回一個字串,並不會執行。

這邊採用 eval方法來實現,拼成一個函式。

eval('context.fn(' + args +')')
複製程式碼

上面程式碼中args 會自動呼叫 args.toString() 方法,因為'context.fn(' + args +')'本質上是字串拼接,會自動呼叫toString()方法,如下程式碼:

var args = ["a1", "b2", "c3"];
console.log(args);
// ["a1", "b2", "c3"]

console.log(args.toString());
// a1,b2,c3

console.log("" + args);
// a1,b2,c3
複製程式碼

所以說第二個版本就實現了,程式碼如下:

// 第二版
Function.prototype.call2 = function(context) {
    context.fn = this;
    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }
    eval('context.fn(' + args +')');
    delete context.fn;
}

// 測試一下
var foo = {
    value: 1
};

function bar(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value);
}

bar.call2(foo, 'kevin', 18); 
// kevin
// 18
// 1
複製程式碼

完美!!

模擬實現第三步

還有2個細節需要注意:

  • 1、this 引數可以傳 null 或者 undefined,此時 this 指向 window
  • 2、this 引數可以傳基本型別資料,原生的 call 會自動用 Object() 轉換
  • 3、函式是可以有返回值的

實現上面的三點很簡單,程式碼如下

// 第三版
Function.prototype.call2 = function (context) {
    context = context ? Object(context) : window; // 實現細節 1 和 2
    context.fn = this;

    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }

    var result = eval('context.fn(' + args +')');

    delete context.fn
    return result; // 實現細節 2
}

// 測試一下
var value = 2;

var obj = {
    value: 1
}

function bar(name, age) {
    console.log(this.value);
    return {
        value: this.value,
        name: name,
        age: age
    }
}

function foo() {
    console.log(this);
}

bar.call2(null); // 2
foo.call2(123); // Number {123, fn: ƒ}

bar.call2(obj, 'kevin', 18);
// 1
// {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }
複製程式碼

完美!!!

call和apply模擬實現彙總

call的模擬實現

ES3:

Function.prototype.call = function (context) {
    context = context ? Object(context) : window; 
    context.fn = this;

    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }
    var result = eval('context.fn(' + args +')');

    delete context.fn
    return result;
}
複製程式碼

ES6:

Function.prototype.call = function (context) {
  context = context ? Object(context) : window; 
  context.fn = this;

  let args = [...arguments].slice(1);
  let result = context.fn(...args);

  delete context.fn
  return result;
}
複製程式碼

apply的模擬實現

ES3:

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;

    var result;
    // 判斷是否存在第二個引數
    if (!arr) {
        result = context.fn();
    } else {
        var args = [];
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push('arr[' + i + ']');
        }
        result = eval('context.fn(' + args + ')');
    }

    delete context.fn
    return result;
}
複製程式碼

ES6:

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;
  
    let result;
    if (!arr) {
        result = context.fn();
    } else {
        result = context.fn(...arr);
    }
      
    delete context.fn
    return result;
}
複製程式碼

思考題

callapply 的模擬實現有沒有問題?歡迎思考評論。


PS: 上期思考題留到下一期講解,下一期介紹重點介紹 bind 原理及實現

參考

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

MDN之Array.prototype.push()

MDN之Function.prototype.apply()

MDN之Array.prototype.slice()

MDN之Array.isArray()

JavaScript常用八種繼承方案

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

進階系列目錄

  • 【進階1期】 呼叫堆疊
  • 【進階2期】 作用域閉包
  • 【進階3期】 this全面解析
  • 【進階4期】 深淺拷貝原理
  • 【進階5期】 原型Prototype
  • 【進階6期】 高階函式
  • 【進階7期】 事件機制
  • 【進階8期】 Event Loop原理
  • 【進階9期】 Promise原理
  • 【進階10期】Async/Await原理
  • 【進階11期】防抖/節流原理
  • 【進階12期】模組化詳解
  • 【進階13期】ES6重難點
  • 【進階14期】計算機網路概述
  • 【進階15期】瀏覽器渲染原理
  • 【進階16期】webpack配置
  • 【進階17期】webpack原理
  • 【進階18期】前端監控
  • 【進階19期】跨域和安全
  • 【進階20期】效能優化
  • 【進階21期】VirtualDom原理
  • 【進階22期】Diff演算法
  • 【進階23期】MVVM雙向繫結
  • 【進階24期】Vuex原理
  • 【進階25期】Redux原理
  • 【進階26期】路由原理
  • 【進階27期】VueRouter原始碼解析
  • 【進階28期】ReactRouter原始碼解析

交流

進階系列文章彙總如下,內有優質前端資料,覺得不錯點個star。

github.com/yygmind/blo…

我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階3-3期】深度解析 call 和 apply 原理、使用場景及實現

相關文章