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

Your browser is out-of-date!

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

×