Try   HackMD

javascript概念三明治

Chapter1 前生今世

  • 我認為的重點:

    js發明之前瀏覽器僅作為顯示靜態網頁的環境,但如:註冊、登入、表單驗證等行為,必須透過後端伺服器處理再回傳,使用者體驗不佳,js為此而誕生。

    1995年最流行瀏覽器NetScape,當時僅有瀏覽功能,為了有使用者互動而找了Brendan Eich及Java開發商Sun合作,期望設計出能在瀏覽器運行且簡單快速上手、但可以解決瀏覽器困境的語言。

    10天先創造出LiveScript,被要求夠像Java但沒那麼複雜,後名Javascript

    ECMAScript:
    1996年微軟為了避免Java商標問題,發布自己的Jscript,可以在IE運作,為了避免不同版本,NetScape找了ECMA一同制定一套標準,ECMA條件是需要用他們名稱來命名,1997年推出了規範『ECMAScript』,ES6大改版,增加許多強大語法,此後前端領域快速成長。

Chapter2 基礎介紹

  • 我認為的重點:

變數

宣告:早期var、現在常用let、 const,描述有可能會變動的值,所以稱為變數

js的等號表示賦值,把右邊的值丟給左邊。

變數命名規範:只能用$、_及英文字母開頭,大小寫有區分(price and Price are different)

不能用保留字

根據開發團隊來決定一致的命名風格

宣告變數即是js為我空出一個空間並貼標籤,賦值就把值丟進這個空間,往後我取到這個標籤就可以拿到裡面對應的值

型別

  • String
  • Number
  • Boolean
  • Undefined
  • Null
  • Object
  • Array

Object跟Array屬於複合型別(物件型別),裡面又可以存放多種型別的數值

typeof(),判斷型別的語法。

typeof 字串:"string"
typeof 數值:"number"
typeof 布林值:"boolean"
typeof 物件:"object"
typeof 函式:"function"
typeof 未定義的變數:"undefined"

String(字串)

用單引號''或是雙引號""包起來,必須成對出現。

使用" + "可以將字串串連

ES6之後新寫法可以直接將變數寫到字串中間:

var spark = "Spark"; var intro = `Hello, My name is ${spark}, nice to meet you.`; console.log(intro)

轉型成字串:
js函式 String(),也能利用字串相加的方式觸發js強制轉型,運算子兩邊型別不同時,js會想辦法將兩邊轉成相同型別,例如:數字 + 字串會被強制都轉為字串後相加。

Number(數值)

加減乘除blablabla

轉型成數值:
Number()轉成數值
parseInt()轉出整數部分
parseFloat()轉出包含小數部分的完整數值

NaN(Not a Number)

資料型別無法轉為有效數值或無法正常計算,js會轉出NaN,他也屬於數字型別。

typeof 10 //number Number('10andSomeThingElse'); //NaN typeor(NaN) //number

Undefined(未定義)

變數已宣告但還沒賦值,預設值就是undefine。

要注意,宣告後未賦值是 undefined,只是使用上會怪怪的但符合規範,連宣告也沒有會導致 not defined,這是會報錯的。

Null(空值)

null 通常會是開發者給予的值,表示要讓他為空的,而 undefined 比較像是沒有給值的時候預設的,所以書中建議不要將 undefined 賦值給變數,保留沒給值會預設 undefined的 特性,會更清楚 null 與 undefined 的區別。

null 與 undefined 的另一個大差異,undefined 的型別是 undefined,null 的型別是 object,據說是當初設計的失誤,但後人太頻繁使用這個 bug,萬一改掉會影響太多現有的成果,評估後認為不修改。

Boolean(布林)

true or false,搭配if判斷式。

轉型成布林值:
Boolean(),簡略的判斷依據,有沒有內容,有沒有被定義,例如:undefined、null轉成布林值是false,數值0及空字串也會轉成false。

書中提到通常在用到邏輯運算子"!"的時候他就會轉成布林值,但通常會用在『不等於』的判斷,會把原來應該是 true 的值轉為 false,此時可以用"!!"來把它轉回來,就能得到原來資料對應的布林值,我這邊卡了10分鐘,終於看懂他在供殺毀。

var someString = "bla bla bla"; //因為是有內容的字串,所以轉成布林值應該是 true !someString; //會被反向轉換成false !!someString; //反向他的反向!此時得到原本someString對應的布林值true

Object(物件)

書本寫『一連串鍵與值配對的組合』(key-value),鍵又稱property(屬性)。

建立一個物件:

var anObject = { key1 : "value1", key2 : "value2", ... }

取得物件裡指定屬性的值:

anObject["key1"] anObject.key1

中括號裡面key1是字串,所有物件的屬性名稱型別都是字串

物件中屬性對應的內容基本上什麼型別都可以放,物件、函式等。

另外,如果取了不存在的屬性的值,會取到 undefined。

Array(陣列)

書裡寫說先把陣列看成沒有屬性的物件,把一系列的數值放一起形成的組合

建立一個陣列:

var array = ["value1", "value2"];

裡面可以放的資料類型沒有限制,放陣列也可以,通常用element(元素)稱呼。

陣列擁有index(索引),從0開始算,用號碼來區別每個元素。

var array = ["value1", "value2", "value3", ...]; console.log(array[0]) //"value1"

陣列的長度:
用 .length,來取得陣列的長度

var array = ["1", "2", "3"]; console.log(array.length) //3

function(函式)

名詞從數學來,負責接收一個值,轉成另外的結果後回傳,但是程式裡的函式可以不接收、不回傳。

可以將重複的邏輯或行為寫成函式,隨傳即用的工具組合包。

函式的宣告:
透過function來宣告這是個函式,一般情況要有名字才能呼叫,後面會提到匿名函式,不一定要名字。

function doubleTheNumber(x){ return x * 2; }

執行:
後面加上()就可以執行,通常稱作invoke(呼叫)。

doubleTheNumber(10);

函式的終點:
通常會執行完最後一行,結束,但如果中間有return,他就會提早結束並回傳return值。

function doubleMe(x) { return x * 2; //return值後就結束了 x = x + 3; //這行是多餘的,不會執行 } doubleMe(2); //4

Operator(運算子)

  • 算數運算子(Arithmetic Operators)
  • 指派運算子(Assignment Operators)
  • 比較運算子(Comparision Operators)
  • 邏輯運算子(Logical Operators)

用格式區分可以分成一元運算子、二元運算子跟三元運算子,『可以』理解為需要跟『幾個數值』放一起才能運算。

例如:算數運算子『+』要兩個數值做運算才有意義,所以『+』算二元運算子,跟運算子放一起做計算的稱為運算元,但不重要不用特別記((?。

前面提到『+』跟『!』可以作為數值轉型方法,只需要一個數值就可以運算,所以此時他們是一元運算子。

『=』指派運算子

把右邊的值指派給左邊變數。

var x = y //將y指派給x

『+』加法運算子

加法運算子不只用在數值型別,兩邊型別不同時會觸發 Coercion『強制轉型』,JS貼心設計,想辦法將兩邊轉為同樣型別再計算。
比較常見的強制轉型情境:

console.log(3 + "4") //"34"

數值3轉為字串"3",做字串的相加

console.log(3 + undefined) //NaN

undefined轉為數值NaN,做數值的相加

console.log(3 + {}) //3[object Object]

將物件視為字串"[object Object]"處理,所以變成字串相加

『-』減法運算子

減法運算就沒有多種情況,只有計算數值,遇到不是數值型別就轉成數值計算。

console.log(10 - "4") //6

遇到字串轉數值後計算。

console.log(10 - "4a") //NaN

如果轉出數值是無效數就是NaN,不管跟誰計算都會是NaN

『/』除法運算子

比較單純就是除法計算,需要注意的是無限大的情況,當分母是0的時候,運算會得到無限大的數值型別Infinity,分子是負數就會得到-Infinity

『*』乘法運算子

* 問題:

  1. console.log 是js提供的原生語法嗎?

    image

    印象中只要不是ECMAscript就應該試運行環境提供的

  2. 運算子後面是可計算的字串(裡面是數值)就會轉成數值嗎,文中的敘述我不太懂

    image

C: 幫你測試一下,看起來放不可計算的也會變成數值XD

var whattype = +"aww"; 
console.log(whattype); // NaN
console.log(typeof whattype); // number

// 但變成二元運算子就又不一樣了
var whattype = 5 + "aww";
console.log(whattype); // 5aww
console.log(typeof whattype); // string

Chapter 6 同步與非同步

同步/非同步

同步

同一時間只做一件事,在JS中就是一次只跑一段程式碼,JS環境中一切都是同步來處理,意味著JS中不可能同時執行兩個函式或是執行多段程式碼邏輯。

image

非同步

同一時間內不只處理一件事,透過非同步方式,讓JS進入全域執行環境到結束的期間能夠額外處理一些需要等待的邏輯,又不會影響到原來的主程式。非同步處理的邏輯,會等到主程式執行完畢,JS引擎有空擋後才會執行。

非同步的誤解:概念上感覺很像『平行』,好像類似『多執行緒』,同時能夠執行多個行為,但JS沒有這樣的概念,非同步是將其分配執行的先後順序來達到好像同時進行的效果。

image

Event Queue 與 Event Loop

存在於瀏覽器內而不是JS原生(Node js也有)

JS引擎

JS引擎底下依功能大致分三部分:

  • 全域執行環境
  • 執行環境堆疊
  • 記憶體堆積

在網頁上為了讓網頁有『監聽事件』、『計時』、『拉取第三方API』等背景作業功能,提供一些部分來達成:

  • Event Queue
  • Web API
  • Event Table
  • Event Loop

Event Queue

主程式以外需要非同步的函式將會存放到Event Queue中,等整個主執行環境運行結束後,才依序執行Event Queue裡面的函式。

執行順序為:

  1. JS引擎執行到非同步函式(setTimeout)
  2. 開始計算設定的時間並繼續往下運行
  3. 算完時間後將函式丟進Event Queue等待運行
  4. 當JS引擎運行完畢,主執行環境結束後,將Event Queue內的函式推到JS主執行環境,產生堆疊。
    image

Event Table

負責記錄非同步的目的達成後有哪些事要做,例如計時完畢、API資料獲取完畢、事件被觸發等等。例如setTimeout中有計算秒數的資料會被丟進Event Table中,等秒數數完(目的達成),就會被推到Event Queue中等待。

Event Loop

不斷檢查主執行環境堆疊是不是空的,是空的就去找Event Queue裡有沒有等待被執行的函式並丟進去執行。

由於非同步的處理常常會需要把非同步的函式放進另一個非同步裡,當需求變多時就會看到以下的結構:

image
這樣容易造成閱讀及維護的困難,為了解決問題,可以使用到Promise。

Promise

ES6以後的新語法,白話文:『我承諾幫你做,但不確定能不能成功,等我做完告訴你結果。』,在我看來應該是幹話文比較對。
原則上就是兩個關鍵字『成功』、『失敗』。
這邊我必須跳過作者製作餐點(多此一舉)的舉例。

Promise的狀態

  • Pending:執行中,還沒得到結果。
  • Fulfilled:成功狀態,對應回呼函式為resolve
  • Rejected:失敗狀態,對應回呼函式為reject
  • Settled:結果確定了。

Promise基本用法

Promise是物件,所以建立一個Promise使用:

let promise = new Promise(function(resolve, reject){...})

new Promise是使用建構式來創建一個物件,他這裡不想告訴我們是什麼,要等到後面的章節。
function中的兩個參數resolvereject分別用於成功時呼叫即失敗時呼叫的兩個函式。

Promise物件中的then

Promise被執行後,後面可以用then方法並傳入兩個callback function作為參數分別表示成功與失敗時要執行的動作。

let promise = new Promise(function(resolve, reject){ //... if(...){ resolve('success'); }else{ reject('rejected because...') } }); var handleResolve = function(resolveValue) {...} var handleReject = function(rejectValue) {...} promise.then(handleResolve, handleReject)

then中的第一個參數handleResolve是當promise成功後(即傳出resolve)要執行的函式,handleReject則是promise失敗後(即傳出reject)執行的函式。
這邊寫法也可以在成功時將('success')傳給handleResolve當作參數執行。
再來一連串同步搭配非同步邏輯,利用then來轉寫所搭配的非同步邏輯,供三小?
這邊說了.then方法呼叫回呼函式的方式會以非同步方式呼叫,意思是當JS引擎執行到Promise區塊時,會把整個Promise的祖宗三代都註冊起來(即後面的.then``.catch等),告訴JS引擎這一串跟Promise有關的東西內部的回呼函式要丟到非同步區塊等待呼叫執行。

Promise物件的catch

catch只用於Promise得到reject時,目的跟上面的hadleReject相同。

Promise狀態總結

Promise在執行時只要還沒執行resolvereject,就屬於Pending狀態,執行resolve後promise狀態轉為Fulfilled,且用.then來處理,執行reject後promise狀態轉為Rejected,則用.catch來處理。

直接取得被解決的Promise結果(我看是你值得被解決)

let promise = Promise.resolve('some value'); promise.then(function(value){console.log(value)})
let promise = Promise.reject('some error'); promise.catch(function(value){console.log(value)})

Promise.resolve中傳入另一個Promise,得到的會是另一個Promise.resolve的值。

let promise1 = Promise.resolve(12); let promise2 = Promise.resolve(promise1); console.log(promise1 == promise2)// true 都是12

then的串連

.then後可以接.then,因為每次Promise呼叫.then時,會在建立一個新的Promise。
當promise解決後丟resolve的值給後面的.then當作參數使用,後面的.then得到值以後產生新的Promise,等到回呼函式return值以後好像丟進resolve並丟到下一個.then當參數使用

let promise = Promise.resolve(12); promise.then((value1) => { console.log(value1); //12 return value1 * 2; }).then((value2) => { console.log(value2); //24 })

如果Promise.resolve接收到另一個Promise,被傳入的Promise會直接被解開傳入resolve的值。

let promise1 = Promise.resolve(1); let promise2 = Promise.resolve(2); let promise3 = promise1.then(() => { return promise2; //這邊回傳另一個Promise,會解開promise2直接得到2 }) promise2.then((value) => { console.log(value); //2 }) promise3.then((value) => { console.log(value); //2 });

Promise.all

Promise.all中放入多個Promise,當所有Promise都被成功兌現後(即所有Promise都執行了resolve),Promise.all才會被兌現,而把裡面所有resolve的結果組成陣列後,當成參數丟給後面的.then執行;又如果其中一個被拒絕了(執行了reject),則只傳出第一個reject的值。

let p1 = Promise.resolve(1); let p2 = Promise.resolve(2); let p3 = Promise.reject("No!"); let successGroup = Promise.all([p1, p2]); successGroup.then((value) => { console.log(value); //[1, 2] }); let rejectedGroup = Promise.all([p1, p2, p3]); rejectedGroup.catch((value) => { console.log(value); //"No!" })

Promise.race

一樣傳入多個Promise進去,這時裡面只要有一個Promise擺脫Pending狀態(執行了resolvereject),則會成為唯一個Promise.race的回傳值。

let p1 = Promise.reject("No!"); let p2 = Promise.resolve(1); let p3 = Promise.resolve(2); let raceGroup = Promise.race([p1, p2, p3]); raceGroup.then((value) => { console.log(value); //"No!" });

image

MacroTask

宏任務,就是前面提到會被放進Event Queue等待被執行的任務,所以Event Queue也會稱作Macrotask Queue、Task Queue,這是為了與Microtask做區隔。

image

MicroTask

通常由Promise產生,thencatch會以非同步方式進行,即他會被排到全域執行環境結束後才會被執行,所以當Promise脫離Pendind狀態後,thencatch會被放到某個地方等待執行,但不是Event Queue,而是叫Microtask Queue。

兩者都是非同步等待執行,他們的順序會是:
每一個Macrotask Queue裡的Task執行完後,若Microtask Queue裡面有任務,Event Loop就會讓他優先執行,直到Microtask Queue被清空為止,也就是Microtask會穿插在每個Macrotask之間執行。

image

這時候需要注意的是:
JS主程式運行本身也是一個Macrotask,所以若有Microtask將會在主程式運行完以後執行,所以結果會如下:

image

Async 非同步

直接使用async宣告函式就會是一個非同步函式,且用Promise實現,執行後會收到一個Promise:

async function asyncAction () { return 2; } asyncAction(); asyncAction().then((value) => console.log(value));

實際上會幫你改寫成:

async function asyncAction () {
    return Promise.resolve(2);
}

如此一來可以更簡單寫出非同步函式,但後面還是有連續呼叫then的問題,這時候可以使用await

Await

在用async宣告韓式以後,裡面的邏輯若要等到Promise解析後再執行,就在函式前加上await,他將會等待後面的Promise解決後執行。

let promise = new Promise((resolve) => { setTimeout(() => resolve('Resolve Promise'), 1000); }); async function asyncAction () { let promiseResult = await promise; console.log(promiseResult); } asyncAction();

當執行到async搭配await,後面接了Promise,會停下來等這個Promise被解析後回傳值,所以會在1秒後執行resolve並回傳"Resolve Promise",然後賦值給promiseResult印出。

await只等用在async宣告的函式內。

trycatch

try區塊出錯時將錯誤用throw丟給下方的catch()執行。

let fakeApi = () => new Promise((resolve) => { throw "Api has some error!"; resolve("Api result"); }); async function asyncAction(){ try{ let apiResult = await fakeApi(); }catch(err){ console.log(err); } } asyncAction(); //Api has some error!

try區塊中的await後面的fakeApi()函式裡會throw出錯誤訊息,當try區塊有任何東西被throw出來後將會被視為不正常狀況而跳到catch區塊進行,所以這時候就把剛剛throw的訊息console.log出來。

如果寫成下面這樣,asyns函式內沒有處理錯誤情況,如果沒有使用catch去接錯誤,asyncAction()將會回傳一個Promise物件,所以只要透過catch就能拿到函式內的錯誤訊息。

let fakeApi = () => new Promise((resolve) => { throw "Api has some error!"; resolve("Api result"); }); async function asyncAction() { let apiResult = await fakeApi(); return apiResult; } asyncAction().catch((err) => console.log(err)); //Api has some error!

結合Promise概念

理解async/await後,可以用Promise的觀念來活用,可以結合前面的Promise.allPromise.race一起使用。

async function fetchData1() { //... return "asyncAction1"; } async function fetchData2() { //... return "asyncAction2"; } async function getApiData() { const allData = await Promise.all([fetchData1(), fetchData2()]); return allData; } getApiData().then((value) => console.log(value)); //["asyncAction1", "asyncAction2"]

Chapter 7 物件

前面介紹物件時只有基本認識,已知使用:

const object = { name: "Jeremy", shoes: 5, size: "11.5", ... }

這種簡單建立一個物件的方法,表示一連串的鍵與值的配對。

建造物件的方式

new運算子

他舉例:

var emptyObject = new Object();

這邊透過new語法建造出一空物件,接著new的是一函式Object()的呼叫,這裡Object本身也是一物件(函式是物件的一種),是JS原生提供的全域物件,他說後面再詳細介紹。(啊是要等多久)

typeof Object; //"function"

可以看到Object這個酷東西是一個函式。
他說去看官方文件會發現new本身是一運算子,通常運算子被執行後會產生一個值,這裡new後即將產生的值其實就會是一物件。皆在new後方的函式一般稱為建構函式(constructor)、或是函式建構式(Function Constructor),特別用來創造一物件的特殊函式,這種函式通常只用於搭配new來建立物件,不會當成一般函式使用。
物件的內容可以在建構函式裡面設定,但要透過this來實現。

function Cocktail () { this.name = "Martini"; this.volume = "200ml"; this.price = 120; } var someDrink = new Cocktail(); console.log(someDrink); //Cocktail {name: "Martini", volume: "200ml", price: 120}

這邊自己做了Cocktail這個建構函式來建立一個有關調酒相關資訊的物件,裡面用了this,大概可以理解到他在被執行的時候建立了物件並設定裡面的內容(name: "Martini")。
書中解釋了使用new時JS做了這些事:

  • 建立一個全新的物件
  • 這個新建構的物件自帶一個prototype連結(又要等後面再討論)
  • 使用new呼叫的建構函式內創造了一個叫this的變數,會與當下建立的物件做連結,至少在這邊是這樣,根據不同地方、不同規則他代表的值可能也有所不同。
  • 通常會回傳當下建構的新物件,除非該函式提供了自己的替代物件來回傳(有點疑問想討論),

我在想是不是建構函式裡面設定了其他行為來回傳另一個他想回傳的東西,這麼一來就取代了原本回傳自己這個行為?

image

這裡可以注意到函式宣告時是大寫開頭,因為當初意識到了如果跟一般函式長一樣,容易搞混,萬一呼叫建構函式時忘記加上new,JS還是會正常執行不會報錯,但建構函式本身並沒有設定回傳值,所以這麼一來就只會得到undefined

image

所以後來就設定了建構子的命名傳統:建構函式的字首以大寫表示
這麼一來就可以清楚知道這要與new搭配使用了!

image
這邊出來的結果會是Cocktail {name: "Martini", volume: "200ml", price: 120},跟我之前實驗的一樣,不知道他少了物件前面的名字有沒有差別?

這邊提到了new搭配建構函式能實現類似『類別』的資料格式,紀錄了一種特定的資料規格,是物件導向的基礎概念。但實際開發上通常會用另一種叫建立class的方式達成,比較少用建構函式,但也可能在許多專案中看到,所以還是要了解一下。

物件字面值

只需要{...}就能建立一物件,最普遍的方式。

image

存取物件內容

var person = { name: "john", age: 10, } console.log(person.name); //"john"
  • 使用.存取物件屬性
  • 使用[]存取物件的鍵值(key)
    兩個都能取到物件中對應的內容,所以通常兩種名詞是指同件事情。

使用.存取時要注意,只有當屬性名稱只包含英文、數字_$符號的情況才能使用(除了_$以外的符號都不能有,空白也不行),也不能使用保留字,而且如果屬性是數字開頭就會報錯。

image

使用[]就沒有上述的名稱限制,但[]存取的是鍵值對應的內容,而鍵值都會是字串,所以[]要放字串

image
因為是字串,所以就可以是任何字元,所以萬一取了不正常的屬性名稱,就只能用[]來存取了。

因為[]搭配字串的特性讓我們可以利用動態方式取出不同屬性對應的值,相對的也可以在建立物件時,動態的決定裡面的屬性名稱:

var gender = "male" var person = { [gender]: "John", } console.log(person); //{male: "John"}

image
書上寫會印出"{male: "John"}",字串?應該打錯了。

物件常用的操作

檢查物件內有無子彈((某屬性才對

  • hasOwnProperty
    當每個物件建立時,都已經被賦予了一些prototype(後面會提到,總之就是物件一出生就與生俱來的東西),hasOwnProperty就是其中之一:
const hero = { name: "Spider Man", }; var hasName = hero.hasOwnProperty("name"); var hasRealName = hero.hasOwnProperty("realName"); console.log(hasName); //true console.log(hasRealName); //false

但是這邊概念就有點混亂,hasOwnPropertyhero的一個屬性嗎,書裡寫是,也不是(我覺得不是),hero可以找到這個屬性,也可以使用他,但他並不存在於hero這個物件上。

hero是在某個基礎上被建立的,hasOwnProperty就是來自這個基礎,這種關係稱為『繼承』,hero本身沒有定義這個方法,但透過建立他的基礎,我們可以取用到他。

我對這邊的理解是:hasOwnProperty不是hero的屬性,而是他繼承的函式,hero本身包含的就是裡面的屬性跟對應的內容,而prototype是建立物件時額外提供的客服,這個客服是多個物件共享的,所以當我們操作物件時想用hasOwnProperty來檢查時,就必須打給客服才能使用。

實際上比較正確的理解是:hasOwnPropertyObject的方法,Object是一個種類,就好比Object之於哺乳類,而hasOwnProperty之於「哺乳」這個行為,所以人類會哺乳室因為人類「是」哺乳類,「哺乳」是「哺乳類」這個種類繼承下來的行為。

hero屬於ObjectheroObject的一種,所以hero可以使用從Object繼承來的方法,所以並不是hero裡面有Object的方法,而是hero繼承了Object的方法,而這些繼承來的東西就會在prototype裡。

in運算子

const hero = { name: "Spider Man", }; console.log("name" in hero); //true console.log("realName" in hero) // false

使用in也可以簡單的知道物件中有沒有這個屬性。
inhasOwnProperty最大的差異就是in可以檢查到繼承來的方法:

const someObject = { value: "some value", }; console.log("hasOwnProperty" in someObject); //true console.log("toString" in someObject); // true

直接存取想檢查的屬性

const hero = { name: "Spider Man", }; const hasRealName = hero.realName === undefined; console.log(hasRealName); //true

直接存取不存在的屬性時就只會取到undefined,這時候判斷取到的值是不是undefined就可以了!

但這個方法可能會失誤,如果確實存在這個屬性但內容被設定成undefined,這時候就會失誤,那就要強制轉型判斷:

const hero = { name: "Spider Man", }; if(!!hero.name) { console.log("exist"); }else{ console.log("not exist"); } //"exist" if(!!hero.realName) { console.log("exist"); }else{ console.log("not exist"); } //"not exist"

!!hero.name運算時會否定兩次,負負得正,如果屬性的name有任何內容就會回傳true
!!hero.realName運算時會否定兩次,負負得正,hero.realName並不存在,所以會回傳false

巡訪物件

Object.keys

常用!!Object.keys會將物件裡的擁有的所有屬性抽出來放到陣列中。

var obj = { property1: "value1", property2: "value2", } Object.keys(obj); //["property1", "property2"]

這樣我們就得到一個陣列包含這個物件的所有屬性了~
假設有個物件是參賽者資訊,裡面屬性為參賽者編號,內容則是姓名,就可以寫個判斷來知道名單哪個編號有沒有報名過:

var participantInfo = { 12: "Ming", 23: "Zoe", 34: "Alex", 56: "Alan", 48: "Sam", } function checkIsSignUp(id){ var participantIds = Object.keys(participantInfo); var result = false; participantIds.forEach(function (key) { if(key === id){ result = true; } }) return result; } checkIsSignUp("12"); //true

這邊書上寫checkIsSignUp(12),但屬性是字串,所以永遠都找不到而回傳false,要改成checkIsSignUp("12")才找得到而回傳true。(除非比較使用兩個等於不是三個等於)

Object.values

Object.values則是取得物件所有屬性所對應到的內容,也是回傳一個陣列。

var obj = { property1: "value1", property2: "value2", } Object.values(obj); //["value1", "value2"]

一樣我們可以反過來查詢參賽者的姓名有沒有報名過:

var participantInfo = { 12: "Ming", 23: "Zoe", 34: "Alex", 56: "Alan", 48: "Sam", } function checkIsSignUp(name){ var participantNames = Object.values(participantInfo); var result = false; if(participantNames.indexOf(name) >= 0){ result = true; } return result; } checkIsSignUp("Alex"); //true

這邊使用indexOf方法來尋找陣列中符合要求的索引值,如果沒有找到就會回傳-1,所以判斷回傳值有沒有大於0就能知道有沒有在名單裡了!

Object.entries

可以得到屬性及內容,會回傳一個二維陣列(陣列內還有一層陣列):

var obj = { property1: "value1", property2: "value2", } Object.entries(obj); //[["property1", "value1"], ["property2", "value2"]]

如此一來就能將前面兩個例子結合起來,但查找的時候要多進一層陣列去查找:

var participantInfo = { 12: "Ming", 34: "Alex", }; function checkIsSignUp(idOrName){ var infoGroups = Object.entries(participantInfo); var result = false; infoGroups.forEach(function(infoGroup) { if(infoGroup.indexOf(idOrName) >= 0){ result = true; } }); return result; } checkIsSignUp("Alex"); //true

楊哲豪

物件的解構賦值

取出物件中的資料時也順便宣告新的變數:

var userInfo = {name: "John", height: 168}; var name = userInfo.name; console.log(name); //"John"

宣告name時取出物件的資料並賦值。


var userInfo = {name: "John", height: 168}; var { name } = userInfo; console.log(name); //"John"

宣告一物件結構,JS會去搜尋物件裡面的這個屬性的資料並賦值。


var userInfo = {name: "John", height: 168}; var { name, height } = userInfo; console.log(name, height); //"John" 168

所以這邊就會尋找nameheight這兩個屬性的值並賦值,這邊就也能一次宣告多組變數並賦值。


要注意的是解構賦予值時如果宣告了不存在的屬性,將會得到undefined

var userInfo = {name: "John", height: 168}; var { name, height, age } = userInfo; console.log(age); //undefined

為了避免取到undefined,可以宣告時給預設值,如果JS遇到屬性的內容是undefined時就會拿預設值作為宣告的值:

var userInfo = {name: "John", height: 168}; var { name, height, age = 18 } = userInfo; console.log(age); //18

在取用多組物件的資料時,又想用解構賦值,為了避免新的變數名稱衝突,可以在解構賦值內宣告別名:

var userInfoA = { name: "Jeremy", height: 180 }; var userInfoB = { name: "Watson", height: 186 }; var { name: nameOfUserA } = userInfoA; var { name: nameOfUserB } = userInfoB; console.log(nameOfUserA); //"Jeremy" console.log(nameOfUserB); //"Watson"

兩個物件都有屬性name,所以另外命名兩個變數來取值,就能避免名稱衝突。

可以理解成先取得該屬性的值,再指派給新宣告的變數:

var userInfoA = { name: "Jeremy",... };
                 
var { name: aliasName } = userInfoA;

會等價於:

var userInfoA = { name: "Jeremy",... };
                 
var aliasName = userInfoA.name;

陣列的解構賦值

同樣選告一個陣列的結構,會依照兩陣列中相對應的位置賦值:

var nameArray = ["Jeremy", "Kuo"]; var [firstName, lastName] = nameArray; console.log(firstName); //"Jeremy" console.log(lastName); //"Kuo"

複製物件

展開物件

使用...

var userInfo1 = { name: "Jeremy", age: "27" }; var userInfo2 = { height: "180", ...userInfo1 }; console.log(userInfo2); //{height: '180', name: 'Jeremy', age: '27'}

那如果在一個空物件裡展開另一個物件,再將屬性的內容覆蓋過去,就能達到複製一份擁有同樣屬性的物件:

var userInfo1 = { name: "Jeremy", age: "27" }; var userInfo2 = { ...userInfo1 }; userInfo2.name = "Watson"; userInfo2.age = "29"; console.log(userInfo1); //{name: 'Jeremy', age: '27'} console.log(userInfo2); //{name: 'Watson', age: '29'}

把兩個擁有相同屬性的物件合併時要注意,一個屬性只會保留放在後方的物件屬性的值:

var user1 = { name: "Jeremy", age: "27" }; var user2 = { name: "Watson", age: "29" }; var merge = { ...user1, ...user2 }; console.log(merge); //{name: 'Watson', age: '29'}

也可以運用在陣列中,且因為沒有屬性所以不用擔心屬性值會覆蓋:

var array1 = [1, 2, 3]; var array2 = [4, 5, 6]; var resultArray = [ ...array1, ...array2 ]; console.log(resultArray //[1, 2, 3, 4, 5, 6]

Object.assign

接收兩個參數,第一個參數放目標物件,第二個放來源物件,會以第一個物件為基礎將第二個物件添加進去,並回傳結果物件:

var user = { name: "Jeremy", age: "27" }; var otherInfo = { gender: "male" }; var result = Object.assign(user, otherInfo); console.log(result); //{name: 'Jeremy', age: '27', gender: 'male'}

來源物件可以不只一個:

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

一樣需要注意同樣的屬性只會保留最後的值。

深拷貝與淺拷貝

淺拷貝

使用...Object.assign複製時,感覺好像是另一個全新隔離的物件,但當物件中存放另一個物件時就不是這麼一回事了,這個物件底下的另一個A物件會透過物件參考的方式形成,而新物件裡的A物件跟舊物件裡面的A物件會有所連結,這就是『淺拷貝』(Shallow Copy),意思是只有表層的資料被複製。

let originObject = { valueA: "a string", innerObject: { valueB: "b string", }, }; let newObject = { ...originObject }; newObject.innerObject.valueB = "modified b"; console.log(originObject.innerObject.valueB); //"modified b" console.log(newObject.innerObject.valueB); //"modified b" console.log(originObject.innerObject === newObject.innerObject); //true

上述例子可以看到,本來只想修改newObject裡面的innerObject的值,但原來的originObject裡的innerObject也被改掉了,又判斷這兩個innerOrigin是不是完全一樣,結果確實是同一個innerObject

深拷貝

相對於淺拷貝,深拷貝就是完全複製整個物件的內容,JS中比較難達到這個效果:利用依序取得屬性內容的方式判斷每個屬性內容是不是物件,如果是就重新建立一個物件然後複製進去。


預設淺拷貝的原因:

var objectA = { a: "a" }; var objectB = { b: objectA }; objectA.a = objectB; var objectCopy = { ...objectB }; objectCopy = { { b: objectA } }; = { { b: { a: objectB } } } = { { b: { a: { b: objectA } } } } = { { b: { a: { b: { a: objectB } } } } }

這個例子中複製objectB的過程中如果是深拷貝,就會產生永無止境的迴圈,不斷地建立無數個物件;但如果是淺拷貝,objectCopy.b就會跟objectA連結在一起,而objectCopy.b.a會跟objectB連結在一起,這樣就不會建立無數個物件。

實現深拷貝可以用JSON格式轉為純文字再轉回JS物件,或是新版JS的 。但很少情況會用到,有興趣再另外研究。

物件的屬性描述器

物件的屬性其實還有一些附加的訊息,可以控制屬性的行為,一般看不到,透過屬性描述器才可以看到或修改:

var obj = { name: "an object", } var descriptor = Object.getOwnPropertyDescriptor(obj, "name"); console.log(descriptor); //{value: 'an object', writable: true, enumerable: true, configurable: true}

Object.getOwnPropertyDescriptor全域方法,接收兩個參數(想描述的物件, 想描述的屬性):

  • value:該屬性的值。
  • writable:定義屬性可改與否。
  • enumerable:定義屬性被巡訪(ex:Object.keys)時能被列出與否。
  • configurable:定義此屬性的描述器可修改與否。
  • get:屬性上的gutter函式,定義取用屬性時的行為,沒設就是undefined
  • set:屬性上的setter函式,定義指派屬性時的行為,沒設就是undefined

Object.defineProperty

想改變屬性的設定要透過Object.defineProperty來定義:

var obj = {}; Object.defineProperty(obj, "a", { configurable: true, enumerable: true, value: 100, writable: true, }); console.log(obj); //{a: 100}

Writable

決定這個屬性能不能被改變:

var userInfo = {}; Object.defineProperty(userInfo, "name", { value: "John", writable: false, enumerable: true, configurable: true, }); userInfo.name = "new Name"; console.log(userInfo.name); //"John"

當設定為false時,即使重新賦值了值也不會改變。

Enumerable

決定屬性被巡訪時能不能被取得:

var userInfo = {}; Object.defineProperty(userInfo, "name", { value: "Jeremy", enumerable: true, }); Object.defineProperty(userInfo, "age", { value: 20, enumerable: false, }); console.log(userInfo); //{name: 'Jeremy', age: 20} console.log(Object.keys(userInfo)); //['name'] console.log(userInfo.hasOwnProperty("age")); //true

物件中有nameage兩個屬性,但屬性設定enumerable: false時,使用Object.keys就無法取得,但這個屬性透過hasOwnProperty來檢查還是存在的。

如果想取的所有存在的屬性,就可以用Object.getOwnPropertyNames

...
var allOwnProperties = Object.getOwnPropertyNames(userInfo);
console.log(allOwnProperties); //['name', 'age']

Configurable

決定描述器能不能被修改:

var userInfo = {}; Object.defineProperty(userInfo, "name", { value: "Jeremy", writable: true, configurable: false, }); userInfo.name = "Watson"; console.log(userInfo.name); //Watson Object.defineProperty(userInfo, "name", { configurable: true, }); //TypeError: Cannot redefine property: name

configurable: false後再嘗試修改描述器就會出錯。

Get、Set

屬性描述器裡的getset為兩個隱藏的函式,決定物件屬性被取用時即被賦值時的行為,預設是undefined,但如果有被設定,那操作屬性時就會依照內容來取用跟賦值。

var userInfo = {}; Object.defineProperty(userInfo, "name", { get() { return "get what I want inside." }, set() { this._name = "set inside." }, }); userInfo.name = "outside setting"; //在這邊賦值 var getNameOutside = userInfo.name; //在這邊取得值 console.log(userInfo); //{_name: 'set inside.'} console.log(getNameOutside); //"get what I want inside."

getNameOutside從物件中取屬性name的值,但我在get()設定了當取得屬性的值時,就給他"get what I want inside."這個值;當我賦值給name時,因為我在set()設定當他被賦值時,就將此物件的_name屬性賦值"set inside."

只要描述器有設定Get或Set,就會被歸類為『存取描述器』(Accessor Descriptor),沒有的話就被歸類為『資料描述器』(Data Descriptor),存取描述器中,valuewritable的值會被忽略,以setget為主。

This

JS中的this

this可以很方便地從執行環境內取得外部物件,但不夠瞭解就會容易指向錯誤的物件,出現不如預期的情況。

this綁定方式

  • 預設
  • 隱含
  • 明確
  • new運算子

預設的綁定

一般函式呼叫時,函式內的this就會指到全域:

function normalFunction() { console.log(this.a); } var a = "global"; normalFunction(); //"global"

隱含的綁定

呼叫的是物件裡的函式,this就會指向這個物件:

function speak() { console.log(this.message); } var obj = { message: "I am a object.", speak: speak, }; obj.speak(); // "I am a object." speak(); //undefined

obj裡面的speak屬性設定speak()這個函式,透過呼叫obj裡面的speak(),就順利拿到objmessage內容;在全域呼叫speak(),因為window底下沒有message這個屬性,所以得到undefine

隱含綁定的消失

var obj = { name: "A", say: function() { console.log(this.name); }, }; var say = obj.say; var name = "global"; say(); //"global"

當我們指派sayobj.say時,他就只參考到函式本身,所以呼叫say時會失去原有的背景而被視為一般函式呼叫,也就是會找到window

另一個例子:

var name = "A" var userInfo = { name: "Fang", changeName: function() { this.name = "Jeremy"; console.log(this.name); //"Jeremy" function changeNameAgain() { this.name = "Watson"; } changeNameAgain(); console.log(this.name); //"Jeremy" }, }; userInfo.changeName(); console.log(name); //"Watson"

在呼叫userInfo.changeName時,函式的this如預期的指向了userInfo,所以把原本的 "Fang" 改成了 "Jeremy" ,但在裡面的changeNameAgain沒有參考對象,所以被視為一般函式呼叫,此時他的this就會指向全域,所以是全域的name被改成了 "Watson"。

明確的綁定

透過callapply可以明確指向目標物件:

function speak() { console.log(this.name); } var userInfo = { name: "Jeremy", }; speak(); //這裡在瀏覽器會印出空白,window有預設name: "" speak.call(userInfo); //"Jeremy" speak.apply(userInfo); //"Jeremy"

但他這邊說兩者差異是傳入參數的方式不同,第一個參數是要指向的物件,第二個參數是要傳入該函式的參數,有看沒有懂。

硬綁定

確保某個函式裡呼叫this時都與目標物件綁定:

var userInfo = { message: "userInfo message", }; function saySomething() { console.log(this.message); } function speak() { console.log("this in speak = ", this); saySomething.call(userInfo); } speak(); //"this in speak = window..." //"userInfo message" speak.call({}); //"this in speak = {}" //"userInfo message"

可以看到本身的this還是受環境影響,但在函式中使用硬綁定後,不管怎麼呼叫他會都指向我指定的物件。


且透過硬綁定搭配回呼函式來重複使用邏輯:

function bind(fn, thisTarget) { return function () { fn.call(thisTarget); }; } var objA = { a: "a", }; var objB = { b: "b", }; function say() { console.log(this); } var bindSay = bind(say, objA); bindSay(); //{a: 'a'} bindSay.call(objB); //{a: 'a'}

即使我在外面綁定了objB,但bindSay函式內的邏輯永遠都綁定objA,所以呼叫時永遠是綁定objA


JS有另一種方法bind,能做到的跟上面的例子一樣:

var objA = { a: "a", }; function say() { console.log(this); } var bindSay = say.bind(objA); bindSay(); //{a: 'a'} bindSay.call(window); //{a: 'a'}

new與建構函式的綁定

回到前面講過的new運算子,當我new一個建構函式後:

  • 建立新物件
  • 帶有prototype連結
  • 當中的this會指向當下建立的物件
  • 回傳當下建構的物件,除非函式有另外設定

箭頭函式裡的this

官方說法是『沒有this綁定』,箭頭函式的this會根據函式本身程式碼的實際位置綁定:

var obj = { func: function () { console.log("this in normal function.", this); }, arrowFunc: () => { console.log("this in arrow function.", this); }, }; console.log(obj.func()); //"this in normal function." {func: ƒ, arrowFunc: ƒ} console.log(obj.arrowFunc()); //"this in arrow function." Window{...}

此時觀察到箭頭函式的this沒有綁定obj,而是window,就是因為箭頭函式沒有this綁定。


知道箭頭函式this的特性後,可以利用它來改善前面隱含綁定失效的現象:

var userInfo = { name: "Jeremy", changeName: function () { this.name = "Watson"; var changeNameAgain = () => { this.name = "Fang"; console.log(userInfo.name); }; setTimeout(changeNameAgain, 1000); }, }; userInfo.changeName();

箭頭函式是在setTimeout一秒後被當一般函式呼叫,但他的this還是找到了userInfo而把"Watson"改成了"Fang",就是因為箭頭函式的this在宣告時會繼承當下環境(changeName())所指向的this且永遠綁定。

Chapter 8 物件與原型

主要探討物件的本質及概念。

物件與類別

物件導向:一種撰寫程式碼的風格,某些語言會遵循這個風格設計它的語法,像是Java或C++,所以要使用這些語言要先了解物件導向。

物件導向的核心思想

有著「類別」(Class)與「物件」(Object)概念,概念來自建築工程,「類別」就是設計圖,紀錄著建築物的許多基礎規格、特徵、設計、材料等,而物件就是最終被建造出來的建築物,負責實現類別所定義的基礎規格。

image

物件導向的繼承關係

物件導向中,「繼承」(Inheritance)是指類別可以以另一個類別為基礎,往上擴充或修改,可以很方便且成本較低的創造新的類別,目的可以說是為了讓某些屬性可以共用

image

生活化的比喻:繼承與被繼承物件之間的關係,生物界的分類,「動物」與「鳥」作為比喻,只要是動物都會有共同的行為,例如呼吸,鳥類是建立在動物的基礎上(動物的一種),所以鳥也會呼吸,但飛行是鳥類特有的行為,所以可以說鳥類繼承了動物的行為(呼吸),且具有自己特有的行為(飛行)。

JS是不是物件導向語言

書裡寫是,也不是(?),我認為不是,但他模擬了許多行為去模仿物件導向,為了吸引更多使用物件導向的語言使用者能更願意寫JS,所以雖然JS底層沒有真正物件導向的基礎元素,但透過其他行為模擬出許多與物件導向風格類似的語法、元素。

前面的new搭配「建構函式」,並在函式內操作this產生物件,就是JS用來模擬物件導向中「類別」的方式。

再來就是JS為了達到「繼承」的概念,建立了物件之間的連結,稱為「原型」(prototype),

原型

原型的概念

每個JS物件上都有一個隱藏屬性prototype代表物件與另一個物件的連結,也是物件的原型。

當JS物件在取用某個屬性時,如果在物件中找不到,就會嘗試到prototype這個隱藏的屬性中查找,

驗證原型的方法:使用Object.create建立新的物件,丟入的參數為想要作為基礎的原型。

var prototypeA = {name: "prototype A."} var objectB = Object.create(prototypeA); console.log(Object.getPrototypeOf(objectB)); //{name: 'prototype A.'}

上面以prototypeA作為基礎建立一個物件objectB,透過ES5之後提供的方法getPrototypeOf可以取得物件的原型。

再來就是驗證JS嘗試尋找原型中的屬性:

var prototypeA = {name: "prototype A."} var objectB = Object.create(prototypeA); console.log(objectB); //{} console.log(objectB.name); //"prototype A."

可以看到objectB為空物件,當我們嘗試在objectB中取用不存在的name這個屬性時,他跑到原型prototypeA中且成功找到了"prototype A."

建構函式的 prototype 屬性

JS中的所有函式(包含建構函式)都有prototype屬性,主要是使用於建構函式創建物件時也建立了新物件與其原型的連結

原型就是建構函式中的prototype這個物件:

function UserInfo(name){ this.name = name; } const user1 = new UserInfo("Jeremy"); const user2 = new UserInfo("Watson"); console.log(Object.getPrototypeOf(user1) === UserInfo.prototype); //true console.log(Object.getPrototypeOf(user2) === UserInfo.prototype); //true console.log(Object.getPrototypeOf(user1) === Object.getPrototypeOf(user2)); //true

經過測試可得知,不管事user1還是user2的原型,都是UserInfo.prototype這個物件。

物件的__proto__屬性

JS在每個物件上提供了__proto__屬性,讓我們可以觀察物件的原型,但不是ECMA規範中的內容,只是大多數瀏覽器提供了這個屬性,取用這個屬性跟前面Object.getPrototypeOf的結果相同。

書中接下來會用__proto__來表示一物件的原型,但官方表示不推薦使用,不同瀏覽器也有相容性問題,所以不推薦在任何實際開發的產品使用,如果有需要取得原型,比較推薦使用Object.getPrototypeOf

function UserInfo(name){ this.name = name; } const user1 = new UserInfo("Jeremy"); console.log(UserInfo.prototype === user1.__proto__); //true

原型繼承

JS透過建構函式模擬類別透過原型模擬繼承,如此一來在前面透過UserInof建構函式建立的物件,其原型都會跟UserInfo.prototype這個物件連結在一起,當我們試圖改變UserInfo.prototype裡的內容,被創造出的物件也能共享到這個改變:

function UserInfo(name){ this.name = name; } UserInfo.prototype.setAge = function(age){ this.age = age; }; const user1 = new UserInfo("Jeremy"); user1.setAge(27); const user2 = new UserInfo("Watson"); user2.setAge(29); console.log(user1); //UserInfo {name: 'Jeremy', age: 27} console.log(user2); //UserInfo {name: 'Watson', age: 29}

透過設定UserInfo.prototype.setAge來加入setAge這個方法,user1user2都能使用這個方法。

也可以觀察一下setAge中的this,呼叫user1.setAge(27)時,裡面的this綁定了前面的物件user1,因此可以順利新增一個age進去。

這裡也提到setAge為什麼不直接寫在建構函式中,當物件建立時順便建立setAge這個函式,當我用建構函式建立1000個物件,等於分別建立了1000個setAge函式,但他們長一模一樣,不需要獨一無二存在每個物件而去浪費記憶體空間,所以為了達到同樣的目的只需要放在他們的原型裡被共用就行了。

原型鏈

前面提過的:當JS在物件找不到某屬性,會嘗試往物件的原型找,也就是物件的__proto__屬性,而這個物件的原型也是一個物件,所以也會有更底層的原型,也就是原型的原型,所以當物件找不到某屬性就往物件的原型找,再找不到就再往物件的原型的原型去找,這樣一連串的原型組成的連結就被稱為原型鏈。

書中也舉陣列的例子,陣列也是一種物件,所以推論陣列與物件有某種繼承關係:

var array = []; console.log(array.__proto__ === Array.prototype); //true console.log(array.__proto__.__proto__ === Object.prototype); //true console.log(array.__proto__.__proto__.__proto__); //null

可以看到array的原型是Array這個建構函式的prototype,再往下一層就可以發現array的原型Array.prototype,它的原型就是Object.prototype。然後再往下找一層,也就是Object.prototype的原型找到了null,表示原型連結到底了。

類別之間的繼承

傳統繼承概念:被繼承的「後代類別」(Child Class)所產生出來的物件,一開始就應該具備「前代類別」(Parent Class)的屬性跟方法。
嘗試透過JS實現上述目的:

function Human(birthday, height, weight){ this.birthday = birthday; this.height = height; this.weight = weight; } function User(firstName, lastName){ this.firstName = firstName; this.lastName = lastName; } Human.prototype.getHumanHeight = function() { return this.height; } User.prototype.getFullName = function() { return this.firstName + this.lastName }

上面先建立了兩個原本互不相干的類別,再來就是建立兩者之間的繼承關係,有兩個方向:

  • 類別之間原型物件的繼承:原型中的方法必須能與後代的物件共享。
  • 類別之間建構函式的內容繼承:建構函式中的內容(建立屬性值等等)需要能與後代的物件共享。

我覺得他有很努力想講得更清楚了,但這邊就是這麼難懂,直接看例子:

類別之間原型物件的繼承

透過new搭配這兩個建構函式生成的物件,都會各有一個prototype物件,一般情況他們互不相干,我們需要建立他們的連結,也就是建立原型鏈。

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

這邊把User.prototype這個原型物件改成了另一個新的物件,而這個新的物件是個空物件且原型指向了Human.prototype這個原型物件,如此一來,User就是繼承於Human了。

image
使用這個方法改掉會造成原本User.prototype中的constructor這個屬性消失,這本來是一個預設行為且constructor會指回建構函式本身,不一定會用到但是為了盡量符合預設行為,就在將他加回去:

User.prototype.constructor = User;

image

我自己來實測一下:

function Human(birthday, height, weight){ this.birthday = birthday; this.height = height; this.weight = weight; } function User(firstName, lastName){ this.firstName = firstName; this.lastName = lastName; } User.prototype = Object.create(Human.prototype); User.prototype.constructor = User; Human.prototype.getHumanHeight = function() { return this.height; } User.prototype.getFullName = function() { return this.firstName + this.lastName } const user1 = new User("Jeremy", "Kuo"); console.log(user1); //User {firstName: 'Jeremy', lastName: 'Kuo'}

成功建立了新物件user1,且將我的姓名填入,觀察看看裡面:

image
[[Prototype]]原型顯示為Human,且constructor指回User這個建構函式。

類別之間建構函式的內容繼承

成功將不同原型互相建立繼承關係後,再來就是讓建構函式操作到的屬性也能夠給後代操作使用:

//... function User(firstName, lastName, birthday, height, weight){ this.firstName = firstName; this.lastName = lastName; Human.call(this, birthday, height, weight); } const user2 = new User("Watson", "Lee", null, 186, 67); console.log(user2); //User {firstName: 'Watson', lastName: 'Lee', birthday: null, height: 186, weight: 67}

這是一個經典方法,在後代(User)建構函式中呼叫前代(Human)的建構函式,透過call來綁定到Human這個建構函式上,這樣就能同時操作User中的屬性,又能拉取Human中操作的屬性進來。

這邊要注意的是,User在取用Human的屬性時,需要將Human中的所有屬性依序列入參數。

綜合以上兩點就能做就能模擬出類別:

function Human(birthday, height, weight){ this.birthday = birthday; this.height = height; this.weight = weight; } function User(firstName, lastName, birthday, height, weight){ this.firstName = firstName; this.lastName = lastName; Human.call(this, birthday, height, weight); } User.prototype = Object.create(Human.prototype); User.prototype.constructor = User; Human.prototype.getHumanHeight = function() { return this.height; } User.prototype.getFullName = function() { return this.firstName + this.lastName } const user1 = new User("Jeremy", "Kuo", "8/24", 180, 87); console.log(user1); //User {firstName: 'Jeremy', lastName: 'Kuo', birthday: '8/24', height: 180, weight: 87}

模擬的順序要是先建立兩個類別,然後讓類別之間的prototype做連結,然後才加上各自的方法,如果先方法再做Object.create(),那寫好的方法會被覆蓋掉,這是模擬的時候要注意的小細節。

Class 語法糖

ES6之後出現了class語法,能以更接近物件導向的方式來寫JS。

Class 基本用法

建構函式容易與函式搞混,改成使用class來宣告試試看:
建構函式寫法:

//建構函式 function User(name){ this.name = name; } let user1 = new User("Jeremy"); User.prototype.getName = function() { return this.name; };

class宣告:

class User { constructor(name){ this.name = name; } getName() { return this.name; } }

原本建構函式的內容放到constructor函式內,原本放到prototype要共享的方法也可以在class內直接宣告,但這邊跟一般函式宣告不同,實際上還是宣告了一個屬性並放入一個函式,但這邊可以直接縮寫成只寫函式宣告,除了constructor函式名稱固定且必須放建構函式的邏輯,其餘沒有名稱限制。

class宣告式的防呆機制

前面使用建構函式時,必須搭配new運算子來建立物件,但就算沒有搭配new使用,函式還是有效執行,不會有錯誤提示,因此不小心誤用也不容易發現;使用class宣告的類別,只能搭配new來呼叫才有效,否則JS是會報錯的:

class User { constructor(name){ this.name = name; } } User(); //SyntaxError: Identifier 'User' has already been declared

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

透過class來實現繼承,可搭配extends

class Human { constructor(birthday, gender){ this.birthday = birthday; this.gender = gender; } getBirthday(){ return this.birthday; } } class User extends Human { constructor(name, birthday, gender){ super(birthday, gender); //必須先呼叫 this.name = name; } } const user1 = new User("Jeremy", "8/24", "male"); console.log(user1); //User {birthday: '8/24', gender: 'male', name: 'Jeremy'}

使用extends運算子來建立兩者繼承關係,接者在constructor函式內呼叫super來繼承來自前代的屬性。

還是需要注意,super前代的屬性需要繼承所有的屬性。

而且在這邊一定要先呼叫super(前代屬性)再接上後代屬性,(這是規範),不然會報錯,因此建造出來的屬性順序會跟前面用建構函式的方式不一樣。

static 定義靜態方法

staticclass一樣語法糖,透過static定義的方法,只能透過類別取得,所建立的物件沒辦法取得

class Human { constructor(birthday, gender) { this.birthday = birthday; this.gender = gender; } getBirthday(){ return this.birthday; } static describe() { return "Homo sapiens"; } } class User extends Human{ constructor(name, birthday, gender) { super(birthday, gender); this.name = name; } } const user1 = new User("Jeremy", "8/24", "male"); console.log(Human.describe()); //"Homo sapiens" console.log(User.describe()); //"Homo sapiens" console.log(user1.userDescribe); //undefined

新增在建構函式的屬性亦是如此:

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

但透過建構函式理解靜態方法是沒有意義的,因為建構函式本身是物件,在物件新增屬性的行為是很合理的。

class 內的 super

在後代類別必須呼叫super才能繼承前代類別的屬性,如果要取得前代的函式也是透過super

class Human { constructor(gender){ this.gender = gender; } getGender() { return this.gender; } } class User extends Human { constructor(name, gender) { super(gender); this.name = name; } getGender() { return super.getGender(); } } const user = new User("Jeremy", "male"); console.log(user.getGender()); //'male'

這邊我不太懂,我將UsergetGender拿掉,user還是可以使用HumangetGender

JS 內建物件

  • Object
  • Array
  • Number
  • String
  • Boolean
    這些都是可以搭配new的建構函式,不搭配new就只是一般韓式呼叫,通常在做數值轉型,且比起建構函式,我們會更常使用Object.keysArray.isArray這些全域物件方法,在程式碼的任何地方使用。

內建物件也是物件

例如Object可以當作建構函式,也有像Object.keys方法可以使用,因為Object本身也是物件,keys是裡面的屬性;map這個屬性可以用在所有陣列也是因為這些陣列連結到了Array.prototype這個原型物件上,而可以用的共用方法。

原始型別對應的內建物件

使用內建物件當建構函式執行時會得到對應純值的物件:

var number = new Number(3); var string = new String("some String"); console.log(number); //Number {3} console.log(string); //String {"some String"}

所以可以發現建立出來的是物件裡面包著我期望的值;如果沒有搭配new執行,會將內容當參數傳進去轉型:

var string = String(3); var number = Number("3") console.log(string); //"3" console.log(number); //3

我們使用建構函式的方式建立出來的物件好處就是能夠享用建構函式中的原型物件提供的方法;例如搭配new呼叫String後會得到字串物件,就能透過這個物件使用處理字串相關的方法:

var string = new String("some string"); console.log(string.length); //11; console.log(string.indexOf("e")); //3;

也可增加方法到原型中來讓其他物件共用:

String.prototype.calculateLetters = function (letter) { var letterCount = 0; var stringArray = this.split(""); stringArray.forEach((char) => { if(letter === char) { letterCount += 1; } }); return letterCount; }; var string = new String("tomorrow"); console.log(string.calculateLetters('r')); //2

要注意的是,要建立方法時需要檢查原本有沒有同樣名稱的屬性,如果不小心改掉內建的方法會造成麻煩。

原始型別包裹物件(Wrapper Object)

前面也許有觀察到,即使不使用String建構函式建立字串物件,也能順利地使用字串的方法:

var string = "JavaScript"; console.log(string.indexOf("r")); //6

原因就是JS會自動幫我們將字串轉成對應型別的物件,我們就能輕鬆取得對應型別的方法,當我們嘗試取用物件上的屬性的那一刻,就產生對應型別的物件將你的資料包起來,就是所謂的「包裹物件」,而當使用完方法後,包裹物件就會消失,所以我們才很難感受到他的存在。

所以往後建立一般型別的資料時就不建議主動使用建構函式建立,因為建構函式會建立出物件,如果沒注意到就會以為他是數值或字串等而造成邏輯意外。