js中的執行上下文、做用域、閉包和this

學習js也有一段時間了,可是往往提到執行上下文、做用域、閉包、變量提高、this等關鍵詞時心中老是有一個模糊的概念,好像知道又好像不知道,所以我想和你們系統的討論這幾個概念。但願可以幫到和我同樣還爲這幾個熟悉而陌生的詞感到苦惱的同窗!javascript

image.png

1. js的數據類型

這裏我不打打算一開始就討論上面那些概念,每種語言都有內建的數據類型,不一樣的創建方式也意味着不同的使用方式。而是從js的數據類型開始一步一步分析,則可讓你摸清楚上面幾個概念的前因後果。前端

1.1 js是弱類型、動態的語言

  • 靜態語言和動態語言
    • 靜態語言:在使用以前就須要確認其變量數據類型的稱爲靜態語言。像C、C++、java等都是靜態語言。
    • 動態語言:在運行過程當中須要檢查數據類型的語言,像js、py等都是動態語言。
  • 弱類型語言和強類型語言
    • 弱類型語言:支持隱式類型轉換的語言,像C、js是弱類型語言
    • 強類型語言:不支持隱式類型轉換的語言,像python和java即是強類型語言

若是你細心的留意過js語言的一門細節,就會發現js是一門弱類型的動態語言vue

在js代碼會在if判斷語句中自動將表達式計算成布爾類型的值,同時在js中聲明的變量的定義無需肯定它是字符串、數字或者布爾等其餘類型,這意味着你能夠在一個變量中保存不一樣類型的數據。值得一提的是這種動態語言的類型再帶來極大便利性的同時也會帶來一些使人困擾的問題,在vue這門優秀的框架中使用了Flow對js作了靜態類型語言檢查。java

1.2 基本類型和引用類型

上面咱們知道了js是一門弱類型的動態語言,那麼咱們接下來看看js中的數據類型python

在js中數據類型分爲基本類型和引用類型:面試

(1)基本類型有:瀏覽器

  • null
  • undefined
  • boolean
  • number
  • string
  • symbol(ES6引入)

(2)js的引用類型是從object的子類型,有以下幾種:緩存

  • Object
  • Function
  • Array
  • RegExp
  • Date
  • 包裝類:String、Number、Boolean
  • Math

2. js的內存模型

js中對不一樣類型的數據的操做不是相同的,要想理解其中的差別,先得搞清楚js中存儲模型。(從極客上拿的圖)markdown

image.png

在js執行的過程當中,主要有三種類型內存空間,分別是:代碼空間棧空間堆空間閉包

2.1 棧空間和堆空間

基本類型的數據類型都存儲在棧空間,引用類型的值保存在堆中的。

// 定義四個變量
var num1 = 5
var num2 = num1;
var obj1 = {
    name: '小豬皮皮呆'
}
var obj2 = obj1

// 修改num1和obj1
num1 = 4
obj1.name = '小豬'

// 輸出四個變量
console.log(num1) // 4
console.log(num2) // 5
console.log(obj1.name) // 小豬
console.log(obj2.name) // 小豬
複製代碼

上面代碼num1和num2的輸出咱們可以很好的理解,由於在js中基本類型的數據類型都存儲在棧空間。若是一個變量向另外一個變量賦值基本類型的值,會在變量對象上建立一個新值,而後把該值複製到爲新變量分配的位置上。

image.png

那麼爲何obj1和obj2的name輸出的結果都改變了呢?這是由於在js中引用類型的值保存在堆中的。若是一個變量向另外一個變量賦值引用類型的值,一樣會在變量對象上建立一個新值,而後把該值複製到爲新變量分配的位置上,但與基礎類型不一樣的是,這個值是一個指針,這個指針指向了中的同一個對象,所以在修改其中任何一個對象都是在對同一個對象修改。

image.png

看完上面的內容,相信你對棧和堆已經有了必定的理解,接下來咱們來看看js中傳遞參數的方式。在js中,全部函數的參數都是按值傳遞的,也就是說把函數外部的值複製給函數內部使用,就像把值從一個變量複製到另外一個變量裏同樣。

這就意味着,基本類型值得傳遞和引用類型值的傳遞就如同上述所說的複製過程是同樣的。

// 基本類型的傳遞
function addTen(num){
      num += 10
      return num
}
var count = 20
var result = addTen(count)
alert(count) //20
alert(result) //30

// 引用類型的傳遞
function setName(obj) {
    obj.name = "小豬皮皮呆"
}
var person = {}
setName(person)
console.log(person.name) // 小豬皮皮呆
複製代碼

在這裏有些同窗可能會將引用類型傳遞參數的方式搞錯,會發出疑問:訪問變量有按值和按引用兩種方式,爲何傳遞參數只有按值傳遞?

對於上例的基礎類型的值的傳遞能夠很容易的理解,可是引用類型的傳遞在局部中的修改會在全局中反應出來,會有同窗誤覺得引用類型的傳遞是按參數傳遞的。但其實真正的過程是這樣的:

  • 建立了一個對象,保存倒了person變量中
  • 調用setName函數,person變量傳遞到setName中
  • person的值複製給了obj,複製的是一個指針,指向了堆中的一個對象
  • 修改了obj
  • person中也體現出來了

從上述的過程當中,能夠看出來,person這個變量是按值傳遞的。咱們再看個例子來講明這個問題

function setName(obj){
    obj.name = "小豬皮皮呆"
    obj = new Object()
    obj.name = "三元大神"
}
var person = {}
setName(person)
alert(person.name) // 小豬皮皮呆
複製代碼

若是是按引用傳遞,顯示的值應該是「三元大神」,但js中的引用類型的傳遞也是按值傳遞的,因此打印出來的是「小豬皮皮呆」。

3. js代碼的執行流程

看到這裏,確定不少人要開始罵了,這我的標題黨啊,開頭說了要理清楚執行上下文、做用域、閉包、變量提高、this這些東西,怎麼到如今還隻字未提。都已經看到這裏了,彆着急!本文的思路是自頂向下的,從最外層你熟悉的地方開始講起,慢慢的滲透到底部的各個概念,將各個知識點串在一塊兒,造成知識體系。

showName() // 小豬
console.log(myName) // undefiend
var myName = "小豬皮皮呆"
function showName() {
    console.log("小豬")
}
複製代碼

上面代碼的執行結果相信你們都不意外,這就是咱們耳熟能詳的變量提高,可是他的內部到底發生了些什麼,纔會出現這種結果呢?

不少地方給出的解釋是js代碼在執行的過程當中,js引擎會把變量的聲明部分和函數的聲明提高到代碼的開頭部分。變量被提高後會設置默認值,也就是undefined。 這種說法沒有錯,可是咱們要更深刻的去看看這個所謂的變量提高內部發生了什麼。

接下來咱們要開始咱們便進入了本文的重點部分,js代碼的執行流程分爲兩部分:編譯執行

  • 編譯:在上述的解釋中,js引擎會把變量的聲明部分和函數的聲明提高到代碼的開頭部分,這其實並不許確。在第二部分js的內存模型咱們看到在js執行的過程當中,主要有三種類型內存空間,分別是:代碼空間棧空間堆空間。實際上變量和函數聲明在代碼裏的位置不會改變,由一開始編寫的代碼決定的。接下來在編譯階段後,會造成兩部份內容:執行上下文可執行代碼。變量和函數聲明會被js引擎放入執行上下文中。
  • 執行:在上述一切準備就緒後,js引擎便會一行一行的執行可執行代碼

image.png

3.1 執行上下文

看到這裏,終於迎來了咱們要討論的第一個重點:什麼是執行上下文?

執行上下文的建立分爲三種狀況:

  • 執行全局代碼,編譯全局代碼,建立全局上下文,且只有一個
  • 調用函數,函數體內代碼會被編譯,建立函數上下文,函數執行完畢後該函數上下文會被銷燬
  • 使用eval函數,不多遇到,在此不討論。

而在js中,上下文的管理則由調用棧負責,js執行過程當中三種內存空間之一的棧空間。咱們來看看它是如何負責的:

  1. js編譯全局代碼,建立全局上下文,將其壓入棧底
  2. 全局代碼執行console.log,打印出undefined
  3. 爲myName變量賦值「小豬皮皮呆」
  4. 調用setName函數,js對其進行編譯,建立setName函數的執行上下文
  5. setName函數執行完畢,setName函數的執行上下文彈出棧並銷燬
  6. 全局代碼執行完畢,彈出棧,代碼運行結束

image.png

看到這裏咱們即可以回答以前的問題了。所謂的變量提高就是js代碼執行的過程當中,會先將代碼進行編譯,編譯的過程當中變量的聲明和函數的聲明會被放入調用棧中造成上下文調用棧,剩餘下的會生成執行代碼。這就形成了變量提高的現象。

順帶一提,調用棧的大小有限,若是入棧執行的上下文超過必定數目,js引擎就會報錯,這種現象就叫棧溢出,看下面一段代碼:

function stackOverFlow (a, b) {
    return stackOverFlow (a, b)
}
console.log(stackOverFlow(1, 2))
複製代碼

image.png

看到這裏,相信你已經理解了什麼是執行上下文,什麼是變量提高。是否是很簡單呢?接下來我會帶領同窗們繼續看剩下的幾個概念,有了上面的基礎,剩下的內容則更好理解。

4. 做用域和做用域鏈

在上文中咱們已經瞭解了變量提高,因爲 js 存在變量提高這種特性,從而致使了不少與直覺不符的代碼,這也是 JavaScript 的一個重要設計缺陷。

var name = "小豬皮皮呆"
function showName(){
    console.log(name);
    if (0) {
        var name = "小豬"
    }
    console.log(name)
}
showName()
// undefined
// undefiend
複製代碼

在咱們熟悉調用棧後,在執行到showName時,會生成一個showName()的上下文,裏面會將函數內部的name放入變量環境中並賦值undefined,因此第一個console沒有打印出「小豬皮皮呆」,第二個打印以前由於if語句裏面的語句沒有執行,因此打印出的依然是undefined。

(1)做用域

而爲何會存在這種特性還得從做用域提及,js中存在三種做用域,ES6以前只兩種做用域:

  • 全局做用域
  • 函數做用域
  • 塊級做用域(ES6新增)

(2)做用域鏈

這段代碼很容易讓人以爲會打印結果會是「小豬皮皮呆」,這和咱們接下來要提到的另外一個概念做用域鏈有關

function bar() {
    console.log(name)
}

function foo() {
    var name = "小豬皮皮呆"
    bar()
}

var name = "小豬"

foo() // 小豬
複製代碼

相信前面的執行上下文部分同窗們已經理解了,接下來咱們會結合執行上下文來看做用域鏈

  • 每一個執行上下文的變量環境中,都包含了一個外部引用,用來指向外部的執行上下文,咱們把這個外部引用稱爲 outer。
  • 當一段代碼使用了一個變量的時候,js引擎會在當前執行上下文查找該變量,若是沒有找到,會繼續在outer執行的執行上下文中去尋找。這樣一級一級的查找就造成了做用域鏈

image.png

  • 做用域鏈的生成由代碼決定,和調用無關。因此一開始代碼bar編譯好了後outer就指向全局上下文,所以打印的不是foo()內部的「小豬皮皮呆」

(3)塊級做用域

上面提到了ES5以前只有全局做用域和函數做用域,ES6爲了解決變量提高帶來的問題,引入了塊級做用域。這個你們都很熟悉,可是js如何作到即支持變量提高的特性又支持塊級做用域呢?

咱們繼續從執行上下文的角度解決這個問題

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 聲明的變量並無被存放到詞法環境中。

image.png

  • 執行到代碼塊
    • 代碼塊內部的let聲明存放在了一個新的區域中

image.png

  • 執行console.log(a)

image.png

  • 看成用域塊執行結束以後,其內部定義的變量就會從詞法環境的棧頂彈出

image.png

上述造成的新的做用域鏈即是js對變量提高和塊級做用域同時支持的實現。

一個常見的問題:如何解決下面的循環輸出問題?

for(var i = 1; i <= 5; i ++){
  setTimeout(function timer(){
    console.log(i)
  }, 0)
}
複製代碼
  • 緣由:setTimeout是宏任務,等同步任務執行完畢後i爲6,因此會輸出五個6
  • 解決辦法:使用let,造成塊級做用域
for(let i = 1; i <= 5; i ++){
  setTimeout(function timer(){
    console.log(i)
  }, 0)
}
複製代碼

4.1 閉包

在瞭解了做用域鏈後再去理解閉包就十分簡單了!

  • 什麼是閉包?

ES5中存在兩個做用域:全局做用域、函數做用域,函數做用域會在函數運行結束後自動銷燬 做用域鏈:查找一個變量時會從自身的做用域開始沿着做用域鏈一直向上查找 閉包:利用了做用域,能夠將函數內部的做用域的變量訪問到

(1)閉包如何產生:

  • 返回函數 (常見)
const a = 2
function out () {
  let a = 1
  return function b () {
    console.log(a)
  }
}
const b = out()
b() // 1
複製代碼
  • 函數看成參數傳遞 :看成參數的函數能夠訪問到函數主體的內部做用域
var a = 1
function bar(fn) {
  var a = 2
  console.log(fn)
}

function baz() {
  console.log(a)
}

bar(baz) // 1
複製代碼
  • 在定時器、事件監聽、Ajax請求、跨窗口通訊、Web Workers或者任何異步中,只要使用了回調函數,其實就是上面那種狀況,將函數看成參數,也就是在使用閉包。
// 定時器
setTimeout(function timeHandler(){
  console.log('111');
}, 100)

// 事件監聽
$('#app').click(function(){
  console.log('DOM Listener');
})
複製代碼
  • 當即執行函數:
var a = 2;
(function IIFE(){
  // 輸出2
  console.log(a);
})();
複製代碼

IIFE(當即執行函數表達式)建立閉包, 保存了全局做用域window和當前函數的做用域,所以能夠全局的變量。

for(var i = 1; i <= 5; i ++){
  (function(j){
      setTimeout(function timer(){
        console.log(j)
      }, 0)
  })(i)
}
複製代碼

(2)應用場景:

  • 柯里化:

函數柯里化、前端經典面試題解密-add(1)(2)(3)(4) == 10究竟是個啥?

function add (...args) {
  return args.reduce((a, b) => a + b)
}

function currying(fn) {
  let args = []
  return function _c (...newArgs) {
    if (newArgs.length) {
      args = [...args, ...newArgs]
      return _c
    } else {
      return fn.apply(this, args)
    }
  }
}

let addCurry = currying(add)
let total = addCurry(1)(2)(3, 4)(5, 6 ,7)()
console.log(total) // 28
複製代碼

(3)缺點:全局使用閉包會形成內存泄漏,因此儘可能少用

5. this

在上面一小節中咱們介紹了bar編譯好了後outer就指向全局上下文,所以打印的不是foo()內部的「小豬皮皮呆」,大多數人會產生這樣的異或即是將this和做用域鏈的概念弄混了。

而真實狀況是,做用域鏈這套機制不支持咱們直接得到對象內部的變量,而又獨立的成立了一套新的機制,絕對不要將二者混爲一談!

var obj = {
    name: "小豬皮皮呆",
    showName: function () {
        console.log(name)
    }
}

var name = "小豬"
obj.showName() // 小豬
複製代碼

上面是一個經典的面試題,輸出的結果是「小豬」而不是內部的「小豬皮皮呆」,有了以前對上下文和做用域鏈的理解,能夠很容易的去解釋,不在此贅述。

再強調一遍:做用域和this之間沒有任何關係!this單獨存在於執行上下文中,和執行上下文中的變量環境、詞法環境、outer是並行的關係。

那麼this要如何使用呢?若是想上述代碼輸出內部的name,即可以使用this來實現。

var obj = {
    name: "小豬皮皮呆",
    showName: function () {
        console.log(this.name)
    }
}

var name = "小豬"
obj.showName() // 小豬皮皮呆
複製代碼

接下來再對this的指向作一個總結:

  • 默認綁定:在全局執行上下文中,this的指向全局對象。(在瀏覽器中,this引用 Window 對象)。
  • 隱式綁定:在函數執行上下文中,this 的值取決於該函數是如何被調用的。若是它被一個引用對象調用,那麼this會被設置成那個對象,不然this的值被設置爲全局對象或者undefined(在嚴格模式下)
  • 顯示綁定:apply、call、bind
  • 箭頭函數的this由外層(函數或全局)的做用域來決定
var person = {
    name: "小豬皮皮呆",
    changeName: function () {
        setTimeout(function(){
            this.name = "小豬"
        }, 100)
    }
}

person.changeName()
複製代碼

上述代碼想要經過changeName方法修改person內部的name屬性,可是該代碼存在一些問題,咱們便根據上述對this指向的總結來解決這題。

(1) 緩存內部的this

var person = {
    name: "小豬皮皮呆",
    changeName: function () {
        var self = this
        setTimeout(function(){
            self.name = "小豬"
            console.log(person.name)
        }, 100)
    }
}

person.changeName() // 小豬
複製代碼

(2) 使用call、apply或bind顯示綁定

var change = function () {
    this.name = "小豬"
}
var person = {
    name: "小豬皮皮呆",
    changeName: function () {
        setTimeout(function(){
            change.call(person)
            console.log(person.name)
        }, 100)
    }
}

person.changeName() // 小豬
複製代碼

(3) 使用箭頭函數

var person = {
    name: "小豬皮皮呆",
    changeName: function () {
        setTimeout(() => {
            this.name = "小豬"
            console.log(person.name)
        }, 100)
    }
}

person.changeName() // 小豬
複製代碼

好啦,到此爲止文章開頭提到的那幾個詞已經爲你們梳理過一遍了,若是以爲還不錯的話,給小豬皮皮呆一個👍吧!

參考文獻

  • 《js高級程序設計》第三版
  • 極客的專欄
  • 《你不知道的js》
  • 偶像神三元的博客
相關文章
相關標籤/搜索