首頁 > 軟體

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

2022-08-10 14:02:33

變數提升的原理: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的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com