一篇鞏固基礎的文章,也多是一系列的文章,梳理知識的遺漏點,同時也探究不少理所固然的事情背後的原理。javascript
爲何探究基礎?由於你不去面試你就不知道基礎有多重要,或者是說當你的工做經歷沒有亮點的時候,基礎就是檢驗你好壞的一項指標。html
JS基礎都會有哪些考點:閉包,繼承,深拷貝,異步編程等一些常見考點,爲何不管是當我仍是個學生的時候被面試仍是到如今當面試官去考別人,都仍是問這些?項目從jQuery都過渡到React全家桶了,js仍是考這些?java
由於這些知識點很典型,一個知識點弄懂須要先把不少前置的其餘的知識點弄懂。好比閉包,閉包背後就有做用域,變量提高,函數提高,垃圾收集機制等知識點。因此這些知識點每每能以點概面,考察不少基礎的東西。git
先來看看閉包(Closure)。程序員
文章裏提到了一些知識點:github
與JAVA,C++,C等靜態語言不一樣,JavaScript是不須要編譯的。在JAVA中,程序員寫的JAVA代碼要被編譯器編譯成機器語言,而後執行。面試
編譯express
通常程序中的一段源代碼在執行以前會經歷三個步驟,統稱爲「編譯」:編程
將 AST 轉換爲可執行代碼的過程稱被稱爲代碼生成。這個過程與語言、目標平臺等息息相關。拋開具體細節,簡單來講就是有某種方法能夠將 var a = 2; 的 AST 轉化爲一組機器指令,用來建立一個叫做 a 的變量(包括分配內存等),並將一個值儲存在 a 中。windows
解釋器
JavaScript則不一樣,JavaScript中對應編譯的部分叫作解釋器(Interpreter)。這二者的區別用一句話來歸納就是:編譯器是將源代碼編譯爲另一種代碼(好比機器碼,或者字節碼),而解釋器是直接解析並將代碼運行結果輸出。
JavaScript編譯運行過程當中有三個重要的角色:引擎,編譯器,做用域。三者互相配合這樣工做:
舉個例子:
var a = 1;
這段代碼交給解釋器以後:
var a
,編譯器詢問做用域是否已經有一個該名稱的變量存在於同一個做用域的集合中。若是是,編譯器會忽略該聲明,繼續進行編譯;不然它會要求做用域在當前做用域的集合中分配內存聲明一個新的變量,並命名爲 a。上述代碼在執行過程當中開起來就好像:
var a; a = 1;
這不就是很熟悉的變量提高嗎,可是爲何會有變量提高呢,能夠理解爲代碼的聲明和賦值是分別在編譯和運行時執行,二者之間的數據銜接全靠做用域(事實上並非這樣,後面會提到)。
這裏咱們很熟悉,有兩種異常:編譯異常,運行異常。
編譯異常
編譯器在編譯的時候發生錯誤,編譯中止好比:
很明顯編譯器沒法知道將1賦值給誰,無法寫出對應的機器語言,編譯中止。
運行異常
引擎在運行時候發生錯誤,例如:
引擎向做用域獲取a,可是編譯器未在做用域中聲明a,運行報錯。
聲明瞭a,並將a賦值爲1,可是a沒法運行,運行報錯。
LHS查詢 RHS查詢RHS 查詢與簡單地查找某個變量的值別無二致,而 LHS 查詢則是試圖找到變量的容器自己,從而能夠對其賦值。
ES5 中引入了「嚴格模式」。同正常模式,或者說寬鬆 / 懶惰模式相比,嚴格模式在行爲上
有不少不一樣。其中一個不一樣的行爲是嚴格模式禁止自動或隱式地建立全局變量。所以,在
嚴格模式中 LHS 查詢失敗時,並不會建立並返回一個全局變量,引擎會拋出同 RHS 查詢
失敗時相似的 ReferenceError 異常。接下來,若是 RHS 查詢找到了一個變量,可是你嘗試對這個變量的值進行不合理的操做,
好比試圖對一個非函數類型的值進行函數調用,或着引用 null 或 undefined 類型的值中的
屬性,那麼引擎會拋出另一種類型的異常,叫做 TypeError。
和C#、Java同樣JavaScript有自動垃圾回收機制,也就是說執行環境會負責管理代碼執行過程當中使用的內存,在開發過程當中就無需考慮內存分配及無用內存的回收問題了。
JavaScript垃圾回收的機制很簡單:找出再也不使用的變量,而後釋放掉其佔用的內存,可是這個過程不是時時的,由於其開銷比較大,因此垃圾回收器會按照固定的時間間隔週期性的執行。
變量生命週期
什麼叫再也不使用的變量?再也不使用的變量也就是生命週期結束的變量,固然只多是局部變量,全局變量的生命週期直至瀏覽器卸載頁面纔會結束。局部變量只在函數的執行過程當中存在,而在這個過程當中會爲局部變量在棧或堆上分配相應的空間,以存儲它們的值,而後再函數中使用這些變量,直至函數結束(閉包特殊)。
一旦函數結束,局部變量就沒有存在必要了,能夠釋放它們佔用的內存。貌似很簡單的工做,爲何會有很大開銷呢?這僅僅是垃圾回收的冰山一角,就像剛剛提到的閉包,貌似函數結束了,其實尚未,垃圾回收器必須知道哪一個變量有用,哪一個變量沒用,對於再也不有用的變量打上標記,以備未來回收。用於標記無用的策略有不少,常見的有兩種方式:標記清除和 引用計數,這裏介紹一下標記清除:
標記清除(mark and sweep)
這是JavaScript最多見的垃圾回收方式,當變量進入執行環境的時候,好比函數中聲明一個變量,垃圾回收器將其標記爲「進入環境」,當變量離開環境的時候(函數執行結束)將其標記爲「離開環境」。至於怎麼標記有不少種方式,好比特殊位的反轉、維護一個列表等,這些並不重要,重要的是使用什麼策略,原則上講不可以釋放進入環境的變量所佔的內存,它們隨時可能會被調用的到。
垃圾回收器會在運行的時候給存儲在內存中的全部變量加上標記,而後去掉環境中的變量以及被環境中變量所引用的變量(閉包),在這些完成以後仍存在標記的就是要刪除的變量了,由於環境中的變量已經沒法訪問到這些變量了,而後垃圾回收器相會這些帶有標記的變量機器所佔空間。
大部分瀏覽器都是使用這種方式進行垃圾回收,只是垃圾收集的時間間隔不一樣。
做用域負責收集並維護由全部聲明的標識符(變量)組成的一系列查詢,並實施一套很是嚴格的規則,肯定當前執行的代碼對這些標識符的訪問權限。
做用域共有兩種主要的工做模型。第一種是最爲廣泛的,被大多數編程語言所採用的詞法做用域,咱們會對這種做用域進行深刻討論。另一種叫做動態做用域,仍有一些編程語言在使用(好比 Bash 腳本、Perl 中的一些模式等)。
詞法做用域是由你在寫代碼時將變量和塊做用域寫在哪裏來決定的,所以當詞法分析器處理代碼時會保持做用域不變(大部分狀況下是這樣的)。
動態做用域並不關心函數和做用域是如何聲明以及在何處聲明的,只關心它們從何處調用。換句話說,做用域鏈是基於調用棧的,而不是代碼中的做用域嵌套。
JavaScript中大部分場景都是詞法做用域,函數中的this則是動態做用域,咱們先仔細討論詞法做用域。
詞法做用域中,又可分爲全局做用域,函數做用域和塊級做用域。
默認進入的就是全局做用域,在瀏覽器上全局做用域一般都被掛載到windows上。
函數做用域的含義是指,屬於這個函數的所有變量均可以在整個函數的範圍內使用及複用(事實上在嵌套的做用域中也可使用)。
var a = 1; function fn () { // 函數做用域起點 var a = 2; console.log(a); } // 函數做用域終點 fn(); // 函數做用域這行業是,由於涉及到參數傳值 console.log(a);
很常見,簡單來講用{}來包裹起來的,一般能夠複用的代碼就是,好比for循環,switch case,while等等。
for(var i=0; i<10; i++){ // 塊做用域 console.log(i); } // 塊做用域 var a = 1; switch (a) { // 塊做用域 case 1: { // 塊做用域 // .... } case 2: { // 塊做用域 // .... } default: { // 塊做用域 // .... } } while (a) { // 塊做用域 // .... } { // 硬寫了一個塊做用域 let a = 2; console.log(a); }
看一個例子:
function func (a) { var b = a * 2; function foo (c) { console.log(a, b, c); } foo(b*3) { let a = 2; console.log(a); // 2 } } func(1); // 1,2,3
經過上面這個例子咱們來分析:
let
定義了一個a,這裏的變量有:a在詞法做用域中,函數運行時遇到變量,回去在其詞法做用域中尋找對應變量,而在動態做用域中,則是根據當前運行狀況來肯定,最多見的就是this關鍵字。
var b = 1; var c = 123; function fn (a) { console.log(a); console.log(b); console.log(this.c); } fn('hello'); var obj = { b: 2, c: 12, fn: fn } var o = { obj: obj } obj.fn('world'); o.obj.fn('!');
fn分別在全局做用域中執行,和obj的屬性執行。
變量a是fn的函數做用域中定義的,屬於詞法做用域範疇;
變量b沒有在函數做用域中定義,向上尋找,在全局做用域中找到,也是詞法做用域範疇;
this.c屬於動態做用域,函數執行的時候順着調用棧動態尋找,this老是指向調用函數者。
不一樣做用域之間是如何協做的,這就涉及到了做用域鏈。
做用域查找會在找到第一個匹配的標識符時中止。在多層的嵌套做用域中能夠定義同名的標識符,這叫做「遮蔽效應」(內部的標識符「遮蔽」了外部的標識符)。拋開遮蔽效應,做用域查找始終從運行時所處的最內部做用域開始,逐級向外或者說向上進行,直到碰見第一個匹配的標識符爲止。
不一樣做用域之間是能夠嵌套的,全部的局部做用域都在全局做用域這個大容器之中,做用域之間的嵌套關係就比如堆棧和出棧。
仍是上面的例子:
在每一個做用域內查找變量,若是對於的做用域內沒法找到變量,則去其做用域鏈的上一級查找,直到找到第一個結果返回,不然返回undefined。
若是多個做用域內有相同名稱的變量,則會找到距離當前做用域最近的變量。
一開始編譯運行過程的時候咱們就知道了JS中存在變量提高,實際上分紅兩種狀況:變量聲明提高和函數聲明提高。
一般JS引擎會在正式執行以前先進行一次預編譯,在這個過程當中,首先將變量聲明及函數聲明提高至當前做用域的頂端,而後進行接下來的處理。
這個咱們應該很熟悉了,舉個例子:
console.log(a); // undefined var a = 1; console.log(a); // 1
按照閱讀邏輯,在a聲明以前調用a,會發生RHS異常,從而觸發ReferenceError。
可是實際運行的時候,並無報錯,由於上面的代碼看起來被編譯成了:
var a; console.log(a); a = 1; console.log(a);
這樣理解看起來是否是就很合理了。
可是值得注意的是,變量提高只會提高至本做用域最頂端,而不會誇做用域:
var foo = 3; function func () { var foo = foo || 5; console.log(foo); // 5 } func();
在func裏面的是函數做用域,全局做用域的一個子集,因此在函數做用域中調用變量foo應該就近尋找當前做用域內有無變量,找到一個即中止尋找。上述代碼看起來:
var foo = 3; function func () { var foo; foo = foo || 5; console.log(foo); // 5 } func();
與變量聲明相似的,函數在聲明的時候也會發生提高的狀況:
func(); // 'hello world' function func () { console.log('hello world'); }
類似的,若是在同一個做用域中存在多個同名函數聲明,後面出現的將會覆蓋前面的函數聲明;
對於函數,除了使用上面的函數聲明,更多時候,咱們會使用函數表達式,下面是函數聲明和函數表達式的對比:
console.log(foo1); //函數聲明 function foo1() { console.log('function declaration'); } console.log(foo2); //匿名函數表達式 var foo2 = function() { console.log('anonymous function expression'); }; console.log(bar); console.log(foo3); //具名函數表達式 var foo3 = function bar() { console.log('named function expression'); }; console.log(bar);
JavaScript中的函數是一等公民,函數聲明的優先級最高,會被提高至當前做用域最頂端。上述的例子能夠發現:只有函數聲明的時候,纔會發生變量提高,函數不管是匿名函數/具名函數表達式,均不會發生函數聲明提高。
二者同時存在提高,那個優先級更高:
console.log(a); var a = 1; function a () { console.log('hello'); } console.log(b); function b () { console.log('hello'); } var b = 1;
上面例子能夠看到,當變量和函數同名的時候,不管誰聲明在後,都是函數的優先級最高,變量爲函數讓路。
至於變量提高的緣由:Note 4. Two words about 「hoisting」
通過前面知識點鋪墊以後,終於來到了閉包。
function closure () { var a = 1; function result () { return a; } return result; } closure()();
上面這個例子是個很常見的閉包,變量a在函數closure內,不該該在其做用域外被訪問,可是經過返回result函數實現了在外部訪問到了a,這就是一個簡單的閉包。
事實上閉包的定義:(wiki pedia)
閉包,又稱 詞法閉包(Lexical Closure)或 函數閉包(function closures),是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即便已經離開了創造它的環境也不例外。
簡單說就是,函數內定義了一個引用了其做用域內變量的函數,而後將該函數當作一個值傳遞到其餘地方,該函數在運行的時候,雖然運行環境已經不是其詞法做用域,可是還能夠訪問到其詞法做用域中的變量。
或者說咱們能夠這樣理解:
本質上不管什麼時候何地,若是將函數(訪問它們各自的詞法做用域)看成第一級的值類型並處處傳遞,你就會看到閉包在這些函數中的應用。在定時器、事件監聽器、Ajax 請求、跨窗口通訊、Web Workers 或者任何其餘的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包!
這裏關鍵點:函數,函數引用了其做用域內的變量,在其詞法做用域外被調用。來看一些常見的例子:
function closure () { var a = 1; function result () { return a; } window.result = result; } closure(); result();
上面例子的變形,closure不在return result,而是掛載到window對象上。很顯然result的詞法做用域在不是全局做用域,知足閉包的條件,也是一個閉包。
function wait(message) { setTimeout( function timer() { console.log( message ); }, 1000 ); } wait( "Hello, closure!" );
這個有點意思,延遲很常見。timer的詞法做用域是[wait函數做用域,全局做用域],wait裏面起了一個延遲隊列任務,timer被當作參數傳遞到了延遲裏,而timer裏面還調用了message。這樣的話,wait執行結束以後並不會被內存回收,1s以後,timer執行,其詞法做用域都還在,知足閉包條件,是一個閉包。
一道經典題目:輸出結果
for(var i = 0; i<5; i++){ setTimeout(function(){ console.log(i); }, 100); }
代碼運行以後,打印5個5。這裏setTimeout定義以後不會被當即執行,而是加入到隊列中延遲執行,執行的時候運行匿名函數,匿名函數打印i,i不在匿名函數做用域中,順着做用域鏈向上尋找,在全局做用域中找到i,這時候的i已是5了,因此均打印5。
這裏變形一下:還保留for循環,以及setTimeout形式,要求結果輸出0,1,2,3,4,怎麼改?
不少種方法,咱們分紅不一樣方向去考慮:
1. 使用塊級做用域
變量i其實是個全局做用域變量,for循環,每次都重複聲明i,可使用塊級做用域,聲明不一樣的塊級做用域中的變量:
for(let i = 0; i<5; i++){ setTimeout(function(){ console.log(i); }, 100); }
或者,賦值轉換:
for(var i = 0; i<5; i++){ let a = i; setTimeout(function(){ console.log(a); }, 100); }
這樣的話,匿名函數執行的時候,函數做用域內沒有i,去塊級做用域尋找i,找到並返回結果,並不會直接尋找到全局做用域。
2. 閉包
閉包應該是最容易想到的,由於他的場景知足在其詞法做用域外被調用,怎麼使用閉包:當即執行函數(IIFE)
for(var i = 0; i<5; i++){ (function(i){ setTimeout(function(){ console.log(i); }, 100); })(i); }
當即執行函數創造了一個新的匿名函數做用域,這個做用域內的i是定義的時候傳進來的,settimeout函數執行時候線上尋找到該做用域,並打印變量。
3.bind函數
或者使用bind函數能夠直接更改匿名函數的做用域:
for(var i = 0; i<5; i++){ setTimeout(function(i){ console.log(i); }.bind(this, i), 100); }
4.奇技淫巧
只針對這個題目,可使用進棧出棧保持順序:
var arr = []; for(var i = 0; i<5; i++){ arr.unshift(i); setTimeout(function(){ console.log(arr.pop()); }, 100); }