JS作用域作用鏈及this使用原理詳解

變量提升的原理:JavaScript的執行順序

變量提升:JavaScript代碼執行過程中 JavaScript引擎把變量的聲明部分和函數的聲明部分提升到代碼開頭的行為 (變量提升後以undefined設為默認值)

callName();
function callName() {
	console.log('callName Done!');
}
console.log(personName);
var personName = 'james';
//變量提升後 類似以下代碼
function callName() {
	console.log('callName Done!');
};
var personName = undefined;
callName();//callName已聲明 所以正常輸出calName Done!
console.log(personName);//undefined
personName = 'james';
//代碼所作改變:
1.將聲明的變量和函數移到瞭代碼頂部
2.去除變量的var 聲明

JavaScript代碼的執行流程:有些人認為 變量提升就是將聲明部分提升到瞭最前面的位置 其實這種說法是錯的 因為變量和函數聲明在代碼中的位置是不會變的 之所以會變量提升是因為在編譯階段被JavaScript引擎放入內存中(換句話來說 js代碼在執行前會先被JavaScript引擎編譯 然後才會進入執行階段)流程大致如下圖

那麼編譯階段究竟是如何做到變量提升的呢 接下來我們一起來看看 我們還是以上面的那段代碼作為例子

第一部分:變量提升部分的代碼

function callName() {
	console.log('callName Done!')
}
var personName = undefined;

第二部分:代碼執行部分

callName();
console.log(personName);
personName = 'james'

執行圖如下

可以看到 結果編譯後 會在生成執行上下文和可執行代碼兩部分內容

執行上下文:JavaScript代碼執行時的運行環境(比如調用一個函數 就會進入這個函數的執行上下文 確定函數執行期間的this、變量、對象等)在執行上下文中包含著變量環境(Viriable Environment)以及詞法環境(Lexicol Environment) 變量環境保存著變量提升的內容 例如上面的myName 以及callName

那既然變量環境保存著這些變量提升 那變量環境對象時怎麼生成的呢 我們還是用上面的代碼來舉例子

callName();
function callName() {
	console.log('callName Done!');
}
console.log(personName);
var personName = 'james';
  • 第一、三行不是變量聲明 JavaScript引擎不做任何處理
  • 第二行 發現瞭function定義的函數 將函數定義儲存在堆中 並在變量環境中創建一個callName的屬性 然後將該屬性指向堆中函數的位置
  • 第四行 發現var定義 於是在變量環境中創建一個personName的屬性 並使用undefined初始化

經過上面的步驟後 變量環境對象就生成瞭 現在已經有瞭執行上下文和可執行代碼瞭 接下來就是代碼執行階段瞭

代碼執行階段

總所周知 js執行代碼是按照順序一行一行從上往下執行的 接下來還是使用上面的例子來分析

  • 執行到callName()是 JavaScript引擎便在變量環境中尋找該函數 由於變量環境中存在該函數的引用 於是引擎變開始執行該函數 並輸出"callName Done!"
  • 接下來執行到console.log(personName); 引擎在變量環境中找到personName變量 但是這時候它的值是undefined 於是輸出undefined
  • 接下來執行到瞭var personName = 'james'這一行 在變量環境中找到personName 並將其值改成james

以上便是一段代碼的編譯和執行流程瞭 相信看到這裡你對JavaScript引擎是如何執行代碼的應該有瞭更深的瞭解

Q:如果代碼中出現瞭相同的變量或者函數怎麼辦?

A:首先是編譯階段 如果遇到同名變量或者函數 在變量環境中後面的同名變量或者函數會將之前的覆蓋掉 所以最後隻會剩下一個定義

function func() {
	console.log('我是第一個定義的')
}
func();
function func() {
	console.log('我是將你覆蓋掉的')
}
func();
//輸出兩次"我是將你覆蓋掉的"

調用棧:棧溢出的原理

你在日常開發中有沒有遇到過這樣的報錯

根據報錯我們可以知道是出現瞭棧溢出的問題 那什麼是棧溢出呢?為什麼會棧溢出呢?

Q1:什麼是棧呢?

A1:一種後進先出的數據結構隊列

Q2:什麼是調用棧?

A2:代碼中通常會有很多函數 也有函數中調用另一個函數的情況 調用棧就是用來管理調用關系的一種數據結構

當我們在函數中調用另一個函數(如調用自身的遞歸)然後處理不當的話 就很容易產生棧溢出 比如下面這段代碼

function stackOverflow(n) {
	if(n == 1) return 1;
	return stackOverflow(n - 2);
}
stackOverflow(10000);//棧溢出

既然知道瞭什麼是調用棧和棧溢出 那代碼執行過程中調用棧又是如何工作的呢?我們用下面這個例子來舉例

var personName = 'james';
function findName(name, address) {
	return name + address;
}
function findOneDetail (name, adress) {
	var tel = '110';
	detail = findName(name, address);
	return personName + detail + tel
};
findOneDetail('james', 'Lakers')

可以看到 我們在findOneDetail中調用瞭findName函數 那麼調用棧是怎麼變化的

第一步:創建全局上下文 並將其壓入棧底

接下來開始執行personName = 'james'的操作 將變量環境中的personName設置為james

第二步:執行findOneDetail函數 這個時候JavaScript會為其創建一個執行上下文 最後將其函數的執行上下文壓入棧中

接下來執行完tel = ‘110'後 將變量環境中的tel設置為110

第三步:當執行detail = findName()時 會為findName創建執行上下文並壓入棧中

接下來執行完findName函數後 將其執行上下文彈出調用棧 接下來再彈出findOneDetail的執行上下文以及全局執行上下文 至此整個JavaScript的執行流程結束

所以調用棧是JavaScript引擎追蹤函數執行的一個機制 當一次有多個函數被調用時 通過調用棧就能追蹤到哪個函數正在被執行以及各函數之間的調用關系

如何利用調用棧

1.使用瀏覽器查看調用棧的信息

點擊source並打上斷點刷新後就可以再Call Stack查到調用棧的信息(也可以通過代碼中輸入console.track()查看)

2.小心棧溢出

當我們在寫遞歸的時候 很容易發生棧溢出 可以通過尾調用優化來避免棧溢出

塊級作用域:var、let以及const

作用域

作用域是指在程序中定義變量的區域,該位置決定瞭變量的生命周期。通俗地理解,作用域就是變量與函數的可訪問范圍,即作用域控制著變量和函數的可見性和生命周期

我們都知道 使用var會產生變量提升 而變量提升會引發很多問題 比如變量覆蓋 本應被銷毀的變量依舊存在等等問題 而ES6引入瞭let 和const兩種聲明方式 讓js有瞭塊級作用域 那let和const時如何實現塊級作用域的呢 其實很簡單 原來還是從理解執行上下文開始

我們都知道 JavaScript引擎在編譯階段 會將使用var定義的變量以及function定義的函數聲明在對應的執行上下文中的變量環境中創建對應的屬性 當時我們發現執行上下文中還有一個詞法環境對象沒有用到 其實 詞法環境對象便是關鍵之處 我們還是通過舉例子來說明一下

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()
  • 第一步:執行並創建上下文

  • 函數內部通過var聲明的變量 在編譯階段全都被存放到變量環境裡面瞭
  • 通過let聲明的變量 在編譯階段會被存放到詞法環境(Lexical Environment)中
  • 在函數的作用域內部 通過let聲明的變量並沒有被存放到詞法環境中
  • 接下來 第二步繼續執行代碼 當執行到代碼塊裡面時 變量環境中a的值已經被設置成瞭1 詞法環境中b的值已經被設置成瞭2

這時候函數的執行上下文就如下圖所示:

可以看到 當進入函數的作用域塊是 作用域塊中通過let聲明的變量 會被放到詞法環境中的一個單獨的區域中 這個區域並不郵箱作用域塊外面的變量 (比如聲明瞭b = undefined 但是不影響外面的b = 2

其實 在詞法作用域內部 維護瞭一個小型的棧結構 棧底是函數最外層的變量 進入一個作用域塊後 便會將過海作用域內部耳朵變量壓到棧頂 當作用域執行完之後 就會彈出(通過letconst聲明的變量)

當作用域塊執行完之後 其內部定義的變量就會從詞法作用域的棧頂彈出

小結

塊級作用域就是通過詞法環境的棧結構來實現的 而變量提升是通過變量環境來實現 通過這兩者的結合 JavaScript引擎也就同時支持瞭變量提升和塊級作用域瞭。

作用域鏈和閉包

在開始作用域鏈和閉包的學習之前 我們先來看下這部分代碼

function callName() {
	console.log(personName);
}
function findName() {
	var personName = 'james';
	callName();
}
var personName = 'curry';
findName();//curry
//你是否以為輸出james 猜想callName不是在findName中調用的嗎 那findName中已經定義瞭personName = 'james' 那為什麼是輸出外面的curry呢 這其實是和作用域鏈有關的

在每個執行上下文的變量環境中 都包含瞭一個外部引用 用來執行外部的執行上下文 稱之為outer

當代碼使用一個變量時 會先從當前執行上下文中尋找該變量 如果找不到 就會向outer指向的執行上下文查找

可以看到callNamefindName的outer都是指向全局上下文的 所以當在callName中找不到personName的時候 會去全局找 而不是調用callNamefindName中找 所以輸出的是curry而不是james

作用域鏈是由詞法作用域決定的

詞法作用域就是指作用域是由代碼中函數聲明的位置來決定的 所以詞法作用域是靜態的作用域 通過它就能夠預測代碼在執行過程中如何查找表示符

所以詞法作用域是代碼階段就決定好的 和函數怎麼調用的沒有關系

塊級作用域中的變量查找

我們來看下下面這個例子

function bar() {
    var myName = " 極客世界 "
    let test1 = 100
    if (1) {
        let myName = "Chrome 瀏覽器 "
        console.log(test)
    }
}
function foo() {
    var myName = " 極客邦 "
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = " 極客時間 "
let myAge = 10
let test = 1
foo()

我們知道 如果是let或者const定義的 就會儲存在詞法環境中 所以尋找也是從該執行上下文的詞法環境找 如果找不到 就去變量環境 還是找不到則去outer指向的執行上下文尋找 如下圖

閉包

JavaScript 中 根據詞法作用域的規則 內部函數總是可以訪問其外部函數中聲明的變量 當通過調用一個外部函數返回一個內部函數後 即使該外部函數已經執行結束瞭 但是內部函數引用外部函數的變量依然保存在內存中 我們就把這些變量的集合稱為閉包

舉個例子

function foo() {
    var myName = " 極客時間 "
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 極客邦 ")
bar.getName()
console.log(bar.getName())

首先我們看看當執行到 foo 函數內部的return innerBar這行代碼時調用棧的情況 你可以參考下圖:

從上面的代碼可以看出 innerBar 是一個對象 包含瞭 getNamesetName的兩個方法 這兩個方法都是內部定義的 且都引用瞭函數內部的變量

根據詞法作用域的規則 getNamesetName總是可以訪問到外部函數foo中的變量 所以當foo執行結束時 getNamesetName依然可以以後使用變量myNametest 如下圖所示

可以看出 雖然foo從棧頂彈出 但是變量依然存在內存中 這個時候 除瞭setNamegetName 其他任何地方都不能訪問到這兩個變量 所以形成瞭閉包

那如何使用這些閉包呢 可以通過bar來使用 當調用瞭bar.seyName時 如下圖

可以使用chrome的Clourse查看閉包情況

閉包怎麼回收

通常 如果引用閉包的函數是一個全局變量 那麼閉包會一直存在直到頁面關閉 但如果這個閉包以後不再使用的話 就會造成內存泄漏

如果引用閉包的函數是各局部變量 等函數銷毀後 在下次JavaScript引擎執行垃圾回收的時候 判斷閉包這塊內容不再被使用瞭 就會回收

所以在使用閉包的時候 請記住一個原則:如果該閉包一直使用 可以作為全局變量而存在 如果使用頻率不高且占內存 考慮改成局部變量

小練

var per = {
	name: 'curry';
	callName: function() {
		console.log(name);
	}
}
function askName(){
	let name = 'davic';
	return per.callName
}
let name = 'james';
let _callName = askName()
_callName();
per.callName();
//打印兩次james
//隻需要確定好調用棧就好 調用瞭askName()後 返回的是per.callName 後續就和askName沒關系瞭(出棧) 所以結果就是調用瞭兩次per.callName 根據詞法作用域規則 結果都是james 也不會形成閉包

this:從執行上下文分析this

相信大傢都有被this折磨的時候 而this確實也是比較難理解和令人頭疼的問題 接下來我將從執行上下文的角度來分析JavaScript中的this 這裡先拋出結論:this是和執行上下文綁定的 每個執行上下文都有一個this

接下來 我將帶大傢一起理清全局執行上下文的this和函數執行上下文的this

全局執行上下文的this

全局執行上下文的this和作用域鏈的最底端一樣 都是指向window對象

函數執行上下文的this

我們通過一個例子來看一下

function func() {
	console.log(this)//window對象
}
func();

默認情況下調用一個函數 其執行上下文的this也是指向window對象

那如何改變執行上下文的this值呢 可以通過apply call 和bind實現 這裡講下如何使用call來改變

1.通過call

let per = {
	name: 'james',
	address: 'Lakers'
}
function callName() {
	this.name = 'curry'
}
callName.call(per);
console.log(per)//name: 'curry', address: 'Lakers'

可以看到這裡this的指向已經改變瞭

2.通過對象調用

var person = {
	name: 'james';
	callName: function() {
		console.log(this.name)
	}
}
person.callName();//james 

使用對象來調用其內部方法 該方法的this指向對象本身的

person.callName() === person.callName.call(person)

這個時候我們如果講對象賦給另一個全局變量 this又會怎樣變化呢

var person = {
	name: 'james';
	callName: function() {
		this.name = 'curry';
		console.log(this.name);
	}
}
var per1 = person;//this又指向window
  • 在全局環境中調用一個函數 函數內部的this指向全局變量window
  • 通過一個對象調用內部的方法 該方法的this指向對象本身

3.通過構造函數設置

當使用new關鍵字構建好瞭一個新的對象 構造函數的this其實就是對象本身

this的缺陷以及應對方案

1.嵌套函數的this不會從外層函數中繼承

var person = {
	name: 'james',
	callName: function() {
		console.log(this);//指向person
		function innerFunc() {
			console.log(this)//指向window
		}
		innerFunc()
	}
}
person.callName();
//如何解決
1.使用一個變量保存
let _this = this //保存指向person的this
2.使用箭頭函數
() => {
    console.log(this)//箭頭函數不會創建其自身的執行上下文 所以箭頭函數中的this指向外部函數
}

2.普通函數中的this指向全局對象window

在默認情況下調用一個函數 其指向上下文的this默認就是指向全局對象window

總結

相信看到這裡 大傢對於作用域 作用域鏈 執行上下文和this都有瞭更深的理解 筆者後期還會更新更多關於瀏覽器的原理和實踐 感興趣的小夥伴可以點波關註一起學習 文中錯誤之處請在評論區指出!

以上就是JS作用域作用鏈及this使用原理詳解的詳細內容,更多關於JS作用域作用鏈this的資料請關註WalkonNet其它相關文章!

推薦閱讀: