幾乎全部語言的最基礎模型之一就是在變量中存儲值,而且在稍後取出或修改這些值。在變量中存儲值和取出值的能力,給程序賦予了狀態。這就引申出兩個問題:這些變量被存儲在哪裏?程序如何在須要的時候找到它們?回答這些問題須要一組明肯定義的規則,它定義瞭如何存儲變量,以及如何找到這些變量。咱們稱這組規則爲:做用域。javascript
在說 javascript 中的做用域以前,我想應該先了解一下 LHS 和 RHS 查詢,這對於做用域的理解有所幫助。 前端
雖然 javascript
被認爲是一門解釋型語言/動態語言,可是它實際上是一種編譯型的語言。通常來講,須要運行一段 javascript
代碼,有兩個必不可少的東西:JS 引擎 和 編譯器。前者相似於總管的角色,負責整個程序運行時所需的各類資源的調度;後者只是前者的一部分,負責將 javascript
源碼編譯成機器能識別的機器指令,而後交給引擎運行。java
在 javascript
中,一段源碼在被執行以前大概會經歷如下三個步驟,這也被稱之爲 編譯:node
var a = 2;
。這段程序極可能會被打斷成以下 token:var
,a
,=
,2
,和 ;
。token
的流(數組)轉換爲一個「抽象語法樹」(AST —— Abstract Syntax Tree
),它表示了程序的語法結構。編譯器一頓操做猛如虎,生成了一堆機器指令,JS 引擎開心地拿到這堆指令,開始執行,這個時候咱們要說的 LHS
和 RHS
就登場了。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
比較難發現。
總之,引擎想對變量進行獲取 / 賦值,就離不開 LHS
和 RHS
,然而這兩個操做只是手段,到哪裏去獲取變量纔是關鍵。LHS
和 RHS
獲取變量的位置就是 做用域。
簡單來講,做用域 指程序中定義變量的區域,它決定了當前執行代碼對變量的訪問權限。
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
的值,a
和 b
都是從外部 foo
函數的做用域中獲取到的。
當可執行代碼內部訪問變量時,會先查找本地做用域,若是找到目標變量即返回,不然會去父級做用域繼續查找...一直找到全局做用域。咱們把這種做用域的嵌套機制,稱爲 做用域鏈。
用圖片表示,上述代碼一共有三層做用域嵌套,分別是:
foo
做用域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
標準提出了使用 let
和 const
代替 var
關鍵字,來「建立塊級做用域」。也就是說,上述代碼改爲以下方式,塊級做用域是有效的:
if (true) { let a = 1; } console.log(a); // ReferenceError 複製代碼
關於
let
和const
的更多細節,進入 傳送門
在 javascript
中,咱們有幾種建立 / 改變做用域的手段:
定義函數,建立函數做用(推薦):
function foo () { // 建立了一個 foo 的函數做用域 } 複製代碼
使用 let
和 const
建立塊級做用域(推薦):
for (let i = 0; i < 5; i++) { console.log(i); } console.log(i); // ReferenceError 複製代碼
try catch
建立做用域(不推薦),err
僅存在於 catch
子句中:
try { undefined(); // 強制產生異常 } catch (err) { console.log( err ); // TypeError: undefined is not a function } console.log( err ); // ReferenceError: `err` not found 複製代碼
使用 eval
「欺騙」 詞法做用域(不推薦):
function foo(str, a) { eval( str ); console.log( a, b ); } var b = 2; foo( "var b = 3;", 1 ); // 1 3 複製代碼
使用 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 複製代碼
上面的代碼中,構建了 module1
和 module2
兩個表明模塊的函數,兩個函數內分別定義了一個同名變量 a
,因爲函數做用域的隔離性質,這兩個變量被保存在不一樣的做用域中(不嵌套),JS 引擎在執行這兩個函數時會去不一樣的做用域中讀取,而且外部做用域沒法訪問到函數內部的 a
變量。這樣一來就巧妙地解決了 全局做用域污染 和 變量名衝突 的問題;而且,因爲函數的包裹寫法,這種方式看起來封裝性好多了。
然而上面的函數聲明式寫法,看起來仍是有些冗餘,更重要的是,module1
和 module2
的函數名自己就已經對全局做用域形成了污染。咱們來繼續改寫:
// 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(); // 這就造成了一個閉包 複製代碼
咱們能夠簡單剖析一下上面代碼的運行流程:
foo()
,此時會建立一個 foo
函數的執行上下文,執行上下文內部存儲了 foo
中聲明的全部變量函數信息。foo
運行完畢,將內部函數 bar
的引用賦值給外部的變量 baz
,此時 baz
指針指向的仍是 bar
,所以哪怕它位於 foo
做用域以外,它仍是可以獲取到 foo
的內部變量。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 網站演示。
點擊這個按鈕啓動記錄,而後切換到網頁進行操做,錄製完成後點擊 stop
按鈕,開發者工具會從錄製時刻開始記錄當前應用的各項數據狀況。
選中JS Heap
,下面展示出來的一條藍線,就是表明了這段記錄過程當中,JS 堆內存信息的變化狀況。
有大佬說,根據這條藍線就能夠判斷是否存在內存泄漏的狀況:若是這條藍線一直成上升趨勢,那基本就是內存泄漏了。其實我以爲這麼講有失偏頗,JS 堆內存佔用率上升並不必定就是內存泄漏,只能說明有不少未被釋放的內存而已,至於這些內存是否真的在使用,仍是說確實是內存泄漏,還須要進一步排查。
藉助開發者工具的 Memory 選項,能夠更精確地定位內存使用狀況。
當生成了第一個快照的時候,開發者工具窗口已經顯示了很詳細的內存佔用狀況。
字段解釋:
Constructor
— 佔用內存的資源類型Distance
— 當前對象到根的引用層級距離Shallow Size
— 對象所佔內存(不包含內部引用的其它對象所佔的內存)(單位:字節)Retained Size
— 對象所佔總內存(包含內部引用的其它對象所佔的內存)(單位:字節)將每項展開能夠查看更詳細的數據信息。
咱們再次切回網頁,繼續操做幾回,而後再次生成一個快照。
這邊須要特別注意這個 #Delta
,若是是正值,就表明新生成的內存多,釋放的內存少。其中的閉包項,若是是正值,就說明存在內存泄漏。
下面咱們到代碼裏找一個內存泄漏的問題:
使用嚴格模式,避免不經意間的全局變量泄露:
"use strict"; function foo () { b = 2; } foo(); // ReferenceError: b is not defined 複製代碼
關注 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 { // ... } } 複製代碼
避免過分使用閉包。
大部分的內存泄漏仍是因爲代碼不規範致使的。代碼千萬條,規範第一條,代碼不規範,開發兩行淚。
javascript
語言層面只原生支持兩種做用域類型:全局做用域 和 函數做用域 。全局做用域程序運行就有,函數做用域只有定義函數的時候纔有,它們之間是包含的關係。javascript
中使用的是 「詞法做用域」,所以函數做用域的範圍在函數定義時就已經被肯定,和函數在哪執行沒有關係。chrome
開發者工具查找代碼中致使了內存泄露的代碼。DOM
綁定事件、避免過分使用閉包。最重要的,仍是代碼規範。 😃本篇文章已收錄入 前端面試指南專欄