ES2015系列--塊級做用域

關於文章討論請訪問:https://github.com/Jocs/jocs....javascript

當Brendan Eich在1995年設計JavaScript第一個版本的時候,考慮的不是很周到,以致於最第一版本的JavaScript有不少不完善的地方,在Douglas Crockford的《JavaScript:The Good Parts》中就總結了不少JavaScript很差的地方,好比容許!===的使用,會致使隱式的類型轉換,好比在全局做用域中經過var聲明變量會成爲全局對象(在瀏覽器環境中是window對象)的一個屬性,在好比var聲明的變量能夠覆蓋window對象上面原生的方法和屬性等。前端

可是做爲一門已經被普遍用於web開發的計算機語言來講,去糾正這些設計錯誤顯得至關困難,由於若是新的語法和老的語法有衝突的話,那麼已有的web應用沒法運行,瀏覽器生產廠商確定不會去冒這個險去實現這些和老的語法徹底衝突的功能的,由於誰都不想失去本身的客戶,不是嗎?所以向下兼容便成了解決上述問題的惟一途徑,也就是說在不改變原有語法特性的基礎上,增長一些新的語法或變量聲明方式等,來把新的語言特性引入到JavaScript語言中。java

早在九年前,Brendan Eich在Firefox中就實現了初版的let.可是let的功能和現有的ES2015標準規定有些出入,後來由Shu-yu Guo將let的實現升級到符合現有的ES2015標準,如今纔有了咱們如今在最新的Firefox中使用的let 聲明變量語法。git

問題一:沒有塊級做用域

在ES2015以前,在函數中經過var聲明的變量,不論其在{}中仍是外面,其均可以在整個函數範圍內訪問到,所以在函數中聲明的變量被稱爲局部變量,做用域被稱爲局部做用域,而在全局中聲明的變量存在整個全局做用域中。可是在不少情境下,咱們迫切的須要塊級做用域的存在,也就是說在{}內部聲明的變量只可以在{}內部訪問到,在{}外部沒法訪問到其內部聲明的變量,好比下面的例子:github

function foo() {
    var bar = 'hello'
    if (true) {
        var zar = 'world'
        console.log(zar)
    }
    console.log(zar) // 若是存在塊級做用域那麼將報語法錯誤:Uncaught ReferenceError
}

在上面的例子中,若是JavaScript在ES2015以前就存在塊級做用域,那麼在{}以外將沒法訪問到其內部聲明的變量zar,可是實際上,第二個console卻打印了zar的賦值,'world'。web

問題二:for循環中共享迭代變量值

在for循環初始循環變量時,若是使用var聲明初始變量i,那麼在整個循環中,for循環內部將共享i的值。以下代碼:面試

var funcs = []
for (var i = 0; i < 10; i++) {
    funcs.push(function() {
        return i
    })
}
funcs.forEach(function(f) {
    console.log(f()) // 將在打印10數字10次
})

上面的代碼並無按着咱們但願的方式執行,咱們原本但願是最後打印0、一、2...9這10個數字。可是最後的結果卻出乎咱們的意料,而是將數字10打印了10次,究其緣由,聲明的變量i在上面的整個代碼塊可以訪問到,也就是說,funcs數組中每個函數返回的i都是全局聲明的變量i。也就說在funcs中函數執行時,將返回同一個值,而變量i初始值爲0,當迭代最後一次進行累加,9+1 = 10時,經過條件語句i < 10判斷爲false,循環運行完畢。最後i的值爲10.也就是爲何最後全部的函數都打印爲10。那麼在ES2015以前可以使上面的循環打印0、一、二、… 9嗎?答案是確定的。編程

var funcs = []
for (var i = 1; i < 10; i++) {
    funcs.push((function(value) {
        return function() {
            return value
        }
    })(i))
}
funcs.forEach(function(f) {
    console.log(f())
})

在這兒咱們使用了JavaScript中的兩個很棒的特性,當即執行函數(IIFEs)和閉包(closure)。在JavaScript的閉包中,閉包函數可以訪問到包庇函數中的變量,這些閉包函數可以訪問到的變量也所以被稱爲自由變量。只要閉包沒有被銷燬,那麼外部函數將一直在內存中保存着這些變量,在上面的代碼中,形參value就是自由變量,return的函數是一個閉包,閉包內部可以訪問到自由變量value。同時這兒咱們還使用了當即執行函數,當即函數的做用就是在每次迭代的過程當中,將i的值做爲實參傳入當即執行函數,並執行返回一個閉包函數,這個閉包函數保存了外部的自由變量,也就是保存了當次迭代時i的值。最後,就可以達到咱們想要的結果,調用funcs中每一個函數,最終返回0、一、二、… 9。數組

問題三:變量提高(Hoisting)

咱們先來看看函數中的變量提高, 在函數中經過var定義的變量,不論其在函數中什麼位置定義的,都將被視做在函數頂部定義,這一特定被稱爲提高(Hoisting)。想知道變量提高具體是怎樣操做的,咱們能夠看看下面的代碼:瀏覽器

function foo() {
    console.log(a) // undefined
    var a = 'hello'
    console.log(a) // 'hello'
}

在上面的代碼中,咱們能夠看到,第一個console並無報錯(ReferenceError)。說明在第一個console.log(a)的時候,變量a已經被定義了,JavaScript引擎在解析上面的代碼時其實是像下面這樣的:

function foo() {
  var a
  console.log(a)
  a = 'hello'
  console.log(a)
}

也就是說,JavaScript引擎把變量的定義和賦值分開了,首先對變量進行提高,將變量提高到函數的頂部,注意,這兒變量的賦值並無獲得提高,也就是說a = "hello"依然是在後面賦值的。所以第一次console.log(a)並無打印hello也沒有報ReferenceError錯誤。而是打印undefined。不管是函數內部仍是外部,變量提高都會給咱們帶來意想不到的bug。好比下面代碼:

if (!('a' in window)) {
  var a = 'hello'
}
console.log(a) // undefined

不少公司都把上面的代碼做爲面試前端工程師JavaScript基礎的面試題,其考點也就是考察全局環境下的變量提高,首先,答案是undefined,並非咱們期許的hello。緣由就在於變量a被提高到了最上面,上面的代碼JavaScript實際上是這樣解析的:

var a
if (!('a' in window)) {
  a = 'hello'
}
console.log(a) // undefined

如今就很明瞭了,bianlianga被提高到了全局環境最頂部,可是變量a的賦值仍是在條件語句內部,咱們知道經過關鍵字var在全局做用域中聲明的變量將做爲全局對象(window)的一個屬性,所以'a' in windowtrue。因此if語句中的判斷語句就爲false。所以條件語句內部就根本不會執行,也就是說不會執行賦值語句。最後經過console.log(a)打印也就是undefined,而不是咱們想要的hello

雖然使用關鍵詞let進行變量聲明也會有變量提高,可是其和經過var申明的變量帶來的變量提高是不同的,這一點將在後面的letvar的區別中討論到。

關於ES2015以前做用域的概念

上面說起的一些問題,不少都是因爲JavaScript中關於做用域的細分粒度不夠,這兒咱們稍微回顧一下ES2015以前關於做用域的概念。

Scope: collects and maintains a look-up list of all the declared identifiers (variables), and enforces a strict set of rules as to how these are accessible to currently executing code.

上面是關於做用域的定義,做用域就是一些規則的集合,經過這些規則咱們可以查找到當前執行代碼所需變量的值,這就是做用域的概念。在ES2015以前最多見的兩種做用域,全局做用局和函數做用域(局部做用域)。函數做用域能夠嵌套,這樣就造成了一條做用域鏈,若是咱們自頂向下的看,一個做用域內部能夠嵌套幾個子做用域,子做用域又能夠嵌套更多的做用域,這就更像一個‘’做用域樹‘’而非做用域鏈了,做用域鏈是一個自底向上的概念,在變量查找的過程當中頗有用的。在ES3時,引入了try catch語句,在catch語句中造成了新的做用域,外部是訪問不到catch語句中的錯誤變量。代碼以下:

try {
  throw new Error()
} catch(err) {
  console.log(err)
}
console.log(err) //Uncaught ReferenceError

再到ES5的時候,在嚴格模式下(use strict),函數中使用eval函數並不會再在原有函數中的做用域中執行代碼或變量賦值了,而是會動態生成一個做用域嵌套在原有函數做用域內部。以下面代碼:

'use strict'
var a = function() {
    var b = '123'
    eval('var c = 456;console.log(c + b)') // '456123'
    console.log(b) // '123'
    console.log(c) // 報錯
}

在非嚴格模式下,a函數內部的console.log(c)是不會報錯的,由於eval會共享a函數中的做用域,可是在嚴格模式下,eval將會動態建立一個新的子做用域嵌套在a函數內部,而外部是訪問不到這個子做用域的,也就是爲何console.log(c)會報錯。

經過let來聲明變量

經過let關鍵字來聲明變量也經過var來聲明變量的語法形式相同,在某些場景下你甚至能夠直接把var替換成let。可是使用let來申明變量與使用var來聲明變量最大的區別就是做用域的邊界再也不是函數,而是包含let變量聲明的代碼塊({})。下面的代碼將說明let聲明的變量只在代碼塊內部可以訪問到,在代碼塊外部將沒法訪問到代碼塊內部使用let聲明的變量。

if (true) {
  let foo = 'bar'
}
console.log(foo) // Uncaught ReferenceError

在上面的代碼中,foo變量在if語句中聲明並賦值。if語句外部卻訪問不到foo變量,報ReferenceError錯誤。

letvar的區別

變量提高的區別

在ECMAScript 2015中,let也會提高到代碼塊的頂部,在變量聲明以前去訪問變量會致使ReferenceError錯誤,也就是說,變量被提高到了一個所謂的「temporal dead zone」(如下簡稱TDZ)。TDZ區域從代碼塊開始,直到顯示得變量聲明結束,在這一區域訪問變量都會報ReferenceError錯誤。以下代碼:

function do_something() {
  console.log(foo); // ReferenceError
  let foo = 2;
}

而經過var聲明的變量不會造成TDZ,所以在定義變量以前訪問變量只會提示undefined,也就是上文以及討論過的var的變量提高。

全局環境聲明變量的區別

在全局環境中,經過var聲明的變量會成爲window對象的一個屬性,甚至對一些原生方法的賦值會致使原生方法的覆蓋。好比下面對變量parseInt進行賦值,將覆蓋原生parseInt方法。

var parseInt = function(number) {
  return 'hello'
}
parseInt(123) // 'hello'
window.parseInt(123) // 'hello'

而經過關鍵字let在全局環境中進行變量聲明時,新的變量將不會成爲全局對象的一個屬性,所以也就不會覆蓋window對象上面的一些原生方法了。以下面的例子:

let parseInt = function(number) {
  return 'hello'
}
parseInt(123) // 'hello'
window.parseInt(123) // 123

在上面的例子中,咱們看到let生命的函數parsetInt並無覆蓋window對象上面的parseInt方法,所以咱們經過調用window.parseInt方法時,返回結果123。

在屢次聲明同一變量時處理不一樣

在ES2015以前,能夠經過var屢次聲明同一個變量而不會報錯。下面的代碼是不會報錯的,可是是不推薦的。

var a = 'xiaoming'
var a = 'huangxiaoming'

其實這一特性不利於咱們找出程序中的問題,雖然有一些代碼檢測工具,好比ESLint可以檢測到對同一個變量進行屢次聲明賦值,可以大大減小咱們程序出錯的可能性,但畢竟不是原生支持的。不用擔憂,ES2015來了,若是一個變量已經被聲明,不管是經過var仍是let或者const,該變量再次經過let聲明時都會語法報錯(SyntaxError)。以下代碼:

var a = 345
let a = 123 // Uncaught SyntaxError: Identifier 'a' has already been declared

最好的老是放在最後:const

經過const生命的變量將會建立一個對該值的一個只讀引用,也就是說,經過const聲明的原始數據類型(number、string、boolean等),聲明後就不可以再改變了。經過const聲明的對象,也不能改變對對象的引用,也就是說不可以再將另一個對象賦值給該const聲明的變量,可是,const聲明的變量並不表示該對象就是不可變的,依然能夠改變對象的屬性值,只是該變量不能再被賦值了。

const MY_FAV = 7
MY_FAY = 20 // 重複賦值將會報錯(Uncaught TypeError: Assignment to constant variable)
const foo = {bar: 'zar'}
foo.bar = 'hello world' // 改變對象的屬性並不會報錯

經過const生命的對象並非不可變的。可是在不少場景下,好比在函數式編程中,咱們但願聲明的變量是不可變的,不論其是原始數據類型仍是引用數據類型。顯然現有的變量聲明不可以知足咱們的需求,以下是一種聲明不可變對象的一種實現:

const deepFreeze = function(obj) {
    Object.freeze(obj)
    for (const key in obj) {
        if (typeof obj[key] === 'object') deepFreeze(obj[key])
    }
    return obj
}
const foo = deepFreeze({
  a: {b: 'bar'}
})
foo.a.b = 'zar'
console.log(foo.a.b) // bar

最佳實踐

在ECMAScript 2015成爲最新標準以前,不少人都認爲let是解決本文開始羅列的一系列問題的最佳方案,對於不少JavaScript開發者而言,他們認爲一開始var就應該像如今let同樣,如今let出來了,咱們只須要根據現有的語法把之前代碼中的var換成let就行了。而後使用const聲明那些咱們永遠不會修改的值。

可是,當不少開發者開始將本身的項目遷移到ECMAScript2015後,他們發現,最佳實踐應該是,儘量的使用const,在const不可以知足需求的時候才使用let,永遠不要使用var。爲何要儘量的使用const呢?在JavaScript中,不少bug都是由於無心的改變了某值或者對象而致使的,經過儘量使用const,或者上面的deepFreeze可以很好地規避這些bug的出現,而個人建議是:若是你喜歡函數式編程,永遠不改變已經聲明的對象,而是生成一個新的對象,那麼對於你來講,const就徹底夠用了。

相關文章
相關標籤/搜索