這一次,徹底弄懂 JavaScript 函數(shù)執(zhí)行機(jī)制
一、作用域&上下文
1、 作用域
作用域就是JS函數(shù)和變量的可訪問范圍,分為全局作用域、局部作用域和塊級(jí)作用域。全局作用域是整個(gè)程序都能訪問到的區(qū)域,web環(huán)境下為window對(duì)象,node環(huán)境下為Global對(duì)象。局部作用域也就是函數(shù)作用域,在函數(shù)內(nèi)部形成一個(gè)獨(dú)立的作用域,函數(shù)執(zhí)行結(jié)束就銷毀,函數(shù)內(nèi)部的變量只能在函數(shù)內(nèi)部訪問。塊級(jí)作用域,使用let或const關(guān)鍵字聲明變量之后,會(huì)生成塊級(jí)作用域,聲明的變量只在這個(gè)塊中有效,并且在這個(gè)塊中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引擎檢測(cè)到有塊級(jí)作用域產(chǎn)生時(shí),系統(tǒng)會(huì)生成一個(gè)暫時(shí)性死區(qū),存儲(chǔ)所有l(wèi)et或const聲明的變量名。當(dāng)訪問暫時(shí)性死區(qū)中保存的變量時(shí),系統(tǒng)會(huì)拋出錯(cuò)誤,提示需要先聲明再使用,當(dāng)碰到變量聲明語句時(shí),聲明變量,并從暫時(shí)性死區(qū)中刪除該變量,后面就能正常訪問了。
2、上下文
context上下文代表代碼執(zhí)行中this代表的值,JS函數(shù)中的this總是指向調(diào)用這個(gè)函數(shù)的對(duì)象;使用call,apply,bind等修改this指向的除外。
二、函數(shù)執(zhí)行
執(zhí)行期上下文執(zhí)行期上下文是在函數(shù)執(zhí)行的時(shí)候生成的,定義了函數(shù)在執(zhí)行時(shí),函數(shù)內(nèi)部生成的代表當(dāng)前執(zhí)行函數(shù)的具體信息。產(chǎn)生執(zhí)行期上下文第一步是創(chuàng)建激活對(duì)象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ù)本身。看一個(gè)例子:
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í)際參數(shù)會(huì)提前賦值給對(duì)應(yīng)的變量,但是函數(shù)內(nèi)部聲明的變量的值則被初始化為undefined 。
function add (a: number, b: number): number {
return a + b;
}
Add 函數(shù)生成的作用域包含如下:
可以看到,函數(shù)的作用域[[scope]]是一個(gè)數(shù)組,里面包含一個(gè)window對(duì)象,即全局對(duì)象。如果函數(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]]中包含兩個(gè)對(duì)象,一個(gè)是全局對(duì)象,一個(gè)是add函數(shù)內(nèi)部的值。由此可知,函數(shù)作用域的生成是基于函數(shù)定義環(huán)境的,它會(huì)保存定義時(shí)當(dāng)前環(huán)境的數(shù)據(jù)。經(jīng)過上面的過程,我們能夠整理出整個(gè)函數(shù)執(zhí)行的過程:
可以看到validateNum函數(shù)的作用域鏈上保存了函數(shù)可以訪問的全部變量或函數(shù),首先是自己生成的激活對(duì)象AO內(nèi)的變量,包含函數(shù)內(nèi)部定義的變量和函數(shù)以及實(shí)參變量
二、函數(shù)執(zhí)行結(jié)束,內(nèi)存釋放
函數(shù)執(zhí)行結(jié)束之后,函數(shù)釋放自己執(zhí)行時(shí)創(chuàng)建的激活對(duì)象AO,在一段時(shí)間之后AO對(duì)象以及內(nèi)部的變量會(huì)被當(dāng)作垃圾回收掉,釋放內(nèi)存空間。validateNum 函數(shù)執(zhí)行完之后, validateNum AO 被釋放,但是[[scope]]屬性仍然存在validateNum函數(shù)對(duì)象中。Add 函數(shù)執(zhí)行結(jié)束之后,add AO 對(duì)象被釋放,AO對(duì)象中validateNum函數(shù)也被釋放,但是add函數(shù)的仍然存在。最終內(nèi)存中的狀態(tài)是這樣的。
三、閉包
閉包是一塊內(nèi)存空間始終被系統(tǒng)中某個(gè)變量引用著,導(dǎo)致這塊內(nèi)存一直不會(huì)被釋放,形成一個(gè)封閉的內(nèi)存空間,尋常不可見,只有引用它的變量可訪問。
正常情況下,函數(shù)執(zhí)行結(jié)束之后,所產(chǎn)生的所有變臉都會(huì)被內(nèi)存回收,但是有例外情況,就是,如果所產(chǎn)生的內(nèi)存空間仍然被其他地方的變量所引用,那么,這些空間不會(huì)被內(nèi)存回收,成為隱藏在內(nèi)存空間里的黑戶,只會(huì)被引用這片空間的變量訪問,如果這種情況存在很多,那么勢(shì)必會(huì)造成內(nèi)存不會(huì)釋放,造成內(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í)行時(shí),會(huì)定義el元素的點(diǎn)擊事件函數(shù)clickHandle,clickHandle的[[scope]]中會(huì)保存add函數(shù)產(chǎn)生的AO。clickHandle 函數(shù)會(huì)被綁定在el元素上,只要el元素存在并且綁定了clickHandle事件響應(yīng)函數(shù),那么clickHandle函數(shù)也會(huì)一直存在,導(dǎo)致clickHandle函數(shù)對(duì)象中[[scope]]中保存的add函數(shù)的AO對(duì)象也會(huì)一直存在,不會(huì)被內(nèi)存釋放,就像有一個(gè)小黑屋,把a(bǔ)dd函數(shù)的AO對(duì)象關(guān)起來了,垃圾回收機(jī)制會(huì)忽略這塊內(nèi)存。閉包本質(zhì)上是保存了其他函數(shù)執(zhí)行時(shí)產(chǎn)生的激活對(duì)象AO。
四、后續(xù)
當(dāng)函數(shù)內(nèi)部的函數(shù)不引用外部變量時(shí),不會(huì)形成閉包
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]]作用域鏈中不會(huì)包含該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引擎會(huì)根據(jù)引用到的變量,做一波優(yōu)化,只保存用到的變量,并且會(huì)把這部分變量從JS執(zhí)行棧中轉(zhuǎn)移出去,減少執(zhí)行棧內(nèi)存占用。函數(shù)內(nèi)部不會(huì)被用到的函數(shù)不會(huì)聲明,而普通變量的聲明則不受影響validateNum 函數(shù)不會(huì)被調(diào)用的情況下:
validateNum 函數(shù)會(huì)被調(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件第一次渲染時(shí),綁定了事件監(jiān)聽函數(shù),此時(shí)聲明的函數(shù)的作用域鏈中保存了當(dāng)時(shí)的數(shù)據(jù)狀態(tài)(value)的初始值,當(dāng)頁面狀態(tài)發(fā)生變化時(shí),函數(shù)組件會(huì)重新渲染執(zhí)行,但是事件監(jiān)聽函數(shù)仍然還是第一次生成的,[[scope]]中保存了初始的value值,所以在函數(shù)執(zhí)行過程中,從作用域鏈中訪問到的value始終是初始值。在setTimeout以及其他延時(shí)回調(diào)中也存在類似的情況。
針對(duì)這種情況有兩種解決辦法:
第一種:類似事件監(jiān)聽的場(chǎng)景,在useEffect中,添加需要用到的依賴,當(dāng)依賴發(fā)生變化時(shí),重新注冊(cè)監(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)部訪問的時(shí)候?qū)嶋H上是都是在訪問同一個(gè)引用里面的屬性,都能確保拿到的是最新數(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);
}