深入學習js之——call和apply#10

MagicalLouis發表於2019-03-31

深入學習js系列是自己階段性成長的見證,希望通過文章的形式更加嚴謹、客觀地梳理js的相關知識,也希望能夠幫助更多的前端開發的朋友解決問題,期待我們的共同進步。

如果覺得本系列不錯,歡迎點贊、評論、轉發,您的支援就是我堅持的最大動力。


開篇

ECMAScript3 給 Function 的原型定義了兩個方法,他們是 Function.prototype.callFunction.prototype.apply 在實際開發中特別是在一些函式式風格的程式碼書寫中,call 和 apply 方法尤其重要。

call 和 apply 的區別

Function.prototype.callFunction.prototype.apply都是非常常用的方法,他們的作用一模一樣,區別僅僅是傳入的引數形式不同。

apply 接收兩個引數,第一個引數指定了函式體內部 this 物件的指向,第二個引數為一個帶下標的集合,這個集合可以是陣列,也可以為類陣列,apply 方法把這個集合中的元素作為引數傳遞給被呼叫的函式。

var func = function(a, b, c) {
  console.log([a, b, c]); // => [1,2,3]
};
func.apply(null, [1, 2, 3]);
複製程式碼

在這段程式碼中,引數 1,2,3 被放在一個陣列中一起傳遞給 func 函式,他們分別對應 func 引數列表中的 a, b, c

call 傳入的引數數量不固定,跟 apply 相同的是,第一個引數也是代表函式體內的 this 指向,從第二個引數開始往後,每個引數被依次傳入函式:

var func = function(a, b, c) {
  console.log([a, b, c]); // 輸出 [1,2,3]
};
func.call(null, 1, 2, 3);
複製程式碼

當呼叫一個函式時候,js 的解析器並不會計較形參和實參的數量、型別以及順序上的區別,js 的引數在內部就是用一個陣列來表示的,從這個意義上面來說,call 比 apply 的使用率更高,我們不必關心具體有多少引數被傳入函式,只要使用 call 一股腦的推進去就可以了。

apply 是包裝在 call 上面的一顆語法糖,如果我們明確的知道了函式接收多少個引數,而且想一目瞭然的表達形參和實參的對應關係,那麼就可以使用 apply 來傳遞引數。

當我們使用 call 或者 apply 的時候,如果我們傳入的第一個引數為 null,函式體內部的 this 會指向預設的宿主物件,在瀏覽器中則是 window:

var func = function(a, b, c) {
  alert(this === window); // true
};
func.apply(null, [1, 2, 3]);
複製程式碼

但是在嚴格模式下面,函式體內部的 this 還是 null

var func = function(a, b, c) {
  "use strict";
  alert(this === null); // 輸出true
};

func.apply(null, [1, 2, 3]);
複製程式碼

有時候我們使用 call 或者 apply 的目標並不是在於指定 this 指向而是另有用途 比如借用其他物件的方法,那麼我們可以傳入 null 來代替某一個具體的物件;

Math.max.apply(null, [1, 2, 4, 5]); // 輸出5
複製程式碼

寫到這裡我們總結一下:

他們倆之間的差別在於引數的區別,call 和 aplly 的第一個引數都是要改變上下文的物件,而 call 從第二個引數開始以引數列表的形式展現,apply 則是把除了改變上下文物件的引數放在一個陣列裡面作為它的第二個引數。

call 和 apply 的用途

1.改變 this 指向:

call 和 apply 最常見的用途就是改變函式內部的 this 指向,我們看個例子:

var obj1 = {
  name: "louis"
};

var obj2 = {
  name: "jack"
};

window.name = "window";

var getName = function() {
  alert(this.name);
};

getName(); //輸出 window
getName.call(obj1); // 輸出 louis
getName.call(obj2); // 輸出 jack
複製程式碼

當執行 getName.call(obj1)這句程式碼的時候,getName 函式體內的 this 指向 obj1 物件,所以此處的

var getName = function () {
  alert(this.name);
}

實際上相當於:

var getName = function () {
  alert(obj1.name); // 輸出louis
}
複製程式碼

實際開發中,我們會經常遇到 this。指向被不經意改變的場景,比如有一個 div 節點,div 節點的 onclick 事件中的 this 指向本來是指向這個 div 的:

document.getElementById("div1").onclick = function() {
  alert(this.id); // div1
};
複製程式碼

假如該事件中有一個內部函式 func,在事件內部呼叫 func 的時候,func 函式體內部的 this 就指向了 window 而不是我們預期的 div,見如下程式碼;

document.getElementById("div1").onclick = function() {
  alert(this.id); // 輸出:div1
  var func = function() {
    alert(this.id); // 輸出:undefined  window 上面沒有id 屬性
  };
  func();
};
複製程式碼

這個時候我們可以使用 call 來修正 func 函式內部的 this,使其依然指向 div:

document.getElementById("div1").onclick = function() {
  var func = function() {
    alert(this.id); // 輸出:div1
  };
  func.call(this);
};
複製程式碼

2.Function.prototype.bind

大部分的高階瀏覽器都實現了內建的 Function.prototype.bind, 用來指定函式內部的 this 指向即使沒有原生的 Function.prototype.bind 實現,我們來模擬一個也不是難事。

Function.prototype.bind = function(context) {
  var self = this; // 儲存原函式
  return function() {
    // 返回一個新的函式
    return self.apply(context, arguments);
    // 執行新的函式的時候,會把之前傳入的context 當作新函式體內的this
  };
};

var obj = {
  name: "sven"
};

var func = function() {
  alert(this.name); // 輸出:sven
}.bind(obj);
func();
複製程式碼

我們通過Function.prototype.bind來包裝'func'函式,並且傳入一個物件 context 當做引數,這個 context 就是我們想要修正的 this 物件。

Function.prototype.bind的內部實現中,我們先把 func 函式的引用儲存起來,然後返回一個新的函式。當我們在將來執行 func 函式時,實際上先執行的是這個剛剛返回的新函式。在新函式內部,self.apply(context,arguments)這句程式碼才是執行原來的 func 函式,並且指定 context 物件為 func 函式體內的 this。

3.借用其他物件的方法

我們知道,杜鵑既不會築巢,也不會孵雛,而是把自己的蛋寄託給雲雀等其他鳥類,讓它們代為孵化和養育。同樣,在 JavaScript 中也存在類似的借用現象。

借用方法的第一種場景是“借用建構函式”,通過這種技術,可以實現類似於繼承的效果:

function Parent(value) {
  this.val = value;
}
Parent.prototype.getValue = function() {
  console.log(this.val);
};
function Child(value) {
  Parent.call(this, value);
}
Child.prototype = new Parent();

const child = new Child(1);

child.getValue(); // 1
child instanceof Parent; // true
複製程式碼

借用方法的第二種運用場景跟我們的關係更加緊密。

函式的引數列表 arguments 是一個類陣列物件,雖然它也有"下標",但是它並非真正的陣列,所以也不能像陣列一樣進行排序操作或者往集合裡面新增一個新的元素,這種情況下,我們常常使用 Array.prototype 物件上面的方法,比如想往 auguments 中新增一個新的元素,通常會借用 Array.prototype.push:

(function() {
  Array.prototype.push.call(arguments, 3);
  console.log(arguments); // [1,2,3]
})(1, 2);
複製程式碼

在操作 arguments 的時候,我們經常非常頻繁地找 Array.prototype 物件借用方法。

想把 arguments 轉成真正陣列的時候,可以借用Array.prototype.slice 方法;想要擷取 arguments 列表中的頭一個元素的時候,又可以借用Array.prototype.shift方法,那麼這種機制的內部實現原理是什麼呢?我們可以看看 V8 引擎原始碼,我們以 Array.prptotype.push()為例子,看看具體實現:

  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;”
  }
複製程式碼

通過這段程式碼可以看到,Array.prototype.push 實際上是一個屬性複製的過程,把引數按照下標依次新增到push的物件上面,順便修改了這個物件的length屬性,至於被修改的物件是誰,到底是陣列還是類陣列物件,這一點並不重要

按照這種推斷,我們可以把”任意“的物件傳入 Array.prototype.push;

var a = {};
Array.prototype.call(a,'first');

console.log(a.length);// 輸出 1
console.log(a[0]); // first
複製程式碼

前面之所以把"任意"兩個字加了雙引號,是因為可以借用Array.prototype.push方法的物件還需要滿足以下兩個條件: 1、物件本身要可以存取屬性 2、物件的length屬性可以讀寫。

對於第一個條件,物件本身存取屬性並沒有問題,但是如果借用Array.prototype.push方法的不是一個object型別資料而是一個number型別的資料呢?因為number是基本資料型別,我們無法在number 身上存取其他的資料,那麼從下面的測試程式碼可以發現,一個number型別的資料是不能借用到Array.prototype.push 方法:

var a = 1;
Array.prototype.push.call(a,'first');
console.log(a.length);// 輸出 undefined
console.log(a[0]); // 輸出 undefined
複製程式碼

對於第二個條件,函式的length 屬性就是一個只讀的屬性,表示形參的個數,我們嘗試把一個函式當做this傳入 Array.prototype.push:

var func = function(){}
Array.prototype.push.call(func,'first');
console.log(func.length);
複製程式碼

報錯:cannot assign to read only property ‘length’ of function(){}

call的模擬實現

為了實現 call 我們首先用一句話簡單的介紹一下 call :

call() 方法在使用一個指定的 this 值和若干個指定的引數值的前提下呼叫某個函式或者方法

舉一個例子:

var foo = {
  value: 1
};

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

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

這裡需要注意兩點: 1、call 改變了 this 的指向,指向到了 foo 2、bar 函式執行了

接下來我們嘗試模擬實現 call 的這個功能:

模擬實現第一步:

試想當我們呼叫 call 的時候,把 foo 物件改造如下:

var foo = {
  value: 1,
  bar: function() {
    console.log(this.value);
  }
};
複製程式碼

這個時候 this 就指向了 foo

但是這樣卻給 foo 本身新增了一個屬性,這樣可不行!

不過沒有關係,我們使用 delete 刪除了就行

所以我們的模擬的步驟可以分為:

1、將函式設定為物件的屬性。 2、執行這個函式。 3、刪除這個函式。

以上的例子就是 :

// 第一步
foo.fn = bar;
// 第二步
foo.fn();
// 第三步
delete foo.fn;
複製程式碼

fn 是物件的屬性名,反正最後也要刪除它,所以起成什麼名字無所謂 根據這個思路,我們可以嘗試寫一版,call2 函式:

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

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

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

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

上述程式碼中, 因為一個函式呼叫了 call2 這個函式,因此在call2 函式的內部可以拿到這個this,同時這個this 指向的就是呼叫call2的函式 我們模擬的目的也是將這個函式作為引數新增進context這個被繫結的物件上面

模擬實現第二步

最一開始我們說了,call 函式還能給定引數執行函式,舉一個例子:

var foo = {
  value: 1
};

function bar(name, age) {
  console.log(name);
  console.log(age);
  console.log(this.value);
}
bar.call(foo, "kevin", 18);
// kevin
// 18
// 1
複製程式碼

注意:傳入的引數並不確定,這可怎麼辦? 不急,我們可以從 Arguments 物件中取值,取出第二個到最後一個引數,然後放到一個陣列裡面。

比如這樣:

  // arguments = {
  //   0:foo,
  //   1:'kevin',
  //   2:18,
  //   lenght:3
  // }
  因為arguments 是類陣列物件,所以可以使用for 迴圈

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

  // 執行之後 arguments 為 ["arguments[1]","arguments[2]","[arguments[3]"]

複製程式碼

不定長的引數的問題解決了,接著我們要把這個引數陣列放到要執行的函式的引數裡面去,這裡我們使用 eval 方法拼接成一個函式,類似於這樣:

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

這裡 args 會自動呼叫 Array.toString() 這個方法。

這裡的eval可能不是那麼容易理解,這裡做一個簡單的補充說明:

eval函式接收的引數是一個字串(這點非常重要) 定義和用法:

eval() 函式可計算某個字串,並執行其中的的 JavaScript 程式碼。

語法:

eval(string)

string必需。要計算的字串,其中含有要計算的 JavaScript 表示式或要執行的語句。該方法只接受原始字串作為引數,如果 string 引數不是原始字串,那麼該方法將不作任何改變地返回。因此請不要為 eval() 函式傳遞 String 物件來作為引數。

簡單來說吧,就是用JavaScript的解析引擎來解析這一堆字串裡面的內容,這麼說吧,你可以這麼理解,你把eval看成是<script>標籤。

eval('function Test(a,b,c,d){console.log(a,b,c,d)};Test(1,2,3,4)')
複製程式碼

所以我們第二版刻克服了兩個問題,程式碼如下:


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
複製程式碼

模擬第三步驟

模擬程式碼已經完成了 80%,還有兩個小點需要注意:

1、this 引數可以傳遞 null,當為 null 的時候,視為指向 window

舉一個例子:

var value = 1;
function bar() {
  console.log(this.value);
}
bar.call(null); // 1
複製程式碼

雖然這個例子本身是不是使用 call 的結果都一樣

2、函式是可以有返回值的

舉個例子:

var obj = {
  value: 1
};
function bar(name, age) {
  return {
    value: this.value,
    name: name,
    age: age
  };
}

console.log(bar.call(obj, "kevin", 18));
// Object{
//  value:1,
//  name:'kevin',
//  age:18
// }
複製程式碼

不過都很好解決,讓我們直接看第三版也就是最最後一版的程式碼:

// 第三版
Function.prototype.call2 = function(context) {
  var context = 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;
};
// 測試一下
var value = 2;

var obj = {
  value: 1
};

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

bar.call2(null); // 2

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

到這裡 我們完成了 call 的模擬實現,給自己一個 贊。

apply 的模擬實現

apply 的模擬實現和 call 類似,在這裡直接給出程式碼:

// 測試一下
var value = 2;

var obj = {
  value: 1
};

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

bar.call2(null); // 2

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

深入學習JavaScript系列目錄

歡迎新增我的個人微信討論技術和個體成長。

深入學習js之——call和apply#10
歡迎關注我的個人微信公眾號——指尖的宇宙,更多優質思考乾貨

深入學習js之——call和apply#10

相關文章