我認為的重點:
js發明之前瀏覽器僅作為顯示靜態網頁的環境,但如:註冊、登入、表單驗證等行為,必須透過後端伺服器處理再回傳,使用者體驗不佳,js為此而誕生。
1995年最流行瀏覽器–NetScape,當時僅有瀏覽功能,為了有使用者互動而找了Brendan Eich及Java開發商Sun合作,期望設計出能在瀏覽器運行且簡單快速上手、但可以解決瀏覽器困境的語言。
10天先創造出LiveScript,被要求夠像Java但沒那麼複雜,後名Javascript
ECMAScript:
1996年微軟為了避免Java商標問題,發布自己的Jscript,可以在IE運作,為了避免不同版本,NetScape找了ECMA一同制定一套標準,ECMA條件是需要用他們名稱來命名,1997年推出了規範『ECMAScript』,ES6大改版,增加許多強大語法,此後前端領域快速成長。
宣告:早期var、現在常用let、 const,描述有可能會變動的值,所以稱為變數
js的等號表示賦值,把右邊的值丟給左邊。
變數命名規範:只能用$、_及英文字母開頭,大小寫有區分(price and Price are different)
不能用保留字
根據開發團隊來決定一致的命名風格
宣告變數即是js為我空出一個空間並貼標籤,賦值就把值丟進這個空間,往後我取到這個標籤就可以拿到裡面對應的值
Object跟Array屬於複合型別(物件型別),裡面又可以存放多種型別的數值
typeof(),判斷型別的語法。
typeof 字串:"string"
typeof 數值:"number"
typeof 布林值:"boolean"
typeof 物件:"object"
typeof 函式:"function"
typeof 未定義的變數:"undefined"
用單引號''或是雙引號""包起來,必須成對出現。
使用" + "可以將字串串連
ES6之後新寫法可以直接將變數寫到字串中間:
轉型成字串:
js函式 String(),也能利用字串相加的方式觸發js強制轉型,運算子兩邊型別不同時,js會想辦法將兩邊轉成相同型別,例如:數字 + 字串會被強制都轉為字串後相加。
加減乘除blablabla…
轉型成數值:
Number()轉成數值
parseInt()轉出整數部分
parseFloat()轉出包含小數部分的完整數值
資料型別無法轉為有效數值或無法正常計算,js會轉出NaN,他也屬於數字型別。
變數已宣告但還沒賦值,預設值就是undefine。
要注意,宣告後未賦值是 undefined,只是使用上會怪怪的但符合規範,連宣告也沒有會導致 not defined,這是會報錯的。
null 通常會是開發者給予的值,表示要讓他為空的,而 undefined 比較像是沒有給值的時候預設的,所以書中建議不要將 undefined 賦值給變數,保留沒給值會預設 undefined的 特性,會更清楚 null 與 undefined 的區別。
null 與 undefined 的另一個大差異,undefined 的型別是 undefined,null 的型別是 object,據說是當初設計的失誤,但後人太頻繁使用這個 bug,萬一改掉會影響太多現有的成果,評估後認為不修改。
true or false,搭配if判斷式。
轉型成布林值:
Boolean(),簡略的判斷依據,有沒有內容,有沒有被定義,例如:undefined、null轉成布林值是false,數值0及空字串也會轉成false。
書中提到通常在用到邏輯運算子"!"的時候他就會轉成布林值,但通常會用在『不等於』的判斷,會把原來應該是 true 的值轉為 false,此時可以用"!!"來把它轉回來,就能得到原來資料對應的布林值,我這邊卡了10分鐘,終於看懂他在供殺毀。
書本寫『一連串鍵與值配對的組合』(key-value),鍵又稱property(屬性)。
建立一個物件:
取得物件裡指定屬性的值:
中括號裡面key1是字串,所有物件的屬性名稱型別都是字串
物件中屬性對應的內容基本上什麼型別都可以放,物件、函式等。
另外,如果取了不存在的屬性的值,會取到 undefined。
書裡寫說先把陣列看成沒有屬性的物件,把一系列的數值放一起形成的組合
建立一個陣列:
裡面可以放的資料類型沒有限制,放陣列也可以,通常用element(元素)稱呼。
陣列擁有index(索引),從0開始算,用號碼來區別每個元素。
陣列的長度:
用 .length,來取得陣列的長度
名詞從數學來,負責接收一個值,轉成另外的結果後回傳,但是程式裡的函式可以不接收、不回傳。
可以將重複的邏輯或行為寫成函式,隨傳即用的工具組合包。
函式的宣告:
透過function
來宣告這是個函式,一般情況要有名字才能呼叫,後面會提到匿名函式,不一定要名字。
執行:
後面加上()就可以執行,通常稱作invoke(呼叫)。
函式的終點:
通常會執行完最後一行,結束,但如果中間有return
,他就會提早結束並回傳return
值。
用格式區分可以分成一元運算子、二元運算子跟三元運算子,『可以』理解為需要跟『幾個數值』放一起才能運算。
例如:算數運算子『+』要兩個數值做運算才有意義,所以『+』算二元運算子,跟運算子放一起做計算的稱為運算元,但不重要不用特別記((?。
前面提到『+』跟『!』可以作為數值轉型方法,只需要一個數值就可以運算,所以此時他們是一元運算子。
把右邊的值指派給左邊變數。
加法運算子不只用在數值型別,兩邊型別不同時會觸發 Coercion『強制轉型』,JS貼心設計,想辦法將兩邊轉為同樣型別再計算。
比較常見的強制轉型情境:
數值3轉為字串"3"
,做字串的相加
undefined轉為數值NaN
,做數值的相加
將物件視為字串"[object Object]"
處理,所以變成字串相加
減法運算就沒有多種情況,只有計算數值,遇到不是數值型別就轉成數值計算。
遇到字串轉數值後計算。
如果轉出數值是無效數就是NaN
,不管跟誰計算都會是NaN
比較單純就是除法計算,需要注意的是無限大的情況,當分母是0的時候,運算會得到無限大的數值型別Infinity
,分子是負數就會得到-Infinity
。
console.log 是js提供的原生語法嗎?
印象中只要不是ECMAscript就應該試運行環境提供的
運算子後面是可計算的字串(裡面是數值)就會轉成數值嗎,文中的敘述我不太懂
C: 幫你測試一下,看起來放不可計算的也會變成數值XD
同一時間只做一件事,在JS中就是一次只跑一段程式碼,JS環境中一切都是同步來處理,意味著JS中不可能同時執行兩個函式或是執行多段程式碼邏輯。
同一時間內不只處理一件事,透過非同步方式,讓JS進入全域執行環境到結束的期間能夠額外處理一些需要等待的邏輯,又不會影響到原來的主程式。非同步處理的邏輯,會等到主程式執行完畢,JS引擎有空擋後才會執行。
非同步的誤解:概念上感覺很像『平行』,好像類似『多執行緒』,同時能夠執行多個行為,但JS沒有這樣的概念,非同步是將其分配執行的先後順序來達到好像同時進行的效果。
存在於瀏覽器內而不是JS原生(Node js也有)
JS引擎底下依功能大致分三部分:
在網頁上為了讓網頁有『監聽事件』、『計時』、『拉取第三方API』等背景作業功能,提供一些部分來達成:
主程式以外需要非同步的函式將會存放到Event Queue中,等整個主執行環境運行結束後,才依序執行Event Queue裡面的函式。
執行順序為:
負責記錄非同步的目的達成後有哪些事要做,例如計時完畢、API資料獲取完畢、事件被觸發等等。例如setTimeout
中有計算秒數的資料會被丟進Event Table中,等秒數數完(目的達成),就會被推到Event Queue中等待。
不斷檢查主執行環境堆疊是不是空的,是空的就去找Event Queue裡有沒有等待被執行的函式並丟進去執行。
由於非同步的處理常常會需要把非同步的函式放進另一個非同步裡,當需求變多時就會看到以下的結構:
這樣容易造成閱讀及維護的困難,為了解決問題,可以使用到Promise。
ES6以後的新語法,白話文:『我承諾幫你做,但不確定能不能成功,等我做完告訴你結果。』,在我看來應該是幹話文比較對。
原則上就是兩個關鍵字『成功』、『失敗』。
這邊我必須跳過作者製作餐點(多此一舉)的舉例。
resolve
。reject
。Promise是物件,所以建立一個Promise使用:
new Promise
是使用建構式來創建一個物件,他這裡不想告訴我們是什麼,要等到後面的章節。
function中的兩個參數resolve
跟reject
分別用於成功時呼叫即失敗時呼叫的兩個函式。
Promise被執行後,後面可以用then
方法並傳入兩個callback function作為參數分別表示成功與失敗時要執行的動作。
then
中的第一個參數handleResolve
是當promise成功後(即傳出resolve
)要執行的函式,handleReject
則是promise失敗後(即傳出reject)執行的函式。
這邊寫法也可以在成功時將('success')傳給handleResolve
當作參數執行。
再來一連串同步搭配非同步邏輯,利用then
來轉寫所搭配的非同步邏輯,供三小?
這邊說了.then
方法呼叫回呼函式的方式會以非同步方式呼叫,意思是當JS引擎執行到Promise區塊時,會把整個Promise的祖宗三代都註冊起來(即後面的.then``.catch
…等),告訴JS引擎這一串跟Promise有關的東西內部的回呼函式要丟到非同步區塊等待呼叫執行。
catch
只用於Promise得到reject
時,目的跟上面的hadleReject
相同。
Promise在執行時只要還沒執行resolve
或reject
,就屬於Pending狀態,執行resolve
後promise狀態轉為Fulfilled
,且用.then
來處理,執行reject
後promise狀態轉為Rejected
,則用.catch
來處理。
Promise.resolve
中傳入另一個Promise,得到的會是另一個Promise.resolve的值。
.then
後可以接.then
,因為每次Promise呼叫.then
時,會在建立一個新的Promise。
當promise解決後丟resolve
的值給後面的.then
當作參數使用,後面的.then
得到值以後產生新的Promise,等到回呼函式return
值以後好像丟進resolve
並丟到下一個.then
當參數使用…
如果Promise.resolve接收到另一個Promise,被傳入的Promise會直接被解開傳入resolve的值。
在Promise.all
中放入多個Promise,當所有Promise都被成功兌現後(即所有Promise都執行了resolve
),Promise.all
才會被兌現,而把裡面所有resolve
的結果組成陣列後,當成參數丟給後面的.then
執行;又如果其中一個被拒絕了(執行了reject
),則只傳出第一個reject
的值。
一樣傳入多個Promise進去,這時裡面只要有一個Promise擺脫Pending狀態(執行了resolve
或reject
),則會成為唯一個Promise.race
的回傳值。
宏任務,就是前面提到會被放進Event Queue等待被執行的任務,所以Event Queue也會稱作Macrotask Queue、Task Queue,這是為了與Microtask做區隔。
通常由Promise產生,then
與catch
會以非同步方式進行,即他會被排到全域執行環境結束後才會被執行,所以當Promise脫離Pendind狀態後,then
和catch
會被放到某個地方等待執行,但不是Event Queue,而是叫Microtask Queue。
兩者都是非同步等待執行,他們的順序會是:
當每一個Macrotask Queue裡的Task執行完後,若Microtask Queue裡面有任務,Event Loop就會讓他優先執行,直到Microtask Queue被清空為止,也就是Microtask會穿插在每個Macrotask之間執行。
這時候需要注意的是:
JS主程式運行本身也是一個Macrotask,所以若有Microtask將會在主程式運行完以後執行,所以結果會如下:
直接使用async
宣告函式就會是一個非同步函式,且用Promise實現,執行後會收到一個Promise:
實際上會幫你改寫成:
如此一來可以更簡單寫出非同步函式,但後面還是有連續呼叫then
的問題,這時候可以使用await
。
在用async
宣告韓式以後,裡面的邏輯若要等到Promise解析後再執行,就在函式前加上await
,他將會等待後面的Promise解決後執行。
當執行到async
搭配await
,後面接了Promise,會停下來等這個Promise被解析後回傳值,所以會在1秒後執行resolve
並回傳"Resolve Promise",然後賦值給promiseResult
印出。
await
只等用在async
宣告的函式內。
在try
區塊出錯時將錯誤用throw
丟給下方的catch()
執行。
在try
區塊中的await後面的fakeApi()
函式裡會throw
出錯誤訊息,當try
區塊有任何東西被throw
出來後將會被視為不正常狀況而跳到catch
區塊進行,所以這時候就把剛剛throw
的訊息console.log
出來。
如果寫成下面這樣,asyns
函式內沒有處理錯誤情況,如果沒有使用catch
去接錯誤,asyncAction()
將會回傳一個Promise物件,所以只要透過catch
就能拿到函式內的錯誤訊息。
理解async/await
後,可以用Promise的觀念來活用,可以結合前面的Promise.all
或Promise.race
一起使用。
前面介紹物件時只有基本認識,已知使用:
這種簡單建立一個物件的方法,表示一連串的鍵與值的配對。
他舉例:
這邊透過new
語法建造出一空物件,接著new
的是一函式Object()
的呼叫,這裡Object
本身也是一物件(函式是物件的一種),是JS原生提供的全域物件,他說後面再詳細介紹。(啊是要等多久)
可以看到
Object
這個酷東西是一個函式。
他說去看官方文件會發現new
本身是一運算子,通常運算子被執行後會產生一個值,這裡new
後即將產生的值其實就會是一物件。皆在new
後方的函式一般稱為建構函式(constructor)、或是函式建構式(Function Constructor),特別用來創造一物件的特殊函式,這種函式通常只用於搭配new
來建立物件,不會當成一般函式使用。
物件的內容可以在建構函式裡面設定,但要透過this
來實現。
這邊自己做了Cocktail
這個建構函式來建立一個有關調酒相關資訊的物件,裡面用了this
,大概可以理解到他在被執行的時候建立了物件並設定裡面的內容(name: "Martini"…)。
書中解釋了使用new
時JS做了這些事:
new
呼叫的建構函式內創造了一個叫this
的變數,會與當下建立的物件做連結,至少在這邊是這樣,根據不同地方、不同規則他代表的值可能也有所不同。我在想是不是建構函式裡面設定了其他行為來回傳另一個他想回傳的東西,這麼一來就取代了原本回傳自己這個行為?
這裡可以注意到函式宣告時是大寫開頭,因為當初意識到了如果跟一般函式長一樣,容易搞混,萬一呼叫建構函式時忘記加上new
,JS還是會正常執行不會報錯,但建構函式本身並沒有設定回傳值,所以這麼一來就只會得到undefined
。
所以後來就設定了建構子的命名傳統:建構函式的字首以大寫表示。
這麼一來就可以清楚知道這要與new
搭配使用了!
這邊出來的結果會是Cocktail {name: "Martini", volume: "200ml", price: 120}
,跟我之前實驗的一樣,不知道他少了物件前面的名字有沒有差別?
這邊提到了
new
搭配建構函式能實現類似『類別』的資料格式,紀錄了一種特定的資料規格,是物件導向的基礎概念。但實際開發上通常會用另一種叫建立class
的方式達成,比較少用建構函式,但也可能在許多專案中看到,所以還是要了解一下。
只需要{...}
就能建立一物件,最普遍的方式。
.
存取物件屬性[]
存取物件的鍵值(key)使用.
存取時要注意,只有當屬性名稱只包含英文、數字_
或$
符號的情況才能使用(除了_
跟$
以外的符號都不能有,空白也不行),也不能使用保留字,而且如果屬性是數字開頭就會報錯。
使用[]
就沒有上述的名稱限制,但[]
存取的是鍵值對應的內容,而鍵值都會是字串,所以[]
內要放字串。
因為是字串,所以就可以是任何字元,所以萬一取了不正常的屬性名稱,就只能用[]
來存取了。
因為[]
搭配字串的特性讓我們可以利用動態方式取出不同屬性對應的值,相對的也可以在建立物件時,動態的決定裡面的屬性名稱:
書上寫會印出"{male: "John"}"
,字串?應該打錯了。
prototype
(後面會提到,總之就是物件一出生就與生俱來的東西),hasOwnProperty
就是其中之一:但是這邊概念就有點混亂,hasOwnProperty
是hero
的一個屬性嗎,書裡寫是,也不是(我覺得不是),hero
可以找到這個屬性,也可以使用他,但他並不存在於hero
這個物件上。
hero
是在某個基礎上被建立的,hasOwnProperty
就是來自這個基礎,這種關係稱為『繼承』,hero
本身沒有定義這個方法,但透過建立他的基礎,我們可以取用到他。
我對這邊的理解是:hasOwnProperty
不是hero
的屬性,而是他繼承的函式,hero
本身包含的就是裡面的屬性跟對應的內容,而prototype
是建立物件時額外提供的客服,這個客服是多個物件共享的,所以當我們操作物件時想用hasOwnProperty
來檢查時,就必須打給客服才能使用。
實際上比較正確的理解是:hasOwnProperty
是Object
的方法,Object
是一個種類,就好比Object
之於哺乳類,而hasOwnProperty
之於「哺乳」這個行為,所以人類會哺乳室因為人類「是」哺乳類,「哺乳」是「哺乳類」這個種類繼承下來的行為。
而hero
屬於Object
,hero
是Object
的一種,所以hero
可以使用從Object
繼承來的方法,所以並不是hero
裡面有Object
的方法,而是hero
繼承了Object
的方法,而這些繼承來的東西就會在prototype
裡。
使用in
也可以簡單的知道物件中有沒有這個屬性。
但in
與hasOwnProperty
最大的差異就是in
可以檢查到繼承來的方法:
直接存取不存在的屬性時就只會取到undefined
,這時候判斷取到的值是不是undefined
就可以了!
但這個方法可能會失誤,如果確實存在這個屬性但內容被設定成undefined
,這時候就會失誤,那就要強制轉型判斷:
!!hero.name
運算時會否定兩次,負負得正,如果屬性的name有任何內容就會回傳true
!!hero.realName
運算時會否定兩次,負負得正,hero.realName
並不存在,所以會回傳false
常用!!Object.keys
會將物件裡的擁有的所有屬性抽出來放到陣列中。
這樣我們就得到一個陣列包含這個物件的所有屬性了~
假設有個物件是參賽者資訊,裡面屬性為參賽者編號,內容則是姓名,就可以寫個判斷來知道名單哪個編號有沒有報名過:
這邊書上寫checkIsSignUp(12)
,但屬性是字串,所以永遠都找不到而回傳false
,要改成checkIsSignUp("12")
才找得到而回傳true
。(除非比較使用兩個等於不是三個等於)
Object.values
則是取得物件所有屬性所對應到的內容,也是回傳一個陣列。
一樣我們可以反過來查詢參賽者的姓名有沒有報名過:
這邊使用indexOf
方法來尋找陣列中符合要求的索引值,如果沒有找到就會回傳-1
,所以判斷回傳值有沒有大於0
就能知道有沒有在名單裡了!
可以得到屬性及內容,會回傳一個二維陣列(陣列內還有一層陣列):
如此一來就能將前面兩個例子結合起來,但查找的時候要多進一層陣列去查找:
楊哲豪
取出物件中的資料時也順便宣告新的變數:
宣告name
時取出物件的資料並賦值。
宣告一物件結構,JS會去搜尋物件裡面的這個屬性的資料並賦值。
所以這邊就會尋找name
和height
這兩個屬性的值並賦值,這邊就也能一次宣告多組變數並賦值。
要注意的是解構賦予值時如果宣告了不存在的屬性,將會得到undefined
:
為了避免取到undefined
,可以宣告時給預設值,如果JS遇到屬性的內容是undefined
時就會拿預設值作為宣告的值:
在取用多組物件的資料時,又想用解構賦值,為了避免新的變數名稱衝突,可以在解構賦值內宣告別名:
兩個物件都有屬性name
,所以另外命名兩個變數來取值,就能避免名稱衝突。
可以理解成先取得該屬性的值,再指派給新宣告的變數:
會等價於:
同樣選告一個陣列的結構,會依照兩陣列中相對應的位置賦值:
使用...
:
那如果在一個空物件裡展開另一個物件,再將屬性的內容覆蓋過去,就能達到複製一份擁有同樣屬性的物件:
把兩個擁有相同屬性的物件合併時要注意,一個屬性只會保留放在後方的物件屬性的值:
也可以運用在陣列中,且因為沒有屬性所以不用擔心屬性值會覆蓋:
接收兩個參數,第一個參數放目標物件,第二個放來源物件,會以第一個物件為基礎將第二個物件添加進去,並回傳結果物件:
來源物件可以不只一個:
一樣需要注意同樣的屬性只會保留最後的值。
使用...
或Object.assign
複製時,感覺好像是另一個全新隔離的物件,但當物件中存放另一個物件時就不是這麼一回事了,這個物件底下的另一個A物件會透過物件參考的方式形成,而新物件裡的A物件跟舊物件裡面的A物件會有所連結,這就是『淺拷貝』(Shallow Copy),意思是只有表層的資料被複製。
上述例子可以看到,本來只想修改newObject
裡面的innerObject
的值,但原來的originObject
裡的innerObject
也被改掉了,又判斷這兩個innerOrigin
是不是完全一樣,結果確實是同一個innerObject
。
相對於淺拷貝,深拷貝就是完全複製整個物件的內容,JS中比較難達到這個效果:利用依序取得屬性內容的方式判斷每個屬性內容是不是物件,如果是就重新建立一個物件然後複製進去。
預設淺拷貝的原因:
這個例子中複製objectB
的過程中如果是深拷貝,就會產生永無止境的迴圈,不斷地建立無數個物件;但如果是淺拷貝,objectCopy.b
就會跟objectA
連結在一起,而objectCopy.b.a
會跟objectB
連結在一起,這樣就不會建立無數個物件。
實現深拷貝可以用JSON格式轉為純文字再轉回JS物件,或是新版JS的
。但很少情況會用到,有興趣再另外研究。
物件的屬性其實還有一些附加的訊息,可以控制屬性的行為,一般看不到,透過屬性描述器才可以看到或修改:
Object.getOwnPropertyDescriptor
全域方法,接收兩個參數(想描述的物件, 想描述的屬性):
undefined
。undefined
。想改變屬性的設定要透過Object.defineProperty
來定義:
決定這個屬性能不能被改變:
當設定為false
時,即使重新賦值了值也不會改變。
決定屬性被巡訪時能不能被取得:
物件中有name
跟age
兩個屬性,但屬性設定enumerable: false
時,使用Object.keys
就無法取得,但這個屬性透過hasOwnProperty
來檢查還是存在的。
如果想取的所有存在的屬性,就可以用Object.getOwnPropertyNames
:
決定描述器能不能被修改:
configurable: false
後再嘗試修改描述器就會出錯。
屬性描述器裡的get
跟set
為兩個隱藏的函式,決定物件屬性被取用時即被賦值時的行為,預設是undefined
,但如果有被設定,那操作屬性時就會依照內容來取用跟賦值。
getNameOutside
從物件中取屬性name
的值,但我在get()
設定了當取得屬性的值時,就給他"get what I want inside."
這個值;當我賦值給name
時,因為我在set()
設定當他被賦值時,就將此物件的_name
屬性賦值"set inside."
。
只要描述器有設定Get或Set,就會被歸類為『存取描述器』(Accessor Descriptor),沒有的話就被歸類為『資料描述器』(Data Descriptor),存取描述器中,value
跟writable
的值會被忽略,以set
跟get
為主。
this
可以很方便地從執行環境內取得外部物件,但不夠瞭解就會容易指向錯誤的物件,出現不如預期的情況。
new
運算子一般函式呼叫時,函式內的this
就會指到全域:
呼叫的是物件裡的函式,this
就會指向這個物件:
在obj
裡面的speak
屬性設定speak()
這個函式,透過呼叫obj
裡面的speak()
,就順利拿到obj
的message
內容;在全域呼叫speak()
,因為window
底下沒有message
這個屬性,所以得到undefine
。
當我們指派say
為obj.say
時,他就只參考到函式本身,所以呼叫say
時會失去原有的背景而被視為一般函式呼叫,也就是會找到window
。
另一個例子:
在呼叫userInfo.changeName
時,函式的this
如預期的指向了userInfo
,所以把原本的 "Fang" 改成了 "Jeremy" ,但在裡面的changeNameAgain
沒有參考對象,所以被視為一般函式呼叫,此時他的this
就會指向全域,所以是全域的name
被改成了 "Watson"。
透過call
或apply
可以明確指向目標物件:
但他這邊說兩者差異是傳入參數的方式不同,第一個參數是要指向的物件,第二個參數是要傳入該函式的參數,有看沒有懂。
確保某個函式裡呼叫this
時都與目標物件綁定:
可以看到本身的this
還是受環境影響,但在函式中使用硬綁定後,不管怎麼呼叫他會都指向我指定的物件。
且透過硬綁定搭配回呼函式來重複使用邏輯:
即使我在外面綁定了objB
,但bindSay
函式內的邏輯永遠都綁定objA
,所以呼叫時永遠是綁定objA
。
JS有另一種方法bind
,能做到的跟上面的例子一樣:
回到前面講過的new
運算子,當我new
一個建構函式後:
this
會指向當下建立的物件官方說法是『沒有this綁定』,箭頭函式的this
會根據函式本身程式碼的實際位置綁定:
此時觀察到箭頭函式的this
沒有綁定obj
,而是window
,就是因為箭頭函式沒有this
綁定。
知道箭頭函式this
的特性後,可以利用它來改善前面隱含綁定失效的現象:
箭頭函式是在setTimeout
一秒後被當一般函式呼叫,但他的this
還是找到了userInfo
而把"Watson"
改成了"Fang"
,就是因為箭頭函式的this
在宣告時會繼承當下環境(changeName()
)所指向的this
且永遠綁定。
主要探討物件的本質及概念。
物件導向:一種撰寫程式碼的風格,某些語言會遵循這個風格設計它的語法,像是Java或C++,所以要使用這些語言要先了解物件導向。
有著「類別」(Class)與「物件」(Object)概念,概念來自建築工程,「類別」就是設計圖,紀錄著建築物的許多基礎規格、特徵、設計、材料等…,而物件就是最終被建造出來的建築物,負責實現類別所定義的基礎規格。
物件導向中,「繼承」(Inheritance)是指類別可以以另一個類別為基礎,往上擴充或修改,可以很方便且成本較低的創造新的類別,目的可以說是為了讓某些屬性可以共用。
生活化的比喻:繼承與被繼承物件之間的關係,生物界的分類,「動物」與「鳥」作為比喻,只要是動物都會有共同的行為,例如呼吸,鳥類是建立在動物的基礎上(動物的一種),所以鳥也會呼吸,但飛行是鳥類特有的行為,所以可以說鳥類繼承了動物的行為(呼吸),且具有自己特有的行為(飛行)。
書裡寫是,也不是(?),我認為不是,但他模擬了許多行為去模仿物件導向,為了吸引更多使用物件導向的語言使用者能更願意寫JS,所以雖然JS底層沒有真正物件導向的基礎元素,但透過其他行為模擬出許多與物件導向風格類似的語法、元素。
前面的new
搭配「建構函式」,並在函式內操作this
產生物件,就是JS用來模擬物件導向中「類別」的方式。
再來就是JS為了達到「繼承」的概念,建立了物件之間的連結,稱為「原型」(prototype),
每個JS物件上都有一個隱藏屬性prototype
,代表物件與另一個物件的連結,也是物件的原型。
當JS物件在取用某個屬性時,如果在物件中找不到,就會嘗試到prototype
這個隱藏的屬性中查找,
驗證原型的方法:使用Object.create
建立新的物件,丟入的參數為想要作為基礎的原型。
上面以prototypeA
作為基礎建立一個物件objectB
,透過ES5之後提供的方法getPrototypeOf
可以取得物件的原型。
再來就是驗證JS嘗試尋找原型中的屬性:
可以看到objectB
為空物件,當我們嘗試在objectB
中取用不存在的name
這個屬性時,他跑到原型prototypeA
中且成功找到了"prototype A."
。
JS中的所有函式(包含建構函式)都有prototype屬性,主要是使用於建構函式創建物件時也建立了新物件與其原型的連結。
原型就是建構函式中的prototype這個物件:
經過測試可得知,不管事user1
還是user2
的原型,都是UserInfo.prototype
這個物件。
JS在每個物件上提供了__proto__
屬性,讓我們可以觀察物件的原型,但不是ECMA規範中的內容,只是大多數瀏覽器提供了這個屬性,取用這個屬性跟前面Object.getPrototypeOf
的結果相同。
書中接下來會用__proto__
來表示一物件的原型,但官方表示不推薦使用,不同瀏覽器也有相容性問題,所以不推薦在任何實際開發的產品使用,如果有需要取得原型,比較推薦使用Object.getPrototypeOf
。
JS透過建構函式模擬類別,透過原型模擬繼承,如此一來在前面透過UserInof
建構函式建立的物件,其原型都會跟UserInfo.prototype
這個物件連結在一起,當我們試圖改變UserInfo.prototype
裡的內容,被創造出的物件也能共享到這個改變:
透過設定UserInfo.prototype.setAge
來加入setAge
這個方法,user1
跟user2
都能使用這個方法。
也可以觀察一下setAge
中的this
,呼叫user1.setAge(27)
時,裡面的this
綁定了前面的物件user1
,因此可以順利新增一個age
進去。
這裡也提到
setAge
為什麼不直接寫在建構函式中,當物件建立時順便建立setAge
這個函式,當我用建構函式建立1000個物件,等於分別建立了1000個setAge
函式,但他們長一模一樣,不需要獨一無二存在每個物件而去浪費記憶體空間,所以為了達到同樣的目的只需要放在他們的原型裡被共用就行了。
前面提過的:當JS在物件找不到某屬性,會嘗試往物件的原型找,也就是物件的__proto__
屬性,而這個物件的原型也是一個物件,所以也會有更底層的原型,也就是原型的原型,所以當物件找不到某屬性就往物件的原型找,再找不到就再往物件的原型的原型去找,這樣一連串的原型組成的連結就被稱為原型鏈。
書中也舉陣列的例子,陣列也是一種物件,所以推論陣列與物件有某種繼承關係:
可以看到array
的原型是Array
這個建構函式的prototype
,再往下一層就可以發現array
的原型Array.prototype
,它的原型就是Object.prototype
。然後再往下找一層,也就是Object.prototype
的原型找到了null
,表示原型連結到底了。
傳統繼承概念:被繼承的「後代類別」(Child Class)所產生出來的物件,一開始就應該具備「前代類別」(Parent Class)的屬性跟方法。
嘗試透過JS實現上述目的:
上面先建立了兩個原本互不相干的類別,再來就是建立兩者之間的繼承關係,有兩個方向:
我覺得他有很努力想講得更清楚了,但這邊就是這麼難懂,直接看例子:
透過new
搭配這兩個建構函式生成的物件,都會各有一個prototype
物件,一般情況他們互不相干,我們需要建立他們的連結,也就是建立原型鏈。
這邊把User.prototype
這個原型物件改成了另一個新的物件,而這個新的物件是個空物件且原型指向了Human.prototype
這個原型物件,如此一來,User
就是繼承於Human
了。
使用這個方法改掉會造成原本User.prototype
中的constructor
這個屬性消失,這本來是一個預設行為且constructor
會指回建構函式本身,不一定會用到但是為了盡量符合預設行為,就在將他加回去:
我自己來實測一下:
成功建立了新物件user1
,且將我的姓名填入,觀察看看裡面:
[[Prototype]]
原型顯示為Human
,且constructor
指回User
這個建構函式。
成功將不同原型互相建立繼承關係後,再來就是讓建構函式操作到的屬性也能夠給後代操作使用:
這是一個經典方法,在後代(User)建構函式中呼叫前代(Human)的建構函式,透過call
來綁定到Human
這個建構函式上,這樣就能同時操作User
中的屬性,又能拉取Human
中操作的屬性進來。
這邊要注意的是,User
在取用Human
的屬性時,需要將Human
中的所有屬性依序列入參數。
綜合以上兩點就能做就能模擬出類別:
模擬的順序要是先建立兩個類別,然後讓類別之間的prototype
做連結,然後才加上各自的方法,如果先方法再做Object.create()
,那寫好的方法會被覆蓋掉,這是模擬的時候要注意的小細節。
ES6之後出現了class
語法,能以更接近物件導向的方式來寫JS。
建構函式容易與函式搞混,改成使用class
來宣告試試看:
建構函式寫法:
class
宣告:
原本建構函式的內容放到constructor
函式內,原本放到prototype
要共享的方法也可以在class
內直接宣告,但這邊跟一般函式宣告不同,實際上還是宣告了一個屬性並放入一個函式,但這邊可以直接縮寫成只寫函式宣告,除了constructor
函式名稱固定且必須放建構函式的邏輯,其餘沒有名稱限制。
前面使用建構函式時,必須搭配new
運算子來建立物件,但就算沒有搭配new
使用,函式還是有效執行,不會有錯誤提示,因此不小心誤用也不容易發現;使用class
宣告的類別,只能搭配new
來呼叫才有效,否則JS是會報錯的:
透過class
來實現繼承,可搭配extends
:
使用extends
運算子來建立兩者繼承關係,接者在constructor
函式內呼叫super
來繼承來自前代的屬性。
還是需要注意,super
前代的屬性需要繼承所有的屬性。
而且在這邊一定要先呼叫super
(前代屬性)再接上後代屬性,(這是規範),不然會報錯,因此建造出來的屬性順序會跟前面用建構函式的方式不一樣。
static
和class
一樣語法糖,透過static
定義的方法,只能透過類別取得,所建立的物件沒辦法取得:
新增在建構函式的屬性亦是如此:
但透過建構函式理解靜態方法是沒有意義的,因為建構函式本身是物件,在物件新增屬性的行為是很合理的。
在後代類別必須呼叫super
才能繼承前代類別的屬性,如果要取得前代的函式也是透過super
:
這邊我不太懂,我將User
的getGender
拿掉,user
還是可以使用Human
的getGender
new
的建構函式,不搭配new
就只是一般韓式呼叫,通常在做數值轉型,且比起建構函式,我們會更常使用Object.keys
或Array.isArray
這些全域物件方法,在程式碼的任何地方使用。例如Object
可以當作建構函式,也有像Object.keys
方法可以使用,因為Object
本身也是物件,keys
是裡面的屬性;map
這個屬性可以用在所有陣列也是因為這些陣列連結到了Array.prototype
這個原型物件上,而可以用的共用方法。
使用內建物件當建構函式執行時會得到對應純值的物件:
所以可以發現建立出來的是物件裡面包著我期望的值;如果沒有搭配new
執行,會將內容當參數傳進去轉型:
我們使用建構函式的方式建立出來的物件好處就是能夠享用建構函式中的原型物件提供的方法;例如搭配new
呼叫String
後會得到字串物件,就能透過這個物件使用處理字串相關的方法:
也可增加方法到原型中來讓其他物件共用:
要注意的是,要建立方法時需要檢查原本有沒有同樣名稱的屬性,如果不小心改掉內建的方法會造成麻煩。
前面也許有觀察到,即使不使用String
建構函式建立字串物件,也能順利地使用字串的方法:
原因就是JS會自動幫我們將字串轉成對應型別的物件,我們就能輕鬆取得對應型別的方法,當我們嘗試取用物件上的屬性的那一刻,就產生對應型別的物件將你的資料包起來,就是所謂的「包裹物件」,而當使用完方法後,包裹物件就會消失,所以我們才很難感受到他的存在。
所以往後建立一般型別的資料時就不建議主動使用建構函式建立,因為建構函式會建立出物件,如果沒注意到就會以為他是數值或字串等而造成邏輯意外。