JS 原力覺醒 Day18 - Functional Programming

今天要介紹 Functional Programming ( 簡稱FP ) ,FP 是一種程式設計的思考方式,寫程式寫過一段時間的人幾乎都會聽過這個概念,對某些人來說,想要進入資深階段的話,學習 Functional Programming 是一件不可或缺的事情。

Outline

  • Functional Programming
  • 為什麼要使用 FP
  • 純函式 ( Pure functions )
  • 複合函式( Function composition )
  • 共享的狀態 ( Shared State )
  • 不變性( Immutability )
  • 避免副作用( Side Effect )

Functional Programming

Functional programming ( 簡稱FP ) 用比較嚴謹的說法,是一種程式設計方法 ( Programming Paradigm ),意味著他是一種根據某些基本原則來進行開發的軟體架構,聽名字應該可以了解他是以函式為主的開發方式,與之相對的是物件導向程式設計,指的是以 物件(Class)為主的軟體架構。

為什麼要使用 Functional Programming

使用 FP 可以讓程式碼看起來更簡潔,且對功能的描述更精準、所以也就更好進行測試,對開發來說有不少好處,但是如果你對 FP 以及相關的概念還沒有很熟悉,FP 的程式碼也可能讓你需要更多時間來閱讀。

Functional Programming 對初學者來說聽起來可能會有點嚇人,不過如果你是一個有一點經驗的開發者,那麼你可能其實已經使用過 FP 的概念了,只是你不知道而已。在你能夠真正了解什麼是 Functional Programming 之前,有幾個相關的概念必須先理解:

  • 純函式 ( Pure functions )
  • 複合函式( Function composition )
  • 共享的狀態 ( Shared State )
  • 可修改性 ( Immutability )
  • 避免副作用( Side Effect )

也就是說,如果想要知道 FP 具體來說是什麼的話,就必須了解上述幾個基本概念,在今天的介紹裡面,我會把這些概念依序做簡單的介紹,下面就讓我們一個一個來看看吧。

Pure Function

純函式有很多對 Functional Programming 非常重要的特性,後面有許多進階概念都是基於純函式的概念演變出來的,純函式的特性包含:

  1. 同一個輸入純函式的參數,永遠都會回傳相同的結果。
  2. 純函式永遠都不會有造成 Side Effect 的操作出現,如 API 拉取、裝置的I/O、或者對函式外部資料的修改。

https://ithelp.ithome.com.tw/upload/images/20191003/20106580vpvJ133cIB.jpg

Functional Composition

複合函式的概念來自數學,是指如何組合兩個以上的函式並依照組合的順序去產生另外一個新的函式,或是做些運算。在數學裡面,我們常常用 f(g(x)) 來表示複合函式,意思就是把 g(x) 運算產生的結果值,傳入 f() 函式裡面 。 JS 之所以也能做到類似的行為,是基於 JS 被稱為「一級函式」的概念。(把函式當作參數傳入另外一個函式)

舉例來說,我們想要表現 1 + 2 * 3 的話,可以用兩個函式來表示並組合:

const add = (a, b) => a + b;
const mult = (a, b) => a * b;
add(1, mult(2, 3))

我們寫了加法跟乘法的函式,並將兩個函式組合,就能夠表現出「先乘後加」的行為,這就是複合函式的基本概念。

Shared State

共享的狀態 ( Shared State )是指任何存在被數個分離的範疇。像是像全域範疇或是前面提到的閉包 ( Closure ) 所共享的這類狀態, 通常就是共享的狀態,在 Funtional Progaramming 裡面,共享的狀態應該避免,因為一但函式內有與其他範疇共享的狀態出現,那麼這個函式就不再是純函式了。 一個共享狀態的例子看起來就像這樣:

let age =  15 
function setUserAgeByInfo( info ) {
	age = info.age 
  return age
}
setUserAgeByInfo({age:100})

根據上面的程式碼,一但我們執行上面的函式之後,全域變數 userInfo 就會受到影響,這就是因為該變數(狀態)同時與全域範疇跟函式範疇共享的結果。這還只是比較小規模的例子,想想看,如果同時有十個函式都這樣使用全域變數,那麼會出現開發者無法避免的情況,也就不奇怪了,所以在使用狀態時,越是全域的狀態,就要越小心使用。

Immutability

當我們說一個物件是 Immutable ,那就表示這個物件在被產生之後,就無法再被修改了;反過來說,一個 Mutable 的物件,就是指在物件被產生後,還可以被修改,在 JS 內,用一般的方式產生的物件,就是這類 Mutable 的物件。不變性是 Functional Programming 的核心概念,因為如果沒有不變性的存在,我們在寫 FP 時就難以追蹤到狀態的歷史變化,奇怪的、無法理解的 Bug 就越有可能出現。

在 JS 的 ES6 版本後出現了使用 const 的宣告方式,const 很容易被跟不變性產生聯想,但其實是兩個不同的概念,const 是產生一個無法再被重新指派的變數而已,但是他並非產生一個 Immutable 的物件,不相信的話你可以試試看下面的程式碼:

const user = { name:'Yoda' } 
user.name = 'Luke'

真正的 Immutable Object 可以用 Object.freeze 這個函式被產生出來:

const a = Object.freeze({
  foo: 'Hello',
  bar: 'world',
  baz: '!'
});

a.foo = 'Goodbye';
// Error: Cannot assign to read only property 'foo' of object Object

在 JS 裡面,也有一些函式庫可以用來幫助你以完全 Immutable 的方式來開發,例如 Immutable.js 。

Side Effects

副作用(Side Effects)是指在被呼叫的函式外部,任何可以被看到的狀態改變,剛剛我們提到的狀態共享,就是有可能造成 Side Effect 的原因,Side Effect 的幾個例子如下:

  • 使用consoie.log 印出值
  • 寫入檔案
  • 拉取第三方 API
  • 呼叫其他任何有副作用的函式

副作用在 FP 內必須極力避免,因為如此一來,才能讓函式變得更簡潔,而且更好測試。

我們看了這麼多概念,其實有幾個概念幾乎是重複的,例如避免副作用、減少狀態共享、使用純函式,在我看來,這些概念都著重於「減少依賴」這件事情,也就是兩個不同部分的程式碼,他們所使用到的資訊應該要是完全獨立的,如此一來,也才能夠讓程式碼更乾淨好閱讀。

參考文章

Master the JavaScript Interview: What is Functional Programming?

Functional JS #3: State

JavaScript: What Are Pure Functions And Why Use Them?

JS 原力覺醒 Day17 - this 的四種繫結

今天要談到的是 JS 裡面最常被提出來討論的部分,也就是 this 的指向,前面有提到當全域執行環境被產生出來之後,除了全域物件 window ,一個指向這個 window 物件的 this 也會跟著被產生。所以接下來你就可以用 this 來指稱 window 物件,除此之外, this 並不永遠都指向 window 物件,根據不同的呼叫方式,this 所指向的值也會不一樣,所以,你「如何呼叫」這件事情就會很大一部分影響 this 的指向。

Outline

  • Javascript 裡面的 this
  • 預設繫結 (Default Binding)
  • 隱含的繫結
  • 明確的繫結(Explicit Binding
  • new 繫結
  • this 繫結的優先順序
  • 參考書目

Javascript 裡面的 this

在正式進入 this 解說之前,我們先來了解一下為什麼 this 這麼重要, this 讓我們可以很方便地從執行環境內部取得外部物件,用另一個方式說就是,this 可以讓我們在呼叫函式時們決定要指向哪一個物件。

不過如果沒有好好使用的話,就會出現 this 指向錯誤的物件之類的不如預期的情況出現,所以我們在使用之前,一定要先了解 this 檯面下的運作方式。

四種繫結 ( Binding )

所謂 this 的繫結指的是指向哪一個物件, this 大致上一共有四種繫結,讓我們一個一個來看看:

1. 預設繫結 ( Default Binding )

預設繫結:foo 的 this 被 bind 到全域物件Window底下,這是最常見也最好理解的繫結。

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

2. 隱含的繫結

隱含繫結:隱含的指出 this 綁定的對象,使用 . 可以取用到物件底下的屬性,同時也在告訴 JS this 的指向:

var foo = {
     a:'I am in foo',     
     bar:function(){
	     console.log(this.a); 
     }, 
 } 
 
 foo.bar() //I am in foo;

繫結的失去 ( 繫結在賦值時會失效 )

當你用隱含的繫結去呼叫物件內的函式時, this 會正確的指向該物件,但是一但你將這個函式指派給另外一個變數時,這個變數就只會參考到該函式,而不是擁有該函式的整個物件,這個時候再去執行的時候, this 就會因為找不到該物件而指向全域,這個現象就稱為隱含繫結的失去:

    var obj = {
       a:'obj a',
       foo: function foo(){
         console.log(this.a);
	     },
   }

   var bar = obj.foo;

   var a = ' global a'; //Something Happened. 

   bar(); // global a

3. 明確的繫結(Explicit Binding)

在JS 裡面,函是可以使用 call()、apply(),來指定綁定物件的 thiscallapply 在使用上兩個還蠻相像的,只差在參數傳入的方式,第一個參數都是指定 this 指向的物件, 而第二個以後的參數則是要傳入該函式的參數,apply 是以陣列的方式來決定傳入函式的參數順序,而 call 則是直接以第二個參數後的數量及順序來決定:

function foo(arg1,arg2){
       console.log(this.a);   
}
var obj ={
    a:2,
} 

foo() // undefined
foo.call(obj , arg1 , arg2);//2  
foo.apply(obj,[arg1,arg2]);//2  

//call 跟 apply 基本上行為相同,只差在參數傳入的方式不同

硬繫結 - ( Hard binding )

Hard Bind 是明確繫結的一種變化.可以確保某個 function 的 this 每次被呼叫的時候都與目標物件綁定,可以看到因為多包一層function的關係,即時bar在怎麼用call指定this環境,裡面的主要function :foo.call(obj)依然不會受到影響。

function foo(){
            console.log(this.a); 
        }
        
        var obj = {
            a:2
        }
        
        var bar = function(){
            console.log('this= '+this);  
            foo.call(obj); 
        }
         
        bar();            //this= [object Window]
                           //2
        bar.call(window);  //this= [object Window]
                           //2

4. new 繫結

當一個函式被以 new 的方式呼叫時,神奇的事情發生了:

  1. 會有一個全新的物件被創造出來
  2. 這個新建構的物件帶有 prototype 連結 (先不討論)
  3. 這個新建構的物件會被設為那個函示呼叫的 this
  4. 除非該函式提供了自己的替代物件,不然這個以new調用的函式呼叫會自動回傳這個新建構的物件。

函式搭配 new 關鍵字來創造物件的方式,也是早期物件導向宣告新物件的方式,而後來 class 關鍵字的出現,也讓我們用更直觀的方式宣告物件,因此像這樣使用 function 創造物件的方式也就比較不常見了。

function foo(){
    this.a=2; 
}

var bar = new foo(); 

//{}
//this = {}
//this.a=2 
//{a:2}
//return {a:2}
//bar.a=2

console.log(bar.a); //2

this 繫結的優先順序

當 this 的繫結重複的時候,會以下面的優先順序決定採用哪一種繫結:

預設 < 隱含 < 明確繫結 < new 繫節

參考書目

本篇文章參考 You Dont Know JS 系列的 Scope & Closure

JS 原力覺醒 Day16 - Async / Await:Promise 語法糖

Promise 讓我們有一個可以很方便寫出非同步函式的方法,不過像這樣非同步的程式碼對於我們在閱讀或是 Debug 要判斷執行的先後順序上可能會比較不值觀,今天要來介紹一組讓 Promise 程式碼的可讀性大大提升的語法糖:Async / Await。

  • Promise 語法的問題
  • Async
  • Await

Promise 語法的問題

常常我們在拉 API 的時候會以 Promise 的方式來實作(例如 axios ),而在這個 Promise 裡的 Callback ,如果又想拉取另外一支 API ,就會需要執行另外一個 Promise , 結果就寫出了難以閱讀的程式碼:

let promise = new Promise (( resolve, reject)⇒{

resolve('some value') 

}) 

promise.then((value)⇒{
	let promise2 = new Promise((resolve,reject)=>{
		resolve('value2')
	}) 
	promise2.then((value2)=>{
		...
	})
})

這樣子的寫法可能少少幾行還沒事,但當專案變大之後,如果充滿了這樣子的程式碼,那肯定讓你眼花撩亂,所以我們需要 async / await 來做簡化。

Async

async 語法必須寫在函式宣告前面,用來告訴 JS 這個函式是一個非同步的函式,就像這樣:

async function asyncFunc() {
  return "Hey!";
}

asyncFunc() // will get a resolved promise.

而用 async 語法所宣告的函式,被呼叫時永遠都會回傳一個 Promise,雖然從上述程式碼看不出來,但是 JS 程式碼會幫你用 Promise 然後包起來回傳給你,就像這樣:

function asyncFunc (){
	return new Promise((resolve,reject)=>{
		resolve("Hey!")
	})
} 

Await

await 只能使用在 async 函式內部,在這之外的地方使用的話就會報錯。在 async 函式內部,如果還有其他非同步的程式碼,例如在裡面寫 Promise ,我們就可以用 await ,去告訴 JS 引擎要停下來等待這個非同步程式碼執行完畢,並且等到 Promise 被 resolve 之後才會繼續往下執行。

async function asyncFunc() {
	let data = await new Promise((resolve,reject)=>{
		// do some calculation 
		resolve('api data') 
	})
  console.log(data) //'api data'
}

有了 await 之後我們就可以寫出非常容易閱讀的程式碼, async 關鍵字也很明確告訴你這個韓式內有非同步的程式碼,而如果沒有 await 我們原本還需要透過 .then 函式才能拿到 Promise 執行完畢 resolve 之後的值。

更棒的是,如果你的函式內本來有不只一個 Promise 想要依序執行,使用 await 就可以讓你的邏輯以很清楚的方式表現:

async function asyncFunc() {
	let promise1 = await new Promise((resolve,reject)=>{
		// do some calculation 
		resolve('api data') 
	})
	let promise2 = await new Promise((resolve,reject)=>{
		// get res data of promise1 and do some thing
		resolve('success!') 
	})
  console.log(promise2) //'success'
}

不過有一個小缺點是因為使用 await 的話,因為 JS 引擎會一直等待 Promise 執行完畢,所以如果過度濫用的話,那就失去非同步的意義了,這點在使用時要多多注意,自己斟酌。

Error Handling

使用 async / await 這個語法糖時,為了讓錯誤處理也變得更簡潔,可以搭配 try / catch 使用:

async function asyncFunc() {
	try{
			
		let data = await new Promise((resolve,reject)=>{
			// do some calculation 
			resolve('api data') 
		})
	  console.log(data) //'api data'
	}catch(e){
		console.log('error',e)
	}
}

結論

  • async / await 只是一個 Promise 的語法糖,讓你可以更方便寫出非同步程式碼
  • async 函式一定會回傳一個 Promise
  • await 只能在 async 函式內使用
  • await 語法會讓 JS 引擎等待 Promise 執行完畢後才會繼續往下

JS 原力覺醒 Day15 - Macrotask 與 MicroTask

上一篇針對 Promise 的語法做了一個基本的解說,但其實今天的內容才是我想講的,Promise 的運作邏輯不難理解,但若是 Promise 在整個 JS 以及瀏覽器裡的流程可能就比較複雜了,現在我們都知道幾件事情:

  • 一個 Promise 最終會有兩種狀態
  • 對應 Promise 的不同狀態,會各自觸發 .then 與 .catch 兩個函式
  • 利用 Promise 可以達成非同步行為,而且內容可以自訂

而雖然在上一章節一直提到非同步,但是對於 Promise 裡所謂非同步執行的部分,目前我們還是沒有很明確的解釋,到底是哪一部分會以非同步的方式被執行?以及什麼時候會執行?這是這篇文章想要探討跟說明的。

Outline

  • Tasks
  • Micro Tasks
  • Microtask 與 Macrotask 同時發生的例子

Macrotasks

我們在 Event Queue 章節裡面所提到 Web API 有些具有非同步的行為,而在非同步的目的達成之後,瀏覽器會把給定的對應的函式推送到 Event Queue 裡面,這些一個一個函式正好代表每一件要做的事情,因此在 JS 裡面,以「 Task 」或 「Macrotask 」來稱呼,為了避免混淆,以下將用 Macrotask Queue 來指稱之前提到的 Event Queue 。

https://ithelp.ithome.com.tw/upload/images/20190930/20106580UMeCNMZgKH.jpg

關於 Task 有兩個細節可以注意:

  • 以瀏覽器的角度來看,在每一個 Task 結束之前,不會有任何瀏覽器的 rending 產生
  • 如果一個 Task 執行所花的時間過長,那麼瀏覽器就無法執行其他的 Task ,所以過一段時間之後會提出「頁面沒有回應」的警告,建議你關閉這個分頁,這種情況你應該有遇過。

Microtasks

Microtask 通常由 Promise 產生,Promise 裡用到的 .then / .catch 函式會以非同步的方式來被執行,回想下 Queue 的概念,所以的非同步行為指的是,會在全域執行環境執行完之後才被執行,因此一但 Promise 的 callback 內容執行完成,狀態再也不是 pending 時,.then 或 .catch 的函式內容就會被推送到 Queue 裡面等待執行,這個被推送到 Queue 的函式就是 Microtask。

相對於管理 Web API 所屬事件的 Macrotask Queue ,Promise 產生的 Microtask 也有自己的 Queue ,在 JS 內被稱為 Job Queue 或 Microtask Queue,而 Job Queue 與 Event Queue 運作方式上有一點不一樣。

差在哪裡呢?在 Queue 裡面的每個 Macrotask 執行完畢後 ,就算 Event Queue 裡面還有其他的 Task,JS 引擎依舊會優先執行 Microtask Queue 裡面的所有 Task ,在這個同時也不會重新渲染網頁,換句話說,Microtask 的執行是穿插在每個 Macrotask 之間,兩者的差異也就在執行順序的不同而已。

https://ithelp.ithome.com.tw/upload/images/20190930/20106580a7zj27GtsT.jpg

Microtask 與 Macrotask 同時發生的例子

如果還是覺得很抽象,下面我會帶個例子,直接用程式碼來比較 Macrotask 與 Microtask 執行順序的不同,應該比較能夠讓你了解,看看下面的程式碼:

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("global ex. context");

這段程式碼剛好同時用到 Web API 與 Promise ,各自在呼叫後會產生一個 Macrotask 以及 Microtask ,不過在順序上是哪個會先被執行呢?稍微分析一下:

  • 所有的 Queue 都會等待執行環境堆疊被清空,alert 肯定會先執行
  • setTimeout 對應的函式會被當作一個 Macrotask ,等待時間到之後被送入 Macrotask Queue
  • Promise 對應的 .then 或 .catch 的函式會被當作一個 Microtask 送入 Microtask Queue
  • 在執行環境堆疊清空之後,通常網頁會先做一次 Render,Render 的動作同時也算是一個 Macrotask

因此推測 alert 的順序應該會像是這樣:

  1. "global ex. contenxt"
  2. "timeout"
  3. "promise"

但是並不是!結果會是 "promise""timeout" 還要更先被 log 出來:

  1. "global ex. contenxt"
  2. "promise"
  3. "timeout"

這是為什麼呢?這邊可能會有點抽象,前面我們在分析 JS 語法與運作模式的時候,大多是從 JS 引擎的角度出發。而前面也有提到, Queue 的概念並不屬於 JS 引擎的一部分,相對的歸屬於瀏覽器。對於瀏覽器來說,在網頁頁面開啟時,載入對應的 JS 檔並且執行這件事情,也是一個 Macrotask 。

而剛剛提到 Macrotask 執行完畢後,會優先執行 Microtask ,因此你會看到 "promise" 出現的順序先於 "timeout"

https://ithelp.ithome.com.tw/upload/images/20190930/20106580BZxnDVGnKD.jpg

JS 原力覺醒 Day14 - 一生懸命的約定:Promise

上一章節我們提到有一些 JS 的 Web API 會需要在「背景執行」,同時又不影響整個網頁主程式的運行,這些 API 利用瀏覽器 Event Queue 的機制來達成這個目的,也就是所謂非同步的動作。不過難道只有在使用 這些 Web API 的時候,才能使用到非同步的行為嗎?我們有沒有可能讓自己寫的功能,也具有非同步的行為呢?

答案是,可以的,只是方式不太一樣,如果想要讓自己寫的功能也具有非同步的行為,我們會需要用到今天要討論的主角 — Promise 。

Outline

  • Promise 簡介
  • Promise : 成敗之間
  • 成功的 Promise : Succeed and then
  • 失敗的 Promise : Catch with an error
  • Promise 概念圖
  • Promise : 一個生活化的例子

Promise 簡介

Promise 是什麼呢?以語法字面上的意義來看,用比較白話的方式解釋的話有一種:「我承諾幫你做某件事情,能不能成功還不一定,但是我做完之後會把結果告訴你」的意思。

那麼來看看比較技術層面的定義,在官方文件中的定義則是:

Promise 是一個代表非同步運作的最終狀態的物件 (成功或失敗)

A Promise is an object representing the eventual completion or failure of an asynchronous operation. (MDN)

雖然技術文件的解釋就顯得比較抽象,不過從上面看得出來 Promise 在 JS 裡面是以物件的方式存在,那麼接下來我們就來看看要怎麼使用 Promise 吧,基本的 Primise 宣告方式如下:

let promise = new Promise((resolve, reject) => {
  // executor code
}) 

我們以 Callback 的方式來告訴 Promise ,接下來我們定義的非同步函式要做什麼事情,而且也必須跟 Promise 說,做完想做的事情,得到結果後,怎樣的結果算是成功,怎樣的結果算是失敗?這些都會被記錄在這個 Promise 物件裡面,Promise 物件裡面有幾個相關屬性:

  1. state (狀態) :一個 Promise 裡一共會有三種狀態:
    • fulfilled :成功的狀態
    • rejected:失敗的狀態
    • pending :還在執行中的狀態
  2. result : 執行完 Promise 後的結果值

Promise : 成敗之間

那要定義 Promise 的運行結果? 你可以看到在 Callback 函式內有兩個引數,分別是 resolve 跟 reject ,就是由 JS 提供、用來決定 Promise 結果狀態時使用的兩個函式 :

  1. resolve 用在 Promise 成功且結果如預期時,呼叫這個函式會把 Promise 的 state 設為 fulfilled ,將執行結果數值傳入這個函式會讓上述提到的 Promise 的 result 設為給定的值。

    什麼意思呢?下面的程式碼就是一個 Promise 成功,並且把 result 設為 'Success' 的範例:

    let promise = new Promise((resolve, reject) => {
    	 resolve(' Success ') 
    })  
  1. reject 則與 resolve ,呼叫 reject 會將 state 設為 rejected ,意即失敗。

    let promise = new Promise((resolve, reject) => {
    if(someValueIwant){
    //do other things and resolve
    }else{
    reject(‘Failure’)
    }
    })

成功的 Promise : Succeed and then

寫到這邊有個要注意的重點是,上述提到 Promise 的兩個值 state 跟 result 是沒有辦法直接被取用的,他們只能透過某種方式被取用。所以這邊要講的是 then 函式,then 是指在 Promise 順利執行完成後,要取得結果值的方法。

在前面我們提到,Promise 的 callback 內,我們可以將取得的結果值丟給 resolve 函式,之後我們就可以夠過 .then 來取得這個結果,然後做其他事情。then 函式 一樣接收的是一個 callback ,並且帶有一個參數,這個參數就是 Promise 剛剛計算完的結果,以上述例子為例的話就像這樣:

let promise = new Promise((resovlve,reject)=>{
	//after some calculation
	let result  = 'value from some where'
	resolve(result) 
}) 

promise.then(result => {
		//use result to do something 
}) 

而為什麼要使用 .then 與 callback 的方式呢?因為這樣一來,JS 可以保證這個 callback 在 Promise 執行完之後才被呼叫。

失敗的 Promise : Catch with an error

如果一個 Promise 因為某些原因而被 reject ,那麼上面提到的 .then 裡的 calback 就不會被執行,相反的,他會執行另外一個 callback — 在 .catch 函式內被傳入的 callback。這裡提到的 catch 的用途有點像是在捕捉錯誤時的語法:try & catch 裡的 catch 部分,都是用在錯誤發生時。

 let promise = new Promise((resovlve,reject)=>{
	//after some calculation
	let error = 'some error happended!!'
  if (!result){
		reject( error ) 
	} 
}) 

promise.catch(error => {
	// log the error
})  

Promise 概念圖

「狀態」的概念對使用 Promise 來說是很重要的事情,那麼讓我用一張簡單的狀態圖來表示運行的順序吧,首先 Promise 會有一段執行的時間,所以直到剛剛說的 resolve 函式被執行之前,狀態都會是 pending ,而在這之後如果 resolve 被順利呼叫,Promise 的狀態就會變成 fulfilled ,否則就會是 rejected:

https://ithelp.ithome.com.tw/upload/images/20190929/20106580vaLn6I6Vvn.jpg

Promise : 一個生活化的例子

前面提到,一個 Promise 會有三種狀態:fulfilled 、reject 與 pending 。其實在我們生活中就常常遇到這樣的例子,那就是提款機啦!回想一下剛才提到的「狀態」,提款機其實剛好就有剛剛說的三種狀態可以類比到 Promise 上面!

使用提款機送出提款的要求時,會需要等待一段時間,這時候可以看成 Promise 的執行時間,也就是 pending ,那麼在執行完畢後,可能會發生兩種結果:一種是沒什麼問題 ( fulfilled 的狀態 ),提款機就直接吐錢出來 ( then );另一種是你的餘額不夠,那麼 ATM 直接進入 rejected 拒絕讓你提款,並解顯示錯誤訊息( catch )。

https://ithelp.ithome.com.tw/upload/images/20190929/2010658031IXCxGbUr.jpg

JS 原力覺醒 Day13 - Event Queue & Event Loop 、Event Table

我們越來越深入 JS 運作方式的重要部份了,今天要提到 「 Event Loop 」的概念,這是 JS 最獨特的地方,幾乎沒有其他語言有這個特性。

Outline

  • Parts Of JavaScript Engine
  • Event Queue
  • Event Queue 運行流程
  • Event Table
  • Event Loop

Parts Of JavaScript Engine

之前提到過「 執行環境堆疊 」,函式呼叫時會產生執行環境,若在這個函式執行環境內還有其他函式被呼叫,就會在之上產生另一個執行環境,形成堆疊。而在上層的執行環境結束之前,下層部分的其他程式碼是無法被執行的 — 包含全域執行環境。

因此,只要在這之中某個堆疊執行過久,就算只有一個函式執行環境的堆疊,都有可能影響整個主程式( 全域執行環境 )的運行。不過應用程式裡面總是會有某些功能需要時間來提取 / 運算,這時候為了不讓整個主程式停下來等待太久,我們可以而且其實我們很常把這些比較耗時的工作放到主程式以外的另外一個部分去執行。

而在進入正題之前,必須先複習一下,前幾章節我們提到, JS 引擎底下有三個部分:

  • 「 記憶體堆疊」
  • 「全域執行環境」
  • 「執行環境堆疊」。

https://ithelp.ithome.com.tw/upload/images/20190928/20106580oOL27vmCrq.jpg

然而瀏覽器內可不只有 JS 引擎,接下來我們要提到一個很重要的概念 — 「 Queue 」(又稱 Message / Event / Callback Queue )。

整個瀏覽器的運行環境並非只由 JS 引擎組成。因為 JS 語言特性屬於單執行緒,同時又為了讓網頁具有像「監聽事件」、「計時」、「 拉第三方API 」這些類似「背景作業」的功能,瀏覽器提供了另外一些部分來達成,分別是:

  1. Event Queue
  2. Web API
  3. Event Table
  4. Event Loop

整個由上述部分,包含 JS 引擎所組成的環境,也稱為 JS Runtime Environment ( JRE )

Event Queue

Queue (儲列)是什麼樣的概念呢? 我們先來看一下,在寫網頁程式的時候,有一些所謂「內建的」API 如 SetTimeout / setInterval ,這些 API 不存在於 JavaScript 原始碼內,但你仍然可以在開發時直接使用。因為這些 API 是屬於瀏覽器提供的 Web API 。Web API 並非 JS 引擎的一部分,但他屬於瀏覽器運行流程的一環。

關於 Web API ,舉一些例子:

  • 操作 DOM 節點的 API 如 :document.getElementById
  • AJAX 相關 API 像是:XMLHttpRequest
  • 計時類型的 API ,就像剛剛提到的 setTimeout

這類 Web API 在與 JS 原始碼一起執行的時候,並不會直接影響 JS 主執行環境的運行,否則的話網頁在執行像是拉取第三方 API 資料的動作時,就只能乾等,無法執行任何其他事情了! 所以瀏覽器將這些必須等待執行結果的動作,丟給其他部分去執行,然後讓 JS 引擎可以繼續做他應該做的事情,上述提到要等待執行結果的行為,其實也就是「非同步」的行為。(因為不會一次直接從頭跑到尾做完)

這就是 Event Queue ( 事件儲列 )的工作了, 事件儲列專門用來存放這些非同步的函式,然後等到整個主執行環境運行結束以後,才開始依序執行事件儲列裡面的函式。而所謂 Queue 是一種「先進先出」的資料結構,與 Stack 的「後進先出」相反,所以先被推送到 Queue 裡面的函式會相對於其他函式優先被執行。

Event Queue 運行流程

下面會以 setTimeout 為例,解說 Event Queue的運行流程。

 setTimeout(callbackFunction, timeToDelay)

像是 setTimeout 與 setInterval 這些計時的 API ,是在給定的時間到了之後,執行對應的函式內容。

function executeAfterDelay() {
  console.log("I will be printed after 1000 milliseconds")
}

setTimeout(executeAfterDelay, 1000)

console.log("I will be executed first")

但在給定時間到達之後,確切來說也並非是直接執行,而是會等待整個 JS 的執行環境結束, Call Stack 清空了之後,才開始執行。像上面的程式碼,會在一秒後印出對應的 console 內容,但是 JS 引擎在看到 setTimeout 這個函式的時候,並不會停下來等一秒過後才繼續往下,而是會直接往下執行。

而在 JS 引擎繼續往下執行的時候,剛才我們呼叫setTimeout所造成的計時的動作依然在進行,直到一秒到了以後,瀏覽器會把給定的對應的函式推送到 Event Queue 裡面,然後等待主程式運行完畢。

整個流程看起來像這樣:

  1. JS 引擎執行到瀏覽器提供的 setTimeout 函式
  2. JS 引擎繼續運行,同時瀏覽器開始根據給定的秒數計時
  3. 等待計時完成後,把剛才給定的函式推送到 Event Queue 內
  4. 等待 JS 引擎運行完畢,主執行環境結束後,將 Event Queue 內的函式推送到 JS 主執行環境,產生堆疊(執行該函式)。

https://ithelp.ithome.com.tw/upload/images/20190928/20106580ea5AJm1VDH.jpg

Event Table

Event Table 與 Event Queue 互相搭配的資料集合,他負責記錄在非同步目的達成後,有哪些函式或者事件要被執行,這裡指的非同步目的指的是像計時完畢、API資料獲取完畢、事件被觸發。當我們執行 setTimeout 這個函式時,JS 會把給定的函式與像是倒數的秒數之類的附帶資訊 ( meta data )推送到 Event Table裡面,等到一秒過後(目的達成)該函式就會被正式推送到Event Queue 等待執行。

Event Loop

那麼,什麼又是 Event Loop 呢?可以把 Event Loop 想成是另外一個幾乎無時無刻、每一毫秒都在執行的程式,他負責檢查現在主執行環境堆疊是否是空的?如果是空的,再去檢查 Event Queue ,若 Event Queue 有函式待執行,則將這些函式從 Event Queue 依序推出,並執行。

https://ithelp.ithome.com.tw/upload/images/20190928/20106580oVudusuOwX.jpg

總結

在這個章節,其實你只要能夠了解 JS 內 Event Queue 的概念,知道setTimeout 內的函式是何時被執行、以及怎麼運作的,就可以抓住我想提的非同步運行方式的重點了,其他像是 Event Loop 、Event Table 都只是概念性的名詞解釋,如果你原本對 JS 的非同步特性不是很了解,希望上面的概念模型圖可以幫助到你。

這邊文章同時也會在 Medium 上的 Publication 分享,上面未來會有囊括 前端 / 後端 / DevOps / 資訊安全等相關的技術文章,如果有興趣歡迎追蹤。

JS 原力覺醒 Day12- 傳值呼叫、傳址呼叫

今天要談的是另一個 JS 裡面很重要的特性,我們在做變數宣告與赴值時, JS 引擎是如何為我們保留記憶體位置的?還記得前面有提到 JS 裡面概括可以分為兩大類別:「物件型別」、「原始型別」嗎?這兩種型別,在變數操作時,記憶體位置的運作方式各有不同。

Outline

  • 原始型別的傳值呼叫 ( Call By Value )
  • 物件型別的傳參考呼叫 ( Call By Reference )
  • 補充:Call By Sharing

原始型別的傳值呼叫 ( Call By Value )

原始型別的記憶體位置是透過「傳值呼叫( Call by Value )」的方式來傳遞。那具體來說是怎麼運作呢?我們都知道變數在被宣告的時候,引擎會為我們預留記憶體空間(還記得什麼是「創造階段」嗎?忘記可以往前看),接著這個變數就會被赴值成為我們預期的變數內容。我們姑且稱一個被指派純值的變數為純值變數。

https://ithelp.ithome.com.tw/upload/images/20190927/201065803oQCVhZRB7.jpg
上面我們透過宣告,產生一個變數, var a = 12 ,接著再把 a 指派給另外一個變數 b ,所以現在 b 的值應該與 a 相同。但是 JavaScript 引擎知道這是一個純值之後,就會幫我們另外創造記憶體空間,而就算我們修改 b 的內容,a 也不會受到影響,兩者之間是完全沒有關聯的。

 b = 21 

 console.log(a) //12
 console.log(b) //21

物件型別的傳參考呼叫 ( Call By Reference )

當一個變數被賦予物件型別時候,這個物件實際上並非存在該變數裡面,而是被存在某個位置,既然是「位置」當然有地址,就稱為該物件存放的記憶體位置,而存在這個變數內的就是這個「記憶體位置」。因此這個「以記憶體位置為參考」而在變數間傳遞的存取行為,就稱為「傳參考呼叫」。

https://ithelp.ithome.com.tw/upload/images/20190927/20106580OsgJIIjubY.jpg

現在我們「傳值」所傳遞的是「數值的複製」,而「傳參考」所傳的則是「記憶體的參考位置( 我要去哪裡找這個物件? )」。那傳參考呼叫跟剛開始提到的傳值,在行為上會有什麼不一樣呢?

https://ithelp.ithome.com.tw/upload/images/20190927/201065800HcRlJdm9b.jpg

https://ithelp.ithome.com.tw/upload/images/20190927/20106580AHYSLGu03v.jpg

當我們像剛才那樣新增了一個 a 物件變數,然後再把 a 的值傳給另外一個變數 b ,這時候有一個很重要的問題:「 a 裡面存的值是什麼? 」還記得剛剛提到,是記憶體位置嗎?所以我傳給 b的時候,傳的正是記憶體位置。 因此如果後面我修改了 b 內容的值, a 理所當然的也會被改變,因為他們指的,是同一個物件。

補充:Call By Sharing

如果你多讀幾篇文章,可能會發現有的文章會說「JavaScript 是 Call By Sharing 」。「 Call By Sharing 」這個詞因為定義曖昧,模糊不清的關係,並不被廣泛地使用。「Call By Sharing」也有「 Call By Object-Sharing 」之稱,看到這個詞有沒有覺得跟「 Call By Reference 」意義很像?事實上,還真的有點像,但這個詞的定義更模糊。什麼意思呢?我們先來看看一個與 function 有關的經典例子:

let jediList = ['Anakin' , 'Luke' , 'Ahsoka'] 

function addFellow(list){
	list.push('Yoda') 
}

addFellow(jediList)

console.log('jediList',jediList)

我在這個裡面做了幾件事情:

  1. 在全域宣告陣列以及一個函式
  2. 把這個陣列傳入函式裡面
  3. 修改這個函式被傳入的陣列
  4. 回到全域執行環境,發現剛剛宣告的陣列在函式執行後也一併被修改

為什麼會這樣呢?這就要先提到函式的參數,其實在參數被傳遞進函式的時候,會重新創造一個變數,然後把參數的值丟進這個變數裡面。不過

因為 Call By Reference 傳參考的特性,如果傳入的值是物件,那麼雖然函式試圖創造新的變數與外部環境做區隔,但是指派給這個新變數的值仍然會是「記憶體位置」!因此在這個情況下,函式內對 argument 做的修改,是對傳入物件參考的修改,連帶也會影響到全域環境下的 list 陣列值。

https://ithelp.ithome.com.tw/upload/images/20190927/20106580ianSvW0gQt.jpg

上面是當傳入函式參數是物件型別的情況,但是如果這個參數是原始型別,那麼情況又不同了,還記得原始型別在不同變數之間傳遞時的行為是「傳值」嗎?也就是「數值的拷貝」,所以就不會有上述修改到物件參考的奇怪情況:

https://ithelp.ithome.com.tw/upload/images/20190927/20106580rrkNDOLwsG.jpg

好,上面兩種情況正好運用到今天的兩個重點「傳值呼叫」與「傳參考呼叫」,我們回到剛剛的程式碼,現在,為了討論 Call By Sharing 與 Call By Reference 的差異,我稍微修改一下程式碼,你可以思考一下結果回有怎樣的不同:

let jediList = ['Anakin' , 'Luke' , 'Ahsoka'] 

function addFellow(list){
	 //somebody bad wants to change the result.
   list = ['nobody']
}

addFellow(jediList)

console.log('jediList',jediList) // ['Anakin' , 'Luke' , 'Ahsoka'] 

如何? 根據剛剛的原則,傳入參數是物件,那麼我對這個物件作修改,就會影響到全域環境傳進參數的陣列內容,所以最後 console 出來的結果就是 ['nobody'] 囉?並不是!答案是維持原來的 ['Anakin' , 'Luke' , 'Ahsoka'] ,也就是說在函式內的修改並沒有影響到這個全域變數。

這裡有一個關鍵差別是在做 list = ['nobody'] 的時候,是指派一個全新的陣列物件給 list 變數,JS 知道這點之後就會為這個變數創造一個新的記憶體空間,然後把新指派的陣列存進去,而不會直接修改到外部傳進來的變數,造成連帶影響,這個創造新空間的行為,其實有點像是 Call By Value。

https://ithelp.ithome.com.tw/upload/images/20190927/20106580n5AOAxQnql.jpg

也就是說,雖然透過記憶體位置參考,函式內被傳入的參數,有能力影響 / 修改到外部環境傳進來的變數,但是已經被宣告的物件無論如何都不會因為對這個變數的修改而被消滅。

在看完 wiki 以及數篇文章的說明後,我認為上面的描述就是 Call By Sharing 與 Call By Reference 最大的不同,我相信看到這裡的你應該已經能夠了解它與「記憶體位置」脫不了關係。而 Call By Sharing 則在 Call By Value 與 Call By Reference 兩者之間有著曖昧模糊的地位 - 已經不單純取決於型別,而端看你對變數操作的行為。

結論

今天我們了解了基本的 Call By Value 與 Call By Reference 兩種行為,兩者在 JS 環境內所發生的時間點,Call By Value 發生在當指派給變數的值是純值時,而 Call By Reference 則發生在物件型別。最後,我用一個函式的範例,針對一個比較特殊的名詞 Call By Sharing 做了解說。

你在別的語言可能也會看到以上這些名詞,甚至,在某些語言裡面相同的名詞的意義也完全不同( 如 Call By Reference ) 。但那不重要,在這個篇幅內,我希望看到最後的你,能夠了解 JS 變數與記憶體的關係與運作方式就好。

JS 原力覺醒 Day11 - Falsy / Truthy

上一章節的強制轉型,在布林值轉換的部分有提到 Truthy 與 Falsy ,這個特性我們應該常常碰到,至於背後的運作邏輯如何,今天就讓我們來看看吧:

Outline

  • 使用布林值自動轉型的情境
  • Falsy
  • Truthy
  • 嚴格比較與寬鬆比較

布林值自動轉型的情境

再有多重條件的情況下,那我們寫程式的時候常常用到這樣子的寫法:

if(isTrue) {
	// if isTrue is equal to true 
} else {
	isTrue) {
	// if isTrue is equal to false
} 

while(isTrue){
	// if isTrue is equal to true 
} 

isTrue ? true : false 

邏輯判斷是大概是 JS 裡面最常用到的語法了,而因為 JS 是這個寬鬆靈活的語言,甚至我們寫在判斷式括號內的數值都不一定要是布林值,也可以是物件或字串。因為裡面的值會被 JS 自動轉型,這有點像是用兩個等號來做的寬鬆比較:

if(isTrue) 
//is like 
isTrue == true 

至於邏輯區塊裡面的數值是依照怎麼樣的規則被轉型成為布林值,那就是我們需要探討的部分。

Falsy

在 JavaScript 裡面,每個數值都有其對應的布林值,也因而形成了接下來要提到的轉型邏輯,其在轉型的情況下,ㄧ定會被判斷為 false ,也就是說,與 false 等價,讓我們先來認識一下:

  • 0
  • NaN
  • '' (空字串)
  • false
  • null
  • undefined

Truthy

至於 truthy ,情況就比較多了,到底有多多呢?可以用一句話來解釋:「 除了 falsy 以外的值都是 truthy 」,也就是說只要知道上面 falsy 的值有哪些,就可以知道 truthy 的值有哪些囉! 下面都是 truthy 的狀況:

  • '0' ( 一個內容為 0 的字串 )
  • 'false' ( 一個內容為 false 的字串 )
  • [] ( 空陣列 )
  • {} (空物件)
  • function(){} (空函式)

嚴格比較與寬鬆比較

上一章節為什麼說要盡量使用全等於,這邊說明一下,因為使用兩個等號 == 來比較的時候會觸發自動轉型,而其中就會有比較複雜的轉型邏輯,所以你會比較難以判斷比較的結果。我的建議是,盡量在你需要比較某數值的時候使用全等於(===),也就是嚴格比較,而只在需要判斷某物件是否存在時,才依賴自動轉型。 附上使用兩種判斷方式,分別會產生的結果值,你就會知道為什麼 (圖片來自這個 Repo):

  • ==

https://ithelp.ithome.com.tw/upload/images/20190926/20106580dRJICx6KM2.png

  • ===

    可以看出如果使用全等於,除非等號兩邊的數值完全相同,不然不可能得到 true

    https://ithelp.ithome.com.tw/upload/images/20190926/20106580RfSTvPvXGp.png

JS 原力覺醒 Day010 - 自動轉型 (Coercion)

當 JS 想要對兩個不同型別的數值做運算時, 這兩個數值必須都是同一種類型。這時有兩種方式來達成轉型,一種是使用 JS 內建的方法,像是 toString() 方法來達成;另外一種方法是,如果開發者沒有主動使用 JS 的轉型方法,那麼 JS 就會「貼心」地自動幫你轉型。上述提到的兩種方法,前者稱為「明確的」轉型、後者則是「隱含的」轉型。

Outline

  • 強制轉型 : 明確?不明確?
  • 轉為字串的轉型
  • 轉為布林的轉型
  • 轉為數字的轉型
  • 物件的強制轉型

強制轉型 : 明確?不明確?

當開發者明確的告訴 JS 要轉型的意圖時,例如使用 Number(‘2’),這個動作就稱為「明確的」強制轉型。而也因為 JS 是弱型別的語言,所以數值型別本來就能夠自由地轉換,因此有了「隱含」的轉型的現象出現。雖然「隱含的」轉型聽起來,可以交給 JS 處理就好,什麼事都不用做。不過很可惜,這件事情在後人看來,似乎反倒造成許多不必要的困擾與誤解,因此通常不建議使用。

強制轉型只有三種類型:

  • 轉為字串
  • 轉為布林值
  • 轉為數字

另外,提醒一下純值( Primitive )與 物件型別的強制轉型的運作邏輯不太一樣,(細節後面會提到),不過不管是哪一種類別都能夠執行上面的三種轉型方式,接下來會分別對上述幾種轉型方式做說明。

轉為字串的轉型

明確強制轉型:要將字串明確的轉型,可以使用 String (‘2’) 。

隱含強制轉型: 當使用到 ‘+’ 運算子的時候就會觸發隱含的轉型。

 String(123) // 明確地
 123 + ''    // 隱含地 

而所有的其他類型純值的轉型,你應該都能夠正確預測出來 :

String(111)                   // ---> '111'
String(-12.3)                 // ---> '-12.3'
String(null)                  // ---> 'null'
String(undefined)             // ---> 'undefined'
String(true)                  // ---> 'true'
String(false)                 // ---> 'false'

Symbol 的部分目前有點怪異,只能夠通過明確的轉型來執行,如果你企圖使用 ‘+’ 運算子想要達成隱含的轉型,就會失敗:

String(Symbol('my symbol'))   // 'Symbol(my symbol)'
'' + Symbol('my symbol')      // Cannot convert a Symbol value to a string

轉為布林的轉型

明確強制轉型:布林數值的強制轉型跟字串很像,也是使用 Boolean() 方法來轉型。

隱含強制轉型:則是透過邏輯運算子以及像是 if 判斷式內的條件區塊來觸發:

Boolean(2)          // 明確轉型
if ('yeah') { ... }      // if 或 while 等陳述式條件區塊
2 || 'hello'        // 邏輯運算子觸發
!!2                 // 邏輯運算子觸發

Boolean 值不管再怎麼轉型,最後都只會有兩種結果,其中有一些值一定會被轉型為 false ,JS 裡面用 falsy value 來描述那些必定會被轉型為 false 的數值,這部分在下一章節會有額外說明:

Boolean('')           // false
Boolean(0)            // false     
Boolean(-0)           // false
Boolean(NaN)          // false
Boolean(null)         // false
Boolean(undefined)    // false
Boolean(false)        // false

那麼我們要怎麼判斷哪些值會被轉型為 true 呢? 很簡單:除了 falsy value 以外的值就會被轉為 true ,所以只要搞清楚上述幾種情況即可,以下舉例被轉型為 ture 的情況( Trusy Value ):

Boolean({})             // 空物件也是 true !
Boolean(function() {})  // function 也是物件喔!
Boolean([])             // 空陣列也是物件喔!
Boolean(Symbol())       // true
!!Symbol()              // true

盡量使用「全等於」 ( ‘===’)

這邊提醒一下,在比較兩個數值時,盡量使用三個等號來判斷,而不是兩個。當你使用三個等號來判斷時,除非兩邊的型別與值都完全相同,否則結果一定會是 false ,這時候因為不會有需要轉型的情況發生,減少了許多誤判與產生 bug 的機會。

轉為數字的轉型

明確強制轉型:
數字的明確轉型也跟布林以及字串的轉型差不多,使用 Number(‘12’) 就能將其他類型轉為數字。

隱含強制轉型:

數字的隱含轉型也是透過運算子觸發,不過因為常常牽扯到運算,因此會有比較多種情況需要說明:

  • 比較運算子 ( >, <, <=,>= )

  • 移位運算子 ( bitwise operators : | & ^ ~ )

  • 算數運算子 (- + * / % ),這邊要記得,當使用 ‘+’ 來計算結果的時候,只要其中一個數值是字串,那麼就不會觸發數字類型的隱含轉型( 會轉為字串,這是 JS 的規則 )

  • 單純使用 + 來做數字轉型( ex. 在任一其他類型最前面加上 ‘+’ )

  • 寬鬆的數值比較( == 及 ==)

    Number(‘123’) // 明確的
    +‘123’ // 隱含的
    4 > ‘5’ // 隱含的
    1+null // 隱含的
    123 != ‘456’ // 寬鬆的數值比較

來看一下各種純值類型是怎麼被轉成數值的:

Number(123)                    // 123
Number(" 12 ")                 // 12
Number("-12.34")               // -12.34
Number(true)                   // 1
Number(null)                   // 0
Number(false)                  // 0
Number("\n")                   // 0
Number(" 12s ")                // NaN
Number(undefined)              // NaN

可以看到上面當我們想將含有空白的字串 ’ 12 ‘ 做強制轉型,卻沒有失敗。這是因為 JS 在做轉型之前,會先幫我們把字串的頭尾空白都消除掉( trim ),所以不會報錯。但是如果這個處理過的字串還是無法被轉為數字,那麼 JS 就會回傳錯誤而中止。

比較特別的部分還有,null 會被轉型為 0 而 undefined 會被轉成 NaN ( Not a Number ) ,這是 JS 的規則,我覺得知道就好沒必要特別背起來。Symbol 類型,則不管透過哪一種方式,都無法被轉為數字,所以都會報錯。

物件的強制轉型

當你把物件與運算子一起使用時,JS 必須先把物件都轉為純值,才有辦法做運算,得出最後結果,這時就需要探討物件轉型的規則了。

物件轉布林值

最簡單易懂的部分是物件轉為布林值的邏輯,對物件轉型為布林來說,任何非純值類型的數值,都會被轉為 true。

物件轉字串與數字

對於物件被轉為數字與字串的部分,JS 使用一個叫做 ToPrimitive 的演算流程,這個流程分別使用 toString() 與 valueOf() 搭配整個物件當作此方法的輸入值來判斷結果。由於這兩個都存在於 Object 原型鏈上,所以所有是物件類型的值 ( Object 的後代 ) 都可以呼叫,也可以透過複寫來自定。

通常物件都會有預設的 toString 方法 ,但沒有預設的 valueOf 方法。例如你如果宣告一個物件變數,然後直接呼叫其上面的 toString() 方法,就會得到 「 [object Object] 」,的結果,這是因為,物件預設是以 typeof 的結果來當作回傳值。

let a = {} 
a.toString() // [object Object]

詳細說明可以參考 MDN 官方文件

簡單介紹一下 ToPrimitive 運作流程

  1. 如果輸入值已經是純值,那麼直接回傳這個值。

  2. 試試呼叫這個數入數值 (這個物件/陣列)上的 toString 方法,如果結果是純值,則回傳。

  3. 試試呼叫這個數入數值 (這個物件/陣列)上的 valueOf 方法,如果結果是純值,則回傳。

  4. 如果執行前兩個方法都沒辦法得到純值的型別,就會報錯 (TypeError)。

上面有提到執行 toString() 與 valueOf() 的部分,若 JS 判斷需要轉為數字,則優先執行 valueOf 方法,若失敗才轉為執行 toString ,反之若需要轉為字串則相反,依照上述原來的順序。

//會使用到陣列內建的 toString 方法,將所有陣列值都轉為字串
[1]+[2,3] // "12,3"

結論

好,經過這麼多複雜跟瑣碎的說明,恭喜你看到這裡,現在你知道:

  1. 轉型有幾種類別:分別是轉為字串、數值跟布林
  2. 轉型可以透過兩種方式:明確的 ( explicit ) 以及隱含的( Implicit )
  3. 必要時物件也會被強制轉型,且是透過 ToPrimitive 演算流程來判斷結果
  4. 判斷兩值是否相等時,盡量使用(全等於)三個等號來判斷
  5. 判斷布林值的轉型時,只要不是 Falsy 的值,就會被轉型為 true

雖然強制轉型很複雜而且繁瑣,而且我覺得只要只用全等於就能夠過減少一半的錯誤發生機率,但只要寫程式夠久,總會有需要看髒 Code 的時候。知道有轉型這件事情以及為什麼會發生,對之後要 debug 一定會有幫助。謝謝你看到這裡,那麼,在下一章節,我會針對 Trusy ,跟 Falsy 做一個比較詳細的整理。

JS 原力覺醒 Day9 - 原始型別與物件型別

今天要講到 JS 型別概念,雖然你平常寫 JS 的時候可以看到很多種類別,但其實大致上可以分為兩個比較主要的大分類。

Outline

  • 物件型別 ( Object Type )
  • 原始型別(Primitive Type)

物件型別 ( Object Type )

第一種叫做「物件型別」,「物件」指的是物件。恩,但其實有很多東西本身也算是物件,例如陣列和函式,不相信嗎?讓我們繼續看下去,你可以再 JS 裡面宣告一個陣列,然後用 typeof 去得到這個陣列的型別,結果一定會讓你感到意外:

let arr = []  
console.log(typeof arr)  //object

那麽這樣我要怎麼判斷出陣列了呢? JS 提供了 Array.isArray() 的方法,來讓我們知道某物件是不是陣列。好,那麼函式也是物件型別嗎?當我一樣用 typeof 去觀察的時候,居然得到了不同的結果!

 let hello = function(){
	console.log('hello') 
} 
console.log(typeof hello) // function

「 看吧!聽你在亂講 」,你一定想這麼說,別急,換另一個方法來觀察看看, instanceof 是一個可以觀察某對象是不是另外一個對象的後代,那我們來看看 function 是不是物件的後代:

 console.log( hello instanceof Object ) // true

答案是沒錯。不過為什麼會這樣呢?函式在 JS 裡面算是一個比較特別的物件,稱為「 函式物件 ( Function Object ) 」,所以剛才我們用 typeof 乍看之下才會得到 function 的結果。 而正是因為「函式同時也是物件」這樣的特性,前面我們提到的「函式表達式 ( Function Expression ) 」才能成功!

let someVariable = function() {...} 

而函式物件特別的地方在於,只要搭配 new 關鍵字,他也能夠用來產生新的物件,這與其他物件導向語言產生物件的方法非常類似:

const Foo = function () {};
const bar = new Foo();
bar; // {}
bar instanceof Foo; // true
bar instanceof Object; // true

原始型別 ( Primitive Type )

原始型別又稱為純值 ( Primitive Type ) ,用來表示只代表單一值的一種資料型別,如 12 只代表12,沒有其他意思了,原始型別上也不像 物件型別上,有一些預設方法讓我們能夠直接取用,( Array.isArray )這樣子的東西,不像物件那麼複雜,所以稱為純值。此外,因為 JS 內只有兩種分類,所以「除了原始型別的型別,都是物件型別」,因此只要弄清楚哪些是原始型別,就可以很輕易找出物件型別。

JS 裡面有六種純值:

  • null
  • undefined
  • number ( 0 )
  • string ( “string”)
  • boolean ( true )
  • symbol (目前少用)

其中比較特別的純值是 ES6 之後才出現的 Symbol ,Symbol 類別是透過 Symbol () 方法產生,由於每個 Symbol 值所對到的記憶體位置不一樣,因此很適合用來避免物件屬性意外的被修改。

const a = {};
const symbol1 = Symbol('123');
const symbol2 = Symbol('123'); // they have different memory address in JS 
console.log(typeof symbol1);// expected output: "symbol"

a.symbol1 = 'Hello!';
a[symbol1] // undefined
a['symbol1'] // "Hello!"

上面可以看出使用字串來存取物件屬性跟以 Symbol 來存取會得到不同的結果,因為以往物件的屬性除了用 「.」運算子來存取,但這樣很容易因為重複赴值而被意外的修改,因此 Symbol 就可以用來避免這個問題發生。

" A symbol value may be used as an identifier for object properties; this is the data type’s only purpose. " - MDN Docs

結論

  1. Function 只是一種特殊型態的物件 (函式物件)
  2. Function 可以用來創造新的物件 (搭配 new 關鍵字)
  3. 不是所有的型別都是物件,但是除了純值以外的型別都是物件。
  4. JS 裡面有六種純值
  5. Symbol 可以用來防止物件屬性被意外的修改

JS 原力覺醒 Day08 - Closures

Outline

  • Closure 的形成
  • 經典範例

Closure 的形成

函數內的變數在函式執行完之後,就無法再被參照到,這個時候一開始被分派的記憶體就會被釋放什麼意思呢?

function getEnemyInfo(){
	let enemies = ['Darth Vader','Sheev Palpatine'];
    let enemyLeader = 'Sheev Palpatine'
	return enemies
}

function getBattleInfo(){

  let fellowInfo = ['Clone' , 'Clone' , 'Warship', 'Clone'];
  let enenyInfo = getEnemyInfo() 

  // enemyLeader is not defined 
  // because it's located in another function.
  console.log(enemyLeader) 

  return `the number of fellows is: ${fellowInfo.length }, and
		 the number of enemies is" ${enenyInfo.length}` 
} 

getBattleInfo() 

先來了解一下基本觀念,如上述例子,getEnemyInfo 函式裡面宣告的變數,在函式執行環境結束後(執行完),就再也無法取得,( 也可以說該變數的有效範圍只存在於該函式內 ),因為這時執行堆疊已經剩下全域。除非我把該變數宣告在全域,否則在外面是無法拿到的。

下面來看一個經典的 Closure 例子:

let country = 'United Nations'
let soilder = ['Clone' , 'Clone' , 'Warship', 'Clone']; 
let jedi = ['Yoda' , 'Obi-Wan', 'Anakin']

function addA(numA){
	return function (numB){
		return numA+numB
	} 
} 

let addB = addA(jedi.length)

let fellowNum = addB(soilder.length) 

上面是一個要把兩個數字加起來的 add 函式。 這個函式會返回另外一個函式,之後才會真正把兩個數字加起來,在我們輸入第一個參數之後,就會結束該函式執行環境並返回另外一個函式。

https://ithelp.ithome.com.tw/upload/images/20190923/201065801yPoLwCGrd.jpg

照理說當 addA 函式回傳第二個函式之後,addA 的執行環境就結束,就沒辦法拿到該函式參數 numA ,但對 addB 函式而言,在其內部有引用到他內部沒有的變數 (numA),因此他會轉為向外部環境( Scpoe Chain )尋找 ,JS 引擎就會為此保留這個函式的記憶體空間,不會釋放。

https://ithelp.ithome.com.tw/upload/images/20190923/20106580IF1A2kWGjV.jpg

這看起來就 numA 在 addB 執行環境存在時,暫時為了 addB 而被保留,完全只屬於 addB ,所以這個暫時存在的封閉環境,就被稱為「閉包 ( Closure )」。

經典範例

接下來我們要來看一個很常見,且非常容易讓人誤解的例子:

function pushFuncToArray(){
		var funcArr = [] 
		for (var i=0; i<3; i++){
			 funcArr.push(function(){
					console.log(i)
				}) 
		} 
		return functionArr
} 

var functionArr = pushFuncToArray()

functionArr[0]()
functionArr[1]()
functionArr[2]()

如果你沒有接觸過 Closure ,乍看之下一定會覺得依序的執行結果會是 0,1,2 ,可是~~瑞凡,~~並不是,結果是 3,3,3 ! 這是為什麼?很簡單,我們在把函式推到陣列裡面的時候 ,因為處於 pushFuncToArray 內部且有引用到該函式內的變數 i ,而形成閉包。

https://ithelp.ithome.com.tw/upload/images/20190923/20106580AWlAh7veMH.jpg

JS 引擎的確會暫時為你保留 i 的記憶體空間,不過因為在把函式推送到陣列裡面的時候,並沒有立即引用到 i ,所以等到 pushFuncToArray 結束,一個一個執行 functionArr 裡面的函式時, i 早就已經被 for 迴圈修改為 3 (因為迴圈已經結束,i 維持在跳出迴圈之前的值 ) ,這時候怎麼拿,當然 i 都會是 3 啦!

之後還會講到相同的概念,如果你有點不懂,後面還會再講到,但請盡量確保一定要弄清楚再往下囉! 那麼我們明天見。

JS 原力覺醒 Day07 - 陳述式 表達式

這很基本,不過為了了解後面的說明,還是要提一下,JavaScript 有兩種語法分類:陳述式與表達式。而了解這兩種語法分類之後,後半段會提到,JavaScript 基於這些觀念,在函式宣告上具有獨特不同的運作方式。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • 陳述式( Statement )
  • 表達式 ( Expression )
  • Expression Statements
  • 函式陳述式 (Function Statement)
  • 函式表達式 (Function Expression)

陳述式( Statement )

陳述式一定會做一些事情,但陳述式不會產生數值。所以不能被放在 JS 內預期會產生數值的地方,例如函式的參數、函式的回傳值、或是宣告變數時等號的右邊(不能分配給另一變數)。

陳述式會產生動作

下面這些都是 JavaScritp 裡的陳述式:

  1. 變數宣告

  2. if 判斷式

  3. while 迴圈

  4. for 迴圈

  5. switch 判斷是

  6. for-in 迴圈

  7. 直接的函式宣告

    var a = 3 ;

    if(a === 3){
    //doSomeThing…
    }

    {
    //doSomeThing , this is a Block Statement
    }

    for (var i = 0 ; i<=10 ; i++ ){
    //doSomeThing
    }

    try{

    }catch (){

    }

上面這些例子都是陳述式,會執行某些動作,而且通常是在執行環境產生時就會被執行。值得注意的是區塊陳述 ( Block Statement ),就像 if 的區塊一樣,在裡面會執行一些動作,但執行完成後也不會回傳任何數值,區塊陳述也是一樣的概念,只是一定會執行,不會經過判斷。

另外,變數的宣告為什麼是陳述式?他會做什麼動作嗎?會的,JS 引擎在這個時候會幫你留一個記憶體空間,並把這個變數名稱跟記憶體空間做連結,函式宣告也是一樣的道理,等等就可以看到。

表達式 ( Expression )

表達式是一段可以很長,但會產生結果值的程式碼,而且很常是運算式。

An expression is a phrase of JavaScript that a JavaScript interpreter can evaluate to produce a value. - JavaScript: The Definitive Guide

表達式是一段 JS 直譯器能夠運算並產生數值的程式碼。

1 + 2

functionInvocation()

ture || false d

true && true

a = 3  //會回傳 3 

a === 3

Array.isArray([]) ? doSomeThing() : doOtherThing()  

上面這些用法都是表達式,我們應該都用習慣了,但是如果沒有特別注意就不會特地去分類。好,現在我們知道陳述式會執行動作、表達式會回傳某值,接下來我們要看看比較特別的部分。JS的函式宣告,根據不同的方式可以是宣告式也可以是表達式函式,這兩種方法則分別稱為「函式陳述式」跟「函式表達式」。

函式陳述式( Function Statement )

函式陳述式是藉由直接給定名字來直接宣告一個函式。剛剛有說到變數宣告會使 JS 引擎來幫你保留記憶體空間,所以是陳述式。像這樣子直接的函式宣告,跟變數宣告會產生的行為是一樣的,差別是整個函式內容在語法解析階段都會保留進記憶體空間,這個行為就是之前提到的提升 ( Hoisting ),所以屬於函式的陳述式。

function functionStatement (){
	//doSomething 
} 

函式表達式 ( Function Expression )

另外一種宣告函式的方式是函式表達式,是把一個匿名函式指派給一個變數,這種宣告方式的函式內容不會在一開始就被提升,會被提升的只有該變數而已。在執行階段,才會把函式內容指派給變數,以下面程式碼為例,這個時候 functionExporession 才是一個可以用的函式,而變數的指派屬於表達式,因此這種方式也被稱為函式表達式。

var functionExpression = function(){
	//doSomeThing
}

JS 原力覺醒 Day06 - 提升 Hoisting

今天我們要提到另外一個講到 JS 一定會提到的概念,就是提升 ( Hoisting ),提升是 JavaScript 裡面特有的行為,指的是在宣告一個變數或是函式之前,就先使用它,而且不會出錯,懂了這個概念之後,很多疑雲應該也會迎刃而解。

Outline

執行環境創造

JS 最特別的地方就是在你要使用一個變數之前,不一定要先宣告,只要在相同環境底下的其他地方有宣告,就不會發生錯誤,常見的其他語言如果不先宣告,通常就會出錯。這個現象對其他語言的使用者來說,可能會有點疑惑,不過當我們了解底層語法解析器的運作模式之後,就不會那麼難以理解了。

hello() 
console.log(greeting) // undefined

var greeting = 'hello , master.'
function c3po () {

  console.log('c-3po has been called!')

} 

上面這段程式碼不會出錯,看起來就像底下的變數跟函式宣告被拉到這段程式碼的最上面,

https://ithelp.ithome.com.tw/upload/images/20190921/201065801xX61szKXJ.jpg

回想一下前面我們講到 V8 引擎,全域執行環境產生之後會做兩件事情:

  1. 產生全域物件 window
  2. 產生 this 物件

記憶體空間的指派

其實不只這樣,在執行環境裡面用到的變數跟函式,總要有地方存放,所以在這個階段還會做幾件事情:

  1. 會為所宣告的變數保留記憶體空間,但還不會指派程式碼寫入的值,只會給初始值 undefined。

  2. 也會為一般的函式宣告(使用 function 關鍵字宣告的具名函式)保留記憶體空間,且會將整個函式內容存入記憶體空間。

所以這個變數記憶體空間被保留到哪裡?這個地方就是全域記憶體 ( Global Memory ) ,或稱記憶體堆積 ( Heap )。

https://ithelp.ithome.com.tw/upload/images/20190921/20106580qgs8eG6Kb4.jpg

在這個階段,執行環境正好「剛產生」後,所有宣告的變數都只有做保留記憶體空間的動作,還沒有被赴值,因此也被稱為「創造階段」。創造階段結束後,就會進入「執行階段」,直到這個時候前面宣告的變數才會被赴值,程式碼才會真的被執行。

執行環境與提升

上述在「創造階段」所做的,為變數及函式保留記憶體空間的動作,就被稱為「提升( Hoisting )」。提升這個動作在不論是全域執行環境還是函式執行環境,所有的執行環境都會進行。

let 、const 的提升

let 、const 兩個是在 ES6 之後才出現,用來宣告變數的關鍵字,這兩個關鍵字沒辦法像使用 var 那樣,在函式宣告之前就使用,乍看之下,在這兩個變數上並不會有提升的動作。但是其實是有的,只是 JS 在變數正式被赴值之前不讓你使用而已,在同一執行環境底下,使用 let 宣告變數的語彙環境(實際位置)之前的區域,被稱為 TDZ (Temporal Dead Zone) 。

console.log(name) // Reference Error! 
function doSomeThing( ){
	console.log(name) // Reference Error!  
}
doSomeThing()  
// 在 let 、 const 變數宣告正式發生前,都無法取用(TDZ)
let name = 'Luke'

所以這邊只要知道, 使用 let 、 const 宣告變數雖然還是有提升的作用,但是還是不能像 var 那樣自由的使用,等於有跟沒有一樣。 而我認為這樣子的限制也能減少開發者寫出讓人誤會的程式碼的機會,算是一個好處。

JS 原力覺醒 Day05 - Scope Chain

在上一章我們針對什麼是範疇 ,以及兩種不同的範疇做了說明,而如果 JS 在範疇內找不到某變數, 就會向外尋找。在這章節我會針對這個預設行為做比較詳細的說明。

Outline

  • 複習一下執行堆疊
  • 範疇鍊:攸關語彙環境

複習一下執行堆疊

這邊我直接上一個稍微複雜點的範例:

https://ithelp.ithome.com.tw/upload/images/20190920/20106580DFIazio5b0.jpg

我們從全域呼叫了 a 函式,產生了 a 執行環境,後又在 a 函式裡面呼叫了 c 函式,最後在裡面才呼叫了 b 函式 ( a → c → b),因此會有一連串的執行環境依序被產生,而行程執行堆疊。把概念套用到我們之前的模型,(你現在應該可以想像了),大概長這樣,:

https://ithelp.ithome.com.tw/upload/images/20190920/20106580ZsgqISboCb.jpg

範疇鍊:攸關語彙環境

在 function 產生的執行環境內一但找不到某個變數,預設會向外部環境尋找看看該變數有沒有在外面被宣告,這裡的「外部環境」是什麼呢?有人可能會很直覺的看向上面的執行堆疊圖,然後說這個外部環境是上一個執行環境的堆疊。

其實並不是這樣,這邊有一個很重要的觀念,也是今天的重點: JavaScript 會根據前面提過的「語彙環境」,也就是程式碼的實際位置(全域 或是 函式內),來決定要往哪裡去尋找這個找不到的變數。

https://ithelp.ithome.com.tw/upload/images/20190920/20106580ESRoZSR8nC.jpg

因為 函式 c 在 函式 a 的裡面,因此我們可以說 c函式的外部語彙環境在 a 函式,以此類推,b 函式的外部語彙環境則是全域環境,因此當我們在 b 函式裡面想要呼叫 c 函式內部的變數的時候,理所當然是找不到的(因為 JS 會從 b 函式的執行環境往全域尋找)。 最後附上對應的程式碼圖來驗證上面的說明:

https://ithelp.ithome.com.tw/upload/images/20190920/20106580MO0e1MCr8K.jpg

今天的主題其實就是上述的觀念,所以只要能夠了解執行環境與語彙環境的差異,就能知道什麼是「 Scope Chain ( 範疇鍊)」了,當執行環境往外部環境尋找變數,仍然找不到的時候,就會不斷往更外一層的語彙環境去尋找,直到找到這個變數或是找到全域環境為止(如果到全域仍然找不到,就會跳錯誤)。

例如在這個範例裡面的 c 函式,如果想要取用全域變數的時候,會先往外( a 函式)尋找,發現找不到,才又往全域環境尋找(可往上參考語彙環境堆疊圖)。這一連串的查找動作所產生的物理位置的裡外關係(而不是執行環境產生的先後順序),就是 Scope Chain 。

JS 原力覺醒 Day04 - Function Scope / Block Scope

今天我們要來談談「範疇( Scope )」。

Outline

  • 詞彙環境 ( Lexical Environment )
  • 什麼是範疇 ( Scope )
  • Function Scope
  • Block Scope
  • 總結

語彙環境 ( Lexical Environment )

在講到範疇以前,我必須先提一下一個很重要而且相關的概念,那就是語彙環境,弄清楚語彙環境之後,後續我們講到範疇就比較能夠區分差異性。語彙環境代表程式碼在程式中實際的物理位置。白話一點來說就是:

你把這段程式碼寫在哪 ?

既然是「詞彙」,就代表跟文法、語法相關,所以跟語法分析脫不了關係。語彙環境會影響 JavaScript 與法解析器分析程式碼的結果。回憶一下前幾天我們提到 JS 引擎的時候,JS 在被執行時,會先會被丟給語法分析器解析。

還記得抽象語法樹 ( AST ) 嗎?某段變數宣告寫在全域,或寫在函式宣告裡面,都會產生不ㄧ樣的抽象語法樹。語彙環境的差異就由這個階段產出的 AST 來決定。因此,程式碼的實際位置對我們後續在 JavaScript 其他觀念時非常重要。

什麼是範疇 ( Scope )

上一章節我們提到除了全域的執行環境 ( Global Ex. Context ) 之外,每當一個函式被呼叫的時候,一個對應的函式 ( Function Ex. Context )執行環境也會被產生,執行完之後就會離開。而在這個由函式裡面宣告的變數只能在離開該執行環境之前被取得、採用,而在全域執行環境下也無法直接取得在函式內宣告的變數。像這樣有限的存取範圍就是 「 範疇 」。用白話一點的方式說:

範疇是變數可以被使用的範圍

再讓我們來看個這段程式碼:

https://ithelp.ithome.com.tw/upload/images/20190919/20106580cS5SUT1FCA.jpg

我在全域環境底下想要透過 console 取用 hello 函式裡面的變數,卻得到了「 localText is not defined. 」的錯誤,這是因為在取用該變數的當下,hello 函式並沒有被呼叫而產生執行環境,所以對全域來講這個變數是不存在的( 沒有被宣告 )。這就是 Scope 的基本概念。

Function Scope

Function Scope 可以從字面上直接理解為「 在 function 內的 Scope 」,或是「由 function 來決定 Scope 」。延續上一個例子,我在全域環境底下沒辦法取用函式內的變數,是因為該函式的執行環境還沒有被產生,所以我們只要想辦法讓它產生就行了。但要記得離開執行環境,也就是「結束這個函式之後」,在裡面所宣告的變數就無法被取得了,所以只能在函式內被取用。

https://ithelp.ithome.com.tw/upload/images/20190919/20106580tO8quLoxIp.jpg

那如果是反過來,在函式內想要拿到函式外面宣告的變數的話,會不會有問題? 答案是可以的,因為只要還沒有結束整個 JS 主程式,全域環境都會一直存在,而 JS 在找不到變數時,預設的行為就是會向外尋找有沒有相同名字的變數。因此不會有像前一個例子一樣找不到變數宣告的情況產生。

Block Scope

前面說到 Function Scope 是由函式來決定範疇,這是之前 ES5 版本的行為,在 ES6 之後,出現了 let 、 const 等兩種宣告變數的關鍵字,讓我們除了使用 funciton 來定義範疇,也能 「區塊 ( Block )」來定義。什麼是區塊呢?區塊就是兩個大括號裡面的範圍({ 、 }), if 判斷式 、while 或是 for 迴圈語法用到的大括號範圍就是 Block ,當然, function 的大括號也算。

所以什麼是 Block Scope 呢?其實有個 JS 之前就存在的語法 「 try / catch 」 就有 Block Scope 的特性,讓我們先來看看一段程式碼:

https://ithelp.ithome.com.tw/upload/images/20190919/20106580vsMINFQyhh.jpg

由上面這段程式碼來看,在全域的部分,還是可以取得 foo 變數,因為對使用 var 宣告的變數來說,有沒有大括號並不影響範疇,再來讓我們仔細看看 catch 這個特殊語法區塊,他不是函式,但是卻有一個系統提供的變數 err ,這個變數只能在 catch 區塊裡面被使用(通常是拿來丟出錯誤),當我們從全域呼叫時,就會產生錯誤,像這樣的行為就是 Block Scope 的特性。簡言之:

Block Scope 就是用大括號去定義範疇。

總結

這個章節我們了解什麼是範疇、Function Scope 以及 Block Scope 的觀念了,接下來讓我用一個簡單的例子,整合 var 、 let 、const 等宣告方式,看看兩種定義 Scope 的方式會造成變數存取上有什麼不同的結果 :

https://ithelp.ithome.com.tw/upload/images/20190919/20106580zBlWz671lp.jpg

我們在全域的環境下呼叫 bar 函式,照理說在該函式內產生的變數都可以被存取到,但因為 Block Scope 的關係,由 let 與 const 宣告的變數只能在 if 判斷式內被取得。 因為使用 block scope 能夠更靈活地去管理所宣告的變數,避免重複宣告導致的混淆,在 ES6 出現之後, let 與 const 廣為被推薦使用。

JS 原力覺醒 Day03 - 執行環境與執行堆疊

https://ithelp.ithome.com.tw/upload/images/20190918/20106580DNhAZIQ7hg.jpg

這個章節我們會直接介紹幾個專有名詞,包括前一章節提到的執行環境,加上執行堆疊,如果想要了解後面的提升、範疇等觀念,這些概念都是必要的,在後面的章節也會不斷被提到。

Outline

  • 執行環境
  • 執行堆疊

執行環境 ( Execution Context )

在前面提到直譯語言必須依賴環境才能被執行,在 JavaScript 裡面,提供這個環境的工作就是由 JavaScript 引擎來擔任。所以當我們說「瀏覽器執行/讀了你的 JavaScript 程式碼之後出現了錯誤」,其實並不真的是瀏覽器去讀你的程式碼,而是身為瀏覽器一部分的 JavaScript 引擎在做這件事。上面提到,能夠讓程式碼被執行的環境也被稱為「執行環境( Excution Context )」。

執行環境是一個抽象的概念,概括地來說,任何你JS 程式碼被執行、讀取的地方,像是 function 裡、甚至全域 ,都可以是執行環境。執行環境可以分為以下幾種:

  1. 全域執行環境 ( Global Ex. Context ) : JavaScript 預設的執行環境,不在任何函式裡面的程式碼就是在全域執行環境內,這個執行環境會做幾件事情:
    1. 創造全域環境(全域物件),(在瀏覽器裡面是 window ,在 Node.js 內是 global )
    2. 創造 this 物件,並將其指向這個全域物件,你可以打開瀏覽器的 console 並把 this 的值印出來試試看,關於 this 的指向在後面會提到。
    3. 記憶體指派流程(後面會提到)

https://ithelp.ithome.com.tw/upload/images/20190918/20106580nXss28IclV.jpg

https://ithelp.ithome.com.tw/upload/images/20190918/20106580KkRJpXgNdb.png

  1. 函式執行環境( Functional Ex. Context ): 每當一個函式被呼叫,一個全新的執行環境也會跟著被創造出來,這也代表 JS 已經開始解析你的程式碼並執行。函式執行環境產生時做的事情差不多,差別是不會產生全域物件,而相對的會在函式內產生 argument 物件,內容是執行階段時函式引數的內容。每個函式都有屬於自己的執行環境,但是只會在函式被呼叫的時候才會產生,這種執行環境可以同時存在好幾個。

  2. eval 函式內的執行環境:在 eval 函式內可以透過字串的方式去執行 JavaScript Code ,但是因為 eval 函式現在已經不常被使用,普遍也不被推薦使用,這邊就先不細提,想知道為什麼不被推薦,可以搜尋關鍵字「Eval is evil.」。

執行堆疊 ( Execution Stack )

上面我們提到,每當函式被呼叫時,就會產生對應的執行環境,而當函式裡有另一個函式被呼叫時,執行環境是按照什麼順序被產生的? JavaScript 使用後進先出的「堆疊」結構,依序來儲存隨著函式宣告所產生的執行環境。

各位應該都看過全面啟動吧?主角們為了完成任務不斷深入更下一層的夢境,而在每一層都有必須達成的目標,之後才能夠返回上一層,否則任務就會失敗。今天提到的堆疊其實是類似的概念,我們把「進入夢境」的動作對應到「函式呼叫」。

而到了下一層新的夢境,則呼應「產生執行環境」。你可以在裡面做任何你想做的事情,做完之後 return 回到上一層函式。放心,除非你喝醉了,否則寫 JS 是不會讓你進入混沌狀態的。讓我用一段程式碼來舉例,看看執行環境是如何被產生並且堆疊的:

let movie = 'Inception'

function firstLayerDream(){
			return secondLayerDream()
}

function secondLayerDream(){
			return thirdLayerDream()
}

function thirdLayerDream(){
			return 'Mission Complete.'
}  

let result = firstLayerDream()
  1. 當第一個位在全域的函式 firstLayerDream 被呼叫的時候,專屬的執行環境就產生了。
  2. 並且裡面的程式碼馬上就被「執行」(也就是開始被解析),直到碰到 return 關鍵字才會結束並離開這個執行環境。
  3. 隨著裡面的函式呼叫,第二層、第三層,的執行環境也被創造在裡面,一樣要等到 return ,才會結束並離開。

於是就有了這樣子的先後關係順序:

https://ithelp.ithome.com.tw/upload/images/20190918/20106580Cp4rvwMKBR.jpg

「堆疊」本身其實是一種資料結構,在堆疊裡面,某個元素之上如果還有有其他元素就無法被取出,因此有了「先進後出」的特性。如果你有吃過罐裝的品客應該可以很輕易知道我在說什麼,想想看,你沒辦法直接吃最底部的洋芋片對吧!

https://ithelp.ithome.com.tw/upload/images/20190918/20106580IZZcuynRg0.jpg

總結

今天稍微介紹了幾個非常重要的專有名詞,目前為止都還沒有進入 JS 語法的範疇,但這些觀念對於接下來的內容理解至關重要,請讀者一定要確定了解再往下,別擔心,如果有不理解的,隨時可以透過無線電聯絡我。

JS 原力覺醒 Day02 - JavaScript V8 引擎

在進入 JavaScript 語法的範疇之前,我們要先來看看在這個語言的背後是怎麼運作的,不管是讓你之後能夠更有效率的找出問題,或是想要優化程式碼的運行效能,我想在這個階段好好了解背後的運作模式跟解析流程是非常重要的。

Outline

  • 編譯語言、直譯語言
  • JavaScript V8 Engine 簡介
  • JavaScript V8 Engine 運行流程

編譯語言、直譯語言

程式語言經由運行模式可以分為兩大類,一種是編譯語言,另一種是直譯語言。JavaScript 屬於後者。這兩種類型語言的共通點在於,都必須將我們人類寫的程式碼(高階語言),轉換成電腦看得懂的機器碼(低階語言)。

而兩者最大的不同就在程式碼的編譯時機。編譯語言在開發者寫完一段程式碼之後就會預先編譯,之後就能夠獨立執行,直接與電腦溝通,直譯語言則是在即將要執行時才會透過直譯器,直接動態進行編譯後執行產生的機器碼(一邊解讀、一邊執行),也就是因為要經過直譯器,直譯語言在執行速度上通常會比編譯語言來的慢許多。另外,直譯語言無法獨立執行,必須仰賴一個能夠編譯並且執行產生結果的環境,這也是我們今天的主角 V8 引擎的工作。

https://ithelp.ithome.com.tw/upload/images/20190917/20106580nEX5c63YwW.jpg

JavaScript V8 Engine 簡介

V8 引擎是 Google 做出來讓 JS 跟瀏覽器溝通的的開源專案,這個引擎被使用的非常廣泛,在 Chrome 瀏覽器跟 Node.js ,以及桌面應用程式框架 Electron 之中都有他的身影。而在 V8 出現前,最早最早的 JavaScript 引擎,叫做 SpiderMonkey ,同時也是另一個知名瀏覽器 FireFox 的渲染引擎。

JavaScript V8 Engine 運行流程

V8 引擎的運作流程最重要就是以JavaScript 原始碼將一個一個關鍵字解析成為抽象語法樹,交給直譯器後編譯並執行,大致上可以分為三個階段來描述:

  1. 解析階段: 解析器會先分析 JavaScript 的原始碼,然後分別將變數、關鍵字、語法符號轉成一個特定的格式來表示詞彙關係。這個關係的集合稱為抽象語法樹( AST ),在語法樹裡面每個節點都對應你的程式碼中的各種語法片段,使用 AST 的好處是對電腦底層來說能夠有一個可以辨識的結構。
  2. 直譯 & 執行階段:直譯器會將上個階段的語法樹轉換成特殊的機器代碼稱為 ByteCode ,ByteCode 已經是能夠被執行的機器碼,使用 ByteCode的優勢是可以很快的被編譯成更底層機器碼。
  3. 優化階段:直譯器產生出來的機器碼,執行時會產生相關數據,並被傳給優化編譯器根據數據做出來的假設再次進行編譯,產生優化過的機器碼,如果最後發現優化結果跟前面做出的假設條件不符,則將該次優化拔除,重回上一個階段使用原來的ByteCode 來執行程式( 這個動作稱為De-Optimizing )。

https://ithelp.ithome.com.tw/upload/images/20190917/201065805EztJZWs0q.jpg

今天我們了解了直譯、編譯語言運作方式的不同以及 JavaScript 如何轉為機器碼與電腦溝同的流程,其實有很多地方可以再深入探討,如語法解析、直譯器與優化編譯,不過為了避免偏離主軸太遠,這邊就請大家先了解每個元件跟階段要達成的目的就可以了。下一個章節要開始正式進入 JS 語言的範圍了,我會針對V8 引擎在啟動時所產生給 JavaScript 專屬的特定環境,稱為「執行環境」去做說明。

JS 原力覺醒 Day01 - 開始修行之前

從開始學習 JS 到現在的約莫一年內,我陸陸續續看了許多相關書籍,也在需要特定知識的時候參考有關的文章,但是我認為自己還沒有對這些知識做系統性地整理過,所以我決定開啟這個系列的撰寫並藉由鐵人賽推動自己完成它,內容涵蓋變數提升、 this 、Class 語法糖、圓形鍊、記憶體運作、閉包…等我覺得 JS 學習者必須要會的東西,這個系列不會從如何撰寫基本語法開始說明,你讀完也不會變成大師,但是如果你是想更深入學習的初學者,希望這個系列能夠帶給你一些啟發,並知道要從哪些概念開始學習起。這個系列適合:介於 Junior 與 Senior 中間的 JS 開發者 、以及閒閒沒事想複習一下的 JS 工程師們。

我們在使用 JS 的時候最常感到頭痛的應該就是那些充滿疑雲的奇怪現象(想想那個「為什麼不能動 / 疑?為什麼可以動? 」的笑話 ),然而我想這些無法依照常理來理解的現象也正是讓這個語言變得有趣的主要原因。其實理解 JS 底層運作方式以後,這些魔法也就沒那麼可怕了,希望能夠藉由這個系列讓自己對 Javascript 了解得更透徹,也帶給往後想要深入鑽研 JS 的初學者一個比較具體的認知。

在內容的選擇上,我會根據最近找到的 33 Concept Github Repo裡面提供的一系列資源,挑選我認為想要靈活運用 JS 的話一定要會的基礎知識,並且搭配像是、Udemy 知名的 「 理解奇怪的部分 」、六角學院的 「 JS 深入核心 」系列等含金量比較高的線上課程內容來做補充或是輔助說明。

詳細的大綱會根據本人的撰寫速度來做調整,因此會等全系列完成之後再行整理上來,敬請讀者們見諒,但大致上可以分為三個部分:

  1. 階段一 - To See The Force ( Day02 ~ Day 10 ):

    在這個階段我會提一些 JS 比較常見的預設行為和專有名詞如自動轉型、提升、純值…等,可能也是最常讓人產生疑惑的部分,有的開發者可能常常碰到,但如果沒有特別深究,就容易被忽略或遺忘。

  2. 階段二 - To Feel It ( Day11 ~ Day21 ):

    這部分會有比較深入的概念解說,我認為像是 Class、原形鍊、Event Loop 、Closure…這些也是 JS 最為核心的概念部分。

  3. 階段三 - Bring Everything Together ( Day22 ~ Day30 之後):

    前兩階段對於我來說都是有使用過並且大概了解,但是無法完整描述的知識,剛好藉由鐵人賽來做一個整理。在最後一個部分的內容會比較偏向整合性內容,我想要運用前面所學,組合之後昇華,去接觸以前比較沒有機會思考或是使用到的知識。

    https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

接下來就讓我慢慢帶領各位前進吧,在一些比較抽象的概念上,我會盡量以圖文的方式來解說,不過你可能要還是要有點心理準備,就算是這樣,這還會是一趟漫長的旅程。畢竟不管是哪一門程式語言,在學習之路上都沒有迅速的捷徑,端靠慢慢累積經驗跟不段的促進自己思考,一起加油吧。最後僅此獻給各位讀者:

If you choose the quick and easy path , as Vader did. You will become agent of evil. " - Yoda

React 與 Webpack4 專案建置全實戰 (3)

這篇文章應該是這個系列的最後一篇,之後如果有用到的話還是會慢慢分享 webpack 相關的設定方式。

由於使用 React 通常會搭配 JSX 語法來做開發,而這樣的撰寫方式並不能直接被瀏覽器所解析,所以我們就會需要用到 Babel 來幫助我們轉譯成瀏覽器看得懂的 JS 語法。 因此這篇文章主要會解說如何在 Webpack 裡面 Babel 相關的設定,應該不會太長,讓我們繼續看下去吧!

安裝及設定 babel-loader

前幾章節有說過 loader 是一系列用來轉譯的套件,所以要在 webpack 裡面使用 babel 我們也必須使用 babel-loader,參考 Github 上的安裝指令:

npm install -D babel-loader @babel/core @babel/preset-env webpack

其中 @babel/core 是babel 的主要部分,而 preset-env 是針對那些 JS 最新公佈但瀏覽器還沒有支援的語法做轉譯。

然後只要在 module 裡面加入以下程式碼,就可以開始設定跟使用 babel 囉:

module: {
  rules: [
    {
      test: /\.m?js$/,
      exclude: /(node_modules|bower_components)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}

其中 option 內的 presets 是為了告訴babel想要用什麼模組去做轉譯,babel 把可能的轉換整理成一系列的 preset ,當然也包含 react 的preset 啦,等等就會看到。

安裝 babel-preset-react

如上提到的,babel 也提供了 react 轉譯相關的模組,在 webpack 的設定方式也不難,先安裝:

npm install --save-dev @babel/preset-react

然後ㄧ樣在 preset 裡面加上 ‘@babel/preset-react’, webpack 就會自動套用到啦,然後因為這個 preset也包含 JSX 的轉譯,所以就不用另外安裝相關模組了。到這邊你應該可以開始使用 react 跟 jsx 來做開發了,後面會陸續分享 webpack 相關,比較細節的其他設定,請大家耐心等候囉。

延伸文章

React 與 Webpack4 專案建置全實戰 (2)

這是這個系列的第二部分,如果想從頭開始看的話可以往上一篇前進: React 與 Webpack4 專案建置全實戰 (1)

這個章節會是比較實戰的內容,前面已經解說完 webpack 的基本設定內容,現在讓我們來看看要如何實際執行。

文章大綱

  • 使用 package.json 建立自己的 script 指令集
  • webpack-dev-server
  • 開始撰寫 JS 程式碼

使用 package.json 建立自己的 script 指令集

(已經熟悉 node 的開發者可以跳過)

如同前面所說, webpack 必須在 node 環境下才能夠被執行,所以在專案目錄下通常會有 package.json 檔。

package.json 解說

如果前面你是使用 webpack-cli ,那麼它應該就會幫你產生一份 package.json 檔 ,這份檔案是 node 用來記錄專案所需要的套件的描述檔,讓我們稍微看一下內容:

其中比較重要的有 script 、 dependencies 、 devDependencies 三項,分別是用來:

  1. script : 用來撰寫自己的指令,可以用 npm run “你的 script” 來執行相對應的指令內容。

  2. dependencies : 專案開發時會用到的套件內容,因為 webpack 的使用通常只在開發階段,因此這個部分不會有相依賴的套件。

  3. devDependencies : 開發階段使用的套件,可以在 npm install 時加入 -D ,該套件就會被記錄至此。

加入自己的 script

根據官網,webpack 最基本的指令是:

webpack <entry> -o <output>

其中 entry 跟 output 各自會自動對應到 webpack.config.js 裡面的設定,所以其實直接下 webpack 也行,那麼 ,我們就在 script 裡面加入看看:

package.json
...
"script":{
    "build":"webpack"
}

然後試試看指令 :

 npm run build 

應該沒問題,這樣的好處是之後不管我 webpack 指令怎麼修改,我在 cli 裡面執行的指令都可以是相同的。之後你就可以在 dist 資料夾裡面找到 webpack 處理完的 js 檔。

不過這種編譯方式還是會有換行符號跟空白,佔據多餘的空間,因此我們可以在指令後面加入 mode 參數 :

"script":{
    "build":"webpack --mode=production"
}

可以看到webpack 幫我們把程式碼多餘的空白都被壓成單行了,這樣子的好處是減少程式碼體積加快瀏覽器頁面載入速度,所以通常在開發完成準備正式上線時我們都會這麼做。

webpack-dev-server

但在開發階段你應該不會希望每次修改完檔案都要手動build ,再自己開瀏覽器來看吧!? 這時候我們就希望他能夠即時偵測修改並重新產生新的 bundle 一個比較簡單的方式是:

 webpack --watch  

使用這個方式, webpack 會自動幫我們偵測檔案的變化並重新 build 一次,但還是需要手動重新整理才看得到結果。

安裝 webpack-dev-server

所以這裡我要介紹一個 webpack 裡一個很好用的套件叫做 web-dev-server,他可以直接幫你架起一個本地的開發 server ,除了能夠直接偵測並更新,還能自動幫你刷新頁面,省了不少功夫,毫無疑問是開發必備工具。

首先我們要安裝:

    npm install webpack-dev-server --save-dev

接下來在 webpack.config.js 裡面新增對應的設定區塊:

webpack.config.js 
... 

devServer: {
    contentBase: path.join(__dirname, 'dist'),
    open: true,
    hot: true
  }

“contentBase” 是 server啟動的時候預設的根目錄,通常是對應到編譯完檔案的資料夾 dist ,才能夠看見變更結果。

“open” 是使用 dev-server 指令啟動後自動幫你開啟瀏覽器,“hot” 是所謂的 「 hot module reload 」 ,當檔案有變更的時候自動幫你刷新內容,也是讓我想要使用它的主要原因。

其他 dev-server 相關的設定可以參考 Webpack 官方網站

啟動及運行

我一樣在 package.json 的script 標籤裡面加入 dev-server 相關的指令:

package.json 
... 
"script": "webpack-dev-server  --open"

這裡的 open 參數,跟上面設定檔內的 open 作用相同,所以擇一就可以了,執行完後你應該就會看到自動開啟的網頁內容了,這邊再提醒一次,記得要搭配 WebpackHtmlPlugin,才能夠讓 js 的修改順利插入到 html 裡面。

開始撰寫 JS 程式碼

在前面的段落裡,我們使用 webpack-cli 來快速產生基本的 webpack 設定,包括進入點、輸出資料夾、以及轉譯 sass、css 相關一系列的 loader ,來產生瀏覽器可以看得懂的程式碼。

最後我們用了 webpack 的 WebpackHtmlPlugin,來把我們編譯好的 js 檔直接插入我們預先寫好的 html 樣板中,現在我們可以實際撰寫 js 程式碼來看看結果是不是如同我們所預期。

JS 程式碼測試,抓取元素內容

首先,在 html 樣板裡面有一個 id 是 app 的區塊,這是後面會用來插入 react內容的地方。那麼既然我已經設定好 entry file 跟我的 html 樣板,照理說我應該可以在 js 檔裡面抓到這個 #app 元素,我試著在這個元素裡面插入一些內容:

document.getElementById('app').append('Hello, this is entry file from weboack bundler.')

看起來沒問題:

CSS 測試,套用元素外觀

接下來我要試著引入 CSS 內容,照著前面的說明,我一樣在 src/index.js 裡面加入了:

 import '../scss/index.scss' 

然後在 index.scss 套用所有 body 為橘色試試:

body{
    color:red;
}

也跟預期的一樣!!

你可能會發現這個 html 上面並沒有用來引入 css 內容的 <link> 標籤,這是因為style-loader 是透過插入 <style> 標籤的方式讓 css 內容生效,可以打開瀏覽器檢查工具查看:

測試結果都沒有問題的話,就只剩下引入 react 了! 我在下個章節會提到需要安裝的套件以及設定方式,還有加入 eslint 的設定,如果你也沒問題,那就跟著一起往下吧!

下一章節:React 與 Webpack4 專案建置全實戰 (3)

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×