面試官:說說做用域和閉包吧

幾乎全部語言的最基礎模型之一就是在變量中存儲值,而且在稍後取出或修改這些值。在變量中存儲值和取出值的能力,給程序賦予了狀態。這就引申出兩個問題:這些變量被存儲在哪裏?程序如何在須要的時候找到它們?回答這些問題須要一組明肯定義的規則,它定義瞭如何存儲變量,以及如何找到這些變量。咱們稱這組規則爲:做用域。javascript

LHS 和 RHS 查詢

在說 javascript 中的做用域以前,我想應該先了解一下 LHS 和 RHS 查詢,這對於做用域的理解有所幫助。 前端

雖然 javascript 被認爲是一門解釋型語言/動態語言,可是它實際上是一種編譯型的語言。通常來講,須要運行一段 javascript 代碼,有兩個必不可少的東西:JS 引擎編譯器。前者相似於總管的角色,負責整個程序運行時所需的各類資源的調度;後者只是前者的一部分,負責將 javascript 源碼編譯成機器能識別的機器指令,而後交給引擎運行。java

編譯

javascript 中,一段源碼在被執行以前大概會經歷如下三個步驟,這也被稱之爲 編譯node

  1. 分詞 / 詞法分析:編譯器會先將一連串字符打斷成(對於語言來講)有意義的片斷,稱爲 token(記號),例如 var a = 2;。這段程序極可能會被打斷成以下 token:vara=2,和 ;
  2. 解析 / 語法分析:編譯器將一個 token 的流(數組)轉換爲一個「抽象語法樹」(AST —— Abstract Syntax Tree),它表示了程序的語法結構。
  3. 代碼生成:編譯器將上一步中生成的抽象語法樹轉換爲機器指令,等待引擎執行。

執行

編譯器一頓操做猛如虎,生成了一堆機器指令,JS 引擎開心地拿到這堆指令,開始執行,這個時候咱們要說的 LHSRHS 就登場了。jquery

LHS (Left-hand Side)RHS (Right-hand Side) ,是在代碼執行階段 JS 引擎操做變量的兩種方式,兩者區別就是對變量的查詢目的是 變量賦值 仍是 查詢webpack

LHS 能夠理解爲變量在賦值操做符(=)的左側,例如 a = 1,當前引擎對變量 a 查找的目的是變量賦值。這種狀況下,引擎不關心變量 a 原始值是什麼,只管將值 1 賦給 a 變量。git

RHS 能夠理解爲變量在賦值操做符(=)的右側,例如:console.log(a),其中引擎對變量a的查找目的就是 查詢,它須要找到變量 a 對應的實際值是什麼,而後才能將它打印出來。es6

來看下面這段代碼:github

var a = 2; // LHS 查詢
複製代碼

這段代碼運行時,引擎作了一個 LHS 查詢,找到 a ,並把新值 2 賦給它。再看下面一段:web

function foo(a) { // LHS 查詢
  console.log( a ); // RHS 查詢
}

foo( 2 ); // RHS 查詢
複製代碼

爲了執行它,JS 引擎既作了 LHS 查詢又作了 RHS 查詢,只不過這裏的 LHS 比較難發現。

總之,引擎想對變量進行獲取 / 賦值,就離不開 LHSRHS ,然而這兩個操做只是手段,到哪裏去獲取變量纔是關鍵。LHSRHS 獲取變量的位置就是 做用域

什麼是做用域

簡單來講,做用域 指程序中定義變量的區域,它決定了當前執行代碼對變量的訪問權限。

javascript 中大部分狀況下,只有兩種做用域類型:

  • 全局做用域:全局做用域爲程序的最外層做用域,一直存在。
  • 函數做用域:函數做用域只有函數被定義時纔會建立,包含在父級函數做用域 / 全局做用域內。

因爲做用域的限制,每段獨立的執行代碼塊只能訪問本身做用域和外層做用域中的變量,沒法訪問到內層做用域的變量。

/* 全局做用域開始 */
var a = 1;

function func () { /* func 函數做用域開始 */
  var a = 2;
  console.log(a);
}                  /* func 函數做用域結束 */

func(); // => 2

console.log(a); // => 1

/* 全局做用域結束 */
複製代碼

做用域鏈

上面代碼示範中,可執行代碼塊是可以在本身的做用域中找到變量的,那麼若是在本身的做用域中找不到目標變量,程序可否正常運行?來看下面的代碼:

function foo(a) {
  var b = a * 2;

  function bar(c) {
    console.log( a, b, c );
  }

  bar(b * 3);
}

foo(2); // 2 4 12
複製代碼

結合前面的知識咱們知道,在 bar 函數內部,會作三次 RHS 查詢從而分別獲取到 a b c 三個變量的值。bar 內部做用域中只能獲取到變量 c 的值,ab 都是從外部 foo 函數的做用域中獲取到的。

當可執行代碼內部訪問變量時,會先查找本地做用域,若是找到目標變量即返回,不然會去父級做用域繼續查找...一直找到全局做用域。咱們把這種做用域的嵌套機制,稱爲 做用域鏈。

用圖片表示,上述代碼一共有三層做用域嵌套,分別是:

  1. 全局做用域
  2. foo 做用域
  3. bar 做用域

須要注意,函數參數也在函數做用域中。

詞法做用域

明白了做用域和做用域鏈的概念,咱們來看詞法做用域。

詞法做用域Lexical Scopes)是 javascript 中使用的做用域類型,詞法做用域 也能夠被叫作 靜態做用域,與之相對的還有 動態做用域。那麼 javascript 使用的 詞法做用域動態做用域 的區別是什麼呢?看下面這段代碼:

var value = 1;

function foo() {
  console.log(value);
}

function bar() {
  var value = 2;
  foo();
}

bar();

// 結果是 ???
複製代碼

上面這段代碼中,一共有三個做用域:

  • 全局做用域
  • foo 的函數做用域
  • bar 的函數做用域

一直到這邊都好理解,但是 foo 裏訪問了本地做用域中沒有的變量 value 。根據前面說的,引擎爲了拿到這個變量就要去 foo 的上層做用域查詢,那麼 foo 的上層做用域是什麼呢?是它 調用時 所在的 bar 做用域?仍是它 定義時 所在的全局做用域?

這個關鍵的問題就是 javascript 中的做用域類型——詞法做用域。

詞法做用域,就意味着函數被定義的時候,它的做用域就已經肯定了,和拿到哪裏執行沒有關係,所以詞法做用域也被稱爲 「靜態做用域」。

若是是動態做用域類型,那麼上面的代碼運行結果應該是 bar 做用域中的 2 。也許你會好奇什麼語言是動態做用域?bash 就是動態做用域,感興趣的小夥伴能夠了解一下。

塊級做用域

什麼是塊級做用域呢?簡單來講,花括號內 {...} 的區域就是塊級做用域區域。

不少語言自己都是支持塊級做用域的。上面咱們說,javascript 中大部分狀況下,只有兩種做用域類型:全局做用域函數做用域,那麼 javascript 中有沒有塊級做用域呢?來看下面的代碼:

if (true) {
  var a = 1;
}

console.log(a); // 結果???
複製代碼

運行後會發現,結果仍是 1,花括號內定義並賦值的 a 變量跑到全局了。這足以說明,javascript 不是原生支持塊級做用域的,起碼創做者創造這門語言的時候壓根就沒把塊級做用域的事情考慮進去...(出來背鍋!!)

可是 ES6 標準提出了使用 letconst 代替 var 關鍵字,來「建立塊級做用域」。也就是說,上述代碼改爲以下方式,塊級做用域是有效的:

if (true) {
  let a = 1;
}

console.log(a); // ReferenceError
複製代碼

關於 letconst 的更多細節,進入 傳送門

建立做用域

javascript 中,咱們有幾種建立 / 改變做用域的手段:

  1. 定義函數,建立函數做用(推薦):

    function foo () {
      // 建立了一個 foo 的函數做用域
    }
    複製代碼
  2. 使用 letconst 建立塊級做用域(推薦):

    for (let i = 0; i < 5; i++) {
      console.log(i);
    }
    
    console.log(i); // ReferenceError
    複製代碼
  3. try catch 建立做用域(不推薦),err 僅存在於 catch 子句中:

    try {
     undefined(); // 強制產生異常
    }
    catch (err) {
     console.log( err ); // TypeError: undefined is not a function
    }
    
    console.log( err ); // ReferenceError: `err` not found
    複製代碼
  4. 使用 eval 「欺騙」 詞法做用域(不推薦):

    function foo(str, a) {
     eval( str );
     console.log( a, b );
    }
    
    var b = 2;
    
    foo( "var b = 3;", 1 ); // 1 3
    複製代碼
  5. 使用 with 欺騙詞法做用域(不推薦):

    function foo(obj) {
     with (obj) {
       a = 2;
     }
    }
    
    var o1 = {
     a: 3
    };
    
    var o2 = {
     b: 3
    };
    
    foo( o1 );
    console.log( o1.a ); // 2
    
    foo( o2 );
    console.log( o2.a ); // undefined
    console.log( a ); // 2 -- 全局做用域被泄漏了!
    複製代碼

總結下來,可以使用的建立做用域的方式就兩種:定義函數建立 和 let const 建立。

做用域的應用場景

做用域的一個常見運用場景之一,就是 模塊化

因爲 javascript 並未原生支持模塊化致使了不少使人口吐芬芳的問題,好比全局做用域污染和變量名衝突,代碼結構臃腫且複用性不高。在正式的模塊化方案出臺以前,開發者爲了解決這類問題,想到了使用函數做用域來建立模塊的方案。

function module1 () {
  var a = 1;
  console.log(a);
}

function module2 () {
  var a = 2;
  console.log(a);
}

module1(); // => 1
module2(); // => 2
複製代碼

上面的代碼中,構建了 module1module2 兩個表明模塊的函數,兩個函數內分別定義了一個同名變量 a ,因爲函數做用域的隔離性質,這兩個變量被保存在不一樣的做用域中(不嵌套),JS 引擎在執行這兩個函數時會去不一樣的做用域中讀取,而且外部做用域沒法訪問到函數內部的 a 變量。這樣一來就巧妙地解決了 全局做用域污染變量名衝突 的問題;而且,因爲函數的包裹寫法,這種方式看起來封裝性好多了。

然而上面的函數聲明式寫法,看起來仍是有些冗餘,更重要的是,module1module2 的函數名自己就已經對全局做用域形成了污染。咱們來繼續改寫:

// module1.js
(function () {
  var a = 1;
  console.log(a);
})();

// module2.js
(function () {
  var a = 2;
  console.log(a);
})();
複製代碼

將函數聲明改寫成 當即調用函數表達式(Immediately Invoked Function Expression 簡寫 IIFE),封裝性更好,代碼也更簡潔,解決了模塊名污染全局做用域的問題。

函數聲明和函數表達式,最簡單的區分方法,就是看是否是 function 關鍵字開頭:是 function 開頭的就是函數聲明,不然就是函數表達式。

上面的代碼採用了 IIFE 的寫法,已經進化不少了,咱們能夠再把它強化一下,強化成後浪版,賦予它判斷外部環境的權利——選擇的權力

(function (global) {
  if (global...) {
    // is browser
  } else if (global...) {
    // is nodejs
  }
})(window);
複製代碼

讓後浪繼續奔涌,咱們的想象力不足以想象 UMD 模塊化的代碼:

// UMD 模塊化
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS-like
    module.exports = factory(require('jquery'));
  } else {
    // Browser globals (root is window)
    root.returnExports = factory(root.jQuery);
  }
}(this, function ($) {
  // methods
  function myFunc(){};

  // exposed public method
  return myFunc;
}));
複製代碼

我看着做用域的模塊化應用場景,真的是滿懷羨慕。若是你也和我同樣羨慕而且,想了解更多關於模塊化的東西,請進入 傳送門

閉包

說完了做用域,咱們來講說 閉包

可以訪問其餘函數內部變量的函數,被稱爲 閉包

上面這個定義比較難理解,簡單來講,閉包就是函數內部定義的函數,被返回了出去並在外部調用。咱們能夠用代碼來表述一下:

function foo() {
  var a = 2;

  function bar() {
    console.log( a );
  }

  return bar;
}

var baz = foo();

baz(); // 這就造成了一個閉包
複製代碼

咱們能夠簡單剖析一下上面代碼的運行流程:

  1. 編譯階段,變量和函數被聲明,做用域即被肯定。
  2. 運行函數 foo(),此時會建立一個 foo 函數的執行上下文,執行上下文內部存儲了 foo 中聲明的全部變量函數信息。
  3. 函數 foo 運行完畢,將內部函數 bar 的引用賦值給外部的變量 baz ,此時 baz 指針指向的仍是 bar ,所以哪怕它位於 foo 做用域以外,它仍是可以獲取到 foo 的內部變量。
  4. baz 在外部被執行,baz 的內部可執行代碼 console.log 向做用域請求獲取 a 變量,本地做用域沒有找到,繼續請求父級做用域,找到了 foo 中的 a 變量,返回給 console.log,打印出 2

閉包的執行看起來像是開發者使用的一個小小的 「做弊手段」 ——繞過了做用域的監管機制,從外部也能獲取到內部做用域的信息。閉包的這一特性極大地豐富了開發人員的編碼方式,也提供了不少有效的運用場景。

閉包的應用場景

閉包的應用,大多數是在須要維護內部變量的場景下。

單例模式

單例模式是一種常見的涉及模式,它保證了一個類只有一個實例。實現方法通常是先判斷實例是否存在,若是存在就直接返回,不然就建立了再返回。單例模式的好處就是避免了重複實例化帶來的內存開銷:

// 單例模式
function Singleton(){
  this.data = 'singleton';
}

Singleton.getInstance = (function () {
  var instance;
    
  return function(){
    if (instance) {
      return instance;
    } else {
      instance = new Singleton();
      return instance;
    }
  }
})();

var sa = Singleton.getInstance();
var sb = Singleton.getInstance();
console.log(sa === sb); // true
console.log(sa.data); // 'singleton'
複製代碼

模擬私有屬性

javascript 沒有 java 中那種 public private 的訪問權限控制,對象中的所用方法和屬性都可以訪問,這就形成了安全隱患,內部的屬性任何開發者均可以隨意修改。雖然語言層面不支持私有屬性的建立,可是咱們能夠用閉包的手段來模擬出私有屬性:

// 模擬私有屬性
function getGeneratorFunc () {
  var _name = 'John';
  var _age = 22;
    
  return function () {
    return {
      getName: function () {return _name;},
      getAge: function() {return _age;}
    };
  };
}

var obj = getGeneratorFunc()();
obj.getName(); // John
obj.getAge(); // 22
obj._age; // undefined
複製代碼

柯里化

柯里化(currying),是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,而且返回接受餘下的參數並且返回結果的新函數的技術。

這個概念有點抽象,實際上柯里化是高階函數的一個用法,javascript 中常見的 bind 方法就能夠用柯里化的方法來實現:

Function.prototype.myBind = function (context = window) {
    if (typeof this !== 'function') throw new Error('Error');
    let selfFunc = this;
    let args = [...arguments].slice(1);
    
    return function F () {
        // 由於返回了一個函數,能夠 new F(),因此須要判斷
        if (this instanceof F) {
            return new selfFunc(...args, arguments);
        } else  {
            // bind 能夠實現相似這樣的代碼 f.bind(obj, 1)(2),因此須要將兩邊的參數拼接起來
            return selfFunc.apply(context, args.concat(arguments));
        }
    }
}
複製代碼

柯里化的優點之一就是 參數的複用,它能夠在傳入參數的基礎上生成另外一個全新的函數,來看下面這個類型判斷函數:

function typeOf (value) {
    return function (obj) {
        const toString = Object.prototype.toString;
        const map = {
            '[object Boolean]'	 : 'boolean',
            '[object Number]' 	 : 'number',
            '[object String]' 	 : 'string',
            '[object Function]'  : 'function',
            '[object Array]'     : 'array',
            '[object Date]'      : 'date',
            '[object RegExp]'    : 'regExp',
            '[object Undefined]' : 'undefined',
            '[object Null]'      : 'null',
            '[object Object]' 	 : 'object'
        };
        return map[toString.call(obj)] === value;
    }
}

var isNumber = typeOf('number');
var isFunction = typeOf('function');
var isRegExp = typeOf('regExp');

isNumber(0); // => true
isFunction(function () {}); // true
isRegExp({}); // => false
複製代碼

經過向 typeOf 裏傳入不一樣的類型字符串參數,就能夠生成對應的類型判斷函數,做爲語法糖在業務代碼裏重複使用。

閉包的問題

從上面的介紹中咱們能夠得知,閉包的使用場景很是普遍,那咱們是否是能夠大量使用閉包呢?不能夠,由於閉包過分使用會致使性能問題,仍是看以前演示的一段代碼:

function foo() {
  var a = 2;

  function bar() {
    console.log( a );
  }

  return bar;
}

var baz = foo();

baz(); // 這就造成了一個閉包
複製代碼

乍一看,好像沒什麼問題,然而,它卻有可能致使 內存泄露

咱們知道,javascript 內部的垃圾回收機制用的是引用計數收集:即當內存中的一個變量被引用一次,計數就加一。垃圾回收機制會以固定的時間輪詢這些變量,將計數爲 0 的變量標記爲失效變量並將之清除從而釋放內存。

上述代碼中,理論上來講, foo 函數做用域隔絕了外部環境,全部變量引用都在函數內部完成,foo 運行完成之後,內部的變量就應該被銷燬,內存被回收。然而閉包致使了全局做用域始終存在一個 baz 的變量在引用着 foo 內部的 bar 函數,這就意味着 foo 內部定義的 bar 函數引用數始終爲 1,垃圾運行機制就沒法把它銷燬。更糟糕的是,bar 有可能還要使用到父做用域 foo 中的變量信息,那它們天然也不能被銷燬... JS 引擎沒法判斷你何時還會調用閉包函數,只能一直讓這些數據佔用着內存。

這種因爲閉包使用過分而致使的內存佔用沒法釋放的狀況,咱們稱之爲:內存泄露。

內存泄露

內存泄露 是指當一塊內存再也不被應用程序使用的時候,因爲某種緣由,這塊內存沒有返還給操做系統或者內存池的現象。內存泄漏可能會致使應用程序卡頓或者崩潰。

形成內存泄露的緣由有不少,除了閉包之外,還有 全局變量的無心建立。開發者的本意是想將變量做爲局部變量使用,然而忘記寫 var 致使變量被泄露到全局中:

function foo() {
    b = 2;
    console.log(b);
}

foo(); // 2

console.log(b); // 2
複製代碼

還有 DOM 的事件綁定,移除 DOM 元素前若是忘記了註銷掉其中綁定的事件方法,也會形成內存泄露:

const wrapDOM = document.getElementById('wrap');
wrapDOM.onclick = function (e) {console.log(e);};

// some codes ...

// remove wrapDOM
wrapDOM.parentNode.removeChild(wrapDOM);
複製代碼

內存泄露的排查手段

可能你們都聽過臭名昭著的 「內存泄露」,然而面對茫茫祖傳代碼,如何找到形成內存泄露的地方,卻讓人無從下手。這邊咱們仍是藉助谷歌的開發者工具, Chrome 瀏覽器,F12 打開開發者工具,我找了阮一峯老師的 ES6 網站演示。

Performance

點擊這個按鈕啓動記錄,而後切換到網頁進行操做,錄製完成後點擊 stop 按鈕,開發者工具會從錄製時刻開始記錄當前應用的各項數據狀況。

選中JS Heap,下面展示出來的一條藍線,就是表明了這段記錄過程當中,JS 堆內存信息的變化狀況。

有大佬說,根據這條藍線就能夠判斷是否存在內存泄漏的狀況:若是這條藍線一直成上升趨勢,那基本就是內存泄漏了。其實我以爲這麼講有失偏頗,JS 堆內存佔用率上升並不必定就是內存泄漏,只能說明有不少未被釋放的內存而已,至於這些內存是否真的在使用,仍是說確實是內存泄漏,還須要進一步排查。

memory

藉助開發者工具的 Memory 選項,能夠更精確地定位內存使用狀況。

當生成了第一個快照的時候,開發者工具窗口已經顯示了很詳細的內存佔用狀況。

字段解釋:

  • Constructor — 佔用內存的資源類型
  • Distance — 當前對象到根的引用層級距離
  • Shallow Size — 對象所佔內存(不包含內部引用的其它對象所佔的內存)(單位:字節)
  • Retained Size — 對象所佔總內存(包含內部引用的其它對象所佔的內存)(單位:字節)

將每項展開能夠查看更詳細的數據信息。

咱們再次切回網頁,繼續操做幾回,而後再次生成一個快照。

這邊須要特別注意這個 #Delta ,若是是正值,就表明新生成的內存多,釋放的內存少。其中的閉包項,若是是正值,就說明存在內存泄漏。

下面咱們到代碼裏找一個內存泄漏的問題:

內存泄露的解決方案

  1. 使用嚴格模式,避免不經意間的全局變量泄露:

    "use strict";
    
    function foo () {
    	b = 2;
    }
    
    foo(); // ReferenceError: b is not defined
    複製代碼
  2. 關注 DOM 生命週期,在銷燬階段記得解綁相關事件:

    const wrapDOM = document.getElementById('wrap');
    wrapDOM.onclick = function (e) {console.log(e);};
    
    // some codes ...
    
    // remove wrapDOM
    wrapDOM.onclick = null;
    wrapDOM.parentNode.removeChild(wrapDOM);
    複製代碼

    或者可使用事件委託的手段統一處理事件,減小因爲事件綁定帶來的額外內存開銷:

    document.body.onclick = function (e) {
        if (isWrapDOM) {
            // ...
        } else {
            // ...
        }
    }
    複製代碼
  3. 避免過分使用閉包。

大部分的內存泄漏仍是因爲代碼不規範致使的。代碼千萬條,規範第一條,代碼不規範,開發兩行淚。

總結

  1. javascript 語言層面只原生支持兩種做用域類型:全局做用域函數做用域 。全局做用域程序運行就有,函數做用域只有定義函數的時候纔有,它們之間是包含的關係。
  2. 做用域之間是能夠嵌套的,咱們把這種嵌套關係稱爲 做用域鏈
  3. 可執行代碼在做用域中查詢變量時,只能查詢 本地做用域上層做用域,不能查找內部的函數做用域。JS 引擎搜索變量時,會先詢問本地做用域,找到即返回,找不到再去詢問上層做用域...層層往上,直到全局做用域。
  4. javascript 中使用的是 「詞法做用域」,所以函數做用域的範圍在函數定義時就已經被肯定,和函數在哪執行沒有關係。
  5. 有權訪問另外一個函數內部變量的函數,咱們稱爲 閉包閉包的本質是利用了做用域的機制,來達到外部做用域訪問內部做用域的目的。
  6. 閉包的使用場景很是普遍,然而過分使用會致使閉包內的變量所佔用的內存空間沒法釋放,帶來 內存泄露 的問題。
  7. 咱們能夠藉助於 chrome 開發者工具查找代碼中致使了內存泄露的代碼。
  8. 避免內存泄露的幾種方法:避免使用全局變量、謹慎地爲DOM 綁定事件、避免過分使用閉包。最重要的,仍是代碼規範。 😃

本篇文章已收錄入 前端面試指南專欄

相關參考

往期內容推薦

  1. 完全弄懂節流和防抖
  2. 【基礎】HTTP、TCP/IP 協議的原理及應用
  3. 【實戰】webpack4 + ejs + express 帶你擼一個多頁應用項目架構
  4. 瀏覽器下的 Event Loop
  5. 面試官:說說執行上下文吧
  6. 面試官:說說原型鏈和繼承吧
  7. 面試官:說說 JS 中的模塊化吧
  8. 面試官:說說 let 和 const 吧
相關文章
相關標籤/搜索