动态

详情 返回 返回

聊一聊 JavaScript 中的作用域和閉包 - 动态 详情

哈嘍大家好,我是歸思君~

一、引言

我們知道,作用域(Scope)就是代碼中變量和函數的可訪問的區域,這個區域中決定了變量和函數的生命週期。在當前的高級程序語言中,主要有詞法作用域(靜態作用域)和動態作用域兩種。

  • 靜態作用域:其作用域是在編寫代碼時就已經確定好,靜態作用域是根據變量和函數在代碼中的位置來決定。函數尋找變量時,是在函數定義的位置中尋找,而不是調用的位置。現在大多數編程語言都採用的是靜態作用域,比如 C, C++, Java, JavaScript, Python 等
  • 動態作用域是在程序運行時根據程序的調用棧來動態確定,而不是在寫代碼時靜態確定。在函數尋找變量時,根據函數調用的位置來尋找。這意味着同一個變量名在不同的調用上下文中可能指向不同的變量,可以用 Js 中的 this 值來進行理解,只有在調用時才知道 this 值的指向,動態作用域類型語言中的所有變量都是以這種形式來確定。動態作用域在現代編程語言中較少見,在某些早期語言中比如 Lisp

其實這兩種作用域的區別主要是作用域中的變量和函數,是在編譯期還是運行期確定的,從詞法分析角度講,如果是通過靜態詞法分析而得出的時候,它就被稱為詞法作用域:

JavaScript 中就採用的是詞法作用域(靜態作用域),下面就來詳細看看:

二、全局作用域和函數作用域

從範圍上分,JavaScript中的作用域有三種:全局作用域、函數作用域和塊級作用域。我們先來聊聊全局作用域和函數作用域:

1.全局作用域

In a programming environment, the global scope is the scope that contains, and is visible in, all other scopes. In client-sideJavaScript, the global scope is generally the web page inside which all the code is being executed.

也就是説在一個程序運行環境中,全局作用域指的是能看見的代碼全部及其他的作用域;在內置JS代碼中,全局作用域是指所被執行js代碼的全部區域。其生命週期伴隨着頁面的生命週期

  • Web瀏覽器中,全局作用域是window對象,所有的變量和函數是作為其方法和屬性創建:

    var test = 1000;
    console.log(test);//1000
    //全局作用域中定義的變量可以通過,全局對象調用屬性的方式來獲取
    console.log(window.test);//1000
  • Node環境中,全局作用域是global對象:

    var y = 200;
    console.log(y);//200
    console.log(global);//global
    //在node環境中,全局作用域中的變量不會自動成為global對象的屬性
    console.log(global.y);//undefined

    2.函數作用域

    函數作用域是指:聲明在函數體內的所有變量和函數都是始終可見的,只能在函數內部訪問,其他作用域則無法訪問,在函數執行結束後,其內部定義的變量會被銷燬。

    function test() {
      var num1 = 100;
      var num2 = 1000;
      if(num1 === 100){
          console.log(num2);
      }
      function test2(){
          console.log(num2);
      }
      test2();
    }
    test();//輸出:1000 1000
    //全局作用域無法訪問函數作用域中的變量
    console.log(num2);//Uncaught ReferenceError: num2 is not defined

    3.作用域鏈

    關於作用域鏈,要從調用棧中的執行上下文棧説起,詳情可以看我的這篇文章:ECMAScript下的執行上下文。其中 ECMAScript 6 規範用 Lexcical Environment(詞法環境)、Environment Record(環境記錄項) 來描述詞法和運行期環境。一個詞法環境由環境記錄項和指向外部詞法環境的 outer引用值組成
    比如對於以下嵌套函數,其作用域鏈是:foo3->foo2->foo1->global,該作用域鏈是在代碼寫好後就確定了,與是否調用函數或者代碼執行無關。

    function foo1() {
    //...
    function foo2() {
    //...
      function foo3() {
      //...
    }
     }  
    }

    image.png
    用 ES6 規範描述如下:

    //全局上下文
    GlobalExectionContext = {
     //詞法環境
    LexicalEnvironment: {
      EnvironmentRecord: {
        ...
      }
      outerEnv: <null>,
      }
    }
    //foo1函數上下文
    foo1ExectionContext = {
      LexicalEnvironment: {
              EnvironmentRecord: {
                  ...
              },
              outerEnv: <GlobalLexicalEnvironment>,
          },
    }
    //foo2函數上下文
    foo2ExectionContext = {
      LexicalEnvironment: {
              EnvironmentRecord: {
                  ...
              },
              outerEnv: <foo1ExectionContext>,
          },
    }
    //foo3函數上下文
    foo2ExectionContext = {
      LexicalEnvironment: {
              EnvironmentRecord: {
                  ...
              },
              outerEnv: <foo2ExectionContext>,
          },
    }

    我們以這個例子為例講解

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>作用域</title>
    </head>
    <body>
      <div>作用域鏈</div>
      <button onclick="foo1()">點擊:</button>
      <script>
          var globalName = "global";
          function foo1() {
              debugger
              var foo1Name = "foo1";
              console.log(globalName);//global
              function foo2() {
                  var foo2Name = "foo2";
                  debugger
                  console.log(globalName);//global
                  console.log(foo1Name); //foo1
                  function foo3() {
                      debugger
                      console.log(globalName);//global
                      console.log(foo1Name);//foo1
                      console.log(foo2Name);//foo2
                  }
                  foo3();
              }
              foo2();  
          }
      </script>
    </body> 
    </html>
  • foo1()函數作用域中沒有 globalName 字段時,會順着作用域鏈foo3->foo2->foo1->global去尋找其他作用域是否有該 globalName 字段
  • foo2()函數作用域中沒有 globalNamefoo1Name 字段 時,則沿着作用鏈從 foo1()函數和全局作用域中尋找。foo3 函數作用域和 foo2 類似

可以用 Chrome DevTools 中的 Scope 面板來演示上例代碼在執行過程中的的作用域變化:
再次強調一下,當前代碼的作用域和作用域鏈在代碼寫好就已經確定,與是否執行代碼和函數調用無關。這裏只是為了展示作用域,而採用調用的方式,通過執行上下文查看其對應的作用域:

  • 當代碼執行到 foo1() 時,調用棧(Call Stack)的棧頂是 foo1()函數執行上下文,其作用域是 Scope 中的 Local 作用域:

image.png

  • 當代碼執行到 foo2()時, 調用棧(Call Stack)的棧頂是 foo2()函數執行上下文,其作用域是 Scope 中的 Local 作用域,Local 下面的 Closure(foo1)實際上就是閉包,此時foo2()中函數的 console.log(foolName)foo1() 中的 foo1Name 字段

image.png

  • 當代碼執行到 foo3()時, 調用棧(Call Stack)的棧頂是 foo3()函數執行上下文,其作用域是 Scope 中的 Local 作用域,當前作用域沒有變量,其打印的變量需要從其他作用域中獲取,同樣是沿着作用域鏈查找:

image.png
除了全局作用域和函數作用域,在 JavaScript 中還存在塊級作用域,比如被花括號 { }包圍的代碼語句

// try-catch語句
try {  
    // 作用域1
}catch (e) { 
    // 表達式e位於作用域2
    // 作用域2
}finally { 
    // 作用域3
}

// with語句
//(注:沒有使用大括號)
with (x) /* 作用域1 */; 
// <- 這裏存在一個塊級作用域

// 塊語句
{  
    // 作用域1
}

三、塊級作用域

在 JavaScript 早期設計中, 絕大多數語句中是沒有塊級作用域的,變量所處的作用域只有全局和函數作用域兩種,比如下面的 for 語句

//此時變量都處在全局作用域中
for(var x = 4; x < 10; x++) {
    console.log("inner", x);//1~9
}
//在外部作用域中打印出x值
console.log("outer", x);//10

ES6 後增加 letconst關鍵字,讓 JavaScript 語言中擁有了塊級作用域。但是有兩個特例:

兩個擁有塊作用域的語句

絕大多數語句中是沒有塊級作用域,但是有兩個語句例外:

  • with 語句:() 後的部分存在一個塊級作用域
  • try/catch 語句: catch 分句會創建一個塊級作用域

    with 語句

    已棄用: 不再推薦使用該特性。ECMAScript 5 中該標籤已被禁止
語法
//expression: 將給定的表達式添加到在評估語句時使用的作用域鏈上。
//expression 周圍的括號是必需的。expression是對象
with (expression) 
    //任何語句 這裏存在一個塊級作用域
  statement
JavaScript 查找某個未使用命名空間的變量時,會通過作用域鏈來查找,作用域鏈是跟執行代碼的上下文或者包含這個變量的函數有關。'with'語句將某個對象添加到作用域鏈的頂部,如果在 statement 中有某個未使用命名空間的變量,跟作用域鏈中的某個屬性同名,則這個變量將指向這個屬性值。如果沒有同名的屬性,則將拋出ReferenceError異常。

比如下面的 with語句指定 Math對象作為默認對象:

var a, x, y;
var r = 10;
//PI,cos 和 sin 函數都是 Math 對象內部的函數
//因此不用在前面添加命名空間,後續所有引用都指向 math 對象
with (Math) {
  a = PI * r * r;
  x = r * cos(PI);
  y = r * sin(PI / 2);
}

我們在 Chrome Devtools 中查看 with 塊作用域:
image.png

  • 此時代碼執行到 with 語句內部,調用棧中是全局執行上下文,當前所在作用域是 with 塊作用域
  • 再來看看 with 塊作用域中有什麼參數和內部函數:

image.png
發現不僅 PI,還有其他參數值和內部函數都在 with 塊作用域內部。

缺陷
  • with 中的變量聲明會被添加到外層作用域中:

    var a = {};
    with(a) {
      var x = "name";
    }
    console.log(x);//name
  • 語義不明,參數查找混亂

當對象名和對象中的參數相同時,會出現變量查找混亂的現象:

var a = {name: "a"};
var obj = { obj: "obj"};
function foo(obj) {
    with(obj) {
        console.log(obj);
    }
}
//打印成功,輸出對象
foo(a);//{name: 'a'}
//當對象中的參數和對象名相同時,輸出出現混亂
foo(obj);//obj

try/catch 語句

try語句包含了由一個或者多個語句組成的try塊,和至少一個catch塊或者一個finally塊的其中一個,或者兩個兼有,下面是三種形式的try聲明:

  • try...catch
  • try...finally
  • try...catch...finally

finally子句在try塊和catch塊之後執行但是在下一個try聲明之前執行。無論是否有異常拋出或捕獲它總是執行。
ES3 規範中規定 try/catch 的 catch 分句會創建一個塊作用域,其中聲明的變量僅在 catch 內部有效

比如:

try {
    throw 2;
} catch(a) {
    console.log(a); //2
}
console.log(a);//Uncaught ReferenceError: a is not defined

在 Devtools 中查看其作用域:
image.png

let/const 關鍵字 + {}

ES6 之前,JavaScript 的作用域只有全局作用域和函數作用域兩種。塊級作用域就是用花括號 {} 包裹的代碼,比如判斷、循環語句。在 JavaScript 中,是通過使用 letconst 關鍵字+ {} 來實現塊級作用域的,比如:

{
    var str = "block scope";
    let str1 = "lexical scope";
}
//沒有使用let/const關鍵字前,相當於全局作用域中一部分,不存在塊級作用域
console.log(str);// block scope
//使用let後,全局作用域無法訪問塊級作用域內容
console.log(str1); // Uncaught ReferenceError: str1 is not defined

在 DevTools 中查看 {} 中變量的作用域:
image.png

  • var 聲明的變量仍然在全局作用域中
  • let 聲明的變量在塊作用域 block 中

那麼為何letvar聲明變量在不同的作用域之中呢?咱們從執行上下文的角度來分析:
首先在調用函數或者初始化創建全局上下文時(這裏忽略變量提升問題):

  • 若有 let/const 聲明的變量時,代碼一邊執行,一邊會將這些變量壓入 LexicalEnvironment標識符指向的詞法環境中
  • 若有 var聲明變量時,代碼會一邊執行,一邊會將變量壓入 VariableEnvironment標識符指向的詞法環境中

image.png
注意:兩個標識符指向的都是詞法環境,在執行上下文創建時,兩個標識符初始值指向的是同一個詞法環境

關於執行上下文中的詞法環境和詞法環境記錄項,可以看我這篇文章:從 ECMAScript 6 角度談談執行上下文
在詞法環境內部,相當於一個棧結構,棧頂元素是該作用域最末尾聲明的值。具體查找順序是:
  • 在當前執行上下文中,先對LexicalEnvironment標識符指向詞法環境按照棧頂->棧底順序查找。
  • 在當前執行上下文中,若LexicalEnvironment環境中找不到,則在 VariableEnvironment標識符指向的詞法環境中繼續按照棧的順序查找
  • 如果當前執行上下文中還是找不到,沿着作用域鏈方向依次向外部作用域中的詞法環境中查找,並循環上面兩步

對於如下代碼:

var a = 1;
let b = 2;
function foo(){
    var c = 3;
    let d = 4;
    {
        var e = 5;
        let f = 6;
        console.log(a);//1
    }
}
foo();

如下圖所示,在執行到 console.log(a);這一行時整個尋找路徑是:
image.png

  • 首先 foo 函數執行上下文中,按照詞法->變量環境後進先出順序查找變量
  • 如果還查找不到,就沿着作用域鏈方向到外部執行上下文中尋找

所以塊級作用域就是通過詞法環境標識符指向的棧結構實現的;而變量提升則是將作用域中的 var 聲明變量提前,放在變量環境標識符指向的環境中,並設置成默認值。
講完塊級作用域,咱們來談談閉包

四、閉包

1.什麼是閉包

先來看看 MDN 中閉包的定義:

閉包(closure)是一個函數以及其捆綁的周邊環境狀態(lexical environment,詞法環境)的引用的組合。換而言之,閉包讓開發者可以從內部函數訪問外部函數的作用域。在 JavaScript 中,閉包會隨着函數的創建而被同時創建

再來看看《JavaScript 高級程序設計(第 4 版)》怎麼説的:

閉包指的是哪些引用了另一個函數作用域中變量的函數,通常是在嵌套函數中實現的

所以説閉包的組成部分由:

  • 函數:必須是一個函數,普通函數和匿名函數均可
  • 環境:該函數的詞法環境引用(包括內部變量和外部函數變量的引用)

比如下面例子

function foo1() {
  var name = "foo1"; 
    //此時該內部函數和引用變量的詞法環境共同成為
  function foo2() { //引用外層作用域變量,此時foo2就是一個閉包
    console.log(name); 
  }
  foo2();
}
foo1();

2. 閉包類型

內部函數執行和返回閉包

function foo1() {
  var name = "foo1"; 
    //此時該內部函數和引用變量的詞法環境共同成為
  function foo2() { //引用外層作用域變量,此時foo2就是一個閉包
    console.log(name); 
  }
  foo2();
}
foo1();

當內部 foo2 函數引用外部 foo1 函數中的變量時,閉包並沒有形成。只有當內部函數 foo2 執行後,才會觸發引用外部函數變量操作,形成引用外部作用域的詞法環境,最後形成閉包。
image.png
上面的閉包會隨着 foo1 函數執行完畢而關閉,那麼閉包有可能在外部作用域關閉後繼續存在嗎?下面再來看看一種變體:

function foo1() {
  var name = "foo1"; 
  function foo2() { 
    console.log(name); 
  }
  return foo2;
}
var closure = foo1();
closure();
var test = "test";

這種變體將內部函數 foo2 作為變量返回,然後用引用變量 closure 指向該閉包,哪怕 foo1 執行完畢,因為引用變量 closure 在全局作用域上,這個閉包會一直存在,直到全局執行上下文銷燬。
哪怕執行到 var test = "test",仍然能在全局作用域中找到閉包:
image.png

回調函數閉包

回調函數比較常見,回調函數引用外層作用域的變量時,閉包就產生了,比如:

function delayedExecution() {
  var count = 0;
    
  setTimeout(function() {
    console.log('Count:', count);
  }, 1000);

  count++;
}
//閉包函數在延遲1秒後執行,並輸出 Count: 0。
//這是因為在閉包函數執行時,count 的值已經被捕獲並保存在閉包中
delayedExecution();

在 JS 引擎的內置函數 setTimeout()函數中,會持有一個函數參數的引用,在經過一定時間後,JS 引擎會自動調用該函數,而該函數因為引用了 delayedExecution()中的 count,所以會形成一個閉包。
這種情況和第一種不同支出在於,其閉包執行實際上是由 JS 引擎來完成的

事件處理函數閉包

當將一個閉包作為事件處理函數綁定到 DOM 元素上時,閉包會在事件觸發時執行。閉包可以訪問綁定事件函數時所在的作用域中的變量和參數。

function createButton() {
  var message = 'Button clicked';

  var button = document.createElement('button');
  button.innerText = '請點擊此處';
    //閉包被添加到DOM上
  button.addEventListener('click', function() {
    console.log(message);
  });

  document.body.appendChild(button);
}

createButton();//點擊按鈕後會出現 Button clicked

image.png

高階函數中的閉包

比如在函數柯里化(柯里化是一種將一個接受多個參數的函數轉化為一系列只接受單個參數的函數的技術)中,也有閉包的身影:

function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}
//add5 和 add10 都是閉包
//它們共享相同的函數定義,但是保存不同的詞法環境:
//在 add5 的環境中,x 為 5。而在 add10 中,x 則為 10
var add5 = makeAdder(5); 
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

2.閉包的回收

閉包的回收就涉及到 JS 引擎中的垃圾回收了,可以下次再開一篇文章講解 JS 的垃圾回收機制。對於閉包來説,主要有兩種情況:

  • 局部變量引用閉包
  • 全局變量引用閉包

如果引用閉包的變量是一個局部變量,等函數執行完畢後,這個局部變量也隨之銷燬。下次 JS 引擎在執行垃圾回收時,會判斷該閉包是否被引用,如果不被引用了,就會將這塊閉包的內存進行回收
若是全局變量,則只能等到全局執行上下文關閉後,JS 引擎才能回收這部分閉包占用的內存。所以在全局上引用閉包時,需要注意未來可能造成內存泄露的問題。

小結

下面再來回顧一下文章的整體內容:

  1. JavaScript 採用靜態作用域,主要包括全局作用域、函數作用域和塊級作用域三種

    • 全局作用域在網頁或Node中;
    • 函數作用域內部變量不影響外部;
    • ES6引入letconst後,使用它們與{} 可以產生塊級作用域,ES6之前有withtry/catch 語句可以產生塊級作用域
  2. 作用域鏈的順序在代碼寫好就已經確定,當JS引擎在訪問變量時,

    • 先沿着作用域順序查找執行上下文,
    • 然後在執行上下文中,按照先詞法環境後變量環境的順序查找
    • 如果當前執行上下文查找不到,則繼續按照作用域順序查找其他執行上下文,直到作用域鏈末尾
  3. 閉包實際上是函數和其綁定詞法環境引用的組合,當外部函數結束,內部函數還在引用外部函數變量時,就形成了閉包,常見的閉包的產生有下列情形:

    • 回調函數、嵌套函數中內部函數引用返回、事件處理函數閉包、高階函數閉包等等
  4. 閉包的回收涉及到JS的垃圾回收機制,主要分為兩種:

    • 全局變量引用閉包時,其生命週期會隨全局週期而存亡
    • 局部變量引用閉包時,其生命週期隨外部函數執行上下文週期

參考文章

https://262.ecma-international.org/6.0/

with - JavaScript | MDN

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures

深入JavaScript with語句

09 | 塊級作用域:var缺陷以及為什麼要引入let和const

[從 ECMAScript 6 角度談談執行上下文]

user avatar toopoo 头像 dingtongya 头像 cyzf 头像 zaotalk 头像 linlinma 头像 nihaojob 头像 freeman_tian 头像 qingzhan 头像 kobe_fans_zxc 头像 dirackeeko 头像 aqiongbei 头像 chongdianqishi 头像
点赞 254 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.