00、頭痛的JS閉包、詞法作用域?
被JavaScript的閉包、上下文、巢狀函式、this搞得很頭痛,這語言設計的,感覺比較混亂,先勉強理解總結一下???。
- 為什麼有閉包這麼個東西?閉包包的是什麼?
- 什麼是詞法作用域?
- 函式是如執行的呢?
01、執行上下文 (execution context)
名稱 | 描述 |
---|---|
是什麼? | 執行上下文 (execution context) 是JavaScript 程式碼被解析和執行時所在環境的抽象概念。每一個函式執行的時候都會建立自己的執行上下文,儲存了函式執行時需要的資訊,並透過自己的上下文來執行函式。 |
幹什麼用的? | 當然就是執行函式自身的,實現自我價值。 |
有那些種類? | ① 全域性上下文:全域性環境最基礎的執行上下文,所有不在任何函式內的程式碼都在這裡面。 ? 瀏覽器中的全域性物件就是 window ,全域性作用域下var 申明、隱式申明的變數都會成為全域性屬性變數,全域性的this 指向window 。? 其中會初始化一些全域性物件或全域性函式,如程式碼中的 console 、undefined 、isNaN ② 函式上下文:每個函式都有自己的上下文,呼叫函式的時候建立。可以把全域性上下文看成是一個頂級根函式上下文。 ③ eval() 呼叫內部上下文:eval 的程式碼會被編譯建立自己的執行上下文,不常用也不建議使用。基於這個特點,有些框架會用eval() 來實現沙箱Sandbox。 |
儲存了什麼資訊? | 初始化上下文的變數、函式等資訊 ? thisValue: this 環境物件引用。? 內部(Local)環境:函式本地的所有變數、函式、引數(arguments)。 ? 作用域鏈:具有訪問作用域的其他上下文資訊。 |
誰來用? | 執行上下文由函式呼叫棧來統一儲存和排程管理。 |
生命週期 | 建立(入棧)=> 執行=> 銷燬(出棧),函式呼叫的時候建立,執行完成後銷燬。 |
02、函式呼叫棧是幹啥的?
函式呼叫棧(Function Call Stack),管理函式呼叫的一種棧式結構(後進先出 )佇列,或稱執行棧,儲存了當前程式所有執行上下文(正在執行的函式)。最早入棧的理所當然就是程式初始化時建立的全域性上下文
了,他是VIP會員,會一直在棧底,直到程式退出。
2.1、函式執行流程
?函式執行上下文呼叫流程(也是函式的生命週期):
- 建立-入棧:建立執行上下文,並壓入棧,獲得控制權。
- 執行-幹活:執行函式的程式碼,給變數賦值、查詢變數。如有內部函式呼叫,遞迴重複函式流程。
- 出棧-銷燬:函式執行完成出棧,釋放該執行上下文,其變數也就釋放了,全都銷燬,控制權回到上一層執行上下文。
function first() {
second(); //呼叫second()
}
function second() {
}
first();
上面的程式碼執行過程如下圖所示:
- 程式初始化執行時,首先建立的是全域性上下文
Global
,進入執行棧。 - 呼叫
first()
函式,建立其下文併入棧。 first()
函式內部呼叫了second()
函式,建立second()
下文入棧並執行。second()
函式執行完成並出棧,控制權回到first()
函式上下文。first()
函式執行完成並出棧,控制權回到全域性上下文。
?再來一個函式呼叫棧的示例:
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瀏覽器斷點除錯):
✅ 執行FC
函式程式碼時,其作用域保留了所有要用到的作用域變數,從自己往上,直到全域性物件,閉包就是這麼來的!
var a = 1;
:var申明的變數會作為全域性物件window
的變數。let b = 1;
:全域性環境申明的變數,任何函式都可以訪問,放在全域性指令碼環境中,可以看做全域性的一部分。
✅ 呼叫堆疊中有FC、FB、FA,因為是巢狀函式,FB、FA並未結束,所以還在堆疊中,函式執行完畢就會被立即釋放拋棄。
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 的呼叫堆疊:
03、什麼是詞法作用域?
作用域(scope)就是一套規定變數作用範圍(許可權),並按此去查詢變數的規則。包括靜態作用域、動態作用域,JavaScript中主要是靜態作用域(詞法作用域)。
- ? 靜態作用域(就是詞法作用域):JavaScript是基於詞法作用域來建立作用域的,基於程式碼的詞法分析確定變數的作用域、作用域關係(作用域鏈)。詞法環境就是我們寫程式碼的順序,所以是靜態的,就是說函式、變數的作用域是在其申明的時候就已經確定了,在執行階段不再改變。
- ? 動態作用域:基於動態呼叫的關係確定的,其作用域鏈是基於執行時的呼叫棧的。比如
this
,一般就是基於呼叫來確定上下文環境的。因此this
值可以在呼叫棧上來找,注意的是this
指向一個引用物件,不是函式本身,也不是其詞法作用域。
因此,詞法作用域主要作用是規定了變數的訪問許可權,確定瞭如何去查詢變數,基本規則:
- 程式碼位置決定:變數(包括函式)申明的的地方決定了作用域,跟在哪呼叫無關。
- 擁有父級許可權:函式(或塊)可以訪問其外部的資料,如果巢狀多層,則遞迴擁有父級的作用域許可權,直到全域性環境。
- 函式作用域:只有函式可以限定作用域,不能被上級、外部其他函式訪問。
- 同名就近使用:如果有和上級同名的變數,則就近使用,先找到誰就用誰。
- 逐層向上查詢:變數的查詢規則就是先內部,然逐級往上,直到全域性環境,如果都沒找到,則變數
undefined
。
這裡的詞法作用域,就是前文所說JS變數作用域。而閉包保留了上下文作用域的變數,就是為了實現詞法作用域。
❓那詞法作用域是怎麼實現的呢?——作用域鏈、閉包
父級函式FA()
執行完成後就出棧銷燬了(典型場景就是返回函式)FB()
可以到任何地方執行,那內部函式FB()
執行的時候到哪裡去找父級函式的變數x
呢?
- ✅ 函式內部作用域:首先每個函式執行都會建立自己作用域(執行上下文),查詢變數時優先本地作用域查詢。
- ✅ 閉包:引用的外部(詞法上級)函式作用域就形成了一個閉包,用一個
Closure
_(Closure /ˈkləʊʒə(r)/ 閉包)_物件儲存,多個(外部引用)逐級儲存到函式上下文的[[Scope]]
(Scope /skoʊp/ 作用域)集合上,形成作用域鏈。 - ✅ 作用域鏈的最底層就是指向全域性物件的引用,她始終都在,不管你要不要她。
- ✅ 變數查詢就在這個作用域鏈上進行:自己上下文(詞法環境,變數環境) => 作用域鏈逐級查詢=> 全域性作用域 =>
undefined
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 //同一個閉包函式重複呼叫,內部變數被改變
?閉包簡單理解就是,當前環境中存放在指向父級作用域的引用。如果巢狀子函式完全沒有引用父級任何變數,就不會產生閉包。不過全域性物件是始終存在其作用域鏈[[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]]
集合上有三個物件:
Closure (FunA)
FunA()
函式的閉包,包含他的引數x
、私有變數x1
、x2
。Script
:Script Scope 指令碼作用域(可以當做全域性作用域的一部分),存放全域性Script指令碼環境內可訪問的let
、const
變數,就是全域性作用域內的變數。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變數。
換幾個角度來總結下,建立執行上下文主要搞定下面三個方面:
① 確定 this 的值(This Binding):
- 在全域性上下文中,
this
指向window
。- 函式執行上下文中,如果它被一個物件引用呼叫,那麼
this
的值被設定為該物件,否則this
的值被設定為全域性物件或undefined
(嚴格模式下)- call(thisArg)、apply(thisArg)、bind(thisArg)會直接指定
thisValue
值。
② 內部環境:包括詞法環境和變數環境,就是函式內部的變數、函式等資訊,還有引數arguments
資訊。
③ 作用域鏈(外部引用):外部的詞法作用域存放到函式的[[Scope]]
集合裡,用來查詢上級作用域變數。
05、❓有什麼結論?
- ❓ 變數越近越好:最好都本地化,儘量避免讓變數查詢鏈路過長,一層層切換作用域去找也是很累的。
- ❓ 優先
const
,其次let
,儘量(堅決)不用var
。 - ❓ 注意函式呼叫堆疊的長度,比如遞迴。
- ❓ 閉包函式使用完後,手動釋放一下,
fun = null;
,儘早被垃圾回收。 - ❓儘量避免成為全域性環境的變數,特別是一些臨時變數,全域性物件始終都在,不會被垃圾回收。
- 包括全域性環境申明的的
let
、const
、var
- 切記不用未申明變數
str=''
,不管在哪裡都會成為全域性變數。
- 包括全域性環境申明的的
遠離JavaScript、遠離前端......我以為已經學會了,其實可能還沒入門。
10、GC記憶體管理
值型別變數的生命週期隨函式,函式執行完就釋放了。垃圾回收GC(Garbage Collection)記憶體管理主要針對引用物件,當檢測到物件不再會被使用,就釋放其記憶體。GC是自動執行的,不需干預也無法干預。
GC回收一個物件的關鍵就是——確定他確是一個廢物,麼有任何地方使用他了,主要採用的方法就是標記清理。
- 標記清理(mark-and-sweep):標記記憶體中的所有的可達物件(根和他所有引用的物件),剩下的就是沒人要的,可以刪除了。
引用計數:按變數被引用的次數,這個策略已不再使用了,由於該回收垃圾的策略太垃圾從而被拋棄了。
❓什麼是可達性?
- ?根(roots):當前執行環境(window)最直接的變數,包括當前執行函式的區域性變數、引數;當前函式呼叫鏈上的其他函式的變數、引數;全域性變數。
- ?可達性(Reachability):如果一個值(物件)可以從根開始鏈式訪問到他,就是可達的,就說明這個資料物件還有利用價值。
上圖中FuncA
函式中的區域性變數 obj1
,其值物件{P}
存放在記憶體堆中,此時的值物件{P}
被根變數obj1
引用了,是可達的。
- 如果函式執行完畢,函式就銷燬了,變數引用
obj1
也一起隨她而去。值物件{P}
就沒有被引用了,就不可達了。 - 如果在函式中顯示執行
obj1=null;
同樣的值物件{P}
沒有被引用了,就不可達了。
GC定期執行垃圾回收的兩個步驟:
① 標記階段:找到可達物件並標記,實際的演算法會更加精細。
- 垃圾收集器找到所有的根,並“標記”(記住)它們。
- 繼續遍歷並“標記”被根引用的物件。
- ...繼續遍歷,直到找到所有可達物件並標記。
② 清除階段:沒有被標記的物件都會被清理刪除。
⚠️全域性變數不會被清理:屬於window的全域性變數就是根,始終不會被清理,有背景靠山就是不一樣!
©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀