「前端料包」深究JavaScript做用域(鏈)知識點和閉包

人生不能像作菜,把全部的料都準備好了才下鍋。前端

前言

在學習做用域和做用域鏈知識的時候,我一度都是處於犯迷糊的邊緣,直到前兩天,有人在羣裏聊了有關做用域的面試題,我當時閉上眼睛心想要是問到我該怎麼回答。這兩天查了資料,作了幾道面試題,以爲要輸出一點我本身的理解,本文將經過幾個簡單的代碼片斷和麪試題對JavaScript做用域和閉包的相關知識一探究竟。文中的表述若有不對,歡迎指正~ 如以爲還行請點亮左側的👍🙈vue

一、 一個變量的誕生

var name = 'Jake Zhang'
複製代碼

當咱們看到var name = 'Jake Zhang'的時候,咱們認爲是一條聲明,可是對於js引擎來講,這是一個編譯過程,分爲下面兩部分:git

一、遇到 var name,編譯器會詢問做用域是否已經有一個該名稱的變量存在於同一個做用域的集合中。若是是,編譯器會忽略該聲明,繼續進行編譯;不然它會要求做用域在當前做用域的集合中聲明一個新的變量,並命名爲name(嚴格模式下報錯)。github

二、接下來編譯器會爲引擎生成運行時所需的代碼,這些代碼被用來處理 name = 'Jake Zhang'這個賦值操做。引擎運行時會首先詢問做用域,在當前的做用域集合中是否存在一個叫做 name的變量。若是是,引擎就會使用這個變量;反之,引擎會繼續查找該變量。面試

證實以上的說法:編程

console.log(name); // 輸出undefined
var name = 'Jake Zhang'; 

複製代碼

var name = 'Jake Zhang'的上一行輸出name變量,並無報錯,輸出undefined,說明輸出的時候該變量已經存在了,只是沒有賦值而已。由以上兩步操做一個名爲name的變量就此誕生。數組

上面提到本文的核心詞——做用域,那什麼是做用域呢,接下來我們一探究竟。瀏覽器

二、什麼是做用域

先看這段代碼:bash

function fun() {
  var name = 'Jake Zhang';
   console.log(name); 
}
fun();// 輸出"Jake Zhang"
複製代碼

fun() 執行的時候,輸出一個name變量 ,那麼這個name變量是哪裏來?有看到函數第一行有 定義 name變量的代碼var name = 'Jake Zhang'閉包

繼續看另一段代碼:

var name2 = 'Jake Zhang2';
function fun() {
   console.log(name2);
}
fun(); // 輸出"Jake Zhang2"
複製代碼

一樣,在輸出 name2 時,本身函數內部沒有找到變量name2 ,那麼就 在外層的全局中查找 ,找到了就中止查找並輸出結果。

能夠注意到以上兩段代碼都有查找變量。第一段代碼是在函數fun中找到name變量,第二段代碼是在全局中找到name2變量。 如今給加粗的這兩個詞的後面加上做用域三個字,再讀一遍:第一段代碼是在函數做用域fun中找到name變量,第二段代碼是在全局做用域中找到name2變量。

其實咱們能夠發現,做用域,本質是一套規則,用於肯定在何處以及如何查找變量(標識符)的規則。關鍵點在於:查找變量(或標識符)。

由此咱們即可引出

(1)做用域的定義:

做用域是定義變量的區域,它有一套訪問變量的規則,這套規則用來管理瀏覽器引擎如何在當前做用域以及嵌套的做用域中根據變量(標識符)進行變量查找。

(2)詞法做用域

在上面的做用域介紹中,咱們將做用域定義爲一套規則,這套規則來管理瀏覽器引擎如何在當前做用域以及嵌套的做用域中根據變量(標識符)進行變量查找。

如今咱們提出一個概念:「詞法做用域是做用域的一種工做模型」,做用域有兩種工做模型,在JavaScript中的詞法做用域(靜態做用域)是比較主流的一種,另外一種動態做用域(是不關心函數和變量是如何聲明以及在何處聲明的,只關心它們從何處調用)。 所謂的詞法做用域就是在你寫代碼時將變量和塊做用域寫在哪裏來決定,也就是詞法做用域是靜態的做用域,在你書寫代碼時就肯定了。

請看如下代碼:

function fn1(x) {
	var y = x + 4;
	function fn2(z) {
		console.log(x, y, z);
	}
	fn2(y * 5);
}
fn1(6); // 6 10 50
複製代碼

複製代碼這個例子中有個三個嵌套的做用域,如圖:

  • A 爲全局做用域,有一個標識符:fn1
  • B 爲fn1所建立的做用域,有三個標識符:x、y、fn2
  • C爲fn2所建立的做用域,有一個標識符:z

做用域是由其代碼寫在哪裏決定的,而且是逐級包含的。

(3)塊級做用域

在ES6以前JavaScript並無塊級做用域的概念,咱們來看一段代碼:

for(var i=0;i<5;i++){
console.log(window.i)
    
} //0 1 2 3 4
複製代碼

若是你沒在函數內使用for循環的話,你會驚奇的發現,媽耶,我這個var不等於白var嘛,反正都是全局變量,要知道咱們的變量只能從下往上查找,不能反過來。因此JavaScript並無塊級做用域的概念。 塊級做用域是ES6中新添加的概念,常指的是{}中的語句,如 ifswitch 條件語句或 forwhile 循環語句,不像函數,它們不會建立一個新的做用域。塊級做用域一般經過letconst來體現。

for(let j=0;j<5;j++)(console.log(window.j));//undefined *5
複製代碼

看上面的代碼,能夠和上一個的var i的循環作對比。其實,提到let,const,這裏還涉及到變量提高、暫時性死區等知識點(限於篇幅這裏不作展開,以後會寫相關文章)。

好了,如今若是面試再問你什麼是做用域,這下應該清晰明瞭了吧。記得順便提一下詞法做用域。 接下來讓咱們繼續探索做用域鏈。

三、做用域鏈

咱們回到剛開始講做用域的那段代碼:

var name2 = 'Jake Zhang2';
function fun() {
   console.log(name2);
}
fun(); // 輸出"Jake Zhang2"
複製代碼

咱們在查找 name2 變量時,先在函數做用域中 查找,沒有找到,再去 全局做用域中 查找。你會注意到,這是一個往外層查找的過程,即順着一條鏈條 從下往上查找變量 。這條鏈條,咱們就稱之爲做用域鏈

這樣咱們就得出做用域鏈的概念:在做用域的多層嵌套中查找自由變量的過程是做用域鏈的訪問機制。而層層嵌套的做用域,經過訪問自由變量造成的關係叫作做用域鏈。

來兩張圖幫助理解:

代碼表示:

四、從面試題解析做用域和做用域鏈

一、解密原理

  • 每當執行完一塊 做用域裏的函數後,它就進入一個新的做用域下(通常從下往上找)
  • 當你使用一個變量 或者 給一個變量賦值時,變量是從當前的做用域先找,再從上層做用域找。

第1題:如下代碼的輸出結果

var a = 1
function fn1(){  
  function fn2(){
    console.log(a)
  }
  function fn3(){
    var a = 4
    fn2()
  }
  var a = 2   
  return fn3   
}
var fn = fn1() 
fn() //輸出?

//輸出a=2
//執行fn2函數,fn2找不到變量a,接着往上在找到建立當前fn2所在的做用域fn1中找到a=2
複製代碼

第2題:如下代碼的輸出結果

var a = 1        
function fn1(){
  function fn3(){  
    var a = 4
    fn2()        
  }
  var a = 2
  return fn3    
}

function fn2(){
  console.log(a)  
}
var fn = fn1()   
fn() //輸出多少

//輸出a=1
//最後執行fn2函數,fn2找不到變量a,接着往上在找到建立當前fn2所在的全局做用域中找到a=1
複製代碼

第3題(重點):如下代碼的輸出結果

var a = 1
function fn1(){
  function fn3(){
    function fn2(){
      console.log(a)
    }
    var a
    fn2()
    a = 4
  }      
  var a = 2
  return fn3
}
var fn = fn1()
fn() //輸出多少

//輸出undefined
//函數fn2在執行的過程當中,先從本身內部找變量找不到,再從建立當前函數所在的做用域fn去找,注意此時變量聲明前置,a已聲明但未初始化爲undefined
複製代碼

再來看一組在做用域鏈中查找過程的僞代碼:

第1道題

var x = 10
bar() 
function foo() {
   console.log(x) 
}
function bar(){
   var x = 30
   foo() 
}

/*
第2行,bar()調用bar函數
第6行,bar函數裏面調用foo函數
第3行,foo函數從本身的局部環境裏找x,結果沒找到
第1行,foo函數從上一級環境裏找x,即從全局環境裏找x,找到了var x=10。
foo()的輸出結果爲10。
*/
複製代碼

第2道題

var x = 10;
bar()  //30
function bar(){
  var x = 30;
  function foo(){
    console.log(x) 
  }
  foo();
}   
/*
第2行,bar()調用bar函數
第3行,bar函數裏面是foo函數
第4行,foo函數在本身的局部環境裏尋找x,沒找到。
foo函數到本身的上一級環境,即bar函數的局部環境裏找x,找到var x = 30
因此第2行的bar()輸出爲30
*/
複製代碼

第3道題

var x = 10;
bar() 
function bar(){
  var x = 30;
  (function (){
    console.log(x)
  })() 
}
/*
第2行,bar()調用bar函數
第三行,bar函數裏的function()在本身的局部環境裏尋找x,但沒找到
function()在上級環境即bar的局部環境裏尋找x,找到var x =30,因而顯示結果爲30
*/
複製代碼

五、閉包

前面所說的做用域及詞法做用域都是爲講閉包作準備,詞法做用域也是理解閉包的前置知識,因此若是對 做用域還有點模糊的能夠回頭再看一遍。

(1)從實例解析閉包

閉包(closure),是基於詞法做用域書寫代碼時產生的一種現象。各類專業文獻的閉包定義都很是抽象,個人理解是:** 閉包就是可以讀取其餘函數內部變量的函數**。經過下面的實踐你會知道,閉包在代碼中隨處可見,不用特地爲其建立而建立,隨着深刻作項目後,打代碼的不經意間就已經用了閉包。

實例1:

function a(){
    var n = 0;
    function add(){
       n++;
       console.log(n);
    }
    return add;
}
var a1 = a(); //注意,函數名只是一個標識(指向函數的指針),而()纔是執行函數;
a1();    //1
a1();    //2

複製代碼

分析以下:

  • add的詞法做用域能訪問a的做用域。根據條件執行a函數內的代碼,add當作值返回;
  • add執行後,將a的引用賦值給a1
  • 執行a1,分別輸出1,2

經過引用的關係,a1就是a函數自己(a1=a)。執行a1能正常輸出變量n的值,這不就是「a能記住並訪問它所在的詞法做用域」,而a(被a1調用)的運行是在當前詞法做用域以外。

add函數執行完畢以後,其做用域是會被銷燬的,而後垃圾回收器 會釋放閉包那段內存空間,可是閉包就這樣神奇地將add的做用域存活了下來,a依然持有該做用域的引用。

爲何會這樣呢?緣由就在於aadd的父函數,而add被賦給了一個全局變量,這致使add始終在內存中,而add的存在依賴於a,所以a也始終在內存中,不會在調用結束後,被垃圾回收機制(garbage collection)回收。 因此,在本質上,閉包是將函數內部和函數外部鏈接起來的橋樑。

總結:閉包就是一個函數引用另一個函數的變量,由於變量被引用着因此不會被回收,所以能夠用來封裝一個私有變量。

(2)閉包的用途

閉包能夠用在許多地方。它的最大用處有兩個,一個是前面提到的能夠讀取函數內部的變量,另外一個就是讓這些變量的值始終保持在內存中

(3)閉包的實際應用

使用閉包,咱們能夠作不少事情。好比模擬面向對象的代碼風格;更優雅,更簡潔的表達出代碼;在某些方面提高代碼的執行效率。

實例2:隨處可見的定時器

function waitSomeTime(msg, time) {
	setTimeout(function () {
		console.log(msg)
	}, time);
}
waitSomeTime('hello', 1000);

複製代碼

定時器中有一個匿名函數,該匿名函數就有涵蓋waitSomeTime函數做用域的閉包,所以當1秒以後,該匿名函數能輸出msg。

實例3:用for循環輸出函數值的問題

var fnArr = [];
for (var i = 0; i < 10; i++) {
  fnArr[i] =  function(){
    return i
  };
}
console.log( fnArr[3]() ) // 10
複製代碼

經過for循環,預期的結果咱們是會輸出0-9,但最後執行的結果,在控制檯上顯示則是全局做用域下的10個10。

這是由於當咱們執行fnArr[3]時,先從它當前做用域中找 i 的變量,沒找到i 變量,從全局做用域下找。開始了從上到下的代碼執行,要執行匿名函數function時,for循環已經結束(for循環結束的條件是當i大於或等於10時,就結束循環),而後執行函數function,此時當 i 等於[0,1,2,3,4,5,6,7,8,9]時,此時i 再執行函數代碼,輸出值都是 i 循環結束時的最終值爲:10,因此是輸出10次10。

由此可知:i是聲明在全局做用域中,function匿名函數也是執行在全局做用域中,那固然是每次都輸出10了。

延伸:

那麼,讓 i 在每次迭代的時候都產生一個私有做用域,在這個私有的做用域中保存當前i的值

var fnArr = [];
for (var i = 0; i < 10; i++) {
  fnArr[i] = (function(){
    var j = i
    return function(){
        return j
     }  
  })()
}
console.log(fnArr[3]()) //3
複製代碼

用一種更簡潔、優雅的方式改造:

將每次迭代的 i 做爲實參傳遞給自執行函數,自執行函數用變量去接收輸出值

var fnArr = []
for (var i = 0; i < 10; i ++) {
  fnArr[i] =  (function(j){
    return function(){
      return j
    } 
  })(i)
}
console.log( fnArr[3]() ) // 3
複製代碼

實例4:數組中的遍歷抽象

在這裏我先經過Java的抽象類講一下抽象的概念:

學過Java的對抽象的思想必定不會陌生,先來看Java中的一個抽象類:

public abstract class SuperClass {
  public abstract void doSomething();
}
複製代碼

複製代碼這是Java中的一個類,類裏面有一個抽象方法doSomething,如今不知道子類中要doSomething方法作什麼,因此將該方法定義爲抽象方法,具體的邏輯讓子類本身去實現。 建立子類去實現SuperClass

public class SubClass  extends SuperClass{
  public void doSomething() {
    System.out.println("say hello");
  }
}
複製代碼

複製代碼SubClass中的doSomething輸出字符串「say hello」,其餘的子類會有其餘的實現,這就是Java中的抽象類與實現。

那麼JS中的抽象是怎麼樣的,我想回調函數應該是一個:

function createDiv(callback) {
  let div = document.createElement('div');
  document.body.appendChild(div);
  if (typeof callback === 'function') {
    callback(div);
  }
}
createDiv(function (div) {
  div.style.color = 'red';
})
複製代碼

複製代碼這個例子中,有一個createDiv這個函數,這個函數負責建立一個div並添加到頁面中,可是以後要再怎麼操做這個divcreateDiv這個函數就不知道,因此把權限交給調用createDiv函數的人,讓調用者決定接下來的操做,就經過回調的方式將div給調用者。

這也是體現出了抽象,既然不知道div接下來的操做,那麼就直接給調用者,讓調用者去實現。 這也是咱們在學習vue等框架組件開發的一個基本思想。

好了,如今總結一下抽象的概念:抽象就是隱藏更具體的實現細節,從更高的層次看待咱們要解決的問題。

數組中的遍歷抽象

在編程的時候,並非全部功能都是現成的,好比上面例子中,能夠建立好幾個div,對每一個div的處理均可能不同,須要對未知的操做作抽象,預留操做的入口。

接下來看一下JavaScript的幾個數組操做方法,能夠更深刻的理解抽象的思想:

var arr = [1, 2, 3, 4, 5];
for (var i = 0; i < arr.length; i++) {
  var item = arr[i];
  console.log(item);
}
複製代碼

這段代碼中用for循環,而後按順序取值,有沒有以爲如此操做有些不夠優雅,爲出現錯誤留下了隱患,好比把length寫錯了,一不當心複用了i。既然這樣,能不能抽取一個函數出來呢?最重要的一點,咱們要的只是數組中的每個值,而後操做這個值,那麼就能夠把遍歷的過程隱藏起來:

function forEach(arr, callback) {
  for (var i = 0; i < arr.length; i++) {
    var item = arr[i];
    callback(item);
  }
}
forEach(arr, function (item) {
  console.log(item);
});
複製代碼

複製代碼以上的forEach方法就將遍歷的細節隱藏起來的了,把用戶想要操做的item返回出來,在callback中還能夠將i、arr自己返回:callback(item, i, arr)。 JS原生提供的forEach方法就是這樣的:

arr.forEach(function (item) {
  console.log(item);
});
複製代碼

forEach類似的方法還有map、some、every等。思想都是同樣的,經過這種抽象的方式可讓使用者更方便,同時又讓代碼變得更加清晰。

好了,抽象的簡單介紹就到這了,再日後就是高階函數的知識了,我這小白對高階函數也仍是懵懵懂懂,等我長本事兒了,再來更新。

後話

這篇在我草稿箱躺了大概有兩週了,以前寫了一半硬是寫不下去了,今天終於寫完😪~ 再來默寫一遍做用域:做用域是定義變量的區域,它有一套訪問變量的規則,這套規則來管理瀏覽器引擎如何在當前做用域以及嵌套的做用域中根據變量(標識符)進行變量查找。最後,小生乃前端小白一枚,寫文章的最初衷是爲了讓本身對該知識點有更深入的印象和理解,寫的東西也很小白,文中若有不對,歡迎指正~ 而後就是但願看完的朋友能夠點個喜歡,如不嫌棄,也能夠關注一波~ 我會持續輸出!

我的博客連接

GitHub

CSDN我的主頁

掘金我的主頁

簡書我的主頁

相關文章
相關標籤/搜索