將語法解析器想像成一個程式,在你每次執行 JavaScript 時,這個中介程式會轉換你的程式碼
一個容器,幫助管理正在執行之程式。我們有許多的詞彙環境,程式碼的實際上所在位置,但哪個才是現在正在執行的?就是被執行環所管理。
執行環境包含了你寫的程式碼,正在執行的程式碼,但是它包含的不只你所寫的程式碼,你的程式碼正在被轉換,正在被另一個東西處理
另一個某人寫的程式(編譯器),所以它在執行你的程式碼,另外它也能夠做別的事情。
當我們討論程式碼的詞彙環境時,我們其實是在討論:它被寫在哪裡?它的周圍環境是什麼?
物件就是 名稱/值
配對的組合
A collection of name value pairs.
全域執行環境又稱「基礎執行環境」
當 JavaScript 檔案被執行,就會創造全域執行環境,JavaScript 會自動為你建立了這兩個東西:
如果你在瀏覽器裡全域物件就是 window,你還會得到一個特殊的變數 this,this 會指向全域物件所以就等於 window:
無論你的程式碼何時執行,你的程式碼都被包在執行環境裡。
當執行環境被創造時,在第一階段( Creation Phase 創造階段),變數和函數(這邊指函數陳述式)會被設定放在記憶體裡面,這個步驟叫做「提升」(hoisting)。第二階段是執行。在創造階段我們已經設定好所有的東西了, 然後逐行編譯、轉換程式碼變成電腦看得懂的東西。
在創造階段還會與外部環境連結。
它不是真的把你的程式碼移到最上面,這表示在逐步執行程式碼之前 JavaScript 已經為變數和函數在記憶體中建立空間了,所以當程式被逐行執行時,變數和函數已經存在於記憶體中因此它們可以被找到。
變數的情況有些不同,JavaScript 為變數空出記憶體空間時並不知道它會是什麼值,直到它被執行才會知道,因此此時變數的值是 undefined
,函數則是完全被設定好放進記憶體中。
所有 JavaScript 的變數在一開始都會被設定為
undefined
。
瞭解提升之後,我們可以呼叫一個函數,儘管它在之後才被宣告,因為我們寫出的程式碼不會直接被執行而是會經過 JavaScript 的轉換,在執行環境的創造階段,JavaScript 會在記憶體中空出空間給函數還有變數,這些執行程式時會用到的東西。
範例:
在創造階段(第一階段),函數 b 和變數 a 被放進記憶體,變數 a 的值為 undefined
。
接著執行階段(第二階段):
console.log(a)
(結果為 undefined
)var a = 'Hello'
,將記憶體中的變數 a 的值設為字串 'Hello'console.log(a)
(結果為 'Hello')當未宣告變數 a 時使用到 a 會發生「無法參照」的錯誤,這是因為執行環境被創造時,在創造階段沒有找到 var a
,所以 a 從未在記憶體中出現,所以執行到這行程式碼時會說 「嘿,我沒有在記憶體中找到 a 這個值」所以會顯示參照不到這個值的錯誤 'a is not defined.'
然而,
然而當我宣告 var a
,a 在創造階段時就被放進記憶體中,儘管我還沒設值 JavaScript 已經幫我設為 undefined
,undefined
不代表為空的或不存在,實際上它會佔據記憶體空間,它是一個值,一個特殊關鍵字。
One command at a time. 一次執行一個指令
One at a time and in order. 一次執行一個且照順序
每一次 JavaScript 呼叫函數都會創造一個執行環境然後放進執行堆中,它會有自己的記憶體空間給變數與函數,它會經歷創造階段然後逐行執行函數中的程式碼
範例:
首先,全域執行環境會被創造,這會創造全域物件和 'this',如果是在瀏覽器上全域物件就是 window 物件,然後將變數和函數放入記憶體中(在創造階段),所以函數 a 跟 b 會被放入記憶體(包含 var c
和 var d
和 var e
),接著逐行執行程式碼也就是呼叫 a 函數:
a 會創造自己的執行環境且進入執行堆中:
然後在函數 a 中又呼叫了函數 b 所以 b 又創造了一個自己的執行環境且進入執行堆中的最上層,接著執行 var d
,當 b 函數結束後,b 執行環境會離開執行堆回到 a 函數的執行環境繼續接著執行 var c
:
當 a 函數結束,a 的執行環境會離開執行堆回到全域執行環境執行程式碼最後一行的 var e
。
變數環境只是在描述你創造變數的位置,每個執行環境都有自己的變數環境。
雖然 myVar
被宣告三次,但它們是不同的,彼此沒有關聯。
當 a 函數執行完畢,a 的執行環境會離開執行堆並回到全域執行環境,所以最後一行的 console.log(myVar)
是全域的 1
。
當我們要處理變數時 JavaScript 不只會在目前的執行環境的變數環境中尋找,還會參照外部環境,每個執行環境都有一個可以參照的外部環境。當你需要某個執行環境內的程式碼的變數,如果它無法找到變數,它會到外部環境尋找變數。
記住「範圍」代表我能夠取用這變數的地方,「鏈」是外部環境參照的連結
如何判斷參考到的是哪種外部環境?答案:詞彙環境,程式碼被寫出來的實際位置
別的情況:
因為 b 函數被寫在 a 函數裡面,JavaScript 引擎會認定 b 的外部參照是 a,而 a 的外部環境仍然是全域執行環境,所以當 b 函數找不到 myVar
這個變數,它會往範圍鏈下層尋找,也就是它的外部環境 a,因此值為 2
。
如果在 a 裡面還是找不到,會再往外部找(a 的外部環境是全域執行環境),所以值是 1
。
Scope 範圍或稱「作用域」是指變數可以被取用的區域。如果你呼叫同一個函數兩次,它會各有一個自己的執行環境,裡面的變數看起來相同,但是在記憶體位置中其實是兩個不同的變數。
用來宣告一個變數像 var
一樣,不過 let
是塊級作用域, var
是函數作用域。
const
用來宣告一個常數且一定要賦值,其值不能再藉由指派運算子(例如等號運算子)進行變動,否則報錯。常數名稱可以用大寫表示以利區分:
注意!當你用 const
宣告物件時有例外情況:
因為變數存取物件(包含陣列)的方式是存取記憶體位置,原則上此 obj 物件的記憶體位置(指引)沒有被更動,被更動的是指引指向的值。
回顧一下:從博物館寄物櫃理解變數儲存模型。
不過若你用 const
宣告了一個物件,當你想要修改這個物件的內容時(再將其他值指派給這個變數),JavaScript 引擎會認為你要創建新的物件,所以就會有新的記憶體位址,這樣常數就會被改變,所以會報錯:
let
與 const
是區塊作用域 (block scope) {}
包起來的區域,var
則是函式作用域 (function scope)。
使用 var
宣告變數 a 時,可用範圍在 test function 內,即便 console.log(a)
在 if
block 之外仍能讀取到 a 的值:
使用 let
或 const
宣告變數 a 時,可用範圍在 if
block (大括號) 內,所以 console.log(a)
在離開 if
block 的區域便無法讀取到 a 的值:
使用 let
與 const
宣告變數,在創造階段變數一樣會被放入記憶體中,但沒有初始化為 undefined。到了執行階段,賦值之前嘗試取用的話會發生錯誤,這一段不能取用變數的期間稱為暫時性死死區 (Temporal Dead Zone)。
因應 ES6 的出現,使用上建議不要再用 var
來宣告變數,優先使用 const
,若須重新賦值再使用 let
,藉由限縮變數的活動範圍來減少發生錯誤的可能。
Asynchronous means more than one at a time.
非同步表示在一個時間點不只一個,可能有一段程式在執行時會開始執行另一段程式碼,然後又會再執行別的程式碼。
此小節內容節錄並改寫自 Huang Pei
先說結論:
JavaScript 可以不用一直等到一件事做完再做下一件事,它可以先把非同步事件丟到事件佇列 (Event Queue),先去執行別的事(執行堆Execution Stacks 中的),等消化完執行堆後,再把事件佇列中的事拉出來完成。
所以不是真的非同步!JavaScript 不是一次做很多事,他只是調整了執行的順序,用同步的方式處理非同步事件。
JavaScript 本身是同步的,它一次執行一行程式碼。
然而瀏覽器本身不是只有 JavaScript,還要和其他引擎 (Rendering Engine, Http request..) 相互連動運行,只有 JavaScript 是同步的,那麼要如何和其他引擎及請求互相配合時又繼續跑程式碼呢?
JavaScript 引擎內的等待列則稱為事件佇列(event queue)
這裡面都是事件、事件通知,這些可能要發生的,所以當瀏覽器,在 JavaScript 引擎外的某處有一個需要被通知的事件,在 JavaScript 引擎裡會被放到佇列裡。
當執行堆是空的 JavaScript 才會注意事件佇列。
執行堆尚未被消化,而事件佇列中有尚位處理的 Click 和 HTTP:
當執行堆被清空了,才開始處理 Click 事件,將事件的函數拉出來執行,而 HTTP 待命中:
範例:落落長的程式碼,結論在下面
就算設定了時間函數延遲了 3 秒,在這 3 秒間就算怎樣狂點滑鼠,click event! 的訊息也不會出現,因為要等三秒過後 waitThreeSeconds 函數執行環境結束 => 全域執行環境結束(execution stacks 清光了),才會跑事件佇列內的 click 事件。
JavaScript 處理型別的方式為「動態型別 」(Dynamic typing)
而像 Java 或者 C#,它們用「靜態型別 」(Static typing) 方式處理,這代表你必須在一開始就告訴編譯器你的變數是什麼資料型別,所以你可能會有一個關鍵字, 像是「bool」去表示這個變數的型別是布林值,可能是 True 或 False,如果你將其他型別的值放進這個變數就會得到 error。
JavaScript 有六種基本型別(純值):
運算子只是一個特殊的函數,但它和其他你自己寫的函數不同。一般來說,運算子需要兩個參數來回傳一個結果。例:加減乘除等運算符號 ( + - * /
)
運算子優先性表示哪個運算子被優先運算。
在同一行程式有不只一個運算子時,具有高優先性的運算子會先計算。
如果有很多的運算子在同一行程式碼中,優先性能夠幫助判斷誰先,但如果全部的運算子優先性都相同,這時就要看相依性。
範例:
重要
等號 =
的相依性是 right-to-left,所以先執行 b = c
,值得注意的是 b = c
會回傳等號右邊的參數(也就是 c,值為 4),接著繼續往左執行 a = 4
。
範例:
數值 1 加上字串 2,數值 1 會被強制轉型成字串
數值 + 字串 會變成一個字串
範例:
3 < 2
的回傳值為 falsefalse < 1
,此時 false 會被強制轉型成數字 00 < 1
,所以結果為 true當以下這些值被轉成數值時的情況:
undefined 會被強制轉型為 NaN
null 會被強制轉型為 0
false 會被強制轉型為 0
true 會被強制轉型為 1
雙等號會進行強制轉型而三等號不會
記得前面說過 null
被強制轉換成數值時會變成 0,但是 null == 0
的結果是 false,很奇怪吧!雙等號很有可能會造成非預期的行為還有很多疑問,所以你應該盡量使用三等號 ===
而不是雙等號 ==
,除非你需要或是故意使用雙等號。
什麼是 Object.is?
Object.is
simply has to be thought of in terms of its specific characteristics, rather than its looseness or strictness with regard to the equality operators.簡單來說,Object.is 可以用來處理一些特別的情況,例如 NaN, +0, -0。
if 陳述句中的條件(括號裡面的的程式碼)會被強制轉型成布林值,所以 a 是什麼其實不重要,我們可以利用強制型轉的特性來檢查變數有沒有值。
可以試著用布林內建函數來看看一些特定值被轉成布林值的情況:
「或」運算子 ||
的回傳值是什麼?
「或」運算子會回傳第一個可以被強制轉型為 true
的值,記住回傳不是 true
或者 false
而是一個值。
下面是一個未幫函數的參數添加預設值的例子:
加上預設值的寫法:
ES6 更簡單的預設值寫法:
這些「點」和「中括號」是 Property accessors 又稱「屬性存取器」,他們都只是函數,是運算子,一個取出物件內容的方式。參考運算子相依性表格可以發現「點」和「中括號」都是左相依性 letf-to-right,遇到連續的屬性存取器記得從左邊往右開始執行。
補充:建議你只用「點」運算子,他很簡潔、很清楚也很容易除錯,除非你真的需要用動態字串取用屬性。
直接用大括號 {}
的方式來宣告一個物件就是物件實體語法,這是一個比較快速也建議用的方式:
還能同時建立屬性和方法:
混和「點」運算子和物件實體語法:
為什麼可以這樣做?因為你寫的程式碼並不是真的直接被處理,它會先被JavaScript轉化成電腦能懂的東西。
簡單來說偽裝命名空間就是利用創造物件來避免相同名稱的變數被覆寫的問題
JSON 是一種資料交換的格式,它和物件實體非常相像因為它就是被物件實體寫法所啟發。
上面的物件是一個有效的物件實體寫法,物件實體中的屬性「可以」被引號包起來(單雙引號都可),但是在 JSON 中的屬性「一定」要被引號包起來。所以一個有效的 JSON 格式就是一個有效的物件實體,但不是所有的物件實體語法在 JSON 格式都是有效的。
JSON.stringify()
:將一個物件實體轉換成 JSON 字串。
JSON.parse()
:將一個 JSON 字串轉換成物件實體。
函數可以有屬性和方法,為什麼?因為它就只是個物件!所以它可以連結到純值、連結到物件和連結到其他函數。
在 JavaScript 函數物件有一些隱藏版的特殊屬性,其中有兩個重要的:
第一個是名稱,JavaScript 的函數不一定要有名稱,一個函數可以是匿名的,但它可以有名字。
第二個重要屬性為「程式屬性」(code property),你寫的程式碼會成為函數物件的特殊屬性,你寫的程式碼並非就是函數本身。這個函數是有其他屬性的物件,你寫的程式碼只是其中一種屬性,是你加上去的,這個屬性特別的是,它是可以呼叫的。
範例:
我替 greet 函數新增了一個屬性,因為函數就是物件!
當這個 greet 函數被創造,這個函數物件會被放進記憶體,這個情況中,是全域物件,它有名稱,它的名稱是 greet 因為我幫這函數命名的,然後有程式屬性,包含了我寫的程式碼,你可以想像函數只是程式碼的容器。如果我用括號呼叫 greet,這會呼叫函數,讓它執行,讓執行環境被創造。
什麼是一級函數?
你可以對別的型別,如物件、字串、數值、布林做的事,你都可以對函數做,你可以指派一個變數的值為函數,你也可以將函數當做參數傳入另一個函數。
下面的範例都是表達式:
以 if 陳述式為例:
你不能再將一個 if 陳述式指定給一個變數, 因為 if 陳述式是陳述式,它沒有回傳值。
通常會宣告一個變數,它的值是一個函數,記得函數就是物件,我們運用「等號」運算子將函數物件存入記憶體中,這個變數會指向它的記憶體位址。
範例:
這個例子中,這個函數物件被放到記憶體中並且指向 anonymousGreet
這個變數的位址,要注意的是 anonymousGreet
不是函數的名稱,它是記憶體位址。這個函數沒有名稱,在 function 關鍵字
的括號前面沒有放任何東西而且也不需要,因為我已經知道指向物件位址的變數,所以我不需要一個名稱去參照它,因此這個函數又稱為「匿名函數」。匿名函數就是沒有名稱屬性的函數,但這並沒有關係,因為你可以利用指向物件位址的變數名稱來參照到它。
回顧一下提升:
當執行環境被創造時,在第一階段( Creation Phase 創造階段),變數和函數(這邊指函數陳述式)會被設定放在記憶體裡面,這個步驟叫做「提升」(hoisting)。
從 JavaScript 的運作觀點來看,函數表示式只會提升變數而所有 JavaScript 的變數在一開始都會被設定為 undefined
,因此在執行到函數表示式之前(指派一個函數物件給一個變數),這個變數還是 undefined
,所以它不能拿來當作一個函數呼叫。
當我有一個變數 a 且其值為純值,當執行 b = a
(將 a 的值指派給 b)的時候,變數 b 會指向一個新的記憶體位址且其值是拷貝 a 而來與 a 相同,所以當我改變 a,它不會對 b 有任何影響,因為 b 只是 a 的拷貝,它有自己的記憶體位址。
傳值就是藉由拷貝一個值到另一個不同的記憶體位址。
當我有一個變數 a 且其值為物件(包含函數和陣列),當執行 b = a
(將 a 的值指派給 b)的時候,變數 b 不會得到一個新的記憶體位址而是會指向 a 的記憶體位址,a 的值不會被拷貝,所以當我改變 a, b 也會被影響,因為它們指向同一個記憶體位址。
更多範例:
此小節改寫並節錄自 從博物館寄物櫃理解變數儲存模型。
當你想存取的變數是「純值」 Primitive values (數字、字串等等)的時候,變數裡面存的內容就真的是那個值。
但如果你想存物件的時候,變數裡面存的內容其實是記憶體位址。
a 跟 b 看起來長的一樣卻不相等是因為變數存取物件(包含函數和陣列)內容的方式是存取記憶體位置,a 與 b 指向不同記憶體位置,所以不相等,obj1 和 obj2 同理。然而,c 和 d 存取的內容是純值(這邊是數值),內容就是值本身,所以 c === d
的結果為 true
,儘管它們的的記憶體位址不同。
當函數被呼叫時會創造新的執行環境,每個執行環境會有自己的變數環境,會有參照的外部環境,JavaScript 引擎還會給我們一個不曾宣告的變數**「this」**。在不同情況下,this 會根據函數是如何被呼叫的而指向不同的東西。
在全域環境下變數 this 會指向全域物件,在瀏覽器裡就是 window:
結果:
那麼呼叫函數的時候呢?
結果:
不管是函數陳述式還是函數表示式,它們的 this 都會指向全域物件,在瀏覽器裡就是 window。
那麼物件方法呢?
若一個物件的屬性的值為函數稱為「方法」。
結果:
物件方法中的 this 會指向包含這個方法的物件(變數 c)。這非常好用,我們可以取用到同一物件的方法或屬性。
例如,利用 this 改變包含這個方法的物件:
這裡有一個需要注意的地方,如若在方法裡面又宣告一個函數,這個變數裡的 this 卻會指向全域物件 (window),很多人認為這是 JavaScript 的 bug,沒有程式語言是完美的,它們都有奇怪的地方, JavaScript 也是:
結果:
那要怎麼改善這個問題呢?要確保在方法中宣告的函數裡面的 this 會指向物件本身,可以利用物件傳參考的特性,將變數 this 指派給另外一個變數(這邊取為變數 self):
因為傳參考的特性,變數 self 會指向變數 this 的記憶體位址(方法中的 this 指向 c 物件本身),然而在 setName 這個子函數中沒有宣告變數 self,JavaScript 會從範圍鏈找,setName 子函數的外部環境便是 log 方法,所以能夠取用到變數 self。
結果:
範例:
參數 (arguments) 只是你傳入函數的變數的另一個稱呼而已,也可稱呼參數為 parameters,它們都是一樣的。
arguments
本身也是一個特殊關鍵字,它代表了所有傳入到所呼叫的函數裡作為參數的值。它很像陣列 (array-like) 卻不是陣列,因為它只有一部份的陣列功能。範例:
結果:
隨著 ES6 其餘運算子的出現,arguments
會逐漸過時但它仍然存在,不過不是最好的方式了。
如果你結束了一行程式碼,按下 Enter 鍵, 就出現一個 carriage return,它是個看不見的字元,但的確是個字元。在某些情況下,當語法解析器看到 carriage return 會自動幫你插入分號,所以你可以不自己打出來並不是因為不需要,而是因為 JavaScript 引擎會幫你在它認為該放的地方補上。
然而,這有時候可能會造成一些問題,例如當 JavaScript 引擎在關鍵字 return
後面發現 carrige return(就是按下 Enter 鍵換行意思)會自動替你加上分號也就是會變成 return;
,你會得到 undefined
的結果,因為你並沒有 return 任何東西。
要解決這問題,我們需要告訴語法解析器我們正在做什麼,因為語法解析器會隨著一個字母一個字母解析,所以我們在 return
後面用空格接一個大括號(把大括號移到跟 return
同一行),告訴語法解析器:「我們要開始用物件實體語法了喔!」之後它看見 carrige return 就不會自動插入分號了。
你可能會注意到大括號幾乎都放在跟函數、for 迴圈還有 if 陳述句同一行,這不是每次都是必須的,這樣做是為了萬無一失,避免產生非預期的問題。
範例:
其他範例(最常見的樣子,標準的 IIFE):
注意 這個範例中的 IIFE 是被括號包起來,如果沒有括號包起來,當 JavaScript 引擎看到 function 關鍵字
作為程式碼的第一個字或是在分號之後,會認為它是一個函數陳述式,然而函數陳述式必須要有一個名稱,不可以是匿名的,否則會報錯。
整體概念就是將程式碼都包在 IIFE 裡保證它不會和其他東西衝突(其他被 include 進來的程式碼),所以程式碼是安全的。因此在很多資源庫或框架中,如果你打開他們的原始碼,第一個看到的東西就是括號和函數。
範例:
一個 greeting 變數
存在於全域環境中,另一個在 IIFE 創造的執行環境中,它們兩個都存在,只是在不同的執行環境裡還有不同的記憶體位址。
閉包結論:執行環境會將它的外部變數包住,那些它應該要參考到的變數,這個包住所有取用的變數的現象稱為閉包。閉包只是一個功能,確保當你執行函數時,可以取用到外部變數,它不在乎外部的執行環境已經運行完畢了沒。
範例:
執行完這段程式碼的 console.log 結果為 Hi Tony
,看起來沒什麼問題,然而,不尋常的事情已經發生了。我們停下來思考一下。當 greet 函數被呼叫的時候,變數 whattosay 被創造在這個函數的執行環境裡面,然後這個函數就結束了,它會離開執行堆 (execution stack)。接著我們呼叫 sayHi 函數,sayHi 函數還是能夠參照到變數 whattosay 的值 ('Hi'),即便變數 whattosay 所存在於的 greet 函數的執行環境已經從執行堆離開。為什麼 sayHi 函數仍然知道變數 whattosay (也就是 'Hi')是什麼呢?因為它是閉包!
解析:
當程式開始,全域執行被創造:
執行到這一行 var sayHi = greet('Hi')
時,會呼叫 greet函數,一個新的執行環境被創造然後進入執行堆,變數 whattosay ('Hi') 被傳入到這個變數環境, 接著 greet 函數會立刻創造一個新的函數並且回傳:
在回傳後,greet 函數的執行環境就會離開執行堆,然而當執行環境結束時記憶體空間仍然存在(不必要的記憶體會被 JavaScript 引擎的垃圾回收機制清除):
現在我們回到全域執行環境中了,然後會呼叫 sayHi 指向的函數,一個匿名函數,它會創造新的執行環境,還有我們傳入的的變數 name ('Tony'):
當執行到 console.log(whattosay + ' ' + name)
這行的時候,JavaScript 引擎看到變數 whattosay 時,它會回到範圍鏈去參照外部環境。即使 greet 函數的執行環境已經沒了,已經離開執行堆了, sayHi 的執行環境仍然可以參考到變數 whattosay,仍然可以參考到它的記憶體位址。
執行環境將它的外部變數包住,那些它應該要參考到的變數,這個包住所有取用的變數的現象稱為閉包 (closure):
**我們不需要在意函數何時呼叫也不用擔心它的外部環境是否還在執行,JavaScript 引擎永遠會確保無論我在執行哪個函數,它都能取用到應該要取用到的變數。**閉包只是一個功能,確保當你執行函數時,它會正常運作,可以取用到外部變數,它不在乎外部的執行環境已經運行完畢了沒。
範例:
fs 內的三個函數的 cosole.log 結果都為 3,是因為 變數 i
是由 var 宣告,var 是函數作用域,在 buildFunctions 函數內的 變數 i
只有一個,所以經過 for 迴圈後 變數 i
的值會一直被覆寫,跳離迴圈後 i
的值為 3。當呼叫 fs 內的函數時,會去參照外部環境的 變數 i
也就是 buildFunctions 函數內的 變數 i
,值為 3,所以 fs 內的三個不同的函數都會參照到同一個值為 3 的 i
,因次就會印出三次 3。
需要注意的是 console.log(i)
這行是在程式碼最後三行呼叫函數的時候才會執行,在 for 迴圈裡只是創造函數而已並還沒有呼叫。
記得呼叫 fs 內的函數的時候,儘管 buildFunctions 函數的執行環境已經離開執行堆,存在於 buildFunctions 函數的執行環境內的變數的記憶體位址仍然還在,所以還是能夠參照到,因為閉包的關係:
那麼要如何才能讓依序呼叫 fs 內的函數的 console.log 結果為 0 1 2
而不是 3 3 3
?
第一種方式,方便快速也是比較建議的方法,使用 ES6 的 let 來宣告 變數 i
:
因為 let 是區塊作用域 Block scope({}
大括號範圍內),所以每一次 for 迴圈宣告的 變數 i
都是新的獨立變數,它的值不會被覆寫。你可以將 for 迴圈的程式碼想成這以下這樣:
更詳細精確的 for 迴圈執行模擬可參考 Day 05: ES6篇 - let與const。
本小節參考資料: 阮一峰 let 和 const 命令 | 從 V8 bytecode 探討 let 與 var 的效能問題
第二種方式,為了要保存不同的 變數 i
可使用 IIFE (立即呼叫函數)包住:
每當迴圈執行時,都會有一個立即呼叫的函數 (IIFE),它會創造個別的執行環境,保存當下不同的 i
的值(也就是 變數 j
)。因為閉包的關係,所有的 j
都會好好待在分別不同的執行環境,所以當要呼叫被回傳的匿名函數的時,它能參照到對應的 外部變數 j
而不是跑到最外層去找只有一個且會被覆寫的 i
。
瞭解閉包的特性後,我們能將程式碼寫的易讀性更高。
以下程式碼範例來自章節 框架小叮嚀:重載函式 :
我們可以將上面的程式碼改寫成 factory 的模式(可以將 factory 想像成一個工廠,它會回傳幫我們做事的函數),不需要每次都傳入一樣的參數,我們可以回傳(創造)新的函數,用閉包製造預設的參數:
JavaScirpt 內建的 setTimeOut()
與監聽事件都是非同步事件,它們都會進入事件佇列 (Event Queue),當執行堆清空後(執行環境都結束了)才會開始跑事件佇列,所以其實也會運用到了閉包的特性:
當執行到 setTimeOut()
裡的 console.log(greeting)
時,還是能夠參照到 外部變數 greeting
,儘管 sayHiLater()
的執行環境已經結束。
所有函數都可以取用 call()
、apply()
與 bind()
這三種方法:
bind()
方法會回傳原函數的拷貝,在 bind()
被呼叫時,傳入 bind()
的第一個參數就會被指定為這個新函數的 this
,傳入的剩下的參數也會作為新函數的參數。語法:
範例:
也可以直接這樣寫:
bind()
相關:Function currying範例:
call()
不像 bind()
會回傳一個原函數的拷貝而是真的會呼叫它,傳入 call()
的第一個參數一樣可以改變 this 指向的物件,剩下的就是傳給函數的參數範例:
apply()
基本上與 call()
一模一樣,只是 apply()
接受完第一個參數後,必須接受一個陣列作為參數。語法:
範例:
我們藉由使用 call()
、apply()
或 bind()
來讓 person2 物件借用 person 物件中的 getFullName()
方法 ,這種行為稱為**「函數借用」(Function borrowing)**。
JavaScript 中的所有物件(包含函數)都有一個屬性為 __proto__
,這個屬性會參考到另一個物件,我們稱為 proto,proto 就是該物件的原型 (prototype)。
假設我們現在有一個 物件 obj
,它有屬性 prop1,還會有一個名稱屬性為 __proto__
,這個原型屬性會參照到 物件 proto
,而 物件 proto
就是 物件 obj
的原型,物件 proto
則有屬性 prop2。
如果我們用「點」運算子去存取 prop2 屬性:
在 物件 obj
並沒有屬性 prop2,它便會往原型(也就是 物件 proto
)找,找到之後回傳。這看起來很像 prop2 在 物件 obj
上,但其實在我們的原型物件上。
然而,原型物件也可以指向另一個物件,每個物件可以有自己的原型。我們在假設 物件 obj
的原型 物件 proto
也有自己的原型並且有屬性 prop3:
我們用「點」運算子去存取 prop3 屬性:
物件 obj
上沒有找到屬性 prop3,所以往它的原型 物件 proto
上找,物件 proto
也沒有找到prop3,所以它會繼續往另一個原型proto 找,最後找到 prop3 後回傳。
這個一直往原型尋找屬性的範圍又稱為「原型練 (Prototype chain)」:
原型屬性是隱藏起來的,所以我們並不用寫 obj.proto.proto.prop3
,只要寫 obj.prop3
就好,JavaScript 引擎會搜尋原型鏈找屬性和方法。
有趣的是,如果我有另一個 物件 obj2
,它可以指向同一個原型,所以物件可以分享一樣的原型:
當我取用 obj2.prop2
的時候,會和 obj.prop2
一樣,它們有同樣的記憶體位址,共享一個屬性。prop2 不在 obj 和 obj2 上,這是當JavaScript 引擎到原型鏈上搜索時會指向同一個地方。
實際範例:
物件 john
並沒有 getFullName()
這個方法,所以往原型練找,getFullName()
方法中的 this 會指向呼叫它的物件,在這邊就是指 物件 john
,所以會印出 John Doe。
物件 john
已經有 firstname 屬性,所以不會再往原型練尋找,直接印出 John。
物件 jane
並沒有 getFullName()
這個方法,所以往原型練找,getFullName()
方法中的 this 會指向呼叫它的物件,在這邊就是指 物件 jane
。然而,jane 也沒有 lastname 屬性,所以再往原型 person 找,最後印出 Jane Default。
若我們使用 for in 迴圈將 物件 john
的屬性印出來,會發現原型鏈上的屬性都會被找到印出,這很有用:
但是如果只想找物件上的屬性而不想要去原型鏈上找的話,可以使用 hasOwnProperty()
方法,這是 JavaScript 基本物件的方法:
部分的純值和物件都有原型,除了 JavaScript 的基本物件。
null
被歸類為物件(這是一個萬年 bug 但 JS 也沒有要修正它),它沒有原型,它是原型鏈(prototype chain)的終點。
JavaScript 的創造者 Brandon Eich 當時工作的公司為了要吸引 Java 的開發者而將 JavaScript 命名為 JavaScript。javaScript 聽起來與 Java 相似,但其實一點都不像 Java,這只是行銷手法。其中一個行銷元素就是 Java 的開發者習慣用關鍵字 new 去建立物件,所以 JavaScript 也跟進或者說模仿用關鍵字 new 來建立物件。
new 是一個運算子,語法如下:
建構子 (constructor) 可以是一個 class 或是 function。
描述:
new 運算子會先建立一個空物件,然後呼叫函數建構子,此執行環境中的 this 會指向這個被建立的新物件,如若這個函數建構子沒有回傳值或者回傳值不是物件的話,new 運算子就會回傳 this;如若這個函數建構子的回傳值是物件的話,就回傳該物件。
範例:
JavaScript 所有的函數都有原型屬性 (.prototype
property),從它是空物件時就誕生,除非你將函數作為函數建構子來使用,不然它就只是待在那,永遠不會用到,不過一旦你用 new 運算子呼叫函數,它 (.prototype
property) 就有意義了。
注意!劃重點:函數建構子的原型屬性 (.prototype
property) 就是你用函數建構子創造的物件的原型 (.__proto__
)。
範例:
函數的原型屬性 (.prototype
property) 並不是函數本身的原型喔! .prototype
屬性是函數作為建構子才有意義的屬性,而 .__proto__
屬性是每個物件(包含函數)都有且 .__proto__
的屬性值就是該物件的原型。
承上我們為物件 john 和 jane 添加一個 getFullName()
方法:
那為什麼不將方法跟屬性一樣寫在建構子裡面呢?像以下這樣:
因為函數就是物件,它們會佔據記憶體空間,如果我要 new 1000 個 person,這樣就會有一千個 getFullName()
方法,但如果我是增在原型上就只會有一個。雖然我有一千個物件,但只有一個方法,就效能的觀點來看,將方法放在原型上比較好。
此章節重點就是建議永遠把函數建構子的名稱的第一個字母設為大寫,這樣比較容易除錯。所以上面的範例都應該改成:
這單元如果看不太懂,可以參考一下後面的補充。
JavaScript 內建的函數建構子的名稱也都是首字母為大寫,這此以 String()
為例:
new 運算子會建立一個物件,所以 a 是一個物件但包含著一個字串還有一些附加功能,附加功能指的是 a 的原型 (.__proto__
) 就是建構子 String()
的原型屬性 (.prototype
),所以 a 能取用一些在原型上的屬性或方法,例如:a.length
或 a.repeat(2)
等等。(a 本身並沒有這些屬性和方法,所以會往原型找。)
所以我們能夠添加一些自定義的方法給所有同一個函數建構子所建立的物件使用,同樣以 String()
為例:
再以內建的 Number()
建構子為例:
當你宣告一個數值、字串或布林值時,你會發現其實它們的原型就是 JavaScipt 內建的建構子的原型屬性:
或者當你不宣告直接使用時,它們也會是 JavaScipt 內建的建構子的原型屬性:
備註:數值沒辦法直接使用「點」運算子,必須用括號包起來或者寫成浮點數的形式,數值 3 要寫成 3.0,不然會報錯。不過實作的時候通常都會宣告變數來用,所以應該會很少遇到這個情況。
所以這些純值其實能夠使用它們原型上的屬性或方法:
a 是純值,b 是物件,在用雙等號運算子比較時 b 會進行強制轉型成數值,所以相等;使用三等號運算子則不會強制轉型,a 就是純值,b 就是是物件,所以不相等。
一般來說你應該使用實體語法而不要用函數建構子比較好,除非你知道自己在做什麼。
當你用 for 迴圈遍歷一個陣列時,你會發現索引值 (0、1、2) 其實是屬性名稱,陣列中的值 ('John'、'Jane'、'Jim') 其實是屬性值,因為陣列就是物件。
要注意的是在 第 5 節:JavaScript 的物件導向與原型繼承 的 瞭解原型單元已經有提到過,若使用 for in 迴圈遍歷物件屬性,該物件的原型鏈中的屬性都會被找出來。
所以上面案例,我在陣列 arr 的原型 .__proto__
(也就等於 Array() 建構子的原型屬性 .prototype
)新增的自定義屬性 myCustomFeature 也會被印出來,所以使用標準的 for 迴圈是比較安全的。
Object.create()
會建立一個空物件,它接收另一個物件作為參數且這個參數物件會是新建立的空物件的原型。
範例:
如若瀏覽器沒有支援 Object.create()
的話可以自己寫一個 Polyfill:
課程講師在此章節沒有著墨太多,所以我查了一些資料作為補充。
提前結論:類別 (Classes) 只是語法糖而已,它與函數建構子其實在做一模一樣的事情。
定義:
Classes 實際上是一種特別的函數,就跟你可以平常使用函數一樣,你可以用類別表達式 (class expressions) 和類別宣告式 (class declarations)。需要注意的是 Class 並不會提升 (Hoisting),你必須先宣告你的類別,然後才能存取它,否則會報錯:
範例:
Classes 的主體是大括號 {}
包含的部分,裡面只能有關鍵字 constructor
和其他方法 (methods)。
關鍵字 constructor
也是一個方法,用來建立和初始化一個 class 物件,且一個 class 只能有一個 constructor 否則會拋出 SyntaxError。
關鍵字 extends
用來建立子類別:
類別 Dog 繼承了類別 Animal,Dog 的原型 (.__proto__
) 就是 Animal。
若子類別想要取用變數 this 必須在 constructor
中先呼叫 super()
,否則會報錯。super()
用來呼叫父類別的 constructor()
:
若想判別某值是否為陣列型態可使用 Array.isArray()
:
.prototype
) 是否在一個物件的原型鏈上並回傳一個布林值: