JavaScript入門③-函式(2)原理{深入}執行上下文

安木夕發表於2022-12-02

image.png

00、頭痛的JS閉包、詞法作用域?

被JavaScript的閉包、上下文、巢狀函式、this搞得很頭痛,這語言設計的,感覺比較混亂,先勉強理解總結一下???。

  • 為什麼有閉包這麼個東西?閉包包的是什麼?
  • 什麼是詞法作用域?
  • 函式是如執行的呢?

image


01、執行上下文 (execution context)

名稱 描述
是什麼? 執行上下文 (execution context) 是JavaScript程式碼被解析和執行時所在環境的抽象概念。每一個函式執行的時候都會建立自己的執行上下文,儲存了函式執行時需要的資訊,並透過自己的上下文來執行函式。
幹什麼用的? 當然就是執行函式自身的,實現自我價值。
有那些種類? ① 全域性上下文:全域性環境最基礎的執行上下文,所有不在任何函式內的程式碼都在這裡面。
? 瀏覽器中的全域性物件就是window,全域性作用域下var申明、隱式申明的變數都會成為全域性屬性變數,全域性的this指向window
? 其中會初始化一些全域性物件或全域性函式,如程式碼中的consoleundefinedisNaN
② 函式上下文:每個函式都有自己的上下文,呼叫函式的時候建立。可以把全域性上下文看成是一個頂級根函式上下文。
eval() 呼叫內部上下文eval的程式碼會被編譯建立自己的執行上下文,不常用也不建議使用。基於這個特點,有些框架會用eval()來實現沙箱Sandbox。
儲存了什麼資訊? 初始化上下文的變數、函式等資訊
? thisValuethis環境物件引用。
? 內部(Local)環境:函式本地的所有變數、函式、引數(arguments)。
? 作用域鏈:具有訪問作用域的其他上下文資訊。
誰來用? 執行上下文函式呼叫棧來統一儲存和排程管理。
生命週期 建立(入棧)=> 執行=> 銷燬(出棧),函式呼叫的時候建立,執行完成後銷燬。

image


02、函式呼叫棧是幹啥的?

函式呼叫棧(Function Call Stack),管理函式呼叫的一種棧式結構(後進先出 )佇列,或稱執行棧,儲存了當前程式所有執行上下文(正在執行的函式)。最早入棧的理所當然就是程式初始化時建立的全域性上下文了,他是VIP會員,會一直在棧底,直到程式退出。

2.1、函式執行流程

?函式執行上下文呼叫流程(也是函式的生命週期):

  • 建立-入棧:建立執行上下文,並壓入棧,獲得控制權。
  • 執行-幹活:執行函式的程式碼,給變數賦值、查詢變數。如有內部函式呼叫,遞迴重複函式流程。
  • 出棧-銷燬:函式執行完成出棧,釋放該執行上下文,其變數也就釋放了,全都銷燬,控制權回到上一層執行上下文。

image

function first() {
    second();	//呼叫second()
}
function second() {
}
first();

上面的程式碼執行過程如下圖所示

  1. 程式初始化執行時,首先建立的是全域性上下文Global,進入執行棧。
  2. 呼叫first()函式,建立其下文併入棧。
  3. first()函式內部呼叫了second()函式,建立second()下文入棧並執行。
  4. second()函式執行完成並出棧,控制權回到first()函式上下文。
  5. first()函式執行完成並出棧,控制權回到全域性上下文。

c71b3089775edcdcd2043eba90a70572_u=544850019,275206126&fm=253&app=138&f=PNG&fmt=auto&q=75_w=1280&h=228.webp

?再來一個函式呼叫棧的示例:

var a = 1;
let b = 1;
function FA(x) {
    function FB(y) {
        function FC(z) {
            console.log(a + b + x + y + z);
        }
        FC(3);
    }
    FB(2);
}
FA(1); //8

上面函式在執行FC()時的函式呼叫堆疊如下圖(Edge瀏覽器斷點除錯):

image.png

✅ 執行FC函式程式碼時,其作用域保留了所有要用到的作用域變數,從自己往上,直到全域性物件,閉包就是這麼來的!

  • var a = 1;:var申明的變數會作為全域性物件window的變數。
  • let b = 1;:全域性環境申明的變數,任何函式都可以訪問,放在全域性指令碼環境中,可以看做全域性的一部分。

✅ 呼叫堆疊中有FC、FB、FA,因為是巢狀函式,FB、FA並未結束,所以還在堆疊中,函式執行完畢就會被立即釋放拋棄。

image.png

2.2、堆疊溢位

? 函式呼叫棧容量是有限的!—— 遞迴函式

遞迴函式就是一個多層+自我巢狀呼叫的過程,所以執行遞迴函式時,會不停的入棧,而沒有出棧,迴圈次數太多會超出堆疊容量限制,從而引發報錯。比如下面示例中一個簡單的加法遞迴,在Firefox瀏覽器中遞迴1500次,就報錯了(InternalError: too much recursion),Edge瀏覽器是11000次超出呼叫棧容量(Maximum call stack size exceeded)。

❓怎麼解決呢?

  • 避免遞迴:封裝處理邏輯,轉換成迴圈的方式來處理。或用setTimeout(func,0)傳送到任務佇列單獨執行。
  • 拆分執行:合理拆分程式碼為多個遞迴函式。
function add(x) {
    if (x <= 0)
        return 0;
    return x + add(x - 1);  //遞迴求和
}
add(1000); //Firefox:1000可以,1500就報錯 InternalError: too much recursion
add(10000);//Edge:10000可以執行,11000就報錯 Maximum call stack size exceeded

» Firefox 的呼叫堆疊:

image.png


03、什麼是詞法作用域?

作用域(scope)就是一套規定變數作用範圍(許可權),並按此去查詢變數的規則。包括靜態作用域動態作用域,JavaScript中主要是靜態作用域(詞法作用域)

  • ? 靜態作用域(就是詞法作用域):JavaScript是基於詞法作用域來建立作用域的,基於程式碼的詞法分析確定變數的作用域、作用域關係(作用域鏈)。詞法環境就是我們寫程式碼的順序,所以是靜態的,就是說函式、變數的作用域是在其申明的時候就已經確定了,在執行階段不再改變。
  • ? 動態作用域:基於動態呼叫的關係確定的,其作用域鏈是基於執行時的呼叫棧的。比如this,一般就是基於呼叫來確定上下文環境的。因此this值可以在呼叫棧上來找,注意的是this指向一個引用物件,不是函式本身,也不是其詞法作用域。

image

因此,詞法作用域主要作用是規定了變數的訪問許可權,確定瞭如何去查詢變數,基本規則

  • 程式碼位置決定:變數(包括函式)申明的的地方決定了作用域,跟在哪呼叫無關。
  • 擁有父級許可權:函式(或塊)可以訪問其外部的資料,如果巢狀多層,則遞迴擁有父級的作用域許可權,直到全域性環境。
  • 函式作用域:只有函式可以限定作用域,不能被上級、外部其他函式訪問。
  • 同名就近使用:如果有和上級同名的變數,則就近使用,先找到誰就用誰。
  • 逐層向上查詢:變數的查詢規則就是先內部,然逐級往上,直到全域性環境,如果都沒找到,則變數undefined

這裡的詞法作用域,就是前文所說JS變數作用域。而閉包保留了上下文作用域的變數,就是為了實現詞法作用域。

❓那詞法作用域是怎麼實現的呢?——作用域鏈、閉包

父級函式FA()執行完成後就出棧銷燬了(典型場景就是返回函式)FB()可以到任何地方執行,那內部函式FB()執行的時候到哪裡去找父級函式的變數x呢?

  • ✅ 函式內部作用域:首先每個函式執行都會建立自己作用域(執行上下文),查詢變數時優先本地作用域查詢。
  • ✅ 閉包:引用的外部(詞法上級)函式作用域就形成了一個閉包,用一個Closure_(Closure /ˈkləʊʒə(r)/ 閉包)_物件儲存,多個(外部引用)逐級儲存到函式上下文的[[Scope]](Scope /skoʊp/ 作用域)集合上,形成作用域鏈
  • ✅ 作用域鏈的最底層就是指向全域性物件的引用,她始終都在,不管你要不要她。
  • ✅ 變數查詢就在這個作用域鏈上進行:自己上下文(詞法環境,變數環境) => 作用域鏈逐級查詢=> 全域性作用域 => undefined

image

function FA(x) {
    function FB(y) {
        x+=y;
        console.log(x);
    }
    console.dir(FB);
    return FB;  //返回FB()函式
}
let fb = FA(1);  //FA函式執行完成,出棧銷燬了
fb(2);  //3  //返回的fb()函式保留了他的父級FA()作用域變數x
fb(2);  //5	 //閉包中的x:我又變大了
fb(2);  //7  //同一個閉包函式重複呼叫,內部變數被改變

image.png

?閉包簡單理解就是,當前環境中存放在指向父級作用域的引用。如果巢狀子函式完全沒有引用父級任何變數,就不會產生閉包。不過全域性物件是始終存在其作用域鏈[[Scope]]上的。

?舉個例子

var a = 1;
let b = 2;
function FunA(x) {
    let x1 = 1;
    var x2 = 2;
    function FunB(y) {
        console.log(a + b + x + x1 + x2 + y);
    }
    FunB(2);
    console.dir(FunB)
}
FunA(1); //9
console.dir(FunA)

上面的程式碼示例中,FunA()函式巢狀了FunB()函式,如下圖FunB()函式的[[Scope]]集合上有三個物件:

image.png

  • Closure (FunA) FunA()函式的閉包,包含他的引數x、私有變數x1x2
  • Script:Script Scope 指令碼作用域(可以當做全域性作用域的一部分),存放全域性Script指令碼環境內可訪問的letconst變數,就是全域性作用域內的變數。var變數a被提升為了全域性物件window的“屬性”了。
  • Global:全域性作用域物件,就是window,包含了var申明的變數,以及未申明的變數。

如果把FunB()函式放到外面申明,只在FunA()呼叫,其作用域鏈就不一樣了。


04、執行上下文是怎麼建立的?

執行上下文的建立過程中會建立對應的詞法作用域,包括詞法環境變數環境

  • 建立詞法環境(LexicalEnvironment):
    • 環境記錄EnvironmentRecord:記錄變數、函式的申明等資訊,只儲存函式宣告和let/const宣告的變數。
    • 外層引用outer:對(上級)其他作用域詞法環境的引用,至少會包含全域性上下文。
  • 建立變數環境(VariableEnvironment):本質上也是詞法環境,只不過他只儲存var申明的變數,其他都和詞法環境差不多。
ExecutionContext = {
    ThisBinding = <this value>,
    LexicalEnvironment = { ... },
    VariableEnvironment = { ... },
}

❗變數查詢:變數查詢的時候,是先從詞法環境中找,然後再到變數環境。就是優先查詢const、let變數,其次才var變數。

image

換幾個角度來總結下,建立執行上下文主要搞定下面三個方面:

① 確定 this 的值(This Binding)

  • 在全域性上下文中this指向window
  • 函式執行上下文中,如果它被一個物件引用呼叫,那麼 this 的值被設定為該物件,否則 this 的值被設定為全域性物件或 undefined(嚴格模式下)
  • call(thisArg)、apply(thisArg)、bind(thisArg)會直接指定thisValue值。

② 內部環境:包括詞法環境變數環境,就是函式內部的變數、函式等資訊,還有引數arguments資訊。

③ 作用域鏈(外部引用):外部的詞法作用域存放到函式的[[Scope]]集合裡,用來查詢上級作用域變數。


05、❓有什麼結論?

  • ❓ 變數越近越好:最好都本地化,儘量避免讓變數查詢鏈路過長,一層層切換作用域去找也是很累的。
  • ❓ 優先const,其次let,儘量(堅決)不用var
  • ❓ 注意函式呼叫堆疊的長度,比如遞迴。
  • ❓ 閉包函式使用完後,手動釋放一下,fun = null;,儘早被垃圾回收。
  • ❓儘量避免成為全域性環境的變數,特別是一些臨時變數,全域性物件始終都在,不會被垃圾回收。
    • 包括全域性環境申明的的letconstvar
    • 切記不用未申明變數str='',不管在哪裡都會成為全域性變數。

遠離JavaScript、遠離前端......我以為已經學會了,其實可能還沒入門。

image.png


10、GC記憶體管理

值型別變數的生命週期隨函式,函式執行完就釋放了。垃圾回收GC(Garbage Collection)記憶體管理主要針對引用物件,當檢測到物件不再會被使用,就釋放其記憶體。GC是自動執行的,不需干預也無法干預

GC回收一個物件的關鍵就是——確定他確是一個廢物,麼有任何地方使用他了,主要採用的方法就是標記清理。

  • 標記清理(mark-and-sweep):標記記憶體中的所有的可達物件和他所有引用的物件),剩下的就是沒人要的,可以刪除了。
  • 引用計數:按變數被引用的次數,這個策略已不再使用了,由於該回收垃圾的策略太垃圾從而被拋棄了。

❓什麼是可達性?

  • ?根(roots):當前執行環境(window)最直接的變數,包括當前執行函式的區域性變數、引數;當前函式呼叫鏈上的其他函式的變數、引數;全域性變數。
  • ?可達性(Reachability):如果一個值(物件)可以從根開始鏈式訪問到他,就是可達的,就說明這個資料物件還有利用價值。

image

上圖中FuncA函式中的區域性變數 obj1,其值物件{P}存放在記憶體堆中,此時的值物件{P}被根變數obj1引用了,是可達的。

  • 如果函式執行完畢,函式就銷燬了,變數引用obj1也一起隨她而去。值物件{P}就沒有被引用了,就不可達了。
  • 如果在函式中顯示執行 obj1=null; 同樣的值物件{P}沒有被引用了,就不可達了。

image.png

GC定期執行垃圾回收的兩個步驟:

① 標記階段:找到可達物件並標記,實際的演算法會更加精細。

  • 垃圾收集器找到所有的根,並“標記”(記住)它們。
  • 繼續遍歷並“標記”被根引用的物件。
  • ...繼續遍歷,直到找到所有可達物件並標記。

② 清除階段:沒有被標記的物件都會被清理刪除。

⚠️全域性變數不會被清理:屬於window的全域性變數就是根,始終不會被清理,有背景靠山就是不一樣!


©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀

相關文章