You don't know JavaScript Yet:#3 深入JS的核心
前兩篇文章講的是在JS中比較height-level的部分,在這篇中將會深入討論JS核心的工作原理。這篇是You Don't Know JS Yet: Get Started-Digging to the Roots of JS的閱讀筆記,有更多關於以下議題的討論將放在以後的篇章中。
迭代(Iteration)
在程式中處理大量的資料常見的手法就是使用迭代,在JS中的迭代器也如同其他語言一般一直不斷的在進步,底下我們就來看看JS常用的迭代方式。
ES6 迭代協定
可迭代(iterable)協定允許JS物件定義它們自己的迭代行為,內建的可迭代物件有String
、Array
、Map
與Set
等等,若自己定義的物件則需要自己實現迭代行為,ES6提供了Symbol.iterator
屬性,在物件中透過定義Symbol.iterator
就被認為是一個可迭代的。Symbol.iterator
本身是一個無參數函式,當我們透過for..of
時就會執行這個函數並且返回一個迭代器(iterator)。
迭代器協定定義了next()
這個方法,而next()
必須回傳一個擁有以下兩個屬性之物件的無參數函式:
- done(boolean):若迭代器已迭代完畢整個可迭代序列,則值為
true
。在這個情況下value可以是代表迭代器的回傳值。若迭代器能夠產出序列中的下一個值,則值為false
。相當於完全不指定done
屬性。 - value: 任何由迭代器所回傳的值。可於
done
為true
時省略。
首先看看下面的範例:
function makeIterator(array) {
let nextIndex = 0;
return {
next() {
const iterator =
nextIndex < array.length
? { value: array[nextIndex], done: false }
: { value: undefined, done: true };
nextIndex += 1;
return iterator;
},
};
}
const it = makeIterator(['a', 'b']);
console.log(it.next()); // { value: "a", done: false }
console.log(it.next()); // { value: "b", done: false }
console.log(it.next()); // { value: undefined, done: true }
但這種方式必須手動處理,所以我們可以透過for..of
進行循環迭代:
// given an iterator of some data source:
const it = ['a', 'b'];
// loop over its results one at a time
for (const val of it) {
console.log(`Iterator value: ${val}`);
}
//Iterator value: a
//Iterator: b
再來看看自定義可迭代的物件:
class SimpleClass {
constructor(data) {
this.index = 0;
this.data = data;
}
[Symbol.iterator]() {
return {
next: () => {
if (this.index < this.data.length) {
return {value: this.data[this.index++], done: false};
} else {
this.index = 0; //If we would like to iterate over this again without forcing manual update of the index
return {done: true};
}
}
}
};
}
const simple = new SimpleClass([1,2,3,4,5]);
for (const val of simple) {
console.log(val); //'0' '1' '2' '3' '4' '5'
}
SimpleClass
定義了[Symbol.iterator]
方法,所以我們可以對它的實例進行迭代。
另外一種...
運算子是迭代器的另一種機制,它有兩種對稱形式展開(spread)與其餘(rest)。
在JS當中有兩種可能性需要用到展開:陣列或者作為傳遞參數用,看看下面例子:
// 將迭代器展開傳遞進陣列中,迭代的value都會儲存於vals當中。
var vals = [ ...it ];
// 將迭代器展開傳遞進函式中,迭代的value會作為參數傳遞。
doSomethingUseful( ...it );
...
的展開形式都遵循迭代器協定(與for..of相同),以從迭代器中檢索所有可用值並將其放置(展開)到接收上下文中(陣列,或作為參數傳遞)。
接著我們還看看內建的物件是如何進行迭代的,陣列就如同上述一般沒有什麼問題,字串則可直接使用for..of
遍歷或者也可以透過...
運算子進行操作:
const greeting = "Hello world!";
const chars = [...greeting]
// [ "H", "e", "l", "l", "o", " ",
// "w", "o", "r", "l", "d", "!" ]
而Map
是一個透過key來獲取值的資料結構,它的迭代方式稍有不同,看看以下的範例:
// given two DOM elements, `btn1` and `btn2`
var buttonNames = new Map();
buttonNames.set(btn1,"Button 1");
buttonNames.set(btn2,"Button 2");
for (let [btn,btnName] of buttonNames) {
btn.addEventListener("click",function onClick(){
console.log(`Clicked ${ btnName }`);
});
}
這裡我們透過[btn,btnName]
(這也稱為"array destructuring")來獲取一對key(鍵)/value(值)(btn1
/Button 2
, btn2
/Button 2
)接著就能簡單地進行操作了;若只需要值,我們可以透過values()
來獲取值的部分就好:
for (let btnName of buttonNames.values()) {
console.log(btnName);
}
// Button 1
// Button 2
有時候進行陣列迭代我們需要得到index,這時我們可以使用entries()
:
var arr = [ 10, 20, 30 ];
for (let [idx,val] of arr.entries()) {
console.log(`[${ idx }]: ${ val }`);
}
// [0]: 10
// [1]: 20
// [2]: 30
在多數情況下,JS中內建的迭代器都具有以下三種迭代行式: keys-only(keys()
)、values-only(values()
)與entries(entries()
)。
閉包(Closure)
「閉包是讓函式記住與持續訪問在其範疇之外的變數的一種能力,即使該函式在其他範疇中執行也是如此。」
我們平常在寫程式時一定都有使用過閉包,但可能不是很了解閉包,因為網路上有許多抽象的定義甚至用很正式的學術語言來談論它,但這對我們來說沒有幫助, 所以在這邊我們想給予它一些清楚且具體的定義。
首先看看閉包的兩個特徵:
- 所有的函式都是閉包,而物件則不是。
- 若要觀察閉包,我們需要在與最初定義該函式不同範疇之下,執行該函式。
看看以下例子:
function greeting(msg) {
return function who(name) {
console.log(`${ msg }, ${ name }!`);
};
}
var hello = greeting("Hello");
var howdy = greeting("Howdy");
hello("Kyle");
// Hello, Kyle!
hello("Sarah");
// Hello, Sarah!
howdy("Grant");
// Howdy, Grant!
greeting(..)
會回傳函式who(..)
的一個實例,who(..)
中有使用了greeting(..)
的參數msg
,當我們第一次執行greeting(..)
後,將會把參數msg
的reference分配給hello
變數,第二次呼叫同理。
當greeting(..)
呼叫完畢後我們通常希望垃圾回收機制能幫我們把所有變數從memory中清除掉,但在上面的例子中msg
並沒有被清掉,這就是閉包的功能。此時在hello
與howdy
中的msg
與當初賦予它們的msg
具有相同的reference,也就是greeting(..)
範疇的reference,所以實際上這些變數是直接被保留下來的。
再看看另外一個例子:
function counter(step = 1) {
var count = 0;
return function increaseCount(){
count = count + step;
return count;
};
}
var incBy1 = counter(1);
var incBy3 = counter(3);
incBy1(); // 1
incBy1(); // 2
incBy3(); // 3
incBy3(); // 6
incBy3(); // 9
所有increaseCount()
都共用了count
且在每次執行時會獲取當前count
並且更新計數,因為它們是相同的reference,而step
則作為參數各自獨立。
上面兩個例子的外部範疇都是一個函式,但實際上不一定要是函式,只要外部範疇至少有一個變數是被內部函式存取就好。
for (let [idx,btn] of buttons.entries()) {
btn.addEventListener("click",function onClick(evt){
console.log(`Clicked on button (${ idx })!`);
});
}
每次迭代都為btn
增加點擊事件,其中onClick
使用了外部變數idx
,儘管idx
使用let
宣告,但實際上onClick
已經保留了idx
的值了,所以每次點擊都能夠獲取到當前button的index,此行為與前面的閉包是一樣的邏輯。
閉包在任何語言中被普遍的運用且是編程模式中重要的一環,在You Don't Know JS Yet: Scope & Closures專門討論範疇與閉包,若有不清楚的地方可以先去看看,我會在往後補上該冊的筆記。
關鍵字this
在我看到這部分之前,我對this
的觀念跟書上說的一樣,將其他語言的this
與JS中的this
混為一談,
最常被誤解的一種就是:函式中的this
指向其函式本身,另外一種誤解(我原本也那麼認為):方法中的this
指向其所屬實例,但這兩個都不正確。
在定義函式時,它會將相關的變數通過閉包附加到它的範疇當中,而範疇是用來控制當前函式所有變數的reference。但函式除了範疇之外還有另外一個特徵會影響到它能存取的變數,我們稱其為"execution context"
,它會透過this
關鍵字暴露給函式。
範疇是靜態的,在我們定義函式的時候就決定要存取哪些變數,但execution context是動態的,完全取決於函式呼叫的方式。
你可以把execution context作為一個有形的物件,它的屬性能提供函式執行時使用。
看看下面的例子:
function classroom(teacher) {
return function study() {
console.log(
`${ teacher } wants you to study ${ this.topic }`
);
};
}
var assignment = classroom("Kyle");
外部函式classroom(..)
返回一個study()
的實例,除此之外沒別的了。但內部函式study()
除了將teacher
變數透過閉包保存於它的範疇當中外,裡面還使用了this
關鍵字,
這意味著study()
已與execution context聯繫。接著我們使用classroom(..)
將其內部函式配置給assignment
變數,此時我們執行assignment
(如同執行study()
)會發生什麼事呢?
assignment();
// Kyle wants you to study undefined -- Oops :(
可以預料到this.topic
為undefined
,因為我們未提供任何execution context。由於在執行assignment
時,找不到當前函式的execution context中有topic
這個屬性,所以它就會向外去找
global execution context,但依舊沒找到topic
這個屬性,所以就回傳undefined
。
var homework = {
topic: "JS",
assignment: assignment
};
homework.assignment();
// Kyle wants you to study JS
我們建立一個homework
物件,其中把assignment
作為它的屬性並且執行它,此時this
表示為執行它的物件homework
。
var otherHomework = {
topic: "Math"
};
assignment.call(otherHomework);
// Kyle wants you to study Math
最後一個例子我們透過呼叫call(..)
將一個物件(這裡為otherHomework)傳遞給this
的reference,使其能獲取到topic
屬性。
從上面這些例子來看,this
會根據執行時的行為來動態獲取屬性,也因此提供了更好的靈活性使用來自不同物件的數據與功能。
原型(Prototype)
假設我們要獲取物件的某個屬性不存在會發什麼事呢?得到的就是undefined
,而prototype我們可以把它想像是隱藏在物件定義中的一個屬性,
每一個實例都能用獲取它,當物件找不到它要的屬性時,就會去找prototype中有沒有,當然這還會涉及到一個叫原型鏈(prototype chain)的東西。
「原型鏈是將一連串的物件透過prototype連結起來。」
原型鏈的目的是希望能透過prototype去委派其他物件獲取或執行本身沒有的屬性或者方法,來達到共同協作的功能。
var homework = {
topic: "JS"
};
homework
只有topic
一個屬性,但所有物件預設的prototype都會與物件Object.prototype
鏈結,其中它有toString()
與valueOf()
等方法,
所以看看下面:
homework.toString(); // [object Object]
當我們執行toString()
時會先去找homework
有沒有此方法,但上面我們並沒有定義toString()
,所以它會繼續往下找進而找到Object.prototype.toString()
。
物件鏈結
要建立物件原形鍊,可以透過Object.create(..)
來創建:
var homework = {
topic: "JS"
};
var otherHomework = Object.create(homework);
otherHomework.topic;
// "JS"
Object.create(..)
的參數允許輸入一個物件,該物件將會與新創建的物件鏈結,然後返回新創建(並鏈結)的物件。看看下面的關係圖就能清楚地看出它們之間的關聯性:
原型鏈中的屬性只適合用於獲取,若你直接對屬性賦值,則它只會反映在該物件上,不會對其他原型鏈上的其他物件造成影響:
homework.topic;
// "JS"
otherHomework.topic;
// "JS"
otherHomework.topic = "Math";
otherHomework.topic;
// "Math"
homework.topic;
// "JS" -- not "Math"
但此時otherHomework就會shadowingtopic屬性:
回頭來看this
前面提到this
是動態的,取決於函式如何執行,而上面物件透過原型鏈委派的方式執行方法,此時this
也會跟著prototype改變。
var homework = {
study() {
console.log(`Please study ${ this.topic }`);
}
};
var jsHomework = Object.create(homework);
jsHomework.topic = "JS";
jsHomework.study();
// Please study JS
var mathHomework = Object.create(homework);
mathHomework.topic = "Math";
mathHomework.study();
// Please study Math
jsHomework
與mathHomework
都與homework
鏈結,jsHomework.study()
委派給homework.study()
執行,若在其他語言中,此時的this
可能只會去尋找homework
中有沒有topic
這個屬性,
因為study()
是定義在homework
之中,但在JS中它依舊能夠找到jsHomework
中的topic
,並且合乎預期的執行,這是JS中this
的動態能力。
總結
You Don't Know JS Yet: Get Started大致上就差不多到這邊,原文中還有第四章,但那章是在介紹接下來幾冊的導覽。在原文中有兩個附錄:Appendix A: Exploring Further與Appendix B: Practice, Practice, Practice!,附錄A有些額外的知識可以去看一下,而附錄B則是作者出一些題目讓我們練習用的,強烈建議你可以去看看附錄B,好讓自己更熟悉一點。這本書看到這邊我依舊還有許多疑問沒有弄清楚,但我想往後看下去會越看越明白,後面幾冊的筆記我會再花時間慢慢補上,希望對您有些幫助。