這一次,徹底弄懂 JavaScript 函數(shù)執(zhí)行機(jī)制
一、作用域&上下文
1、 作用域
作用域就是JS函數(shù)和變量的可訪問范圍,分為全局作用域、局部作用域和塊級作用域。全局作用域是整個程序都能訪問到的區(qū)域,web環(huán)境下為window對象,node環(huán)境下為Global對象。局部作用域也就是函數(shù)作用域,在函數(shù)內(nèi)部形成一個獨(dú)立的作用域,函數(shù)執(zhí)行結(jié)束就銷毀,函數(shù)內(nèi)部的變量只能在函數(shù)內(nèi)部訪問。塊級作用域,使用let或const關(guān)鍵字聲明變量之后,會生成塊級作用域,聲明的變量只在這個塊中有效,并且在這個塊中l(wèi)et或const聲明的變量必須先聲明后使用。
var a = 10; // 全局變量
if (true) {
console.log(b) // error 必須先定義后使用
let b = 20; // 塊內(nèi)變量
console.log(b) // 20
console.log(a) // 10
}
console.log(a) // 10
console.log(b) // error not defined
function add (a, b) {
var c = 0; // 局部變量
console.log(c) // 0
return a + b + c;
}
console.log(c) // error not defined
當(dāng)JS引擎檢測到有塊級作用域產(chǎn)生時,系統(tǒng)會生成一個暫時性死區(qū),存儲所有l(wèi)et或const聲明的變量名。當(dāng)訪問暫時性死區(qū)中保存的變量時,系統(tǒng)會拋出錯誤,提示需要先聲明再使用,當(dāng)碰到變量聲明語句時,聲明變量,并從暫時性死區(qū)中刪除該變量,后面就能正常訪問了。
2、上下文
context上下文代表代碼執(zhí)行中this代表的值,JS函數(shù)中的this總是指向調(diào)用這個函數(shù)的對象;使用call,apply,bind等修改this指向的除外。
二、函數(shù)執(zhí)行
執(zhí)行期上下文執(zhí)行期上下文是在函數(shù)執(zhí)行的時候生成的,定義了函數(shù)在執(zhí)行時,函數(shù)內(nèi)部生成的代表當(dāng)前執(zhí)行函數(shù)的具體信息。產(chǎn)生執(zhí)行期上下文第一步是創(chuàng)建激活對象AO(Activation Object)將AO保存到作用域鏈的頂端設(shè)置上下文 this 的值在AO創(chuàng)建之后,在函數(shù)開始執(zhí)行之前,需要將函數(shù)內(nèi)部可訪問的變量在AO中進(jìn)行聲明和必要的初始化將函數(shù)內(nèi)部定義的變量以及函數(shù)參數(shù)放入AO中,初始值為undefined。將函數(shù)的實(shí)際參數(shù)賦值給AO中的變量。將函數(shù)內(nèi)部聲明的函數(shù)放入到AO中,初始值為 函數(shù)本身。看一個例子:
function add (a, b) {
debugger
var temp1 = 100;
function validateNum (n) {
return typeof n === "number";
}
var validateNum = 100;
return a + b;
}
console.log(add(1, 2))
可以看到,在函數(shù)開始執(zhí)行時,函數(shù)的實(shí)際參數(shù)會提前賦值給對應(yīng)的變量,但是函數(shù)內(nèi)部聲明的變量的值則被初始化為undefined 。
function add (a: number, b: number): number {
return a + b;
}
Add 函數(shù)生成的作用域包含如下:
可以看到,函數(shù)的作用域[[scope]]是一個數(shù)組,里面包含一個window對象,即全局對象。如果函數(shù)不是直接在全局作用域中定義,生成的作用域又是什么樣子呢?
function add (a, b) {
function validateNum (n) {
console.log(a, b)
return typeof n === "number";
}
debugger
if (!validateNum(a) || !validateNum(b)) {
throw new Error('type error');
}
return a + b;
}
console.log(add(1,2))
console.log(add(2,3))
從上圖能看出,函數(shù)的作用域[[scope]]中包含兩個對象,一個是全局對象,一個是add函數(shù)內(nèi)部的值。由此可知,函數(shù)作用域的生成是基于函數(shù)定義環(huán)境的,它會保存定義時當(dāng)前環(huán)境的數(shù)據(jù)。經(jīng)過上面的過程,我們能夠整理出整個函數(shù)執(zhí)行的過程:
可以看到validateNum函數(shù)的作用域鏈上保存了函數(shù)可以訪問的全部變量或函數(shù),首先是自己生成的激活對象AO內(nèi)的變量,包含函數(shù)內(nèi)部定義的變量和函數(shù)以及實(shí)參變量
二、函數(shù)執(zhí)行結(jié)束,內(nèi)存釋放
函數(shù)執(zhí)行結(jié)束之后,函數(shù)釋放自己執(zhí)行時創(chuàng)建的激活對象AO,在一段時間之后AO對象以及內(nèi)部的變量會被當(dāng)作垃圾回收掉,釋放內(nèi)存空間。validateNum 函數(shù)執(zhí)行完之后, validateNum AO 被釋放,但是[[scope]]屬性仍然存在validateNum函數(shù)對象中。Add 函數(shù)執(zhí)行結(jié)束之后,add AO 對象被釋放,AO對象中validateNum函數(shù)也被釋放,但是add函數(shù)的仍然存在。最終內(nèi)存中的狀態(tài)是這樣的。
三、閉包
閉包是一塊內(nèi)存空間始終被系統(tǒng)中某個變量引用著,導(dǎo)致這塊內(nèi)存一直不會被釋放,形成一個封閉的內(nèi)存空間,尋常不可見,只有引用它的變量可訪問。
正常情況下,函數(shù)執(zhí)行結(jié)束之后,所產(chǎn)生的所有變臉都會被內(nèi)存回收,但是有例外情況,就是,如果所產(chǎn)生的內(nèi)存空間仍然被其他地方的變量所引用,那么,這些空間不會被內(nèi)存回收,成為隱藏在內(nèi)存空間里的黑戶,只會被引用這片空間的變量訪問,如果這種情況存在很多,那么勢必會造成內(nèi)存不會釋放,造成內(nèi)存泄漏。例如:
var el = document.getElementById('id');
function add (a, b) {
function validateNum (n) {
return typeof n === "number";
}
el.onclick = function clickHandle () {
console.log(a, b)
}
if (!validateNum(a) || !validateNum(b)) {
throw new Error('type error');
}
return a + b;
}
console.log(add(1, 2))
當(dāng)add函數(shù)執(zhí)行時,會定義el元素的點(diǎn)擊事件函數(shù)clickHandle,clickHandle的[[scope]]中會保存add函數(shù)產(chǎn)生的AO。clickHandle 函數(shù)會被綁定在el元素上,只要el元素存在并且綁定了clickHandle事件響應(yīng)函數(shù),那么clickHandle函數(shù)也會一直存在,導(dǎo)致clickHandle函數(shù)對象中[[scope]]中保存的add函數(shù)的AO對象也會一直存在,不會被內(nèi)存釋放,就像有一個小黑屋,把a(bǔ)dd函數(shù)的AO對象關(guān)起來了,垃圾回收機(jī)制會忽略這塊內(nèi)存。閉包本質(zhì)上是保存了其他函數(shù)執(zhí)行時產(chǎn)生的激活對象AO。
四、后續(xù)
當(dāng)函數(shù)內(nèi)部的函數(shù)不引用外部變量時,不會形成閉包
function add (a, b) {
function validateNum (n) {
return typeof n === "number";
}
debugger
if (!validateNum(a) || !validateNum(b)) {
throw new Error('type error');
}
return a + b;
}
console.log(add(1, 2))
生成的作用域鏈如下:
可以看到,如果函數(shù)內(nèi)部生命的函數(shù)沒有使用到外部AO中的變量,那么在函數(shù)的[[scope]]作用域鏈中不會包含該AO。
function add (a, b) {
function validateNum (n) {
console.log(a)
return typeof n === "number";
}
debugger
if (!validateNum(a) || !validateNum(b)) {
throw new Error('type error');
}
return a + b;
}
console.log(add(1, 2))
執(zhí)行階段看到的作用域鏈如下:
可以看到在chrome中如果出現(xiàn)閉包,那么JS引擎會根據(jù)引用到的變量,做一波優(yōu)化,只保存用到的變量,并且會把這部分變量從JS執(zhí)行棧中轉(zhuǎn)移出去,減少執(zhí)行棧內(nèi)存占用。函數(shù)內(nèi)部不會被用到的函數(shù)不會聲明,而普通變量的聲明則不受影響validateNum 函數(shù)不會被調(diào)用的情況下:
validateNum 函數(shù)會被調(diào)用的情況下:
五、react 函數(shù)式組件中的閉包
const [value, setValue] = useState([]);
useEffect(() => {
notificationCenter.on(EVENT_NAME, eventListener);
return () => {
notificationCenter.off(EVENT_NAME, eventListener);
};
}, []);
function eventListener(chatId?: string) {
console.log(value);
}
在事件監(jiān)聽函數(shù)執(zhí)行過程中,發(fā)現(xiàn)無法訪問到最新的 value 數(shù)據(jù)原因是因?yàn)樵诮M件第一次渲染時,綁定了事件監(jiān)聽函數(shù),此時聲明的函數(shù)的作用域鏈中保存了當(dāng)時的數(shù)據(jù)狀態(tài)(value)的初始值,當(dāng)頁面狀態(tài)發(fā)生變化時,函數(shù)組件會重新渲染執(zhí)行,但是事件監(jiān)聽函數(shù)仍然還是第一次生成的,[[scope]]中保存了初始的value值,所以在函數(shù)執(zhí)行過程中,從作用域鏈中訪問到的value始終是初始值。在setTimeout以及其他延時回調(diào)中也存在類似的情況。
針對這種情況有兩種解決辦法:
第一種:類似事件監(jiān)聽的場景,在useEffect中,添加需要用到的依賴,當(dāng)依賴發(fā)生變化時,重新注冊監(jiān)聽事件。
const [value, setValue] = useState([]);
useEffect(() => {
notificationCenter.on(EVENT_NAME, eventListener);
return () => {
notificationCenter.off(EVENT_NAME, eventListener);
};
}, [value]);
第二種:使用ref將需要使用到的變量變?yōu)橐妙愋停?dāng)外部修改以及函數(shù)內(nèi)部訪問的時候?qū)嶋H上是都是在訪問同一個引用里面的屬性,都能確保拿到的是最新數(shù)據(jù)。
const valueRef = useRef([]);
useEffect(() => {
notificationCenter.on(EVENT_NAME, eventListener);
return () => {
notificationCenter.off(EVENT_NAME, eventListener);
};
}, []);
function eventListener(chatId?: string) {
console.log(valueRef.curremt);
}?? 謝謝支持
喜歡的話別忘了 分享、點(diǎn)贊、在看 三連哦~。
點(diǎn)擊下方名片,關(guān)注 前端Sharing ,持續(xù)更新 前端技術(shù)文章。
