帶你理解 JS 容易出錯的坑和細節

yck發表於2017-11-01

目前自己組建的一個團隊正在寫一份面試圖譜,將會在七月中旬開源。內容十分豐富,第一版會開源前端方面知識和程式設計師必備知識,後期會逐步寫入後端方面知識。因為工程所涉及內容太多(目前已經寫了一個半月),並且還需翻譯成英文,所以所需時間較長。有興趣的同學可以 Follow 我的 Github 得到最快的更新訊息。

執行環境(Execution context)

var 和 let 的正確解釋

當執行 JS 程式碼時,會生成執行環境,只要程式碼不是寫在函式中的,就是在全域性執行環境中,函式中的程式碼會產生函式執行環境,只此兩種執行環境。

接下來讓我們看一個老生常談的例子,var

b() // call b
console.log(a) // undefined

var a = 'Hello world'

function b() {
    console.log('call b')
}
複製程式碼

想必以上的輸出大家肯定都已經明白了,這是因為函式和變數提升的原因。通常提升的解釋是說將宣告的程式碼移動到了頂部,這其實沒有什麼錯誤,便於大家理解。但是更準確的解釋應該是:在生成執行環境時,會有兩個階段。第一個階段是建立的階段,JS 直譯器會找出需要提升的變數和函式,並且給他們提前在記憶體中開闢好空間,函式的話會將整個函式存入記憶體中,變數只宣告並且賦值為 undefined,所以在第二個階段,也就是程式碼執行階段,我們可以直接提前使用。

在提升的過程中,相同的函式會覆蓋上一個函式,並且函式優先於變數提升

b() // call b second

function b() {
    console.log('call b fist')
}
function b() {
    console.log('call b second')
}
var b = 'Hello world'
複製程式碼

var 會產生很多錯誤,所以在 ES6中引入了 letlet 不能在宣告前使用,但是這並不是常說的 let 不會提升,let 提升了,在第一階段記憶體也已經為他開闢好了空間,但是因為這個宣告的特性導致了並不能在宣告前使用。

作用域

function b() {
    console.log(value)
}

function a() {
    var value = 2
    b()
}

var value = 1
a()
複製程式碼

可以考慮下 b 函式中輸出什麼。你是否會認為 b 函式是在 a 函式中呼叫的,相應的 b 函式中沒有宣告 value 那麼應該去 a 函式中尋找。其實答案應該是 1。

當在產生執行環境的第一階段時,會生成 [[Scope]] 屬性,這個屬性是一個指標,對應的有一個作用域連結串列,JS 會通過這個連結串列來尋找變數直到全域性環境。這個指標指向的上一個節點就是該函式宣告的位置,因為 b 是在全域性環境中宣告的,所以 value 的宣告會在全域性環境下尋找。如果 b 是在 a 中宣告的,那麼 log 出來的值就是 2 了。

非同步

JS 是門同步的語言,你是否疑惑過那麼為什麼 JS 還有非同步的寫法。其實 JS 的非同步和其他語言的非同步是不相同的,本質上還是同步。因為瀏覽器會有多個 Queue 存放非同步通知,並且每個 Queue 的優先順序也不同,JS 在執行程式碼時會產生一個執行棧,同步的程式碼在執行棧中,非同步的在 Queue 中。有一個 Event Loop 會迴圈檢查執行棧是否為空,為空時會在 Queue 中檢視是否有需要處理的通知,有的話拿到執行棧中去執行。

function sleep() {
  var ms = 2000 + new Date().getTime()
  while( new Date() < ms) {}
  console.log('sleep finish')
}

document.addEventListener('click', function() {
  console.log('click')
})

sleep()
setTimeout(function() {
    console.log('timeout');
}, 0);

Promise.resolve().then(function() {
    console.log('promise');
});
console.log('finish')
複製程式碼

以上程式碼如果你在 sleep 被呼叫期間點選,只有當 sleep 執行結束並且 log finish 後才會響應其他非同步事件。所以要注意 setTimeout 並不是你設定多久 JS 就會準時的響應,並且 setTimeout 也有個小細節,第二個引數設定為 0 也許會有人認為這樣就不是非同步了,其實還是非同步。這是因為 HTML5 標準規定這個函式第二個引數不得小於 4 毫秒,不足會自動增加。

以下輸出建立在 Chrome 上,不同的瀏覽器會有不同的輸出

promise // promise 會進入 Microtask Queue 中,這個 Queue 會優先執行
timeout // setTimeout 會進入 task Queue 中
click // 點選事件會進入 Event Queue 中
複製程式碼

型別

原始值

JS 共有 6 個原始值,分別為 Boolean, Null, Undefined, Number, String, Symbol,這些型別都是值不可變的。

有一個易錯的點是:雖然 typeof null 是 object 型別,但是 Null 不是物件,這是 JS 語言的一個很久遠的 Bug 了。

深淺拷貝

對於物件來說,直接將一個物件賦值給另外一個物件就是淺拷貝,兩個物件指向同一個地址,其中任何一個物件改變,另一個物件也會被改變

var a = [1, 2]
var b = a
b.push(3)
console.log(a, b) // -> 都是 [1, 2, 3]
複製程式碼

有些情況下我們可能不希望有這種問題,那麼深拷貝可以解決這個問題。深拷貝不僅將原物件的各個屬性逐個複製出去,而且將原物件各個屬性所包含的物件也依次採用深複製的方法遞迴複製到新物件上。深拷貝有多種寫法,有興趣的可以看這篇文章

函式和物件

this

this 是很多人會混淆的概念,但是其實他一點都不難,你只需要記住幾個規則就可以了。

function foo() {
  console.log(this.a)
}
var a = 2
foo() 

var obj = {
  a: 2,
  foo: foo
}
obj.foo() 

// 以上兩者情況 this 只依賴於呼叫函式前的物件,優先順序是第二個情況大於第一個情況

// 以下情況是優先順序最高的,this 只會繫結在 c 上
var c = new foo()
c.a = 3
console.log(c.a)

// 還有種就是利用 call,apply,bind 改變 this,這個優先順序僅次於 new
複製程式碼

以上幾種情況明白了,很多程式碼中的 this 應該就沒什麼問題了,下面讓我們看看箭頭函式中的 this

function a() {
    return () => {
        return () => {
            console.log(this)
        }
    }
}
console.log(a()()())
複製程式碼

箭頭函式其實是沒有 this 的,這個函式中的 this 只取決於他外面的第一個不是箭頭函式的函式的 this。在這個例子中,因為呼叫 a 符合前面程式碼中的第一個情況,所以 this 是 window。並且 this 一旦繫結了上下文,就不會被任何程式碼改變。

下面我們再來看一個例子,很多人認為他是一個 JS 的問題

var a = {
    name: 'js',
    log: function() {
        console.log(this)
        function setName() {
            this.name = 'javaScript'
            console.log(this)
        }
        setName()
    }
}
a.log()
複製程式碼

setName 中的 this 指向了 window,很多人認為他應該是指向 a 的。這裡其實我們不需要去管函式是寫在什麼地方的,我們只需要考慮函式是怎麼呼叫的,這裡符合上述第一個情況,所以應該是指向 window。

閉包和立即執行函式

閉包被很多人認為是一個很難理解的概念。其實閉包很簡單,就是一個能夠訪問父函式區域性變數的函式,父函式在執行完後,內部的變數還存在記憶體上讓閉包使用。

function a(name) {
    // 這就是閉包,因為他使用了父函式的引數
    return function() {
        console.log(name)
    }
}
var b = a('js')
b() // -> js
複製程式碼

現在來看一個面試題

function a() {
    var array = []

    for(var i = 0; i < 3; i++) {
        array.push(
            function() {
                console.log(i)
            }
        )
    }

    return array
}

var b = a()
b[0]()
b[1]()
b[2]()
複製程式碼

這個題目因為 i 被提升了,所以 i = 3,當 a 函式執行完成後,記憶體中保留了 a 函式中的變數 i。陣列中 push 進去的只是宣告,並沒有執行函式。所以在執行函式時,輸出了 3 個 3。

如果我們想輸出 0 ,1,2 的話,有兩種簡單的辦法。第一個是在 for 迴圈中,使用 let 宣告一個變數,儲存每次的 i 值,這樣在 a 函式執行完成後,記憶體中就儲存了 3 個不同 let 宣告的變數,這樣就解決了問題。

還有個辦法就是使用立即執行函式,建立函式即執行,這樣就可以儲存下當前的 i 的值。

function a() {
    var array = []

    for(var i = 0; i < 3; i++) {
        array.push(
            (function(j) {
                return function() {
                    console.log(j)
                }
            }(i))
        )
    }

    return array
}
複製程式碼

立即執行函式其實就是直接呼叫匿名函式

function() {} ()
複製程式碼

但是以上寫法會報錯,因為直譯器認為這是一個函式宣告,不能直接呼叫,所以我們加上了一個括號來讓直譯器認為這是一個函式表示式,這樣就可以直接呼叫了。

所以我們其實只需要讓直譯器認為我們寫了個函式表示式就行了,其實還有很多種立即執行函式寫法

true && function() {} ()
new && function() {} ()
複製程式碼

立即執行函式最大的作用就是模組化,其次就是解決上述閉包的問題了。

原型,原型鏈和 instanceof 原理

原型可能很多人覺得很複雜,本章節也不打算重複複述很多文章都講過的概念,你只需要看懂我畫的圖並且自己實驗下即可

function P() {
    console.log('object')
}

var p = new P()

複製程式碼

帶你理解 JS 容易出錯的坑和細節

原型鏈就是按照 __proto__ 尋找,直到 Object。instanceof 原理也是根據原型鏈判斷的

p instanceof P // true
p instanceof Object // true
複製程式碼

帶你理解 JS 容易出錯的坑和細節

相關文章