從規範去看Function.prototype.call到底是怎麼工作的?

weixin_33782386發表於2018-05-07
2976869-1cd1f00d91b44363.png
image.png

今天在學習前端工程化的過程中,遇到一個是實驗中的css屬性:fullscreen,有這樣一個例子:fullscreen偽元素官方demo

<div id="fullscreen">
    <h1>:fullscreen Demo</h1>
    <p>This text will become big and red when the browser is in fullscreen mode.</p>
    <button id="fullscreen-button">Enter Fullscreen</button>
</div>
<script>
var fullscreenButton = document.getElementById("fullscreen-button");
var fullscreenDiv    = document.getElementById("fullscreen");
var fullscreenFunc   = fullscreenDiv.requestFullscreen;
if (!fullscreenFunc) {
     ['mozRequestFullScreen', 'msRequestFullscreen','webkitRequestFullScreen'].forEach(function (req) {
        fullscreenFunc = fullscreenFunc || fullscreenDiv[req];
     });
}  
function enterFullscreen() {
    fullscreenFunc.call(fullscreenDiv);
} 
fullscreenButton.addEventListener('click', enterFullscreen);
</script>

其中有一段程式碼:

function enterFullscreen() {
    fullscreenFunc.call(fullscreenDiv);
} 

雖然結合上下文能看出來是為了相容瀏覽器的fullscreen API,但是其中的Function.prototype.call()我自己其實沒有特別深究過。

為什麼不直接fullscreenFunc(),這樣不能使得fullscreenDiv全屏嗎?

大家都說call與apply都是為了動態改變this的,僅僅是傳入引數的方式不同,call傳入(this,foo,bar,baz),而apply傳入(this,[foo,bar,baz])那麼事實真如大家所說的那樣嗎?既然apply能動態改變this,那麼為什麼還要多此一舉開放一個call?
這其中肯定隱藏著一些祕密,那就是有些事情是apply做不到,而call可以勝任的。
繼續我們的啃規範之旅,去深入到Function.prototype.call()的內部,徹底把它搞清楚。

19.2.3.4 Function.prototype.call (thisArg , ...args)

When the call method is called on an object func with argument, thisArg and zero or more args, the following steps are taken:

  1. If IsCallable(func) is false, throw a TypeError exception.
  2. Let argList be an empty List.
  3. If this method was called with more than one argument then in left to right order, starting with the second argument, append each argument as the last element of argList.
  4. Perform PrepareForTailCall().
  5. Return Call(func, thisArg, argList).

The length property of the call method is 1.

當call方法在帶引數的物件的方法上呼叫時,thisArg和零個或者對個引數,會進行如下的步驟:

  1. 如果IsCallable(func)返回false,丟擲TypeError異常。
  2. 定義argList為一個空的列表。
  3. 如果方法按照從左到右傳入的引數個數不止一個,從第二個引數開始,依次將每個引數從尾部新增到argList陣列。
  4. 執行PrepareForTailCall()
  5. 返回Call(func,thisArg,argList)

有3個點看不懂:

  • IsCallable(func)
  • PrepareForTailCall()
  • Call(func,thisArg,argList)

這些同樣在規範中有對應描述:

7.2.3IsCallable ( argument )

The abstract operation IsCallable determines if argument, which must be an ECMAScript language valueor a Completion Record, is a callable function with a [[Call]] internal method.

重點在於is a callable function with a [[Call]] internal method.,也就是說執行isCallable(func)運算的func,如果函式內部有一個內在的[[Call]]方法,那麼運算結果為true,也就是說這個函式是可呼叫的的。(callable)

14.6.3Runtime Semantics: PrepareForTailCall ( )

The abstract operation PrepareForTailCall performs the following steps:

  1. Let leafContext be the running execution context.
  2. Suspend leafContext.
  3. Pop leafContext from the execution context stack. The execution context now on the top of the stack becomes the running execution context.
  4. Assert: leafContext has no further use. It will never be activated as the running execution context.

A tail position call must either release any transient internal resources associated with the currently executing function execution context before invoking the target function or reuse those resources in support of the target function.

  1. ReturnIfAbrupt(argument).
  2. If Type(argument) is not Object, return false.
  3. If argument has a [[Call]] internal method, return true.
  4. Return false.

雖然看不懂,但還是得硬著頭皮學習一波。
抽象操作PrepareForTailCall執行以下幾個步驟:

  1. 讓葉子上下文成為執行中的執行上下文
  2. 暫停葉子上下文
  3. 頂葉子上下文來自執行上下文的堆。當前的在堆頂部的執行上下文成為執行中的執行上下文
  4. 斷言:葉子上下文沒有其他作用。它再也不會作為執行中執行上下文被啟用。

在呼叫目標函式或者重用這些資源去支援目標函式之前,尾部位置呼叫必須釋放與當前執行函式上下文相關的瞬態內部資源。

  1. ReturnIfAbrupt(argument).
  2. 如果Type(argument)不是物件,返回false。
  3. 如果argument含有[[call]]內部方法,返回true。
  4. 返回 false

看懂一個大概,是為了在函式呼叫棧的尾部呼叫當前函式做準備,其中的執行中執行上下文,正是我們所說的this動態改變的原因,因為本質上this改變並不僅僅是指向的物件發生變化,而是連帶著與其相關的上下文都發生了變化。

所以說,這一步是this動態改變的真正原因。

7.3.12Call(F, V, [argumentsList])

The abstract operation Call is used to call the [[Call]] internal method of a function object. The operation is called with arguments F, V , and optionally argumentsList where F is the function object, V is an ECMAScript language value that is the this value of the [[Call]], and argumentsList is the value passed to the corresponding argument of the internal method. If argumentsList is not present, an empty List is used as its value. This abstract operation performs the following steps:

  1. ReturnIfAbrupt(F).
  2. If argumentsList was not passed, let argumentsList be a new empty List.
  3. If IsCallable(F) is false, throw a TypeError exception.
  4. Return F.[[Call]](V, argumentsList).

Call抽象操作是在呼叫函式物件的內部的[[Call]]方法。這個操作引數型別包括F,V以及可選的argumentList。F指的是呼叫函式,V指的是[[Call]]的this值,然後argumentsList是傳入到[[Call]]內部方法相應引數的值。如果argumentList不存在,那麼argumentList將被置為一個空陣列。這個方法按照下列幾步執行:

  1. ReturnIfAbrupt(F)
  2. 如果沒傳入argumentList,那麼argumentList將會被置為一個空陣列。
  3. 如果IsCallable(F)是false,返回TypeError異常。
  4. 返回 F.[[call]](V,argumentsList).

所以Function.prototype.call(this,...args)執行過程現在很明瞭:

  1. 判斷傳入的func是否有[[call]]屬性,有[[call]]才意味著函式能被呼叫,否則丟擲TypeError異常。
  2. 定義argList為一個空的列表。
  3. 傳參:如果方法按照從左到右傳入的引數個數不止一個,從第二個引數開始,依次將每個引數從尾部新增到argList陣列。
  4. 切換this上下文:執行PrepareForTailCall(),為函式呼叫棧在尾部呼叫函式做準備,切換執行中執行上下文,實現this上下文的動態改變。
  5. 萬事具備,執行Call(func,thisArg,argList),呼叫函式即可。

回到我們的例子:

fullscreenFunc.call(fullscreenDiv);
  1. func為fullscreenDiv DOM 節點的方法:'requestFullscreen' || 'mozRequestFullScreen' || 'msRequestFullscreen'
    || 'webkitRequestFullScreen',由於是fullscreen API,所以isCallable(func)返回true。
  2. 定義一個argList空陣列用來傳參。
  3. 傳參:由於fullscreenFunc.call(fullscreenDiv);只有一個引數,所以直接傳入argList空陣列。
  4. 切換this上下文:停止當前的this葉子上下文,也就是window,切換到fullscreenDiv的執行上下文。
  5. 由於當前瀏覽器為chrome,因此執行 fullscreenDiv.webkitRequestFullscreen.[[call]](this,[])

因此我們之前提的那個為什麼不直接fullscreenFunc(),這樣不能使得fullscreenDiv全屏嗎?,答案就很清楚了?不能。
為什麼呢?

var fullscreenFunc   = fullscreenDiv.requestFullscreen;
if (!fullscreenFunc) {
     ['mozRequestFullScreen', 'msRequestFullscreen','webkitRequestFullScreen'].forEach(function (req) {
        fullscreenFunc = fullscreenFunc || fullscreenDiv[req];
     });
}

下面的程式碼,僅僅是獲得了fullscreenDiv物件的fullscreen request API的引用,而fullscreenFunc的作用域是全域性的window物件,也就是this的當前指向為window。


2976869-d5121abf1a88ac85.png
image.png

而我們是想觸發window的子物件fullscreenDiv的全屏方法,所以需要將this上下文切換為fullscreenDiv,這就是不直接呼叫fullscreenFunc(),需要fullscreenFunc.call(fullscreenDiv)的原因

最近在看龍書,第一章講到動態語言與靜態語言的區別,龍書中講到"執行時決定作用域的語言是動態語言,在編譯時指定作用域的預言是靜態語言"。例子中的以function關鍵字定義的類,this執行中執行上下文的切換,恰恰證明了javascript是一門動態語言;再舉個形象的靜態語言的例子,java會使用class關鍵字構建類,在類內部使用private,public等關鍵字去指定作用域,編譯時就會去約束其作用域,具有非常強的約束性,this始終指向當前類。

剛才和一個java後端同事確認,java也有this關鍵字,但是僅能使用當前類中的方法,B類不能呼叫A類中的方法。

js中的this,是可以通過call或者apply進行動態切換從而去呼叫其他類中的方法的,B類不能呼叫A類中的方法。(注意:我們這裡的類指的是以function關鍵字進行定義的類,暫時不考慮es6的class關鍵字構造類的方式)

說了這麼多,我們再來強調下重點:

加粗的部分是重點!
加粗的部分是重點!
加粗的部分是重點!

拋開V8引擎內部執行call和apply的原理不說,二者最終實現的都是this上下文的動態切換,所以就像大家所說的那樣,都是動態改變this。我們只要心裡知道,其實二者在背後實現動態切換this的操作部分有很大的不同就可以了,當出現由於內部實現細節引起的問題時,我們可以快速定位。

That's it !

期待和大家交流,共同進步,歡迎大家加入我建立的與前端開發密切相關的技術討論小組:

努力成為優秀前端工程師!

相關文章