JS 原力覺醒 Day30 - 我是怎麼活過這三十天的?

總算來到最後一天了,最後一天不會有技術內容,只會有很純的純 Mur Mur,想聽的再請留下。最後我打算記錄一下這三十天的感受,給其他沒參加過鐵人但是正在猶豫要不要參加的朋友參考。

普遍看到參賽方式有幾種情況:

  • 精明準備型:囤好囤滿 30 天,完全事先囤貨所以內容超精緻
  • 微囤貨:不事先準備太多,只囤幾天貨用來緩衝
  • 硬派:「 什麼!?不就是要現學現賣才叫做鐵人嗎? 」的類型

老實說我認為如果不事先準備的話,那麼能不能完賽跟主題的選擇還有自身對主題熟悉度會有很大的關係,所以如果你也正在思考要不要參加,可以從這點著手。如果主題是你想寫但不熟,可以考慮現在開始到下次開賽前先慢慢累積文章量;或者你覺得對主題比較上手,可以挑戰看看自己在短時間內對知識的理解程度。

30天連續發文不只考驗技術

經過評估,我走的是微囤貨路線,因為我是在 9/2 開賽前幾天才知道有鐵人賽這個活動,剛好我的下一個近期目標是對 JS 這個語言更有系統性的認識,當時是覺得這個活動可以用來挑戰一下自己,也正好可以之前寫的 原子化學習 裡面把知識最小化的學習方式做實作驗證,所以想了一下大概要寫什麼之後就跳坑了。到了開賽期限 9/16 前一天,我硬生出大約 10 來篇準備留著緩衝。

沒想到正式開賽才發現我完全低估每天必須發文所帶來的壓力了,因為事先沒有累積太多文章的關係,我幾乎每天都在想著下一篇文章的內容怎麼寫、大綱怎麼擬定、要怎麼畫出核心概念圖才能讓人比較好理解之類的問題。這樣子的狀況持續到大概第 20 篇的時候是最痛苦的,因為越後面的主題我越不熟,需要越多時間,而前面的幾篇卻也因為思考解說方式跟準備圖例的關係花了不少時間,留下來的緩衝時間所剩無幾。

在這個時間點想繼續寫覺得吃力,想放棄又覺得不太對,瞬間覺得自己好像在跑一場已經完成 2/3 ,明明心裡知道快結束了但眼前就是還看不到終點線的尷尬窘境。所以我深深覺得鐵人賽除了考驗技術熟悉度更考驗筆者的心理耐力。在最後雙十連假那幾天實在是最難受的,雖然咬著牙硬寫完了,但是基本上我是ㄧ邊配獵人邊寫完的(喂

使用工具

工欲善其…咳咳,好廢話不多說,稍微介紹一下我這幾天用來幫助寫文章工具,基本上有三個:

  1. Notion

    在正式開賽之前,我先用 Notion 的 Table 整理了三十天的大綱,雖然最後沒有完全ㄧ樣,不過可以讓自己對文章主題有個底,時間的掌握也比較精準,哪些主題自己比較不熟的話就要預留比較多時間。
    https://ithelp.ithome.com.tw/upload/images/20191015/20106580Hsbd3FYXSf.png

  2. 簡單的流程圖繪製軟體

這種軟體的選擇就比較多,我是選 Sketch,一來之前有學過一點,二來覺得他匯出圖片很方便,雖然他主要是用來前端介面設計的工具,但是拉拉簡單的區塊跟流程箭頭還是很好用的。還有另外一套網頁版繪圖軟體也很推薦:Draw.io

https://ithelp.ithome.com.tw/upload/images/20191015/20106580m8ATiR1MZH.png

  1. 瀏覽器開發者工具:

因為我寫的主題不是製作產品型的主題,許多範例程式碼只要可以馬上確認結果就好,這個時候整個瀏覽器都是我的實驗場 :D

https://ithelp.ithome.com.tw/upload/images/20191015/2010658095DQqsedJi.png

是音樂,我加了音樂

如果你以為我是像老派英雄主義電影裡的主角ㄧ單單靠著強大的意志力就輕輕鬆鬆寫完 30 篇文章練成鐵人那就錯了,我也希望我可以。 我曾經抱著很中二的想法,覺得如果世界上沒有音樂的話,我們幹嘛活著?老實說我現在還是深深這麼想的,大概今後也會一直這麼中二下去。總之最後來介紹一下陪伴我度過這地獄般 30 天的幾首曲子:

  • Tauk - Horizon :

    風格上屬於前衛搖滾,我很喜歡他們華麗的效果器加上風格多變的主奏電吉他,雖然沒有人聲,但總能聽得我熱血沸騰,附上近期喜歡的一首曲子:

    Yes

  • Takami Nakamoto - Ashes:

    這個音樂家的作品風格定位上還是比較偏電子舞曲,一般人聽來可能會覺得比較實驗性或藝術性,但我真的很喜歡各種奇異材質的聲響。想暫時脫離現實生活看一下不ㄧ樣世界樣貌的絕對推薦(建議戴耳機):

    Yes

  • Mariya Takeuchi - Plastic Love :

    這首毫無疑問是經典,我真的很喜歡遍佈整首的 Disco 元素,前奏剛下沒多久眼前就浮現煙霧彌漫然後雷射燈球光芒四射的場景,查了一下定位屬於 City Pop ,City Pop 是在 1970 日本傳統音樂受到西方音樂文化元素的影響而發展出來的獨特曲風,在當時由山下達郎組成的樂團 SUGAR BABE 帶起風潮。而竹內瑪莉雅就是山下的妻子,也是早期非常有名的音樂家之一,這首歌在前陣子 City PoP 復甦的時候出現在我的推薦歌單內,聽過後立刻愛上。

    Yes

  • The Brand New Heavies :

    這個團體是 Acid Jazz (酸爵士) 的經典團體之一,Acid Jazz 緣起於 Disco 文化,在 1980 年代開始變成風潮,當時舞廳的 DJ 嘗試將爵士樂中的樂句加以取樣,融入電子音樂裡面並融合了靈魂樂、Funk、R&B 等曲風,因而吸引了年輕世代跟老年族群的注意,也是一個讓當代大眾重新開始接觸爵士樂的契機。

    雖然在 1987 年由知名唱片經營者 Eddie Piller 與 DJ Gilles Peterson 創立同名自有品牌後才正式被命名,不過 Acid Jazz 這種風格受 60 年代迷幻文化影響至深。( Acid 同時也是迷幻藥的別稱 )所以你常常能在這種曲風裡面聽見運用電子特效達成的迷幻效果,同時又因為強烈的律動感而忍不住開始擺動身體,讓我們來聽聽看 The Brand New Heavies 今年出的專輯同名歌曲,你一定會很喜歡:

    Yes

總結

原本以為在我整理完過去的學習經驗,寫出原子化學習已經是我自己在今年最有標誌性的里程碑了,沒想到又幹了一件突破自己耐力極限的事情啊 (X。老實說好幾次都以為我沒辦法完賽了,我也不知道是什麼讓我可以支撐到最後,也許是賽期後半段開始陸陸續續有人追蹤,甚至有讀者會參與內容的討論以及告知筆誤,讓我覺得有莫名一股一定要繼續寫下去的責任感。

其實後面還想有寫但來不及寫的主題:演算法跟設計模式,但在我寫這篇文章的當下,已經有些厲害的工程師以演算法當成 30 天的主題並且已經或快要完賽了:

透過 LeetCode 解救美少女工程師的演算法人生

前端工程師用 javaScript 學演算法

模組化設計

上面稍微推薦一下幾個很棒的相關系列,接下來完賽後我陸陸續續也會去看之前沒時間看的那些主題,如 神Q超人的 TypeScript 或是 六角校長的職場教學,讀者有興趣的話之後也可以跟我一起惡補回來 ( X 。

JS 原力覺醒 Day29 - Set / Map

ES6 之後加入兩種新的資料結構:Map 跟 Set 。 Map 與 Set 都是像字串跟陣列這樣可以被尋訪的類型,也就是說可以使用 for 迴圈去一個一個查找跟操作他們的值。今天就來說明一下這兩個類別跟使用方式吧!
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • Set
  • Map
  • Map 與 Object

Set

Set 的中文翻譯與數學裡面的「集合」相同,「集合」是某個定義好並且具有相同性質的元素的集合,講白話一點就是「一堆東西」。在 JS 內的集合當然代表「一堆值」,他跟陣列有點像,差別在 Set 能夠讓開發者可以方便快速的儲存不重複、獨特的數值。至於 Set 內儲存的元素內容沒有型別限制,可以是純值也可以是物件型別。

Set 除了具有儲存不重複數值的性質外,在上面還有一些很方便的方法可以直接處理數值,讓我們陸續來看看,首先創造一個新的 Set ,創造新的 Set 很簡單,只要在 Set 的建構子傳入一個陣列即可:

 let set = new Set([1,2,3,'Hello','World',true]) 

在 Set 類別上有許多方法讓我們可以用比較語意化的方式操作 Set 內容:

  • add( value ) : 新增一個元素到 Set
  • clear() :刪除所有 Set 內的元素
  • delete( value) :刪除 Set 內特定的某個元素
  • forEach() : 跟 array 上的 forEach 功能相同
  • has( value ) :檢查 Set 內有沒有對應值的元素,這個功能如果在陣列內,必須透過 indexOf 來檢查才能達成。
  • values() :會回傳 Set 內所有數值
  • size :回傳 Set 元素長度

就像前面說過的, Set 內儲存的是不重複的元素,因此如果有相同數值的元素再次被傳入,這個數值就會直接被忽略。

	set.size //6
  set.add('Hello') 
  set.size //6

對 Set 做巡訪的方式跟陣列很相似,一樣可以用 forEach 方法,甚至 Set 可以很方便的直接轉為陣列 :

 let setArr = [...set]

這個特性非常好用,利用這點我們就可以很快速的過濾出陣列內的重複值!

 let duplicatedValueArr = [1,2,3,5,10,19,10,4,5,6,3,1,2]
 let uniqueArr  = [...new Set(duplicatedValueArr)]

這樣子是不是既方便快速又簡潔? 如果單純使用陣列可能還需要透過 filter 跟外部變數來儲存重複值輔助檢查,使用 Set 的話,這些功夫都可以省去。

Map

Map 也是跟陣列、跟 Set 具有相同特性且可被巡訪的物件型別,差別在於, Map 跟物件ㄧ樣是鍵值的組合,也就是說,Map 同時具有跟陣列ㄧ樣可以被巡訪的特色,同時也有物件儲存任意屬性跟數值的能力。

Map 類型上的方法也與 Set 大同小異,差別在 Set 新增元素的方法是使用 add ,而 Map 內必須用 set 方法 ,且新增元素時必須傳入兩個參數,第一個是要儲存的鍵 ( key ),另外一個是要儲存的數值內容 ( value )。

創造新的 Map 的方式與創造 Set 相同,但由於 Map 是鍵-值對的結構,傳入建構子內的陣列內不能夠像 Set 那樣只是個單一元素,而必須要是個鍵-值的組合,所以我們可以用二維陣列來達成,大概像是這樣:

 let map = new Map([['name','Luke'],['Hello','World']]) 

取得 Map 元素 :

  map.get('name') // Luke 

新增元素 :

 map.set('Greeting','I am Anakin') // { ... 'Hello'=>'World', 'Greeting'=> 'I am Anakin'}

其他像是刪除特定元素或是刪除所有 Map 內元素則都跟 Set 上的方法差不多:

 map.delete('Hello')  
 map.clear()
 map.size

Map 與 Object

Map 其實跟物件ㄧ樣都是 鍵-值 的組合,事實上這些結構相似的類型有許多種,如,那麼使用 Map 相比於使用物件有什麼好處呢?還記得前面提到在 JS 內除了原始型別以外的型別都是物件型別嗎?這代表除了物件以外像是 Array 以及Function 這樣的型別都是繼承自 Object,這其中當然包含 Map

所以這兩種型別才有這麼相似的結構 ,性質相同的部分就不用多說了,但是這兩者還是有一些不差異,這些差異可能足以影響資料存取的複雜度以及程式碼閱讀的難易度,所以我們可以認識一下究竟兩者有什麼不同的地方:

  • 鍵值的類型:

    在物件內的鍵值(或屬性名稱) 必須是字串或是 Symbol。而在 Map 內,鍵值可以是任何型別,這包含任何其他的物件或是陣列 。你當然可以試試看用物件來當作另外一個物件的屬性名稱,不過這個物件會被 JS 強制轉型變成 [object Object] 而變成另外一個字串屬性。

      let o = {} 
      let anotherObj = {} 
      o[anotherObj] = 'anotherObject' // {'[object Object]' : anotherObject}
      
      let theThirdObj = {} 
      o [theThirdObj] = 'theThirdObj' //  {'[object Object]' : anotherObject}
    
  • 元素的順序,在 Map 裡面,元素被新增進去之後,順序就會被固定下來。而在 Object 內則無法保證。

  • 繼承關係:Map 繼承於物件 ( Object ) ,而反過來則否,因此在 Map 上那些方便的方法,在 Object 上無法使用。

      let newMap = new Map()
      console.log(newMap instanceof Object) //true
      console.log(Object instanceof newMap) //false
    
  • 可被巡訪:這大概是最大的差別了,因為一般物件上並沒有提供可以直接巡訪的方法,只能透過 for .. in 迴圈達成,或是必須透過 Object.keys 方法把屬性轉為陣列,但是在陣列 、 Set 跟 Map 上都有 forEach 方法可以直接對裡面的元素做巡訪。

Map 與 Object 使用時機

Map 在操作元素上雖然提供了許多語意化的方法,但有時候我們還是會需要像一般物件那樣方便新增元素的方式,最後我們就來看看兩者各適合怎樣的使用情境:

  • 屬性值:這也是兩種型別最大的差別。在知道屬性值都單純只是字串時,使用一般物件就好,因為 Map 雖然可以儲存任何型別的數值,但是因為使用函式建構子創造物件,且在新增、修改元素時必須透過 getset 函式幫忙,因此速度上會比單純使用物件還要慢。
  • JSON 格式:在需要以 JSON 格式來進行開發作業時,選擇一般物件。因為 JS 內的物件可以很直接的被轉為 JSON 格式,這在進行 API 溝通時非常好用。
  • 順序性: 在 Map 內的元素順序會被保留,因此在處理資料時,如果維持順序的穩定很重要,就可以考慮使用 Map
  • 需要一些特定功能:有時候我們會需要某個函式來取得其他屬性資訊,物件因為存取方便的關係,在物件內的屬性如果是函式,就可以直接被執行,Map 就比較麻煩。

總結

除了前面我們提到的幾個基本資料結構,今天我們又認識了 JS 內新的 Map 跟 Set 兩種新的資料型別。在資料結構選擇上永遠是根據你的需求而定,雖然用簡單的物件或陣列組合或許就可以達到,多認識一些這樣子的資料結構不一定會大幅度增加開發速度,但絕對會讓你在開發時有更多其他潛在更好的選擇來達成你的需求。

JS 原力覺醒 Day28 - JS 裡的資料結構

隨著硬體規格條件的提升, 網站商業邏輯的運作也慢慢從以往的後端伺服器轉移到客戶端,因此前端領域的專業知識就變得越來越重要,隨著前端技術被重視,也開始慢慢出現 React 、 Vue 、 之類前端框架的生態圈出現,而後端則慢慢演變為單純的 API 伺服器負責提供資料的存取端口。同樣的,畫面的互動也是另一個越來越被注重的部分,因此怎麼實作出更精緻優雅的前端介面以及互動邏輯也是前端工程師們面臨的新挑戰之一。這些在在的都考驗了工程師們對 JS 這個語言本身更全面的了解。

如同前面所說,隨著商業邏輯慢慢著重在前端,許多資料的規格訂定也常常會跟隨著介面的結構而有所不同,這些隨著前端邏輯而被暫存在前端的資料,變得有點像是後端伺服器放在前端的副 @本。所以,前端工程師的經驗跟專業,就會在資料結構的選擇與判別使用的時機的能力上顯現出差異,而某些資料結構我們在前面的文章多多少少都有提到一些了。在這篇文章內,我想較全面的,對在開發上,常使用到或常見的資料結構做一些說明。

基本上有三種類型資料結構:

  • 陣列型態的資料結構 :Stack 、Queue
  • 以「節點」為基礎的:Linked Lists、 Trees
  • 在資料查找上非常方便的: Hash Tables

Outline

  • Stack
  • Queue
  • Linked Lists
  • Trees
  • Hash Tables
  • 總結
  • 參考文章

Stack

Stack 具有後進後出的特性,堆疊的概念我相信各位 JS 工程師都已經非常熟悉了,而這大概也是 JS 內最重要的資料結構了,在之前講到執行環境堆疊的時候有提過。以程式的方式來說,堆疊的結構就是一個具有 pushpop 兩個方法的陣列,push 可以把元素放到堆疊的最上層,而 pop 可以把元素從堆疊的最上層拿出來。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580fh2OZ6eKkv.jpg

Queue

Queue,序列 也是 JS 語言的核心部分之一,Queue 具有「先進先出」的特性,還記得我們之前提到的 Event Queue 、 MacroTask Queue 以及 MicroTask Queue 嗎?因為有了 Queue 這種樣子的資料結構,JS 才能夠具有非同步這麼具有識別度的特性。那麼以程式的角度來看,Queue ㄧ樣是有兩種方法的陣列:unshiftpopunshift 可以把元素放到 Queue 的最尾端,而 pop 則是把元素從最前端取出來,Queue 也可以反向操作,只要把 unshiftpop 換成 shiftpush

https://ithelp.ithome.com.tw/upload/images/20191013/20106580JRDdzjisVa.jpg

Linked List

Linked List ,鏈結串列是一種有序的、且線性的資料結構,在 Linked List 上每一筆資料都可以被看作是一個節點 ( Node ),每個節點上都包含了兩個資訊:一個是要儲存的數值、一個是指向其他節點位置的指標。Linked List 具有以下特性:

  • 是被一個一個指標串連起來的
  • 第一個節點被稱為 head ,是一個指向第一個節點的參考指標
  • 最後一個節點被稱為 tail 節點,是指向最後一個節點的參考指標
  • 最後一個指摽指向的是 null

Linked List 基本上有 單向( singly ) 跟 雙向 ( doubly ) 兩種類型,在單向的鏈結串列中,只存在一個指向下一個節點的指標。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580Jh38FNHdmU.jpg

而在雙向的鏈結串列中,則會有兩個指標,一個指向上一個節點,一個指向下一個節點。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580nZx8X7Mikl.jpg

Linked List 由於結構的關係,可以在頭、尾,任何地方插入節點,因為只要改變指標的指向就可以了,所以只要搞懂運作方式,他也能實現前面提到的 Queue 跟 Stack 結構的行爲。Linked List 在前後端開發上也很有幫助,在前端 React 框架常常搭配使用的狀態管理器 Redux 中,從畫面到 Action 到 Reducer 這樣子的資料流,就使用了 Linked List 的思考方式,來決定資料的下一個目標( 函式 )。在後端 Express 框架上則用 Linked List 來處理 Http Request 與 Middleware 層的資料流動。

接下來讓我們以雙向的鍊結串列來看看實際上在 JS 內是怎麼使用的,首先我們會需要節點的類別,這樣我們就可以自己指定節點跟下一個節點:

class LinkedNode {
		constructor(value,prev,next){
				this.value = value;
        this.next = next;
        this.prev = prev;
		} 
} 
let head = new LinkedNode(null,null,null)
let node1 = new LinkedNode(1,head,null)

let node2 = new LinkedNode(2,node1,null) 
let node3 = new LinkedNode(3,node2,null) 

node1.next = node2
node2.next = node3

然後我們可以再創一個 LinkedList 類別來記錄這些節點間的關係,

class LinkList {
	constructor(value,prev,next){
		this.head = null 
		this.tail = null
		this.lenght = 1
	} 
	addToHead(){
	} 
}

Linked List 要能再頭地方加入新的節點成為新的 head,因此加入輔助函式看看:

class LinkedNode {
		constructor(value,prev,next){
				this.value = value;
        this.next = next;
        this.prev = prev;
		} 
} 

class LinkList {
	constructor(value){
		this.head = null 
		this.tail = null
		this.addToHead(value)
		this.lenght = 0
	} 
	addToHead(value){
		const newNode = new LinkedNode(value);
		newNode.next = this.head; // 讓原本的 head 成為新節點的 next
		newNode.prev = null // head 並沒有前一個節點 
		this.head = newNode // 最後把原來的 head 換成新的節點
		
		this.lenght += 1
		return this.head
	} 
}

let newList = new LinkList('first')
newList.addToHead('second')
newList.addToHead('third') 

newList.head.value // third 
newList.head.next.value  //second
newList.head.next.next.value //first

接下來我們再實作一個可以從中間刪除任意節點的方法,要找到 Linked List 的某一個數值並且刪除,就只能用尋訪的方式一個一個尋找,這裡我們用 while 回圈以一個類似遞迴的方式來尋找:

class LinkList {
	constructor(value){
		this.head = null 
		this.tail = null
		this.addToHead(value)
		this.lenght = 0
	} 
	addToHead(value){ ...	} 
	removeFromHead(){
			if(!this.head.next) this.head.next = null
			const value = this.head.value;
			this.head = this.head.next 
			this.length-- 
			return value
	} 
	remove(val) {
    if(this.length === 0) {
        return undefined;
    }
    
    if (this.head.value === val) {
        this.removeFromHead();
        return this;
    }
    
    let previousNode = this.head;
    let thisNode = previousNode.next;
    
    while(thisNode) { 
				// thisNode 的參考會隨著 while 而不斷的往 next 去尋找
        if(thisNode.value === val) {
            break;
        }
        
        previousNode = thisNode; // 同時也會不斷紀錄前一個節點
        thisNode = thisNode.next;
    }
    
    if (thisNode === null) {
        return undefined;
    }
    
    previousNode.next = thisNode.next; // 一旦成功找到要刪除的節點,才能夠順利銜接前後節點,達到刪除的效果
    this.length--;
    return this;
}
}

示範實做跟說明幾個函式到這邊,基本上只要知道怎麼修改節點的指向,就可以了解怎麼實作這些操作 Linked List 的方法,包括從 head 刪除節點、從中間新增、刪除節點,以及從最後面新增、刪除,讀者可以自己練習完成看看。

Tree

樹的結構跟 Linked List 有點像,也是從一個節點開始往下長,差別在於 Linked List 裡一個節點只能對到另一個節點,而在樹狀結構內,一個節點可以對到好幾個其他節點,也稱為子節點( Child Node ) ,之前我們講到的 DOM ,正是一種樹狀結構,最上層的 html 是上層節點,而往下延伸出 bodyhead 等下層子節點。

而樹的結構也可以被加上特殊的規則,例如常聽見的二元樹結構, 就是從樹狀結構演變而來,因為在二元樹裡面,每個節點被規定只能擁有另外兩個子節點。而且左邊子節點的數值只能小或等於父節點的數值,而右邊子節點的數值必須大於父節點的數值,以這樣子排列方式,我們就可以有規律的去搜尋或是操作我們需要的節點,例如,整個二元樹的最小值可以在最左邊且最後代的子節點被找到,反之在最右邊後代節點則可以找到最大值。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580EJC2fUxHnF.jpg

在樹的搜尋上則有兩種相似的方式:

  1. 深先搜尋 ( Depth-First Traversal, DFT ) :

    把樹想成由最上面開始往下生長的結構,深先搜尋就是從最上面的根節點,往下垂直的搜尋,深先搜尋裡又分為三種走訪順序,以上面的二元樹圖為例,分別是:

    • 前序 ( Pre Oreder ) :

      順序:訪問根節點 → 訪問左子樹 → 訪問右子樹

      上圖順序: A → B → D → G → C → E → F → H

    • 中序 ( In Order ) :

      順序:訪問左子樹 → 訪問根節點 →訪問右子樹

      上圖順序: D → G → B → A → E → C → F → H

    • 後序 ( Post Order ):

      順序:訪問左子樹 → 訪問右子樹 → 訪問根節點

      上圖順序: G → D → B → E → H → F → C → A

  2. 廣先搜尋 Breadth-First Traversal , BFT )

    廣先搜尋則跟深先搜尋相反,是以水平方向為主的搜尋方式,在樹狀結構裡面,每往下長出一個子節點,就會被視為一層。深先搜尋在執行時是先查看節點有無子節點,如果有的話就盡量往下去搜尋,而廣先搜尋則是在搜尋時先檢查子節點有無其他同一層的節點,然後將這些同層子節點記錄下來,一個一個去搜尋,因此在執行廣先搜尋時,必須用到 Queue 來輔助。

    上圖順序: A → B → C → D → E → F → G → H

樹狀結構與前面鍊狀串列結構實作方法相似,而且樹狀結構若要往下探討可以有很多種變形,例如把不同層的節點串在一下之後就會變成複雜的圖 ( Graph ),這些內容多到可以再寫一篇文章,因此在這邊先不提供範例。

Hash Table

雜湊表是根據鍵值 ( Key ) 來查找對應記憶體位置的資料結構。陣列就是一個很類似 Hash Table 的結構,只不過陣列是利用「索引」來查找資料,因此只能是數字。

可以把 Hash Table 想成是建立在陣列上,透過將不同字串轉成對應的陣列索引來查找,而達到比較靈活的鍵值查找,要達到這樣子的效果,我們會需要實作一個 Hash Function ,來把字串轉換成索引。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580GqUPG5Zh4e.jpg

Hash Function 的運作方式大概會是給每個字元對應的可運算數值,當要查找的時候就把字串內所有字元的數值加起來然後給陣列當成索引值,如果加起來的數值太大,陣列沒有那麼多空間,就必需透過另外的規則簡化(如:加完的數值 mod 10 ) 來取得對應、可行的索引。

getCharNum(char){
	return charCodeAt(char)
}
hashFunction (key) {
	let hashCode = 0 	
	key.split('').forEach(char=>{
		hashCode  += getCharNum(char)		
	}) 	
	return hashCode % 10 
} 

上面是一個 hash function 的實作,當然這只是簡化的範例而已,真正應用在現代系統環境的實作邏輯複雜非常多。透過 Hash Table 的運作方式我們可以利用字串來存取對應的數值,有沒有覺得很熟悉,想到什麼?沒錯就是 JS 的物件!從結果來看 JS 的物件非常像是 Hash Table 的結構,不過根據我的調查結果,有一說是這點會根據 JS 引擎的實作而有所差異,有些引擎裡面是透過混合 Linked List 跟 Hash Table 兩種資料結構來實作物件。

總結

終於講完了這些常見的資料結構,看完之後你應該可以發現這些資料結構大概有一半在前面 JS 相關內容都有提到,分別是:

  • Stack :Call Stack
  • Queue :Task Queue
  • Linked Lists :原型鍊
  • Tree : DOM
  • Hash Table : 物件的 鍵 -值 結構

這些部分如果不深入去看這個語言運作方式的話是不會發覺的,這些資料結構也可以應用在許多系統資料的運算。雖然這個章節只能很粗淺的介紹,但我希望讓原本不熟悉資料結構的人,下次再看到類似的東西可以不會那麼害怕,也能夠更冷靜地往下研究原本要鑽研的知識細節。

參考文章

Data Structures in JavaScript

Objects and Hash Tables in Javascript

Basics of Hash Tables

Hash Table

JS 原力覺醒 Day27 - JS 常用 API - Object.assign && Object.defineProperty

今天要講的是是兩個在操作物件時常用到的 JS API ,有時候我們會需要做一些比較進階的操作,例如對物件屬性做一些比較細節的微調;還有複製物件,但是複製物件的話,因為物件傳參考的特性的關係,在結構複雜的物件上,往往需要特別處理,例如物件內的屬性是另外一個物件。所以我們也會帶到「深拷貝」和「淺拷貝」的概念。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • Object.defineProperty
  • Object.assign
  • 深拷貝
  • 淺拷貝

Object.defineProperty

Object.defineProperty 其實是 Object 函式建構子上的靜態方法(還記得 Obejct 其實是一個函式?),用來對某個物件直接定義一個新的屬性,用法如下:

const object1 = {};

Object.defineProperty(object1, 'property1', {
  value: 42,
  writable: false
}); 

這個方法接受三個參數,第一個是要新增屬性的目標物件,第二個是屬性名稱,第三個是這個屬性的描述器設定。 屬性的描述器?那是什麼?

JS 內物件屬性的描述器有兩種類型,每一種各有不同設定值:

  • 資料描述器 ( Data descriptor ):

    資料描述器是一個帶有值的屬性,其實也就是你要定義屬性的 value 啦。這個屬性有可能是可修改、或是不可被修改的。

  • 存取描述器 ( Accessor descriptor ):

    存取描述器定義的內容包含的 gettersetter 兩個函式。要怎麼存、取這個屬性,就是由存取描述器來負責的。

兩種描述器都有屬於自己的屬性設定值,先分別介紹:

  1. 資料描述器上,有兩個可選值:
  • value ( undefined ) : 定義這個屬性對應的值。
  • writable ( false ): 定義這個屬性是某可以被指派,如果為 true 就代表這個屬性可以透過 如 ob.name= 'new value' 被更新。
  1. 存取描述器上也有兩個可選值:
  • get ( undefined ) : 即物件的 getter 函式,是一個定義物件如何被取用的函式,當物件屬性被取用的時候會被呼叫。
  • set ( undefined ) : 即物件的 setter 函式,是一個定義物件如何被指派的函式。

剩下的幾個設定值是兩種描述器都能夠使用且可選、非必須的。分別是( 括號內的是預設值 ):

  • configurable ( false ) : 定義了這個物件屬性的描述器設定是否可以被修改,如enumerablewritable 、 或是自己本身 configurable
  • enumerable ( false ) : 定義這個屬性在物件裡就屬於可以被巡訪的,也就是使用 Object.keys 或是 for...in 來對物件作遍歷的時候能不能夠存取到。

而要定義的物件屬性的描述器必須一定要是上述兩者中的其中一種,兩者無法同時屬於兩者。

Example - 描述器預設值

var o = {}; // 創造新物件 

Object.defineProperty(o, 'a', {}); // empty descriptor setting

Object.getOwnPropertyDescriptor(o,'a')  
//預設描述器值:
// configurable : false 
// value : undefined 
// writable : false 
// enumable : false 

剛剛說描述器無法同時是資料描述器跟存取描述器,也就是說在 ****defineProperty 的第三個參數描述器設定內,如果有 get 這個設定值出現,就不能再有 value ,否則就會報錯:

var o = {}; // 創造新物件 

Object.defineProperty(o, 'a', {
  value: 37,
  writable: true,
  enumerable: true,
  configurable: true,
	get(){
		return 123
	}
});

//Invalid property descriptor. Cannot both specify accessors and a value or writable attribute

Example - 自訂 gettersetter 函式

自訂 gettersetter 一樣是在 Object.defineProperty 裡面的第三個參數自訂屬性的行為:

var o = {}; 
Object.defineProperty(o, 'a', {
		get() {
        return 'It will return this value if I access o.a' ;
    },
    set() {
        this.myname = 'this is my name string';
    }
});

Object.assign

Object.assign 用來複製所有物件內可被尋訪 (Enumable) 的屬性,而且複製的來源不限於某個物件,可以多個物件一起進行屬性的複製,這個方法的第一個參數跟 defineProperty ㄧ樣都是目標物件,後面可以有複數個參數,就是要被複製屬性的來源。而使用 Object.assign 來進行複製的時候,後面的相同物件屬性會蓋掉前面相同的物件屬性:

let b = Object.assign({foo: 0}, {foo: 1}, {foo: 2});
ChromeSamples.log(b)
// {foo: 2}

所以,如果我想要複製某一物件的內容到一個全新的物件上的話,只要這麼寫:

let oldObject = {
	a:'a', 
	b:{
		c:'cinsideb'
	}
} 

let newObject = Object.assign({},oldObject)

console.log(newObject) //{a: "a", b: {…}}

另外,如果只是單純要把某個物件內容複製到另外一個物件,可以用 ES6 後的新的、比較簡潔好閱讀的寫法 Spread ,也可以達到一樣的效果:

let newObject = { ...oldObject }

淺拷貝 ( Shallow Copy )

在使用 Object.assign 時有一個要注意的地方,就是他雖然可以複製屬性,但要是物件屬性的內容也是另外一個物件時,從這個屬性複製到新物件上的,也只會是這個內層物件的參考,而不是這個物件的拷貝,這個現象就稱為淺拷貝(可以理解為,只複製最外層屬性,往下被複製的都只有參考)。

let oldObject = {
	a:'a', 
	b:{
		c:'c'
	}
} 

let newObject = Object.assign({},oldObject)

newObject.b.c = 'modified c' 

console.log(oldObject) 
/* {
	a:'a', 
	b:{
		c:'modified c'
	}
} */

由上就可以看出,當我修改新的物件的內層屬性物件時,被複製的物件的內層屬性物件 (b.c),也會跟著一起被改動。

深拷貝 ( Deep Clone )

相對於淺拷貝,深拷貝就是完全的複製整個物件內容了。那麼如果要達到這個效果,我們可能要自己動手處理,檢查要複製物件的某屬性是不是物件,如果是的話,就要再以Object.assgn 複製一次,並且這個檢查要搭配遞迴的概念來檢查,才能確保完全的複製。

function cloneDeep(obj){
            if( typeof obj !== 'object'  ){
                return obj
            }
            let resultData = {}
            return recursion(obj, resultData)
        }

function recursion(obj, data={}){
						//對物件屬性做巡訪
            for(key in obj){
                if( typeof obj[key] === 'object'){
										// 如果是物件就繼續往下遞迴
                    data[key] = recursion(obj[key])
                }else{
										// 如果不是物件的話就直接指派
                    data[key] = obj[key]
                }
            }
            return data
        }
let player = {name:'Anakin',friend:{robot:'R2D2'}}
let player2 = cloneDeep(player)
obj.name = 'Darth Vader!!!'
player2.friend.robot = 'no!!!'
console.log(player) // {name:'Anakin猿',friend:{robot:'R2D2'}}

參考文件

MDN 官方文件的說明

Javascript properties are enumerable, writable and configurable

JS-淺拷貝(Shallow Copy) VS 深拷貝(Deep Copy)

JS 原力覺醒 Day26 - 常用 API: setTimeout / setTimeInterval

來講一下常用到的瀏覽器 API ,其實前面在講 Event Queue 的時候就已經提過 setTimeout 了,不過這邊就讓我們從更具實用性的層面來看這些方法。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • setTimeout / setInterval 使用
  • setTimeout / setInterval 清除
  • this in setTimeout / setInterval callback
  • 解決回呼函式內 this 的問題
  • setTimeout / setInterval 、迴圈與 Closure

setTimeout / setInterval 使用

setTimeout

前面有提到 setTimeout 的基本使用方式,而第一個參數傳入的 callback 會被推送到 Event Queue ,待主執行環境堆疊清空以後,才會被執行 ,所以就算第二個參數設定的時間是 0 秒,也不會立刻執行。

function step(stepNum){
	console.log(`step${stepNum}`)
}

step('1') 
setTimeout(function(){step('2')},0)
step('3')

// will print: step1 --> step3 --> step2

setInterval

setInterval 使用方式與 setTimeout 的語法相同,差在 setTimeout 只會執行一次,而 setInterval 則會根據開發者給的時間間隔,每隔一段時間執行一次。

 setInterval(function(){console.log('da')  },1000)
// print : da -> da -> da

setTimeout / setInterval 清除

由於 setTimeout / setInterval 函式本身會回傳一個計時器 id ,我們就可以把這個 id 記錄下來,當頁面要離開用不到的時候使用 clearTimeout / clearInterval 將他們清除:

let timerId = setInterval(function(){
	console.log('do something') 
},1000) 

清除計時器在以前可能還不是會非常被注重的問題,但是像現在主流前端框架把渲染工作交給 JS ,如果使用虛擬路由來控制頁面切換的話,就算頁面切換了,JS 檔案也不會重新載入,主執行環境會一直存在,因此前面設定的計時器在不需要時如果沒有清除,就可能會造成頁面運算的負擔。

this in setTimeout / setInterval callback

setTimeout / setInterval 裡第一個回呼函式內的 this 如果沒有經過處理的話,預設都是指向全域環境 window ,因為這兩個都是屬於 window 物件底下的函式,我們可以推斷我們傳進去的回呼函式是在裡面被執行。雖然沒辦法直接看到 setTimeout 裡面的原始碼,不過可以推斷內容大概是像這樣 ,下面以 Pseudo code 示意:

 window  = {
		... 
		setTimeout:function(timerFunc,time){
			//several minutes later...
			timerFunc() 
		} 
}

之前提過在思考 this 的連結的時候,有提到,「如何呼叫」函式將會影響 this 的指向,想一想「隱含」的繫結, 再對比上面的 setTimeout 內容,可以看出我們傳入的回呼函式在 setTimeout 被呼叫,但因為是直接呼叫,沒有隱含繫結,因此在內的 this 會指向全域。

解決回呼函式內 this 的指向問題

承上一段,那要怎麼樣才能讓 this 指向目前所屬的執行環境,讓開發者在撰寫程式碼的時候更不容易誤解?

方法一

有一個方法是:使用箭頭函式,因為箭頭函式內沒有 this ,更準確來說, 箭頭函式內的 this 與他外部語彙範疇的 this 相等。

let boss = 'Yoda'
let user = {
	name:'Luke',
	introduce:function(){
		setTimeout(()=>{
			console.log('hey, ' + this.name) 
		},1000) 
	} 
} 
user.introduce() // print : hey,Luke

方法二

另外一個方法是,使用 Function.bind,這個方法跟 callapply 都可以指定函式執行環境內要綁定的 this ,差別在呼叫 bind 後會回傳一個全新、綁定過 this 的函式。

let user = {
	name:'Luke',
	introduce:function(){
		setTimeout(getName.bind(this),1000) 
	} 
} 

function getName(){
	console.log('hey, '+ this.name)	
} 

user.introduce() // print : hey,Luke

setTimeout / setInterval 、迴圈與 Closure

這段要講的大概是最經典的面試考題,只要講到跟 Closure 有關的問題,通常一定會提到迴圈。
先來看這段例子:

for(var i =0;i<10;i++){
	setTimeout(
		function (){
			console.log(i)
	},1000) 
}

在一秒過後我們就很驚訝的會發現, JS 吐出了 10 個 10 給我們,這是因為 var 宣告是屬於 function scope 但是 for 迴圈並不是 function ,所以在之內宣告的變數 i 就等於是全域變數。也因此無法透過 fucntion 產生函式執行堆疊或閉包,於是這個回呼函式會被推到 Event Queue,待時間到要執行,去獲取i 的時候,全域的 i 早就已經被 for 迴圈修改而成為10了 ,所以才會有這樣子的結果

解方:

要解決這個問題我們只要想辦法讓維持 setTimeout 回呼函式與每個 i 的聯繫即可,還記得 let 屬於 block scope ?所以用 let 產生的變數是綁在會在不同的 block 上 ,對 for 回圈來說,每次 i+1 的迴圈迭代之後的,都是一個新的 block,再搭配 block scope 的特性,就可以在每個 block 留下與每個 i 的連結:

for(let i =0;i<10;i++){
	setTimeout(
		function (){
			console.log(i)
	},1000) 
}

// print : 1,2....10

或是ㄧ樣利用 function scope 的特性:

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

function getValueOf_i(i){
	setTimeout(function(){
		console.log(i)
	},1000) 
} 

這樣一來當 i 以參數形式傳入另外一個函式時,就會被函式執行環境保留而產生閉包。

JS 原力覺醒 Day25 - CRP : 關鍵渲染路徑

當使用者進入頁面、瀏覽器收到請求並回傳前端相關檔案後,到最後使用者看到的畫面呈現之前,還有很多步驟會被執行,這一連串步驟的總和就稱為 Critical Rendering Path ( 中譯:關鍵渲染路徑),了解關鍵渲染路徑,在網站前端頁面需要做效能優化時,就可以比較容易知道,要從哪裡下手。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

關鍵渲染路徑(以下簡稱 CRP ) 大致上會執行以下六個步驟:

  1. 建構 DOM Tree
  2. 建構 CSSOM Tree
  3. 執行 JavaScript
  4. 創造渲染樹
  5. 產生畫面佈局
  6. 繪製、產生畫面

下面就讓我們一個步驟一個步驟詳細來看:

Step1. 建構 DOM Tree

前一章節有講到網頁的 DOM 是根據 HTML 內容而來,這個轉換的過程有點像這個系列一開頭我們討論 JS 語法解析那段,瀏覽器會根據 HTML Tag 將內容轉為一個一個 Token (標記)

https://ithelp.ithome.com.tw/upload/images/20191010/201065802PnTTlOshi.jpg

之後會根據這些 Token 將對應的標籤轉換成節點,之後根據 Token 的前後關係產生出 DOM Tree 。

https://ithelp.ithome.com.tw/upload/images/20191010/20106580peW9V1tTe4.jpg

Step2. 建構 CSSOM Tree

CSSOM ( CSS Object Model ) 是代表跟 DOM 元素對應樣式的物件。他的表現形式跟 DOM 很像,只是 CSSOM 是依附著每個節點,各個節點都會有對應的樣式 ( Style ),所以基本上 CSSOM Tree 跟 DOM Tree 長的會很像。

https://ithelp.ithome.com.tw/upload/images/20191010/20106580iAbwkUacyT.jpg

這邊要注意的是,CSS 在頁面載入行為裡,是屬於鎖定渲染的資源( Render Blocking Resource ),意思是,在頁面仔入時,只要還沒有拿到所有的 CSS 檔案並成功載入,那瀏覽器就會等到完成載入為止,這意味著,每個網頁上的 CSS 檔案,都會拖到載入速度。

除了 Render Blocking ,也有人說 CSS 是 「Script Blocking 」,因為在瀏覽器載入所有的 CSS 檔案之後,瀏覽器才會進入的我們的下一步「執行 JS」。 在產生 CSSOM 時,越多層的選擇器,在元素與樣式的匹配上會需要更多時間來進行。以下面這兩個 CSS 類別為例:

A : p {  color:red; } 

B : div h1 { font-size:22px;}

第二種 B 情況的 CSS 會需要更多時間來做匹配,首先瀏覽器會先找到頁面上所有的 h1 元素,而後在看這個元素的父類別是不是一個 div 元素 ,因此瀏覽器在匹配樣式時其實是以「從右邊到左邊」的順序來進行的,所以現在你就了解,如果你有加速前端渲染速度的請求,就要減少 CSS 選擇器層級的長度,在這方面,BEM 的 CSS 命名撰寫風格就把層級關係透過命名的方式來表達,同時也大幅度的減少選擇器的少用次數,建議對 CSS 有興趣鑽研的人一定要看一下。

類似的命名風格或規範,除了 BEM 之外還有 OOCSS 跟 SMACSS ,這些規範都是透過一些原則,來達到最大程度的減少重複,除了好維護之外,其實也能提升畫面渲染的效率,這也是為什麼這些規範常常被資深前端人員提起、並視為圭臬的原因。

Step3. 執行 JS

JS 則是鎖定轉譯,在 JS 執行完之前,瀏覽器都不會繼續做 HTML 文件的轉譯跟建構。當瀏覽器轉譯時碰到 <script> ,他會停下來等到 JS 執行完成之後才會再往下。這也是為什麼我們常常說要把 <script> 標籤放到整個網頁最後面的原因。

Step4. 創造渲染樹

渲染樹 (Rendering Tree) 其實就是 DOM 搭配 CSSOM 的結果,在用白話一點的方式來說,就是「最後會被渲染在畫面上」的結構樹,所以如果 CSS 樣式導致某個 Node 沒辦法顯示,( 如display:none ),那麼他就不會出現在渲染樹上。

Step.5 產生畫面編排 ( Layout )

我們已經取得代表元素層級關係的 DOM 樹結構,也匹配了個元素對應的樣式,最終搭配兩者產生出了渲染樹,現在我們離最後產生可視畫面的階段已經不遠了,但是還差一個步驟,我們還必須弄清楚所有元素的實際位置,以及元素該如何呈現,那就是產生畫面編排 ( Layout ) 的步驟。

Layout 產生的方式,會跟 meta tag 裡面的 viewpoint 屬性有很大的關係:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

viewpointmeta tag 用來告訴瀏覽器,頁面要怎麼縮放,還有維度,就是指畫面像素(瀏覽器畫面)跟螢幕像素(硬體)的比例,content 之中,width 用來設定瀏覽器畫面寬度是多少,把他設定成 device-width 的話就是在告訴瀏覽器,畫面顯示的螢幕寬度要跟硬體裝置相同(手機、電腦),如果沒給 width 值的話,瀏覽器就會使用預設的 980px 來當作預設的畫面顯示寬度。這個屬性在 HTML5 後出現,常用在 RWD 的設計實作之中。 initial-scale=1.0 是指預設的縮放程度,最常見的值也是預設值,就是 1

Step6. 繪製、產生畫面

到最後一個步驟,瀏覽器進入到了繪製階段,前面提到一連串很抽象的設定跟結構,終於可以被轉換成一個個像素,繪製階段所花的時間會跟 DOM 結構樹 與 CSSOM 樹的大小、規模有關,越複雜的結構或是樣式就會需要更多時間,應該不難理解。

從開發者工具看渲染順序

我們透過瀏覽器的檢查工具,也能看出上面講的 CRP 六個步驟,是不是真的依照順序進行,以 Chrome 為例子,打開開發者工具,並切換到 Performance 之後,按下錄影,重新整理之後結束錄影,就能夠看到這段時間內瀏覽器是怎麼產生畫面的:

https://ithelp.ithome.com.tw/upload/images/20191010/20106580UtQhk3zM67.jpg

對應前面步驟說明:

1、2: 拉取資源並解析 DOM 樹

  1. 為 index.css 解析 CSSOM 樹

  2. 執行 JavaScript 檔案 ( index.js )

  3. 根據 viewpoint 的 meta tag 產生layout

  4. 繪製螢幕

參考資源

Google 在 Udacity 的教學真的講的蠻仔細的,搭配圖文也能更讓學習者一目瞭然:

JS 原力覺醒 Day24 - DOM

今天要講的是瀏覽器的 DOM 的概念,內容雖然跟 JS 語言比較沒有關係,但是除非你只寫後端 node.js ,否則只要跟介面相關一定會碰到需要處理 DOM 元素的情況出現,今天就讓我們學著好好跟 DOM 相處。

Outline

  • DOM 是什麼?
  • 畫面是如何透過 DOM 被產生的?
  • DOM Tree
  • 與 DOM 互動
  • 總結

DOM 是什麼?

在我們漫長的前端職涯中,每位前端開發者心中都一定曾經出現過、或是被問過這個問題,那就是到底什麼什麼是 DOM 呢?我們都知道 HTML 是透過標籤式的語法來描述網頁中元素與元素的關係,一對標籤通常就代表一個元素,而且標籤又可以放在另外一個標籤之內,因此元素之間是會有上下層級的,而 DOM 呢,就是透過把這樣子的層級結構轉換為對應的「物件」而成的關係模型

DOM 並不是只能透過 HTML 產生,其他類似的語法像是 SVG、XML 這裡的物件並不一定要是 JS 的物件,因為,但是在瀏覽器裡面,是的,這裡我們討論的物件就是 JS 裡的物件,例如我們在操作 DOM 元素時最常用到的 document 物件。

 document.createElement('div')

所以,我認為DOM 是:

將HTML文本的複雜層級關係,轉換成以物件結構的方式來表現 ,讓程式語言得以與之溝通。

https://ithelp.ithome.com.tw/upload/images/20191009/201065801mBHZYtbqQ.jpg

畫面是如何透過 DOM 被產生的?

因為 HTML 語法大部分都是是成雙成對且有層級關係的標籤,而在使用者透過瀏覽器進入網頁,瀏覽器開始讀取 html 檔案,就會開始把開發者寫的 HTML 程式碼(即指 Document ) 內容轉成對應的層級關係結構,所以這種結構才會被稱為 Document Object Model (文件-物件模型)。從使用者進入網頁,到顯示最後使用者的畫面之前,會經歷許多步驟,不過大致上可分為兩個階段:

  • 第一階段:瀏覽器會先讀取 HTML 程式碼,並決定最後要渲染在網頁上的內容
  • 第二階段:瀏覽器實際開始渲染,形成最後看得見的畫面

第一階段執行玩後的結果稱為「渲染樹 ( Render Tree )」,渲染樹就是用來表現會被渲染到最終畫面上的 HTML 元素,還有他們的關係與 CSS 樣式,要完成渲染樹,瀏覽器會需要兩樣東西:

  1. DOM :用來表現 HTML 元素的層級關係
  2. CSSOM: 整個網頁內HTML 元素對應樣式的關聯

DOM Tree

DOM 裡面用來表現元素層級關係的物件又稱為「 節點樹 ( Node Tree) 」,他會有這樣子的名稱是因為結構都是從最上層的某個元素,例如 <body> ,往下慢慢延伸、長出許多的分支,整個結構就像是樹一樣,透過這樣子的關係表現形式,程式語言(JS) 與畫面表現 (HTML) ,才得以互相溝通。以下面這個 html 內容為例:

<html>
<head>
  <title>DOM Example</title>
</head>
<body>
		<h1>	It's All About DOM </h1>
		<p>Hello World!!</p>
</body>
</html>

https://ithelp.ithome.com.tw/upload/images/20191009/20106580zqwZaavHgD.jpg

HTML 中,在另外一個元素標籤裡面的元素就是該元素的子元素,如 <html> 元素在最上層,所以其他包含在這個元素裡面的都是他的子元素,而這些元素內又會有其他包含的元素,如此重複、不斷往下堆疊,**而把每個元素都看成一個節點的話,就會形成 DOM 的結構樹 ( DOM Tree) 。而每個樹的節點在也都對應為一個物件,**如此一來 JS 才能透過 瀏覽器的 API 如 document. querySelector 跟每個元素互動或溝通。

與 DOM 互動

透過 JS 我們可以跟 DOM 互動來改變畫面的呈現,或是新增一些互動的功能,像是:

  • 改變或刪除 DOM 元素
  • 修改元素的 CSS 樣式
  • 讀取及修改 DOM 元素上的屬性 ( id 、 class 、 src 這些標記性的內容)
  • 創造新的 HTML 元素到 DOM 裡面
  • 在 DOM 元素上新增監聽事件(如:點擊)

## 總結

今天我們了解了什麼是 DOM ,DOM 是從開發者寫的 HTML 程式碼轉換而來,但 HTML 語法本身並不是 DOM ,而瀏覽器就是因為透過 DOM ,才能讓 JS 跟畫面的元素溝通。下一篇,我會講解從使用者進入畫面後,瀏覽器是怎麼從生成 DOM ,然後透過一連串的處理,最後才顯示畫面的。

參考資源

JavaScript DOM Tutorial #1 - Introduction

MDN官方說明

DOM Nodes

JS 原力覺醒 Day23 - Class 語法糖

講完了原型鍊,現在我們知道如何透過建構函式去做到類似類別的效果,也透過設定物件的 prototype 屬性達到物件的繼承效果, ES6 之後,甚至出現了 class 關鍵字,讓我們可以用更物件導向的方式去撰寫 JS。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • Class 基本用法
  • class 宣告式的防呆機制
  • 透過 class 宣告來達成類別繼承
  • 原型物件方法
  • static 靜態方法
  • 類別建構子內的 super

Class 基本用法

原本我們必須要透過建構函式來來模擬類別產生物件,但是因為函式子實在太像是函式了,所以很容易被搞混。在 ES6 後出現了 class 宣告的方式,讓相關功能的程式碼整體變得更物件導向且直觀、更好閱讀許多。使用 class 宣告類別的寫法會要使用比較多一點語法,但與建構函式不會相差太多:

  • 建構函式:
    function User(name){
    	this.name = name 
    }  
    let user1 = new User(name) 
    
    User.prototype.getName = function (){
    	return this.name
    } 
  • class 宣告式
    class User{
    	constructor (name){
    		this.name = name 
    	}
    	getName(){
    		return this.name
    	}   
    } 

可以看出使用了 class 宣告以後,原本建構函式的內容還是一樣,只是被移動到 constructor 函式內而已。而原本我們要取用 prototype 才能達成方法的共享,現在也只要直接在 class 內直接宣告就可以了( 是不是真的乾淨很多 ),注意在 class 內的方法宣吿方式跟一般物件屬性的宣告不太一樣,那是 ES6 後出現、用來宣告函式屬性的縮寫,且方法與方法之間不需要以逗號相隔。

class 宣告式的防呆機制

為什麼前面說使用函式宣告式很容易讓開發者把他跟一般函式搞混呢?因為使用 new 運算子搭配函式來創造實體 ( instance ) 的時候,基本上也是一種函式呼叫,而且就算沒有加上 new 運算子,函式呼叫還是有效, JS 不會有提示**,**因此就算真的寫錯了也不容易找到錯誤。而使用 class 來宣告的時候,則只有在使用 new 呼叫的時候,才會有效。

透過建構函式來達成類別繼承

還記得前面提過,想要用建構函式來達成繼承的話,有幾個步驟我們必須自己進行:

  1. 建構函式的繼承:

    為了繼承「前代」建構函式的內容,所以我們必須自己在「後代」建構函式內呼叫前代建構函式 :

        function Human(race){
        		this.race = race
        }
        
        function User(name,race){
        	this.name = name 
        	Human.call(this,race)
        } 
  1. 原型物件的繼承

修改「後代」建構函式的原型物件使原本存在其中的 proto,屬性從參考 Object 改為參考到前代物件,然後再把原型物件內的函式建構子指回「後代」建構函式,完成原型鍊的串接:

    let User.prototype = Object.create(Human.prototype) 
    User.prototype.constructor = Human

透過 class 宣告來達成類別繼承

class 是 ES6 後出現的語法糖,語法糖簡化了整個類別宣告的過程,透過 class 宣告類別,讓這一切複雜的設定都變得簡單許多!我們不需要再去修改原型物件,也能直接完成繼承的效果了。使用 class 來實現繼承,會需要搭配另外一個關鍵字 extends ,步驟如下:

  1. 創造要被繼承的類別 Human
    class Human{
    	constructor (race){
    		this.race = race 
    	}
    	getRace(){
    		return this.race
    	}   
    } 
  1. 創造後代類別 User ,並搭配 extends 指向 Human ,代表 User 繼承 Human
    class User extends Human{
    			constructor (name, race) {
            // invoke our parent constructor function. 
            super(race);
    				this.name = name
          }
    } 

類別建構子constructor 的內容就是原本建構函式的內容;而還記得前面有提到我們必須自己在「後代」建構函式內呼叫「前代」建構函式嗎?現在也不需要這麼麻煩, constructor 內的 super 函式就代表了 被 extendsHuman 建構函式,所以我只要直接呼叫 super 就可以了。

原型物件方法

使用建構函式,我們可以在原型物件上新增共享的方法,在 class 宣告中當然也做得到,其實就是在 constructor 外定義的方法,其實前面已經有提過了:

    class Human{
    	constructor (race){
    		this.race = race 
    	}
    	getRace(){ // will be set on the prototype object
    		return this.race
    	}   
    } 

static 靜態方法

靜態方法是物件導向裡面的概念。靜態方法只能由類別本身取得的方法,在產生出來的實例 ( instance )中是無法取得的。staticclass ㄧ樣是語法糖,使用 static 關鍵字定義的方法,會直接被指派到該 class 上,所以就只能從該類別上直接取得,像是這樣:

    class User {
    	constructor(name){
    		this.name = name
    	}
    	static getUserType (){
    		return 'technical'
    	}
    } 
    
    User.getUsertype() //'techical'

對應前面的建構函式,就有一點像是這樣:

    function User (name){
    	this.name = name
    }
    User.getUserType  = function(){
    		return 'technical'
    }

如果從建構函式來看靜態方法的話可能會稍微有一點奇怪,不過畢竟函式本身也是物件嘛,要在之上新增屬性本來就是合法的。

類別建構子內的 super

剛剛說到類別建構子與建構函式內容相同,而裡面的 super 又代表了被繼承類別(或稱前代類別),所以在「後代」類別建構子內一定要呼叫 super 才能有效完成屬性繼承,而在 class 內定義的其他方法則會被定義到原型物件內,所以如果想要取得「前代」建構函式原型物件內的函式,可以直接用 super 來取用,以前面 Human 類別為例子,在 User 類別內就可以這樣做:

    class User {
    	constructor(name){
    		super() 
    		this.name = name
    	} 
    	getRace(){
    		return super.getRace()
    	}
    } 

總結

在我們了解了 JS 內,原型的運作方式之後,我們利用原型達成了繼承的效果,了解了什麼是原型鍊,之後在今天的這篇文章裡面我們又結合了上述提到的所有知識了解了 class 語法糖的使用方式,還有跟舊版建構函式寫法的對應。儘管一切很複雜,相信讀到這裡的你一定有不少收穫。

雖然快結束了,不過如果你對我寫的系列文有興趣,歡迎訂閱,已經訂閱我的人,也非常感謝你們,你們的閱讀就是我寫下去的最大動力,希望我可以把 30 天都撐完!

JS 原力覺醒 Day22 - 原型共享與原型繼承

前一天我們提到 JS 的原型,以及為什麼會有原型的出現 :為了模擬物件導向的行為。 那麼原型實際上帶來什麼好處?又是透過什麼方式達到繼承的目的?

Outline

  • 原型共享:原型的運作方式
  • 原型鍊
  • 原型繼承

原型共享:原型的運作方式

__proto__ 屬性會在物件產生的時候被加到這個物件上,這個 __proto__ 就是透過參考的方式,將「被生成物件」與函式的「原型物件」做連結 ( 看到 proto 前後的「_」有沒有把他跟「連結」做聯想,是不是覺得這個變數取的很好? )。這個自動產生 __proto__ 參考的行為是 JS 預設的動作,有一點像是這樣:

let user1 = new User() 
user1.__proto__ = User.prototype 

當然因為這件事情是自動發生的,所以我們不需要手動去做這件事情,在開發上也不建議操作 __proro__ 這個變數,請讓他自由,所以整理一下提到的兩個名詞。

  1. proto :會在物件被生成時一起被指派到物件上的屬性,他決定這個物件的原型物件是誰。
  2. prototype :會一直存在於建構函式上的屬性,所有透過該函式產生的物件都有能力存取。

當我們想要取用物件中的某個屬性時,JS 會先去物件中尋找該屬性,如果沒有,就會轉而透過__proto__ 往原型物件屬性,也就是 prototype 原型物件,去尋找這個屬性。由於原型物件 prototype 本身也是物件,所以我們在這個物件內也可以另外新增屬性,而透過前面的說明我們也可以知道原型物件是在被生成物件之間被共享的,所以我們就可以把一些共用的變數或是方法,放到這個共用的物件之內。

let defaultName = 'Darth Vader'
User.prototype.name = defaultName

let user1  = new User() 
let user2  = new User() 

user1.name // 'Darth Vader'
user2.name // 'Darth Vader'

這麼做有什麼好處? 把共用函式放在函式建構子裡面的話,每個被生成物件還是會有一樣的函式阿?是這樣沒錯,但是這樣等於是把同樣的數值或函式複製好幾次,生成幾個物件,JS 就會需要幾個記憶體空間;而要同樣的目的,其實只要放在 prototype 原型物件內就可以用較低成本的方式達成。

原型鍊

剛剛說到當 JS 引擎在物件內找不到某個屬性時會透過 __proto__ 去往 prototype 原型物件去搜尋這個屬性,如果原型物件裡還是找不到,這個原型物件上也還會有一個 __proto__,指向他所屬前代類別的原型物件,例如 JS 內 Array 其實也是物件,所以可以說他的前代就是 Object 物件:

Array.prototype.__proto__ === Object.prototype // true

因此 JS 引擎會再透過原型物件裡的 __proto__ 屬性往上一個原型物件尋找,直到真的找不到為止 ( 會找到 JS 內 Object 物件的原型物件為止,你可以再透過 __proto__ 往上找找,最後會發現他是 null )。這個行為跟當初我們講到範圍鍊 ( Scope Chain ) 的行為類似,所以也稱為「原型鍊 ( Prototype Chain )」。

https://ithelp.ithome.com.tw/upload/images/20191007/20106580PmyBG9ONck.jpg

原型繼承

最後這個部分就讓我實際的程式碼範例來實作繼承,順便藉此說明原型鍊概念的實用性,在繼承的行為裡,透過被繼承的「後代類別」,所產生出來的物件,一開始就應該要直接具有「前代類別」的屬性跟方法,我們來嘗試看看有沒有辦法透過 JS 達到這個目的。

現在假設:

  • 我們有一個 Human 類別跟 User 類別
  • 在 Human 類別的物件上有一個 getRace 方法
  • 在 User 類別的物件上有一個 getUserName 方法

我們的目標是:透過原型鍊實現 Human 與 User 兩者的繼承關係。

function Human (action, height,race){
	this.action = action 
	this.height = height 
	this.race = race
}


function User(fisrtname,lastname){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
}
 

Human.prototype.getHumanRace = function(){
	return this.race
} 

User.prototype.getFullName = function(){
	return this.firstname + this.lastname
}

在正式開始之前我們要先思考一下有哪些部分要處理,才能夠讓要繼承的函式建構子與被繼承的函式建構子共享屬性跟方法,主要有兩個方向:

1. 前後代類別原型物件繼承

因為透過 new 運算子生成物件的時候,這兩個建構函式上都會各有一個 protorype 物件,一般情況下他們各自為政 ,但是在處理繼承的時候我們必須同時考慮兩者之間的連結。

前面提到物件在找不到屬性時,就會往原型物件找,如果原型物件裡還是找不到,就會再透過原型物件裡的 __proto__ 屬性往上一個原型物件尋找,形成原型鍊。原型物件之間要做到繼承就代表了:

透過「後代類別」產生的物件,其上有屬性不管在物件內還是在原型物件上都無法找到時,會轉而往「前代類別」的原型物件尋找

能夠做到這樣子的行為,我們才能說我們透過建立原型屬性的原型鍊,而做到繼承的效果。為了達到這樣子的效果,很顯見的我們必須修改物件上的 __proto__ 連結,但是前面也有提過再開發上不建議直接修改__proto__ 的參考,因為會破壞物件的預設行為,儘管如此,我們還是可以用比較曖昧的方式來修改這個連結:

User.prototype = Object.create(Human.prototype)

我只用一個之前沒看過的 JS 內建方法 Object.create 修改了繼承物件 User 的 prototypeObject.create 可以用來創在一個全新的物件,而且他把第一個參數傳入的物件拿來當作這個新物件的 prototype ,之後我們就可以發現 User 的原型物件,被我們修改成一個新的空物件,而這個物件的原型,正是指向 Human , 透過這樣的方式 ,我們就把兩者之間繼承的原型鍊串起來了。

https://ithelp.ithome.com.tw/upload/images/20191007/20106580jQaA3vn2o0.png

但是如果你有注意到的話,原本在原型物件上都會有個指回建構函式的prototype.constructor 已經不見了,因此我們需要手動把他加回來,JS 才能夠查找到正確的建構函式。

User.prototype.constructor = User

https://ithelp.ithome.com.tw/upload/images/20191007/20106580jaRrWOjwPH.jpg

2. 前後代建構函式內容繼承

透過原型物件確實可以達成共享,但如果透過這個方法來共享某些特定屬性,因為屬性的記憶體空間只有一個,這麼一來如果是像「姓名」、「年齡」這種每個人(實體)都會有不同數值的資料,就不適合放在原型物件內,所以我們要想辦法讓我們在「後代」建構函式內可以直接取得「前代」建構函式內容。

簡單來說就是讓前代類別的內容出現在透過後代類別的建構函式所產生的物件上,這裡有一個很經典的辦法,那就是在後代 ( 繼承類別 ) 建構函式裡面執行前代( 被繼承類別 ) 建構函式:

function Human (height,race){
	this.height = height 
	this.race = race
}


function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
  Human(height,race) // This is not totally correct
}

這麼一來當 User 透過 new 被呼叫的時候,除了會將 User 內的 this 繫結綁到新生成物件上,還會有另外一個充滿使用 this 繫結來設定物件屬性的 Human 方法被執行,如此一來,前代類別的屬性設置就能夠與後代共用,而前兩行定義的 firstnamelastname ,也正好是 User 專屬,Human 不會有的資料屬性,當然我們也可以直接把 Human內定義的屬性搬到 User 內,不過這樣就會變成是重新定義一整個物件屬性,就失去繼承的意義了:

// dont do this if you want to make an inheritance.
//THIS IS AN ANTI-PATERN
function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
	this.height = height 
	this.race = race
}

但是還沒有完,這邊有一個前面提過很重要的觀念,那就是當我們 執行 Human 方法時,裡面的 this 繫結並非透過 new 被觸發,所以並不是指向剛剛透過 User 函式建構子被生成的新物件,這個時候我們要透過「明確的繫結」來修改 this 的指向,來把 User 內的 this 連結到 Human 函式的 this 上,這樣子我們就達成了所有物件屬性的繼承:

function Human (height,race){
	this.height = height 
	this.race = race
}

function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
  Human.call(this,height,race) 
}

Human.prototype.getHumanRace = function(){
	return this.race
} 

User.prototype.getFullName = function(){
	return this.firstname + this.lastname
} 

User.prototype = new Human()

let user1 = new User('John','Kai','black','179')   

https://ithelp.ithome.com.tw/upload/images/20191007/20106580dVHNzBM7ks.png

JS 原力覺醒 Day21 - 原型


上一篇提到 JS 是物件原型導向,而非物件導向的語言,如果想要像物件導向那樣達成物件之間屬性的共用,就需要借助原型的幫忙,所以了解「原型」的概念,對於我們後續想要活用 JS 的物件,或是在 JS 裡面撰寫物件導向風格的程式碼的話是非常重要的。

Outline

  • 物件導向:類別與物件
  • 物件導向:繼承的概念
  • 函式上的原型物件屬性
  • 透過函式建構子產生的物件
  • 總結:原型物件屬性

物件導向:類別與物件

在物件導向語言裡面,類別定義了描述某件事或某個功能的基本概念,就像一件商品或是建築物的設計圖ㄧ樣;而物件則是透過類別裡所描述的的概念實現出來的東西,對比於建築設計圖,就是建築物:

  • 類別 ←→ 建築設計圖
  • 物件 ←→ 建築物

https://ithelp.ithome.com.tw/upload/images/20191006/20106580XxkBIolrjB.jpg

當然上面的比喻只能說是非常非常粗淺的描述,完整的物件導向概念是非常博大精深的。這邊是想讓各位讀者了解它的原理,以及從什麼出發點被創造出來的,知道物件導向的根本概念後,後面我們解說 JS 原型的時候,就不會那麼不知所以然。

物件導向:繼承的概念

前面也提過原型存在的目的是為了達到繼承,那麼我們先來看看繼承是怎樣的概念,在物件導向裡的繼承是指類別可以以另一個類別為基礎,再往上進行擴充、或是修改,這樣一來就可以用很方便且較低成本的方式創造新的類別,因此,姑且說繼承的目的是為了讓「某些屬性可以共用」且可以減少重複。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580UZUALPHjdm.jpg
用生活化的方式比喻的話繼承與被繼承物件之間的關係,有點像圖片內的「動物」這個總稱與「鳥」這樣更明確的稱呼,鳥也是動物的一種,所有動物都有特定共用的行為例如呼吸,但是有些行為可能只有鳥類做得出來例如飛行,因此可以知道,繼承可以讓物件同時具有共用的部分與較為特定的部分。

函式上的原型物件屬性

在物件導向裡面有類別的概念讓物件得以用很快速清楚的方式擴充,而 JS 裡面只有「物件」,所以只能用模擬的方式來達成類似的效果 — 那就要透過原型的幫忙。

前面在講繫結的時候我們提到,函式可以搭配 new 運算子成為「函式建構子」來產生物件,我們先來討論函式建構子的概念是什麼。在 JS 裡面,函式建構子其實與一般函式呼叫沒有差別,只是前面多了 new 這個關鍵字而已。

而在 JS 裡面,一個函式被創造出來的時候,JS 引擎會新增一個 prototype 屬性到這個函式上面,這個 prototype 是一個物件,我們姑且稱之為「原型物件」,在原型物件裡面我們可以找到一個指回該函式的constructor 屬性。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580QnyFbprfLV.jpg

我們用下面的程式碼來當作例子:

 function User(firstName, lastName) {
	this.firstName = firstName,
	this.lastName = lastName,
	this.fullName = function() {
		return this.firstName + " " + this.lastName;
	}
}

var user1 = new User("Gin", "Tsai");

console.log(user1) 

我們用 User 函式當作函式建構子來產生物件,這個函式上面會有一個 prototype 屬性,且他是一個物件,裡面有另外兩個屬性:

  • 剛剛提到的 constructor 屬性,指向回該建構函式 ( User )
  • _proto__ 屬性 ,裡面又是另外一個物件,這一點後面會再詳談

透過函式建構子產生的物件

那麼,當物件透過這個函式建構子被產生之後,會不會有什麼特別的地方呢?相對於 JS 引擎在 function 上面加上 prototype 屬性,在這個新生成的物件上則是會被加上一個 __proto__ 屬性,這個屬性恰好是指向剛剛函式建構子的 prototype 物件。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580aINQGXfUmW.png

User.prototype === user1.__proto__  //true

因此我們透過上面的例子可以得出這樣子一個結論:透過函式建構子生成的物件,其上面會有一個指向該物件所屬函式建構子 prototype 屬性的 __proto__ 屬性,也就是該新生成物件的「原型」。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580sg56q0rUHF.jpg

現在讓我們用同樣的方式創造第二個使用者 user2 ,因為一樣都是透過函式建構子所產生的物件,因此在這個物件上照理說也會有一個 __proto__ 屬性並指向產生這個物件的函式建構子上的原型物件,所以我們可以知道只要是透過函式建構子被生成的物件,他們之間都有一個共享的原型物件( prototype,先知道這一點很重要。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580UpsGcXBrmb.jpg

總結:原型物件屬性

現在我們知道了被生成物件與建構函式之間的關係:

所有透過函式建構子生成的物件,都透過 __proto__ 屬性與函式建構子上的 prototype 屬性做連結,或是說共享這個屬性。

但是光知道這些還沒有辦法知道實際的應用,下一章節我們會介紹這個部分,就讓我們往下看看 JS 是怎麼透過原型來達到繼承以及減少相同函式宣告的重複性的。

參考資源

Prototypes in JavaScript

JS 原力覺醒 Day20 - 物件

今天要提到 JS 裡面物件的概念,「物件」的概念在 JS 裡面是非常重要的,也是 JS 的基本元素。但是相對於物件導向語言的物件,意義上又有一點不一樣。就像前面提到在 JS 裡面函式也是屬於物件,這樣子的行為在一般物件導向的語言裡面是沒有的。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • JS 的物件
  • 創造物件的方式
  • 取用物件的方式
  • 物件原型導向

JS 的物件

在 JS 裡,物件代表一連串「屬性名稱」與「數值」的組合 ( name-value pair )。這些組合湊在一起就形成了對某件事情的描述,就像一本書有許多資訊像是書名、作者、出版日期ㄧ樣,你可以用 JS 物件輕鬆的表示現實世界的許多物品:

{
 title: 'Le Petit Prince', 
 author:'Antoine de Saint-Exupery', 
 pages: '331', 
 ...
} 

創造物件的方式

最基本的用來創造物件的方式有幾種:

  • 物件實字 ( Object Literal )
  • 函式建構子

物件實字 ( Object Literal )

物件實字應該是你最長用到的創造物件方式,使用物件實字創造物件的寫法,跟在 API 傳遞、溝通的時候會用到的 JSON 格式長得很像,都是使用大括號逗號來區分屬性,其實我在文章的開頭就已經使用過了:

let object = { propertyName : 'value', ...} 

函式建構子 ( Function Constructor )

在許多物件導向語言裡面,因為以類別為主的語言特性,通常是以 class 創造物件藍圖,並搭配使用 new 關鍵字來產生新的物件,這也是物件導向的基本概念。雖然 JS 並不是物件導向的語言,但早期為了吸引那些習慣使用物件導向語言的工程師來使用,也創造了使用 new 關鍵字,屬於自己獨特的產生物件的方式,稱為「函式建構子」,也就是把函式內容視為其他物件導向語言的建構子( constructor ) 來使用:

function book  (name,price) {
	this.name = name; 
	this.price = price  
} 

let starWar = new book('star war', 500) 

console.log(starWar) // book {name: "star war", price: 500} 

如果你要產生一個空物件,那麼除了物件實字,你也可以透過下面的方式:

 let obj  = new Object(); 

這是什麼意思?我們都知道 JS 裡面有一個物件叫做 Object,裡面有很多好用的 API 例如 Object.keys 可以取得物件的所有屬性名,但是根據上面的說明,new 應該要搭配函式來使用才對啊?難道 Object 是函式不成?

是的! 在 JS 裡面 Object 就是一個函式,你可以對他使用 typeof 來驗證這個說法:

 typeof Object // function 

既然 Object 本身也是函數,那麼這個說法就合理了,至於為什麼 typeof Object 結果不是 Object ,我想那又是另外一個層面的問題了。

取用物件屬性的方式

取用物件有兩種方式:

  • 最常見的.運算子
  • 使用中括號 []

使用中括號取用物件來取用屬性,因為能夠使用字串的關係,在取用屬性的時候可以比較有彈性:

let user = {
	name:'Yoda'
} 
user.name // Yoda
user['name'] //Yoda

物件原型導向

雖然許多人在 JavaScript 撰寫物件導向風格的程式碼,但 JS 並不是像 JAVA 或是 C# 那樣物件導向的語言,而相對的,JS 是物件原型導向( Object-Prototype Oriented )的語言,在 JS 裡面的每個物件都有一個可以用來與其他物件共用屬性跟方法,或是進行複製的隱藏屬性 : [[ Prototype ]]

這種繼承的行為也稱為原型繼承 ( prototypical inheritance ),相對於其他像是 PHP、JAVA、Python 這種以類別 ( class ) 為基礎的物件導向語言,這算是比較特別的,在後面的章節我會繼續說明 JS 的物件是如何透過原型來共用屬性的。

JS 原力覺醒 Day19 - 一級函式與高階函式

今天要提到的是讓 JS 很適合用來撰寫 Functional Programming 的兩個特性的名詞解釋:「 一級函式」與「高階函式」,如果你寫 JS 一段時間,一定會聽過他,高階函式與一級函式可能聽起來有點複雜,其實並不會,只是字面上意思比較不好理解而已。這兩個特性,讓 JS 可以把函式在其他函式之間互傳,所以也是為什麼有人說 JS 很適合用來寫 Functional Programming 的原因。

Outline

  • 一級函式
  • 高階函式

一級函式 ( First-class functions )

當我們說一個語言具有一級函式的特性時,代表這個語言把函式當作其他物件一樣看待,也因此可以將函式當作參數一樣傳入另外一個函式裡面。在 Functional Programming 裡面,也是因為這個特性,才有辦法做到複合函式 (Function Composition),

而在 JS 內,函式本身也是一個特殊的物件(就是 Function 物件),在一些使用到 callback 概念的程式碼中,你就會看到這個概念是如何被應用的:

function doSomething(fn, data) { 
   return fn(data);
}

我們可以試試下面的程式碼來確認上面的描述 :

 function hello (){
		console.log('hello') 
	} 

hello.a = 'a'
console.log(hello.a) //'a' 

雖然上面的程式碼完全是合法的,因為函式本來就也是物件,但是在實務上請不要這麼做,否則同事或是跟你一起合作的人可能會崩潰,請使用一般的物件。

而既然將函式當作物件一樣看待,那就代表也可以把這個函式指派給變數,這就是我們之前提到的「函式表達式」 ( Function Expression ) 。

let hello = function (){
	//do  some thing 
} 

高階函式 ( High Order Function )

只要是可以接收函式作為參數,或是回傳函式作為輸出的函式,我們就稱為高階函式,例如,JS 裡面很常用的一些對陣列操作的內建API:

  • Array.map( ()⇒{…} )
  • Array.filter( ()⇒{…} )
  • Array.reduce( ()⇒{…} )

也可以被稱為是高階函式,因為他們能夠接收函式作為他們的參數。雖然上述幾個 API 的使用方式乍看之下可能會讓人覺得難以理解,但我們可以試著思考看看他們是怎麼被實作的,其實並沒有那麼複雜,下面就以 Array.map 為例,邊實作、邊思考他的運作方式吧!

由於 Arrray.map 是對陣列元素做巡訪,然後做某些操作之後回傳,所以可能的步驟如下:

  1. 將函式傳入 map 內
  2. 執行一個以陣列長度為執行次數的迴圈
  3. 每次帶入不同的 array id 以表示目前尋訪的進度
  4. 取得陣列元素、逐個進行修改
  5. 逐個放入新的陣列並回傳

自己實際實作 map function 的話看起來會像是這樣:

function arrayMap(fn,array){
	let length = array.length
	let newArray = [] 
	for(let i=0 ; i<length ; i++){
		newArray.push(fn(array[i]))
	}
	return newArray
}

透過上面的程式碼我們自己就實作了高階函式 arrayMap ,可以看到我們自己做的 arrayMap 會在陣列傳入之後,逐個訪問每個元素並傳入我們自己寫的函式 fn ,這個 fn 會根據我們寫的內容將該值做處理之後回傳,然後會直接透過 Array.push 將結果推入新的函式( 看到了嗎?這裡我們用到複合函式的概念 )

arrayMap((item)=>{
	return item * 2 	
},[1,2,3,4])

結論

透過今天對兩個名詞的說明我們知道了一級函式與高階函式這兩個名詞的意義,然後我們也自己試著實作了自己的高階函式:

  • 一級函式是指在一個語言內,函式本身也是物件,因此能夠將函式當成參數傳給另一函式
  • 高階函式則是指一個函式能不能接收函式當作參數,或是回傳函式作為回傳值

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

Your browser is out-of-date!

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

×