前言
Q&A
-
1.問:為什麼要寫這麼長,有必要嗎?是不是腦子秀逗了?
答:我想這是大部分人看到這個標題都會問的問題.因為作為一個男人,我喜歡長一點,也不喜歡分割成幾個部分.一家人就要在一起,整整齊齊.好吧,正經點,其實整篇前言可以說都是在回答這個問題.你可以選擇先看完前言,再決定要不要和書本搭配起來閱讀. 這裡先簡單捋一下:1,內容多:首先這篇讀書筆記本來內容就很多,是對書本的全方位詳解.2,針對新人:針對那種紅寶書草草讀過一遍,對js只浮於介面呼叫的新手.3,留給讀者自己提煉:讀這種社科類書籍一般是先讀厚,再讀薄.這篇筆記就屬於最開始'讀厚'的階段.在讀者徹底讀懂後,再自己進一步提煉.關於怎麼讀書,我後面會詳細介紹. -
2.問:這麼長,那到底包含了些什麼內容?
答:筆記的目錄結構和書本的完全一致.對每一節的內容進行更通俗的解讀(針對新人),對示例進行更深的說明,有的會輔以流程圖,並提供對應的mdn連線;對內容進行歸納,小節脈絡更清晰;新增了大量實際工作時的注意事項,增加了更加清晰和易懂的示例及註釋,並在原文基礎上進行了擴充和總結;對書中的錯誤和說了後面會進行介紹
,而沒有介紹的填坑,翻譯或者容易引起誤會的稱呼的說明;新增了個人讀書時的感受和吐槽. -
3.問:書已經夠多了,還要看你這麼長的筆記?
答:首先你要知道讀這種技術類書籍,不是讀小說!讀完並不意味著你讀懂了.而是需要將書中的知識轉換成你自己的.這篇筆記就是,幫助新手更方便地理解知識點,更流暢地進行閱讀.也可以在讀完一節後,通過對比,發現自己有什麼知識點是不懂或者遺漏,理解有誤的. 並且一些注意事項,容易被誤導的,關於書中觀點的吐槽等等,其實想說的都已經寫在筆記裡了. -
4.問:這本書到底怎麼樣,有沒有其他人說的那麼好?
答:這是一個先揚後抑的回答.首先毫無疑問這是一本非常不錯的書!它系統地全面地對JavaScript進行解讀,優點缺點全都有.當你徹底讀懂這本書後,你對JavaScript的幾乎所有疑問都會得到解答(我對作用域是不是"物件"的疑問?也得到了解答).但它也是有一定門檻的,如果你對JS不熟,常用介面都不熟,很多名詞的表層意思都不太理解.這本書並不適合你,你花在問谷歌孃的時間可能比你讀書的都長,讀起來也是一知半解;不同於其他書,這本書很多時候沒有給出明確的概念定義,需要你自己反覆閱讀理解他的話.每一小節的脈絡結構也不是那麼清晰,有時候需要自己去梳理;不知道是不是翻譯的鍋,很多東西解釋得有點迷,本來很簡單,但卻說一堆並不常用的術語(可能國內不是這麼叫的),看得你一臉懵逼!有時候同一個概念,前後會出現三四個不同的名詞進行指代,沒有任何說明;整本書,具有很強的作者主觀情感在裡面.前半段,把JS捧得很高,說它引擎的各種優化好!但到後半段關於JavaScript中模擬類和繼承"的批評,說它們具有很大誤導性!更是嗤之以鼻!就差爆粗口了,好像JavaScript就是一個異教徒,應該綁在十字架上被燒死!但是他這樣的觀點,都是站在其他類語言的角度來看待,產生的.我想更多的讀者可能是隻接觸過JavaScript這一種語言,對他們來說,其實是根本沒有這些"疑惑"的!
讀書建議:
-
1.不要抱任何功利和浮躁的心來讀書!
這種以理論,概念為主的書,其實大家都是不那麼願意讀的.一是讀起來很費勁,抽象.二是實際工作,幾乎不會用到,在現在浮躁的前端圈這是吃力不討好.那這本書最大的用處是什麼?沒錯,就是被很多人用來應付面試!? 這本身沒什麼問題,你讀懂系列三本書,所有涉及JS的面試都能輕鬆應對.但是當抱著功利心時,你更多的則是敷衍.對書中的概念進行機械的複製,再貼上上自己膚淺的理解.OK,應付那些也是跟風的面試官足夠了.一般你回答了,他們也不會繼續往下問,問深了自己也不清楚,也不好否定你.如果你夠自信,'瞎扯'也可以唬住.如果你答不上,臉皮厚的會讓你回去自己查.真正知道的面試官,其實都是會給你解釋的,他們也不會忙到差這點時間.其實他們心裡也是很樂意展示自己學識豐富的一面.
這種功利讀書方式,即使你讀完了(更多人是半途而廢),對你的技術也不會有任何幫助.因為讀完,你其實是一知半解的.這樣反而更糟,甚至可能會對你之前JavaScript正確的理解產生混淆. -
2.認認真真讀完一本書好過收藏一百篇相關文章(其實你壓根連一半都不會看)!
我一直認為想系統弄懂一門知識,書本才是最好的選擇,它絕對比你東拼西湊找來的一堆文章要好得多!現在前端圈隨便看看,一大堆全是原型鏈,閉包,this...這些內容.裡面的內容大同小異,很多理解也是比較淺顯,考慮的也比較片面.但浮躁的人就是喜歡這種文章,覺得自己收藏了,看了就徹底理解了(!?).其實這些文章裡有很多都是借鑑了本書.
首先,你必須知道知識都是有體系的,不是完全獨立的.例如想要徹底理解,原型鏈,閉包,this.就必須先弄清作用域和函式.知識都是環環相扣,相互關聯的.如果你想徹底弄懂,還是選擇讀書吧,由淺入深,全面理清所有知識點的關聯.記住 "一知半解"永遠比"無知"更糟!(當然不懂裝懂,還振振有詞的人另當別論).
- 3.如何讀書:先讀厚,再讀薄!
首先先把書讀厚: 將每一節裡的所有知識點弄懂,不留遺漏.記下所有提到的知識點,並將重要的知識點高亮標識(電子書的話).然後在自己本地的MD筆記裡,按照一定的邏輯順序,儘量用自己的話語進行闡述總結這些知識點.如果有讀幾遍也不理解的地方,可以查詢MDN,結合自己的實際工作經驗,或者先圈起來,繼續往下讀,隨著後面理解的深入,前面不懂的地方自然也就明瞭了.這篇讀書筆記就是帶你怎麼把書讀厚.
然後把書讀薄: 這部分需讀者你自己在徹底理解的基礎上,並站在全域性的角度進行歸納去總結.先是按章進行思維導圖式的總結.然後章與章之間進行規律總結,並記住特例.例如:作用域與原型鏈都有一個類似的"就近原則",由於就近原則所以就產生了"遮蔽".這些都是需要自己站在全域性融會貫通的角度去總結.雖然網上有別人總結好的,但我們不應該養成什麼都依賴別人,自己直接複製的習慣(如果你想一直做一個'複製貼上'程式設計師的話).
第一部分 作用域和閉包
第一章 作用域是什麼
1.1 編譯原理
傳統編譯的三個步驟
- 1,分詞/詞法分析(Tokenizing/Lexing) : 這個過程會將由字元組成的字串分解成(對程式語言來說)有意義的程式碼塊,這些程式碼塊被稱為詞法單元(token)。例如,考慮程式var a = 2;。這段程式通常會被分解成 為下面這些詞法單元:var、a、=、2、;。空格是否會被當作詞法單元,取決於空格在 這門語言中是否具有意義。
- 2,解析/語法分析(Parsing): 這個過程是將詞法單元流(陣列)轉換成一個由元素逐級巢狀所組成的代表了程式語法結構的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。var a = 2; 的抽象語法樹中可能會有一個叫作 VariableDeclaration 的頂級節點,接下來是一個叫作Identifier(它的值是a)的子節點,以及一個叫作 AssignmentExpression 的子節點。AssignmentExpression 節點有一個叫作 NumericLiteral(它的值是 2)的子節點。
- 3,程式碼生成: 將 AST 轉換為可執行程式碼的過程稱被稱為程式碼生成。這個過程與語言、目標平臺等息息相關。拋開具體細節,簡單來說就是有某種方法可以將 var a = 2; 的 AST 轉化為一組機器指令,用來建立一個叫作 a 的變數(包括分配記憶體等),並將一個值儲存在 a 中。
說明: 此處只需記住第一步:分詞/詞法分析.第二步:解析/語法分析,得到抽象語法樹(AST).第三步:程式碼生成,將抽象語法樹轉換為機器指令.
JavaScript與傳統編譯的不同點:
- 1,JavaScript 引擎不會有大量的(像其他語言編譯器那麼多的)時間用來進行優化.
- 2,JavaScript與傳統的編譯語言不同,它不是在構建之前提前編譯的,大部分情況下,它是在程式碼執行前的幾微秒(甚至更短)進行編譯.
- 3,JavaScript 引擎用盡了各種辦法(比如 JIT,可以延 遲編譯甚至實施重編譯)來保證效能最佳。
- 4,JavaScript的編譯結果不能在分散式系統中進行移植。
1.2 理解作用域
1.2.1 演員表(程式碼編譯到執行的參與者)
首先介紹將要參與到對程式 var a = 2; 進行處理的過程中的演員們,這樣才能理解接下來將要聽到的對話。
- 引擎 從頭到尾負責整個 JavaScript 程式的編譯及執行過程。
- 編譯器 引擎的好朋友之一,負責語法分析及程式碼生成等髒活累活(詳見前一節的內容)。
- 作用域 引擎的另一位好朋友,負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。
1.2.2 對話(程式碼編譯執行過程)
1.2.3 作用域的LHS查詢和RHS查詢
由上圖可知,引擎在獲得編譯器給的程式碼後,還會對作用域進行詢問變數.
現在將例子改為var a = b;此時引擎會對變數a和變數b都向作用域進行查詢.查詢分為兩種:LHS和RHS.其中L代表左.R代表右.即對變數a進行LHS查詢.對變數b進行RHS查詢.
單單從表象上看.LHS就是作用域對=
左邊變數的查詢.RHS就是作用域對=
右邊變數的查詢.但實際上並不是這麼簡單,首先LHS和RHS都是對變數進行查詢,這也是我為什麼要將例子從var a=2;改為var a=b;兩者的區別是兩者最終要查詢到的東西並不一致.LHS是要查詢到變數的宣告(而不是變數的值),從而後面可以為其賦值.RHS是要查詢到變數最終的值.還有一點,LHS 和 RHS 的含義是“賦值操作的左側或右側”並不一定意味著就是“= 賦值操作符的左側或右側”。賦值操作還有其他幾種形式,因此在概念上最 好將其理解為“賦值操作的目標是誰(LHS)”以及“誰是賦值操作的源頭(RHS)”.或者這樣理解如果這段程式碼需要得到該變數的'源值',則會進行RHS查詢.
1.2.4 引擎和作用域的對話
這部分比較簡單就是通過擬人方式比喻引擎和作用域的合作過程.一句話概括就是,引擎進行LHS和RHS查詢時都會找作用域要.
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
複製程式碼
讓我們把上面這段程式碼的處理過程想象成一段對話,這段對話可能是下面這樣的。
引擎:我說作用域,我需要為 foo 進行 RHS 引用。你見過它嗎?
作用域:別說,我還真見過,編譯器那小子剛剛宣告瞭它。它是一個函式,給你。
引擎:哥們太夠意思了!好吧,我來執行一下 foo。
引擎:作用域,還有個事兒。我需要為 a 進行 LHS 引用,這個你見過嗎?
作用域:這個也見過,編譯器最近把它聲名為 foo 的一個形式引數了,拿去吧。
引擎:大恩不言謝,你總是這麼棒。現在我要把 2 賦值給 a。
引擎:哥們,不好意思又來打擾你。我要為 console 進行 RHS 引用,你見過它嗎?
作用域:我們倆誰跟誰啊,再說我就是幹這個。這個我也有,console 是個內建物件。 給你。
引擎:麼麼噠。我得看看這裡面是不是有 log(..)。太好了,找到了,是一個函式。
引擎:哥們,能幫我再找一下對 a 的 RHS 引用嗎?雖然我記得它,但想再確認一次。
作用域:放心吧,這個變數沒有變動過,拿走,不謝。
引擎:真棒。我來把 a 的值,也就是 2,傳遞進 log(..)。
1.3作用域巢狀
當一個塊或函式巢狀在另一個塊或函式中時,就發生了作用域的巢狀。進而形成了一條作用域鏈.因此,在當前作用 域中無法找到某個變數時,引擎就會在外層巢狀的作用域中繼續查詢,直到找到該變數, 或抵達最外層的作用域(也就是全域性作用域)為止。
當引擎需要對作用域進行查詢時.引擎會從當前的執行作用域開始查詢變數,如果找不到, 就向上一級繼續查詢。當抵達最外層的全域性作用域時,無論找到還是沒找到,查詢過程都 會停止。
1.4 異常
例子:
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
複製程式碼
- 如果 RHS 查詢在所有巢狀的作用域中遍尋不到所需的變數,引擎就會丟擲 ReferenceError 異常。例如上面例子中
console.log(a+b)
由於RHS此時是找不到b的值.故會丟擲ReferenceError. - 如果 RHS 查詢找到了一個變數,但是你嘗試對這個變數的值進行不合理的操作, 比如試圖對一個非函式型別的值進行函式呼叫,或著引用 null 或 undefined 型別的值中的 屬性,那麼引擎會丟擲另外一種型別的異常,叫作 TypeError。
- 當引擎執行 LHS 查詢時,如果在頂層(全域性作用域)中也無法找到目標變數,全域性作用域中就會建立一個具有該名稱的變數,並將其返還給引擎,前提是程式執行在非 “嚴格模式”下。例如上面例子中的
b=a;
. - 在嚴格模式中 LHS 查詢失敗時,並不會建立並返回一個全域性變數,引擎會丟擲同 RHS 查詢 失敗時類似的 ReferenceError 異常。
1.5 LHS與RHS小結
- LHS和RHS查詢都是引擎對作用域的查詢
- LHS和RHS查詢都是隻對變數進行查詢
- LHS和RHS都會沿著作用域鏈進行查詢,直到最上層的全域性作用域.如果沒找到的話,在非嚴格模式下,LHS則會在全域性建立一個相同名稱的變數.RHS則會丟擲ReferenceError的異常.
- 如果查詢的目的是對變數進行賦值,那麼就會使用 LHS 查詢;如果目的是獲取變數的值,就會使用 RHS 查詢。
- LHS只是找到變數的容器而已,方便進行賦值
=
操作符或呼叫函式時傳入引數的操作都會導致關聯作用域的賦值操作。此時都會進行LHS查詢- RHS查詢則需要找到變數的值.
第二章 詞法作用域
作用域分為兩種工作模式:
- 1,詞法作用域.是目前最為普遍的,被大多數程式語言所採用的模式.當然JavaScript也是使用的詞法作用域.
- 2,動態作用域.使用較少,比如 Bash 指令碼、Perl 中的一些模式等.
2.1 詞法階段
詞法階段: 大部分標準語言編譯器的第一個工作階段叫作詞法化(也叫單詞化)。詞法化的過程會對原始碼中的字元進行檢查,如果是有狀態的解析過程,還會賦予單詞語義。
詞法作用域: 詞法作用域就是定義在詞法階段的作用域也被稱為靜態作用域。即在JavaScript裡作用域的產生是在編譯器出來的第一階段詞法階段產生的,並且是你在書寫完程式碼時就已經確定了的.
詞法作用域位置: 詞法作用域位置範圍完全由寫程式碼期間函式所宣告的位置來決定.
理解詞法作用域及巢狀: 看下例子:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
複製程式碼
在這個例子中有三個逐級巢狀的作用域。為了幫助理解,可以將它們分成3個逐級包含的"氣泡作用域"。
- 1:包含著整個全域性作用域,其中只有一個識別符號:foo。
- 2:包含著 foo 所建立的作用域,其中有三個識別符號:a、bar 和 b。
- 3:包含著 bar 所建立的作用域,其中只有一個識別符號:c。
注意: 沒有任何函式的氣泡可以(部分地)同時出現在兩個外部作用域的氣泡中,就如同沒有任何函式可以部分地同時出現在兩個父級函式中一樣。
引擎對作用域的查詢:
這一部分在上一節中已經說過,就是從當前作用域逐級向上,直到最上層的全域性作用域.這裡再進一步進行講解.作用域查詢會在找到第一個匹配的識別符號時停止。在多層的巢狀作用域中可以定義同名的識別符號,這叫作“遮蔽效應”(內部的識別符號“遮蔽”了外部的識別符號)。拋開遮蔽效應, 作用域查詢始終從執行時所處的最內部作用域開始,逐級向外或者說向上進行,直到遇見第一個匹配的識別符號為止。
注意:
- 全域性變數會自動成為全域性物件(比如瀏覽器中的 window物件)的屬性,因此可以不直接通過全域性物件的詞法名稱,而是間接地通過對全域性物件屬性的引 用來對其進行訪問。例如:
window.a
通過這種技術可以訪問那些被同名變數所遮蔽的全域性變數。但非全域性的變數如果被遮蔽了,無論如何都無法被訪問到。 - 詞法作用域查詢只會查詢一級識別符號,比如 a、b 和 c。如果程式碼中引用了 foo.bar.baz,詞法作用域查詢只會試圖查詢 foo 識別符號,找到這個變數後,物件屬性訪問規則會分別接管對 bar 和 baz 屬性的訪問。
2.2 欺騙詞法
欺騙詞法: 引擎在執行時來“修改”(也可以說欺騙)詞法作用域.或者說就是在引擎執行時動態地修改詞法作用域(本來在編譯詞法化就已經確定的).
欺騙詞法的兩種機制:(下面這兩種機制理解了解即可,不推薦實際開發使用)
2.2.1 eval
JavaScript 中的 eval(..) 函式可以接受一個字串為引數,並將其中的內容視為好像在書寫時就存在於程式中這個位置的程式碼。即將eval放在該詞法作用域,然後eval攜帶的程式碼就會動態加入到該詞法作用域.
通過下面的例子加深理解:
function foo(str, a) {
eval( str ); // 欺騙!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
複製程式碼
eval(..) 呼叫中的 "var b = 3;" 這段程式碼會被當作本來就在那裡一樣來處理。由於那段程式碼宣告瞭一個新的變數 b,因此它對已經存在的 foo(..) 的詞法作用域進行了修改。當 console.log(..) 被執行時,會在 foo(..) 的內部同時找到 a 和 b,但是永遠也無法找到外部的 b。因此會輸出“1, 3”而不是正常情況下會輸出的“1, 2”。
注意:
- eval(..) 通常被用來執行動態建立的程式碼.可以據程式邏輯動態地將變數和函式以字元形式拼接在一起之後傳遞進去。
- 在嚴格模式下,eval(...)無法修改所在的作用域。
- 與eval(...)類似,setTimeout(..)和 setInterval(..) 的第一個引數可以是字串,字串的內容可以被解釋為一段動態生成的函式程式碼。
- new Function(..) 函式的行為也很類似,最後一個引數可以接受程式碼字串,並將其轉化為動態生成的函式(前面的引數是這個新生成的函式的形參)。這種構建函式的語法比 eval(..) 略微安全一些,但也要儘量避免使用。
var sum = new Function("a", "b", "return a + b;");
console.log(sum(1, 1111)); //1112
複製程式碼
2.2.2 with(不推薦實際使用)
例子:
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被洩漏到全域性作用域上了!
複製程式碼
起初你會覺得o1的a屬性被with裡的a進行了詞法引用被遮蔽了成為了2.而o2沒有a屬性,此時with不能進行詞法引用,所以此時o2.a就會變成undefined.但是,為什麼最後console.log(a)會為2?因為在執行foo(o2)時,with會對其中的a=2進行LHS查詢,但它在o2作用域,foo()作用域,全域性作用域都沒找到,因此就建立了一個全域性變數a並隨後賦值2.
總的來說,with就是將一個沒有或有多個屬性的物件處理為一個完全隔離的詞法作用域,因此這個物件的屬性也會被處理為定義在這個作用域中的詞法識別符號。
注意: 使用 eval(..) 和 with 的原因是會被嚴格模式所影響(限制)。with 被完全禁止,而在保留核心功能的前提下,間接或非安全地使用 eval(..) 也被禁止了。
2.2.3 效能
JavaScript 引擎會在編譯階段進行數項的效能優化。其中有些優化依賴於能夠根據程式碼的詞法進行靜態分析,並預先確定所有變數和函式的定義位置,才能在執行過程中快速找到識別符號。但是eval(..) 和 with會在執行時修改或建立新的作用域,以此來欺騙其他在書寫時定義的詞法作用域。這麼做就會導致引擎無法知道eval和with它們對詞法作用域進行什麼樣的改動.只能對部分不進行處理和優化!因此如果程式碼中大量使用 eval(..) 或 with,那麼執行起來一定會變得非常慢!。
2.3 小結
- 詞法作用域是在你書寫程式碼時就已經決定了的.在編譯的第一階段詞法分析階段產生詞法作用域.此時詞法作用域基本能夠知道全部識別符號在哪裡以及是如何宣告的,從而能夠預測在執行過程中如何對它 們進行查詢。
- eval(..) 和 with。前者可以對一段包含一個或多個宣告的“程式碼”字串進行演算,並藉此來修改已經存在的詞法作用域(在執行時)。後者本質上是通過將一個物件的引用當作作用域來處理,將物件的屬性當作作用域中的識別符號來處理,從而建立了一個新的詞法作用域(同樣是在執行時)。
- 一般不要在實際程式碼中使用eval(...)和with,因為不僅危險,而且會造成效能問題!
第三章 函式作用域和塊作用域
3.1 函式中的作用域
- JavaScript 具有基於函式的作用域,一般情況下每宣告 一個函式都會建立一個函式作用域.
- 函式作用域的含義是指,屬於這個函式的全部變數都可以在整個函式的範圍內使用及複用(事實上在巢狀的作用域中也可以使用)。這樣的好處是JavaScript 變數可以根據需要改變值型別。
3.2 隱藏內部實現
因為
- 子級函式作用域可以直接訪問父級函式作用域裡的識別符號;
- 父級函式作用域不能直接訪問子級函式作用域裡的識別符號.
所以用函式宣告對程式碼進行包裝,實際上就是把這些程式碼“隱藏”起來了。
為什麼要將程式碼進行"隱藏"? 因為最小授權或最小暴露原則。這個原則是指在軟體設計中,應該最小限度地暴露必 要內容,而將其他內容都“隱藏”起來,比如某個模組或物件的 API 設計。 隱藏的好處:
- 實現程式碼私有化,減少外部對內部程式碼的干擾,保持其穩定性.
- 規避衝突: 可以避免同名識別符號之間的衝突, 兩個識別符號可能具有相同的名字但用途卻不一樣,無意間可能造成命名衝突。衝突會導致 變數的值被意外覆蓋。那麼一般規避衝突的手段有哪些?
-
- 全域性名稱空間: 變數衝突的一個典型例子存在於全域性作用域中。當程式中載入了多個第三方庫時,如果它們沒有妥善地將內部私有的函式或變數隱藏起來,就會很容易引發衝突。這些庫通常會在全域性作用域中宣告一個名字足夠獨特的變數,通常是一個物件。這個物件被用作庫的名稱空間,所有需要暴露給外界的功能都會成為這個物件(名稱空間)的屬性,而不是將自己的識別符號暴漏在頂級的詞法作用域中。
- 2.模組管理: 另外一種避免衝突的辦法和現代的模組機制很接近,就是從眾多模組管理器中挑選一個來 使用。實際上就是我們常用的amd,commonjs,import模組機制.
-
3.3 函式作用域
函式宣告與函式表示式:
function foo() {
...
}
複製程式碼
我們知道函式foo內的變數和函式被隱藏起來了,是不會對全域性作用域造成汙染.但是變數名foo仍然存在於全域性作用域中,會造成汙染.那有什麼方法能避免函式名的汙染呢?那就是作為函式表示式,而不是一個標準的函式宣告.這樣函式名只存在於它自己的函式作用域內,而不會存在於其父作用域,這樣就沒有了汙染.舉個函式宣告的例子:
var a = 2;
(function foo(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
複製程式碼
當我們用()包裹一個函式,並立即執行.此時這個包裝函式宣告是從(function
開始的而不是從function關鍵字開始.這樣foo就會被當做一個函式表示式,而不是一個函式宣告(即foo不會存在於父級作用域中).回到上面的例子中,全域性作用域是訪問不到foo的,foo只存在於它自己的函式作用域中.
補充: 什麼是函式宣告和函式表示式 首先我們得了解JS宣告函式的三種方式:
- 函式表示式(Function Expression): 將函式定義為表示式語句(通常是變數賦值,也可以是自呼叫形式)的一部分。通過函式表示式定義的函式可以是命名的,也可以是匿名的。因為它可以沒有函式名,因此常被用作匿名函式.如果有,其函式名也只存在自身的函式作用域.並且函式表示式不能以“function”開頭.函式表示式可以儲存在變數或者物件屬性裡. (在函式宣告前加上運算子是可以將其轉化為函式表示式的.例如
!
,+
,-
,()
.舉個例子:!function(){console.log(1)}()
的結果是1,並不會報錯) - 函式宣告(Function Declaration): 函式宣告是一種獨立的結構,它會宣告一個具名函式,並必須以function開頭. 且函式宣告會進行函式提升.使它能在其所在作用域的任意位置被呼叫,即後面的程式碼中可以將此函式通過函式名賦值給變數或者物件屬性.
- Function()構造器: 即使用Function構造器建立函式.不推薦這種用法, 容易出問題
//Function()構造器
var f =new Function()
// 函式表示式
var f = function() {
console.log(1);
}
// 函式宣告
function f (){
console.log(2);
}
console.log(f())
//思考一下,這裡會列印出什麼
複製程式碼
怎麼區分函式宣告和函式表示式:
看 function 關鍵字出現在宣告中的位置(不僅僅是一行程式碼,而是整個宣告中的位置)。如果 function 是宣告中的第一個詞,那麼就是一個函式宣告,否則就是一個函式表示式。例如上例中,是從(
開始而不是function.
補充: 上面這段是原書的解釋,我覺得這個解釋並不完全,這裡給出我自己的解釋.
- 表象區別:和它說的一樣,只要是以function開頭進行宣告,並且含有函式名的就一定是函式宣告.
- 內在區別:其實我在上面補充兩者的定義時已經說得很清楚了,我再對比總結下.
- 函式提升:函式宣告,會將整個函式進行提升.而函式表示式則不會提升,它是在引擎執行時進行賦值,且要等到表示式賦值完成後才能呼叫。
- 函式表示式是可以沒有函式名的,如果有,它的函式名也只存在於自身的作用域,
var f = function fun(){console.log(fun)}
其他地方是沒有的.這也避免了全域性汙染,也方便遞迴.
3.3.1 匿名和具名
函式表示式可以是匿名的,而函式宣告則不可以省略函式名.有函式名的就是具名函式,沒有函式名的就是匿名函式.
匿名函式的缺點:
-
- 匿名函式在棧追蹤中不會顯示出有意義的函式名,使得除錯很困難。
-
- 如果沒有函式名,當函式需要引用自身時只能使用已經過期的arguments.callee引用,比如在遞迴中。另一個函式需要引用自身的例子,是在事件觸發後事件監聽器需要解綁自身。
-
- 匿名函式省略了對於程式碼可讀性/可理解性很重要的函式名。一個描述性的名稱可以讓程式碼不言自明。
所以給函式表示式指定一個函式名可以有效解決以上問題。始終給函式表示式命名是一個最佳實踐.
PS: 個人意見是如果函式表示式有賦值給變數或屬性名或者就是一次性呼叫的.其實是沒必要加上函式名.因為程式碼裡取名本來就很難,取不好反而會造成誤解.
3.3.2 立即執行函式表示式
比如 (function foo(){ .. })()。第一個 ( ) 將函式變成表示式,第二個 ( ) 執行了這個函式。這就是立即執行函式表示式,也被稱為IIFE,代表立即執行函式表示式 (Immediately Invoked Function Expression);
IIFE可以具名也可以匿名.好處和上面提到的一樣.IIFE還可以是這種形式(function(){ .. }())
.這兩種形式在功能上是一致的。
3.4 塊作用域
函式作用域是JavaScript最常見的作用域單元,有時我們僅會將var賦值變數在if或for的{...}內使用,而不會在其他地方使用.但它仍然會對外層的函式作用域造成汙染.這個時候就會希望能有一個作用域能將其外部的函式作用域隔開,宣告的變數僅在此作用域有效.塊作用域(通常就是{...}包裹的內部)就可以幫我們做到這點.
從 ES3 釋出以來,JavaScript 中就有了塊作用域,而 with 和 catch 分句就是塊作用域的兩個小例子。
3.4.1 with
我們在第 2 章討論過 with 關鍵字。它不僅是一個難於理解的結構,同時也是塊作用域的一個例子(塊作用域的一種形式),用 with 從物件中建立出的作用域僅在 with 宣告中而非外部作用域中有效。
3.4.2 try/catch
try/catch 的 catch 分句會建立一個塊作用域,其中宣告的變數僅在 catch 內部有效。
try {
undefined(); // 執行一個非法操作來強制製造一個異常
}
catch (err) {
console.log( err ); // 能夠正常執行!
}
console.log( err ); // ReferenceError: err not found
複製程式碼
err 僅存在 catch 分句內部,當試圖從別處引用它時會丟擲錯誤。 那麼如果我們想用catch建立一個不是僅僅接收err的塊作用域,該怎麼做呢?
try{throw 2;}catch(a){
console.log( a ); // 2
}
console.log( a ); // ReferenceError
複製程式碼
這樣就建立了一個塊作用域,且a=2,僅在catch分句中存在.在ES6之前我們可以使用這種方法來使用塊作用域.
3.4.3 let
ES6 引入了新的 let 關鍵字,提供了除 var 以外的另一種變數宣告方式。let 關鍵字可以將變數繫結到所在的任意作用域中(通常是 { .. } 內部)。
用 let 將變數附加在一個已經存在的塊作用域上的行為是隱式的。例如在if的{...}內用let宣告一個變數.那什麼是顯式地建立塊作用域呢?就是單獨建立{}
來作為let的塊作用域.而不是借用if或者for提供的{}
.例如{let a=2;console.log(a)}
注意: 使用 let 進行的宣告不會在塊作用域中進行提升.
塊作用域的好處:
- 1,垃圾收集
function process(data){
// 在這裡做點有趣的事情
}
var someReallyBigData=function(){
//dosomeing
}
process(someReallyBigData);
var btn=document.getElementById("my_button");
btn.addEventListener("click",function click(evt){
alert("button click");
//假如我們在這裡繼續呼叫someReallyBigData就會形成閉包,導致不能垃圾回收(這段是書裡沒有,我加上方便理解的)
},false);
複製程式碼
click 函式的點選回撥並不需要 someReallyBigData 變數。理論上這意味著當 process(..) 執行後,在記憶體中佔用大量空間的資料結構就可以被垃圾回收了。但是,由於 click 函式形成了一個覆蓋整個作用域的閉包,JavaScript 引擎極有可能依然儲存著這個結構(取決於具體實現)。 但顯式使用塊作用域可以讓引擎清楚地知道沒有必要繼續儲存 someReallyBigData 了:
function process(data){
// 在這裡做點有趣的事情
}
// 在這個塊中定義的內容可以銷燬了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn=document.getElementById("my_button");
btn.addEventListener("click",function click(evt){
alert("button click");
},false);
複製程式碼
-
- let迴圈
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
複製程式碼
for 迴圈頭部的 let 不僅將 i 繫結到了 for 迴圈的塊中,事實上它將其重新繫結到了迴圈的每一個迭代中,確保使用上一個迴圈迭代結束時的值重新進行賦值。這樣就避免了i對外部函式作用域的汙染.
3.4.4 const
除了 let 以外,ES6 還引入了 const,同樣可以用來建立塊作用域變數,但其值是固定的(常量)。之後任何試圖修改值的操作都會引起錯誤。
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在 if 中的塊作用域常量
a = 3; // 正常!
b = 4; // 錯誤!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
複製程式碼
3.5 小結
函式是 JavaScript 中最常見的作用域單元。本質上,宣告在一個函式內部的變數或函式會在所處的作用域中“隱藏”起來,可以有效地與外部作用域隔開.
但函式不是唯一的作用域單元。塊作用域指的是變數和函式不僅可以屬於所處的作用域,也可以屬於某個程式碼塊(通常指 { .. } 內部)即塊作用域。ES6中就提供了let和const來幫助建立塊作用域.
第四章 提升
4.1 先有雞(賦值)還是先有蛋(宣告)
考慮第一段程式碼
a = 2;
var a;
console.log( a );
複製程式碼
輸出結果是2,而不是undefined
考慮第二段程式碼
console.log( a );
var a = 2;
複製程式碼
輸出結果是undefined,而不是ReferenceError 考慮完以上程式碼,你應該會考慮這個問題.到底是宣告(蛋)在前,還是賦值(雞)在前?
4.2 編譯器再度來襲
編譯器的內容,回憶一下,引擎會在解釋 JavaScript 程式碼之前首先對其進行編譯。編譯階段中的一部分工作就是找到所有的宣告,並用合適的作用域將它們關聯起來。 之後引擎會詢問作用域,對宣告進行賦值操作.
那麼,在編譯階段找到所有的宣告後,編譯器又做了什麼?答案就是提升
以上節的第一段程式碼為例,當你看到 var a = 2; 時,可能會認為這是一個宣告。但 JavaScript 實際上會將其看成兩個宣告:var a;和a = 2;。 第一個定義宣告是在編譯階段進行的。第二個賦值宣告會被留在原地等待執行階段。在第一個宣告在編譯階段時,編譯器會對var a;
宣告進行提升(即把var a;
置於所在作用域的最上面).而a = 2;
則會保持所在位置不動.此時程式碼會變成
var a;
a = 2;
console.log( a );
複製程式碼
由此可知,在編譯階段,編譯器會對宣告進行提升.即先有蛋(宣告)後有雞(賦值)。 哪些宣告會被進行提升?
- 變數宣告:例如上例中的
var a;
.不包括後面的a = 2;
即不包含有賦值操作的宣告. - 函式宣告:注意是函式宣告,而不是函式表示式!(不清楚可以看前面的3.3節,我有詳細說明).函式宣告提升,是將整個函式進行提升,而不是僅僅函式名的提升.
4.3 函式優先
函式宣告和變數宣告都會被提升。但是一個值得注意的細節(這個細節可以出現在有多個“重複”宣告的程式碼中)是函式會首先被提升,然後才是變數。 考慮以下程式碼:
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
複製程式碼
會輸出 1 而不是 2 !這個程式碼片段會被引擎理解為如下形式:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
複製程式碼
注意,var foo 儘管出現在 function foo()... 的宣告之前,但它是重複的宣告(因此被忽略了),因為函式宣告會被提升到普通變數之前。
注意: js會忽略前面已經宣告的宣告(不管是變數宣告還是函式宣告,只要其名稱相同,則後續不會再進行重複宣告).但是對該變數新的賦值,會覆蓋之前的值.
一句話概括:函式宣告的優先順序高於變數宣告,會排在它前面.
4.4 小結
- 對於
var a = 2
JavaScript引擎會將var a和 a = 2當作兩個單獨的宣告,第一個是編譯階段的任務,而第二個則是執行階段的任務。 - 論作用域中的宣告出現在什麼地方,都將在程式碼本身被執行前首先進行處理。 可以將這個過程形象地想象成所有的宣告(變數和函式)都會被“移動”到各自作用域的最頂端,這個過程被稱為提升。
- 宣告本身會被提升,而包括函式表示式的賦值在內的賦值操作並不會提升(即賦值操作都不會提升)。
- 注意:,當普通的 var 宣告和函式宣告混合在一起的時候,並且宣告相同時(var的變數名和函式名相同時,會引發js對重複宣告的忽略)!一定要注意避免重複宣告!
第五章 作用域閉包
5.1 啟示
- JavaScript中閉包無處不在,你只需要能夠識別並擁抱它。
- 閉包是基於詞法作用域書寫程式碼時所產生的自然結果,你甚至不需要為了利用它們而有意識地建立閉包。
5.2 實質問題 && 5.3 現在我懂了
因為這兩小節理解透了其實發現書裡也沒講什麼,這裡就進行合併,並補充擴充我自己的理解和總結.
什麼是閉包?(廣義版)
書中解釋: 當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函式是在當前詞法作用域之外執行。
MDN的解釋: 閉包是函式和宣告該函式的詞法環境的組合。
我的解釋(詳細版): 必須包含兩點:
- 1,有函式.由於函式自身的特性,它能訪問所在的詞法作用域.並能儲存外部詞法作用域的變數和函式到自己的函式作用域.
- 2,有該函式所在的詞法環境.其實在JavaScript中任何函式都會處在一個詞法環境中.不管是全域性作用域還是函式作用域.
綜上簡單版就是:MDN的解釋閉包是函式和宣告該函式的詞法環境的組合。
還可以繼續延伸成極簡版:JavaScript中的函式就會形成閉包。
Tips: 注意到上面對詞法作用域
和詞法環境
兩詞的分開使用了嗎?1,
裡此時函式還沒被執行,所以使用的是詞法作用域即靜態作用域.2,
裡,此時函式被執行,此時詞法作用域就會變成詞法環境(包含靜態作用域與動態作用域).所以其實MDN的解釋其實更準確一點,
我們日常使用時所說的閉包(狹義版,嚴格意義上的):
為了便於對閉包作用域的觀察和使用.我們實際使用時會將閉包的函式作用域暴露給當前詞法作用域之外.也就是本書一直強調的閉包函式需要在它本身的詞法作用域以外執行.作者認為符合這個條件才稱得上是真正的閉包(也就是我們日常使用常說的'使用閉包',並且使用任何回撥函式其實也是閉包).
所以狹義版就是:閉包是函式和宣告該函式的詞法環境的組合,並且將閉包的函式作用域暴露給當前詞法作用域之外.
閉包暴露函式作用域的三種方式:
下面部分是書中沒有的,是自己實際使用時的總結,並且符合這三種形式之一的就是我們日常使用時所說的閉包(狹義版)
- 1,通過外部函式的引數進行暴露.
function foo() {
var a = 2;
function bar() {
baz(a) //通過外部函式的引數進行暴露
}
bar();
};
function baz(val) {
console.log( val ); // 2
}
foo();
複製程式碼
- 2,通過外部作用域的變數進行暴露
var val;
function foo() {
var a = 2;
function bar() {
val=a //通過外部作用域的變數進行暴露
}
bar();
};
foo();
console.log(val) //2
複製程式碼
- 3,通過return直接將整個函式進行暴露
function foo() {
var a = 2;
function bar() {
console.log(a)
}
return bar //通過return直接將整個函式進行暴露
};
var val=foo();
val() //2
複製程式碼
關於閉包的記憶體洩露問題:
首先必須宣告一點:使用閉包並不一定會造成記憶體洩露,只有使用閉包不當才可能會造成記憶體洩露.(吐槽:面試很多新人時,張口就說閉包會造成記憶體洩露)
為什麼閉包可能會造成記憶體洩露呢?原因就是上面提到的,因為它一般會暴露自身的作用域給外部使用.如果使用不當,就可能導致該記憶體一直被佔用,無法被JS的垃圾回收機制回收.就造成了記憶體洩露.
注意: 即使閉包裡面什麼都沒有,閉包仍然會隱式地引用它所在作用域裡的所用變數. 正因為這個隱藏的特點,閉包經常會發生不易發現的記憶體洩漏問題.
常見哪些情況使用閉包會造成記憶體洩露:
- 1,使用定時器未及時清除.因為計時器只有先停止才會被回收.所以決辦法很簡單,將定時器及時清除,並將造成記憶體的變數賦值為null(變成空指標)
- 2,相互迴圈引用.這是經常容易犯的錯誤,並且也不容易發現.舉個例子:
function foo() {
var a = {};
function bar() {
console.log(a);
};
a.fn = bar;
return bar;
};
複製程式碼
這裡建立了一個a 的物件,該物件被內部函式bar引用。然後,a建立了一個屬性fn指向了bar,最後返回了innerFn()。這樣就形成了bar和a的相互迴圈引用.可能有人說bar裡不使用console.log(a)
不就沒有引用了嗎就不會造成記憶體洩露了.NONONO,bar作為一個閉包,即使它內部什麼都沒有,foo中的所有變數都還是隱使地被 bar所引用。這個知識點是我前面忘記提到的,也是書中沒有提到的.算了我現在加到前面去吧.所以即使bar內什麼都沒有還是造成了迴圈引用,那真正的解決辦法就是,不要將a.fn = bar
.
- 3,將閉包引用到全域性變數上.因為全域性變數是隻有當頁面被關閉的時候才會被回收.
- 4,在閉包中對DOM進行不當的引用.這個常見於老IE瀏覽器,現代瀏覽器已經長大了,已經學會了自己處理這種情況了.這裡就不贅述了.想知道的可以自行問谷娘和度娘.
總而言之,解決辦法就是使閉包的能正常引用,能被正常回收.如果實在不行,就是在使用完後,手動將變數賦值null,強行進行垃圾回收.
5.4 迴圈和閉包
看如下例子:
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
複製程式碼
我們期望的結果是分別輸出數字 1~5,每秒一次,每次一個。
但實際結果是,這段程式碼在執行時會以每秒一次的頻率輸出五次 6。
(關於書裡的解釋,我覺得有點說複雜了,沒說到點子上,下面是我的解釋.)
為什麼會是這樣的結果?
timer毫無疑問是一個閉包,它是可以訪問到外部的變數i.在進行for迴圈時,timer()會被重複執行5次,也就是它會 console.log( i )5次.(關鍵部分來了!)這5次i
其實是同一個i
.它是來自於外部作用域,即for裡面宣告的i.在詞法作用域中變數i只可能對應一個唯一的值,即變數和它的值是一一對應的.不會變化的.那這個值到底是多少呢?這個值就是最終值! i的最終值就是6即for迴圈完後i
的值.當引擎執行console.log( i )
時,它會詢問i所對應的作用域,問它i的值是多少.這個時候作用域進行RHS查詢得到的結果就是最終值6.
為什麼我們會以為分別輸出1~5?
因為在for迴圈中,我們錯以為每一次迴圈時,函式所輸出的i是根據迴圈動態變化的.即是1~5累加變化的.但實際上它所訪問的i是同一個固定不變的值,即最終值6.可能你會有這樣的疑惑,那我迴圈還有意義嗎?i其實一開始就確定是6了.沒有變化過!錯!i
變化過,它的確是從1逐步增加到6的.只是外部作用域的i值只可能是迴圈完後的最終值,並且函式timer()並沒有儲存每次i變化的值.它只是訪問了外部作用域的i值即最終的值6. OK我們知道了出錯的地方,就是我們沒有把每次i的值儲存在一個獨立的作用域中.
接下來,看下這個改進的例子結果是多少.
for (var i=1; i<=5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
}
複製程式碼
它的最終值仍然是5個6.為什麼?我們來分析下,上例中,它用了一個匿名函式包裹了定時器,並立即執行.在進行for迴圈時,會創造5個獨立的函式作用域(由匿名函式建立的,因為它是閉包函式).但是這5個獨立的函式作用域裡的i也全都是對外部作用域的引用.即它們訪問的都是i的最終值6.這並不是我們想要的,我們要的是5個獨立的作用域,並且每個作用域都儲存一個"當時"i
的值.
解決辦法: 那我們這樣改寫.
for (var i=1; i<=5; i++) {
(function () {
var j =i;
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})();
}
//這次終於結果是分別輸出數字 1~5,每秒一次,每次一個。
複製程式碼
這樣改寫後,匿名函式每次都通過j儲存了每次i值,這樣i
值就通過j儲存在了獨立的作用域中.注意此時儲存的i值是'當時'的值,並不是迴圈完後的最終值.這樣迴圈完後,實際上就建立了5個獨立的作用域,每個作用域都儲存了一個'當時'i的值(通過j).當引擎執行console.log( j )
詢問其對應的獨立作用域時,得到的值就是'當時'儲存的值,再也不是6了.
我們還可以進一步簡寫為這樣:
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})(i);
}
//結果是分別輸出數字 1~5,每秒一次,每次一個。
複製程式碼
利用塊作用域進行解決:
在es6中,我們不僅可以使用函式來建立一個獨立的作用域,我們還可以使用let宣告來建立一個獨立的塊作用域(在{}
內).所以我們還可以這樣改寫:
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
//結果是分別輸出數字 1~5,每秒一次,每次一個。
複製程式碼
這樣改寫,在每次迴圈時,let都會對i進行宣告.並通過迴圈自帶的{}
建立一個獨立的塊作用域.並且let宣告的i,儲存了'當時'i
的值在當前塊作用域裡.因此當引擎執行console.log( i )
時,它會詢問對應的塊作用域上i的值,得到的結果就是'當時'儲存的值.
延伸:
實際上塊作用域可以稱得上一個'偽'閉包(之所以是偽,是因為閉包規定了只能是函式).因為它幾乎擁有閉包的所有特性.它也可以建立一個獨立的作用域,同樣外部作用域不能訪問塊作用域的變數.但塊作用域可以訪問外部作用域.舉個例子:
function foo() {
var a = 2;
{ //通過{} 顯示錶示塊作用域
let b = a;
console.log('塊作用域內',b) //2
}
console.log('塊作用域外',b) //b is not defined
}
foo()
複製程式碼
說了相同點,說說不同點:1,儲存變數到塊作用域,必須通過let宣告.2,塊作用域不能和函式一樣有名稱(函式名) 很多不方便使用閉包或者比較麻煩的時候,是可以考慮通過塊作用域進行解決.
總結一下一般什麼時候考慮使用閉包:
這部分也是自己工作使用的總結,如果有補充或者不對的地方,歡迎留言指正.
- 1,需要建立一個獨立的作用域並隱藏一些變數或函式,不被外部使用;或者想儲存一些外部作用域的變數或函式到這個獨立作用域.
- 2,只想暴露一部分自身作用域的變數或函式給外部使用.
5.5 模組
首先看下面的例子:
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
複製程式碼
首先我們對上面這段程式碼進成分行分析:
私有資料變數:something, another
內部函式:doSomething, doAnother
直接說結論,上面這個例子就是模組模式.它return返回的這個物件也就是模組也被稱為公共API(至少書中是這樣稱呼的).CoolModule()就是模組構造器或者叫模組函式.
注意:
- 這裡的模組和我們所說的模組化開發不是完全一樣的!
- 模組不一定非要是標準物件,也可以是一個函式,函式本質上也是物件,函式也可以有自己的屬性.
- 書中有這樣一句話
CoolModule() 只是一個函式,必須要通過呼叫它來建立一個模組例項。如果不執行外部函式,內部作用域和閉包都無法被建立。
我覺得這句話有必要延伸說一下.函式呼叫一次就會建立一個該函式的作用域(不呼叫就不會建立),包括建立它裡面的變數和函式.
模組模式:
模組模式需要具備以下2個條件:(這裡結合上面的例子,對書中的定義進行說明方便理解)
- 1, 必須有外部的封閉函式(即CoolModule),該函式必須至少被呼叫一次(每次呼叫都會建立一個新的模組例項-->模組例項指的就是函式return返回的物件)。
- 2, 封閉函式(即CoolModule)必須返回至少一個內部函式(即doSomething, doAnother),這樣內部函式才能在私有作用域中形成閉包,並且可以訪問或者修改私有的狀態(即something, another)。
模組:
表面上看由模組函式(例子中的CoolModule)所返回的物件就是模組.但模組還必須還包含模組函式的內部函式(即閉包函式).只有包含了才能真正稱得上是模組.才強調一次這裡的模組與模組化裡的模組是有區別的,也不是nodejs裡的模組.
模組函式:
模組函式也就是模組構造器,例子中的CoolModule().一般它有兩個常見用法.
- 通過接受引數,對輸出的模組進行修改.
- 通過新增模組裡新增相關的內部函式,實現對輸出模組資料的增刪改查.(書中用
命名將要作為公共API返回的物件
.我覺得命名
應該是用錯了,應該是修改
即增刪改查更好)
5.5.1 現代的模組機制
大多數模組依賴載入器 / 管理器本質上都是將這種模組定義封裝進一個友好的 API。 下面就介紹一個簡單的模組管理器實現例子(對書中的例子進行逐行解讀):
//首先例項化我們的模組管理器,取名myModules
var MyModules=(function Manager() {
//作為我們的模組池,儲存所有定義的模組
var modules={};
/**
*使用類似AMD的方式定義新模組,接收3個引數
*name:模組名
*deps:陣列形式表示所依賴的其他模組
*impl:模組功能的實現
**/
function define(name,deps,impl) {
//遍歷依賴模組陣列的每一項,從程式池中取出對應的模組,並賦值.
//迴圈完後,deps由儲存模組名的陣列變成了儲存對應模組的陣列.
for (var i=0;i<deps.length;i++) {
deps[i]=modules[deps[i]];
}
//將新模組儲存進模組池,並通過apply注入它所依賴的模組(即遍歷後的deps,實際上就是用deps作為impl的入參)
modules[name]=impl.apply(impl,deps);
}
//從模組池中取出對應模組
function get (name) {
return modules[name];
}
//暴露定義模組和獲取模組的兩個api
return {
define: define,
get: get
}
})()
複製程式碼
說明: 後面書中說了這麼一句為了模組的定義引入了包裝函式(可以傳入任何依賴)
,這裡包裝函式指的是Manger(),同樣也是我們上節提到的模組函式.首先說明下什麼是包裝函式.例如函式A當中還有一個函式B.當我們想要呼叫函式B的時候,則需要先呼叫函式A.那麼函式A就叫做函式B的包裝函式.也就是說我們想呼叫某個模組時,需要先呼叫它的包裝函式即這裡的Manger().接著是後面那句並且將返回值,也就是模組的 API,儲存在一個根據名字來管理的模組列表中。
注意這裡的返回值是指impl的返回值.
接著看通過管理器來定義和使用模組
MyModules.define('bar',[],function () {
function hello (who) {
return "Let me introduce: " + who;
}
//返回公共API 即提供一個hello的介面
return {
hello:hello
};
});
MyModules.define('foo',['bar'],function (bar) {
var hungry = "hippo";
functin awesome () {
//這裡的bar為返回模組bar返回的公共API
console.log( bar.hello( hungry ).toUpperCase() );
}
//返回公共API 即提供一個awesome的介面
return {
awesome:awesome
}
})
var bar=MyModules.get('bar');//通過管理器獲取模組'bar'
var foo=MyModules.get('foo');//通過管理器獲取模組'foo'
console.log(
//呼叫模組bar的hello介面
bar.hello( "hippo" )
); // Let me introduce: hippo
//呼叫模組foo的awesome介面
foo.awesome(); // LET ME INTRODUCE: HIPPO
複製程式碼
這節的主要內容還是瞭解現在是如何對模組進行一個規範處理.主要是兩部分內容,一個是通過名稱和依賴合理定義模組並儲存.另一個則是通過名稱對儲存的模組的呼叫.其實還可以再增加一個刪除模組的方法.
5.5.2 未來的模組機制
ok,這節說的模組,就是我們常說的模組化開發.並且主要提到的就是ES6裡常用的import.沒什麼好說的.
5.6 小結
吐槽: 同一個函式概念在5.5這一個小節裡,居然換著花樣蹦出了三個名字!一會叫模組構造器!一會叫模組函式!以及最後的包裝函式!每變化一次,都得想一遍它指的是啥!真的是無力吐槽了!!!!
閉包:當函式可以記住並訪問所在的詞法作用域,並且函式是在當前詞法作用域之外執行,這時 就產生了閉包。
模組有兩個主要特徵:
- (1)為建立內部作用域而呼叫了一個包裝函式(模組構造器的例項化,不想對頻繁換名字吐槽了);
- (2)包裝函式的返回值(也就是模組)必須至少包括一個對內部函式的引用,這樣就會建立涵蓋整個包裝函式內部作用域的閉包。
第二部分
第一章 關於this
1.1 為什麼要用this
因為this 提供了一種更優雅的方式來隱式“傳遞”一個物件(即上下文物件)引用,因此可以將 API 設計得更加簡潔並且易於複用。
1.2 誤解
下面兩種常見的對於 this 的解釋都是錯誤的(看看就好,就不過多解讀了,以免增加了對錯誤的印象)。
1.2.1 指向自身
人們很容易把 this 理解成指向函式自身.
具名函式,可以在它內部可以使用函式名來引用自身進行遞迴,新增屬性等。(這個知識點其實在第三章提過,既然這裡又提了一遍,我也再說一遍.)例如:
function foo() {
foo.count = 4; // foo 指向它自身
}
複製程式碼
匿名函式如果想要呼叫自身則,需要使用arguments.callee
不過這個屬性在ES5嚴格模式下已經禁止了,也不建議使用.詳情可以檢視MDN的說明.
1.2.2 它的作用域
切記: this 在任何情況下都不指向函式的詞法作用域。你不能使用 this 來引用一個詞法作用域內部的東西。 這部分只需記住這一段話就行.
終極疑問: JavaScript裡的作用域到底是物件嗎?
這小節最令我在意的是裡面這句話"在 JavaScript 內部,作用域確實和物件類似,可見的識別符號都是它的屬性。但是作用域“物件”無法通過 JavaScript程式碼訪問,它存在於JavaScript 引擎內部。"它讓我想起了最開始學JS的一個疑問,JavaScript裡的作用域到底是物件嗎.雖然"在JS裡萬物皆物件".但是作用域給人的感覺卻不像是一個物件.更像是一個範圍,由函式的{}
圍城的範圍,限制了其中變數的訪問.但直覺告訴我它和物件還是應該有點聯絡的.直到讀到書中的這段話,更加印證了我的感覺.
在JavaScript裡,作用域其實是一個比較特殊的物件,作用域裡所有可見的識別符號都是它的屬性.只是作用域物件並不能通過JavaScript程式碼被我們訪問,它只存在於JavaScript引擎內部.所以作用域作為一個"物件"是經常被我們忽略.
1.3 this到底是什麼
this 是在執行時(runtime)進行繫結的,並不是在編寫時繫結,它的上下文(物件)取決於函式呼叫時的各種條件。this 的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式。
當一個函式被呼叫時,會建立一個活動記錄(有時候也稱為執行上下文)。這個記錄會包含函式在哪裡被呼叫(呼叫棧)、函式的呼叫方法、傳入的引數等資訊。this 就是記錄的其中一個屬性,會在函式執行的過程中用到。(PS:所以this並不等價於執行上下文)
1.4 小結
- 學習 this 的第一步是明白 this 既不指向函式自身也不指向函式的詞法作用域
- this 實際上是在函式被呼叫時發生的繫結,它指向什麼完全取決於函式在哪裡被呼叫(關於this你必須記住的話)。
第二章 this全面解析
2.1 呼叫位置
通過上節我們知道,this的繫結與函式的呼叫位置有關.那呼叫位置是什麼.呼叫位置就是函式在程式碼中被呼叫的位置(而不是宣告的位置)。
要尋找呼叫位置,最重要的是要分析呼叫棧(就是為了到達當前執行位置所呼叫的所有函式)。我們關心的呼叫位置就在當前正在執行的函式的前一個呼叫中。PS:呼叫棧其實是一個解釋起來有點複雜的概念.這裡我就不過多解釋,這裡推薦一篇文章,解釋得不錯.
這節書裡的例子解釋得不錯,這裡就不復制程式碼了.其實分析呼叫棧只是為了在執行時找到我們關心的函式到底在哪裡和被誰呼叫了. 但是實際別寫程式碼時,其實並不會分析得這麼清楚的,我們還是隻需記住this的指向就是我們呼叫該函式的上下文物件.意思就是我們在哪裡呼叫該函式,this就指向哪裡
.並且檢視呼叫棧還可以通過瀏覽器的開發者工具,只需在疑惑的程式碼上一行加上debugger即可.瀏覽器在除錯模式時,我們就可以在呼叫列表裡檢視呼叫棧.我們一般也僅在查詢bug時,會使用該方法.
2.2 繫結規則
在找到呼叫位置後,則需要判定程式碼屬於下面四種繫結規則中的哪一種.然後才能對this進行繫結.
注意: this繫結的是上下文物件,並不是函式自身也不是函式的詞法作用域
2.2.1 預設繫結
什麼是獨立函式呼叫:對函式直接使用而不帶任何修飾的函式引用進行呼叫.簡單點一個函式直接是func()
這樣呼叫,前面什麼都沒有.不同於通過物件屬性呼叫例如obj.func()
,也沒有通過new關鍵字new Function()
;也沒有通過apply,bind,call強制改變this指向.
預設繫結: 當被用作獨立函式呼叫時(不論這個函式在哪被呼叫,不管全域性還是其他函式內),this預設指向到window;
注意: 如果使用嚴格模式(strict mode),那麼全域性物件將無法使用預設繫結,因此 this 會繫結到 undefined.
2.2.2 隱式繫結
隱式繫結: 函式被某個物件擁有或者包含.也就是函式被作為物件的屬性所引用.例如obj.func()
.此時this會繫結到該物件上.
隱式丟失: 不管是通過函式別名或是將函式作為入參造成的隱式丟失.只需找到它真正的呼叫位置,並且函式前沒有任何修飾也沒有顯式繫結(下節會講到)(非嚴格模式下).那麼this則會進行預設繫結,指向window.
注意: 實際工作中,大部分this使用錯誤都是由對隱式丟失的不理解造成的.記住函式呼叫前沒有任何修飾和顯式繫結(其實就是call、apply、bind),this就指向window
2.2.3 顯式繫結
在分析隱式繫結時,我們必須在一個物件內部包含一個指向函式的屬性,並通過這個屬性間接引用函式,從而把 this 間接(隱式)繫結到這個物件上。如果我們不想在物件內部包含函式引用,而想在某個物件上強制呼叫函式,此時則需要顯式繫結.
顯式繫結: 可以直接指定 this 的繫結物件,被稱之為顯式繫結。基本上就是我們常使用的call、apply、bind方法都是顯式繫結.(如果這三個方法不能熟練使用的,建議找度娘或者谷娘學習後,再看這節.)
注意:
如果你傳入了一個原始值(字串型別、布林型別或者數字型別)來當作 this 的繫結對 象,這個原始值會被轉換成它的物件形式(也就是new String(..)、new Boolean(..)或者 new Number(..))。這通常被稱為“裝箱”。
硬繫結: 使用call、apply、bind方法強制顯式地將this進行繫結,稱之為硬繫結。
硬繫結的典型應用場景就是建立一個包裹函式(其實就是常說的封裝函式),傳入所有的引數並返回接收到的所有值.
在封裝函式中,我們常使用apply.一方面是因為它可以手動繫結this,更重要的是因為可以用apply的第二個引數,方便地注入所有傳入的引數.例如之前提到的modules[name]=impl.apply(impl,deps)
.因為我們不知道傳入的引數有多少個,但我們可以方便地使用一個deps
將其全部注入.另一個常用的是foo.apply( null,argue)
當我們將apply的第一個引數設定為null時,此時this就會預設繫結到window.切記使用這種用法時確保函式foo內沒有使用this. 否則很可能會造成全域性汙染.如果是第三方庫的函式就建議不要使用了,因為你不知道別人的函式是否使用了this(關於這部分內容,下節會繼續提到).還有一種常用就是foo.call( this)
.這樣foo裡的this都會指向當前呼叫的上下文環境.
API呼叫的“上下文”: 第三方庫的許多函式,以及 JavaScript 語言和宿主環境中許多新的內建函式,都提供了一個可選的引數,通常被稱為“上下文”(context),其作用和 bind(..) 一樣,確保你的回撥函式使用指定的 this。
2.2.4 new繫結
JavaScript 中 new 的機制實際上和麵向類的語言完全不同。在 JavaScript 中,建構函式只是一些 使用 new 操作符時被呼叫的函式。它們並不會屬於某個類,也不會例項化一個類。實際上, 它們甚至都不能說是一種特殊的函式型別,它們只是被 new 操作符呼叫的普通函式而已。實際上並不存在所謂的“建構函式”,只有對於函式的“構造呼叫”。
使用 new 來呼叫函式,或者說發生建構函式呼叫時,會自動執行下面的操作。
- 1,建立(或者說構造)一個全新的物件。
- 2,這個新物件會被執行[[原型]]連線。
- 3,這個新物件會繫結到函式呼叫的this。
- 4,如果函式沒有返回其他物件,那麼new表示式中的函式呼叫會自動返回這個新物件。
示例:
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2
複製程式碼
使用 new 來呼叫 foo(..) 時,我們會構造一個新物件並把它繫結到 foo(..) 呼叫中的 this 上。
說明:對於上面這句話進行解釋下,如果在一個函式前面帶上 new 關鍵字來呼叫, 那麼背地裡將會建立一個連線到該函式的 prototype 的新物件,this就指向這個新物件;
2.3 優先順序
直接上結論:
new繫結=顯示繫結>隱式繫結>預設繫結
說明: new繫結與顯示繫結是不能直接進行測試比較,但通過分析發現new繫結內部其實是使用了硬繫結(顯示繫結的一種),所以new繫結和顯示繫結優先順序應該差不多.但話說回來,一般實際使用時,不會這種複雜的交錯繫結.所以只需記住下面的判定即可.
判斷this:
現在我們可以根據優先順序來判斷函式在某個呼叫位置應用的是哪條規則。可以按照下面的順序來進行判斷:
- 1,函式是否在new中呼叫(new繫結)?如果是的話this繫結的是新建立的物件。 var bar = new foo()
- 2,函式是否通過call、apply(顯式繫結)或者硬繫結呼叫?如果是的話,this繫結的是 指定的物件。var bar = foo.call(obj2)
- 3,函式是否在某個上下文物件中呼叫(隱式繫結)?如果是的話,this 繫結的是那個上 下文物件。var bar = obj1.foo()
- 4,如果都不是的話,使用預設繫結。如果在嚴格模式下,就繫結到undefined,否則繫結到 全域性物件。var bar = foo() 就是這樣。對於正常的函式呼叫來說,理解了這些知識你就可以明白 this 的繫結原理了。
2.4 繫結例外
2.4.1 被忽略的this
如果你把 null 或者 undefined 作為 this 的繫結物件傳入 call、apply 或者 bind,這些值在呼叫時會被忽略,實際應用的是預設繫結規則,this會繫結到window上.
使用情景:
一種非常常見的做法是使用 apply(..) 來“展開”一個陣列(也可以用來方便地引數注入),並當作引數傳入一個函式。類似地,bind(..) 可以對引數進行柯里化(預先設定一些引數).通過自帶bind方法實現柯里化是很方便的,比自己寫要簡化好多.
注意:
- 在 ES6 中,可以用 ... 操作符代替 apply(..) 來“展 開”陣列,foo(...[1,2]) 和 foo(1,2)是一樣的,這樣可以避免不必要的 this 繫結。可惜,在 ES6 中沒有柯里化的相關語法,因此還是需要使用 bind(..)。
- 當使用null或者undefined進行繫結時,要確保該函式內沒有使用this,否則此時很容易對全域性變數造成破壞!尤其是使用第三方庫的方法!
更安全的this
如果函式內使用了this,直接使用null則可能會對全域性造成破壞.因此我們可以通過建立一個“DMZ”(demilitarized zone,非軍事區)物件——它就是一個空的非委託的物件(委託在第 5 章和第 6 章介紹)。讓this繫結到這個"DMZ上.這樣就不會對全域性造成破壞.
怎麼建立DMZ呢.就是通過Object.create(null) 建立一個空物件.這種方法和 {} 很像,但是並不會建立 Object.prototype 這個委託,所以它比 {}“更空”更加安全.
PS:實際使用一般不會遇到這種情況(也可能是我太菜,沒遇到),如果函式內有this,那肯定是有需要呼叫的變數或函式,直接把它繫結到一個空物件上.那什麼都取不到,還有什麼意義?所以函式沒有this就傳入null.如果有this就把它繫結到真正需要它的物件上,而不是一個空物件上.這些是我自己的見解,如果有不妥的,歡迎留言指正.
2.4.2 間接引用
function foo() {
console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2 其實就是foo() 此時this預設繫結到window
複製程式碼
例子中的間接引用其實是對函式的理解不深造成的.其實(p.foo = o.foo)()就是(foo)(),這樣就是全域性呼叫foo()所以this預設就繫結到了window上.
注意:
對於預設繫結來說,決定 this 繫結物件的並不是呼叫位置是否處於嚴格模式,而是 函式體是否處於嚴格模式。如果函式體處於嚴格模式,this 會被繫結到 undefined,否則 this 會被繫結到全域性物件。(對於這段話其實在2.2.1節就應該說了!)
2.4.3 軟繫結
硬繫結會大大降低函式的靈活性,使用硬繫結之後就無法使用隱式繫結或者顯式繫結來修改 this。這時候則需要使用軟繫結.
Tips: 這裡給的軟繫結方法還是挺好的.但是建議還是在自己的程式碼裡使用,並註釋清除.以免別人使用,對this錯誤的判斷.
2.5 this詞法
ES6 中介紹了一種無法使用上面四條規則的特殊函式型別:箭頭函式。
箭頭函式不使用 this 的四種標準規則,而是根據外層(函式或者全域性)作用域來決定 this。(而傳統的this與函式作用域沒有任何關係,它只與呼叫位置的上下文物件有關.這點在本章開頭就已經反覆強調了.)
重要:
- 箭頭函式最常用於回撥函式中,例如事件處理器或者定時器.
- 箭頭函式可以像 bind(..) 一樣確保函式的 this 被繫結到指定物件
- 箭頭函式用更常見的詞法作用域取代了傳統的 this 機制。
注意: 這種情況:
function module() {
return this.x;
}
var foo = {
x: 99,
bar:module.bind(this) //此時bind繫結的this為window.
}
var x="window"
console.log(foo.bar())//window
複製程式碼
在 ES6 之前我們就已經在使用一種幾乎和箭頭函式完全一樣的模式:
function foo() {
var self = this; // lexical capture of this
setTimeout( function(){
console.log( self.a );
}, 100 );
}
var obj = {
a: 2
};
foo.call( obj ); // 2
複製程式碼
雖然 self = this 和箭頭函式看起來都可以取代 bind(..),但是從本質上來說,它們想替代的是 this 機制。(的確是這樣,我一般會用me替代self.因為少兩個單詞=.=)
關於this的編碼規範建議:
-
- 只使用詞法作用域並完全拋棄錯誤this風格的程式碼;
-
- 完全採用 this 風格,在必要時使用 bind(..),儘量避免使用 self = this 和箭頭函式。
在自己實際工作中,其實是兩種混用的,絕大部分情況下都會使用詞法作用域風格.因為有時候你真的很難做到完全統一.我現在的習慣是,在寫任何函式時,開頭第一個就是var me =this;
這樣在看到函式第一眼,就知道:哦,這個函式是用詞法作用域風格的.尤其函式內涉及到回撥.這樣就避免了寫著寫著發現this繫結到其他地方去了,一個函式裡面this不統一的情況.
2.6 小結
(這裡總結得很好,我就全部copy了) 如果要判斷一個執行中函式的 this 繫結,就需要找到這個函式的直接呼叫位置。找到之後就可以順序應用下面這四條規則來判斷 this 的繫結物件。
-
- 由new呼叫?繫結到新建立的物件。
-
- 由call或者apply(或者bind)呼叫?繫結到指定的物件。
-
- 由上下文物件呼叫?繫結到那個上下文物件。
-
- 預設:在嚴格模式下繫結到undefined,否則繫結到全域性物件。
一定要注意,有些呼叫可能在無意中使用預設繫結規則。如果想“更安全”地忽略 this 繫結,你可以使用一個 DMZ 物件,比如 ø = Object.create(null),以保護全域性物件。
ES6 中的箭頭函式並不會使用四條標準的繫結規則,而是根據當前的詞法作用域來決定 this,具體來說,箭頭函式會繼承外層函式呼叫的 this 繫結(無論 this 繫結到什麼)。這其實和 ES6 之前程式碼中的 self = this 機制一樣。
特別注意: 其中最需要注意的就是當你使用jquery或vue時,此時this是被動態繫結了的.大多數 jQuery 方法將 this 設定為已選擇的 dom 元素。使用 Vue.js時,則方法和計算函式通常將 this 設定為 Vue 元件例項。vue文件中所有的生命週期鉤子自動繫結 this 上下文到例項中,因此你可以訪問資料,對屬性和方法進行運算。這意味著你不能使用箭頭函式來定義一個生命週期方法 (例如 created: () => this.fetchTodos())。這是因為箭頭函式繫結了父上下文,因此 this 與你期待的 Vue 例項不同,this.fetchTodos 的行為未定義。
也包括使用第三方ajax時,例如axios.解決方法也很簡單,要麼使用傳統的function或者使用let _this=this
進行接管.其實當你使用vue時,你預設的思想就是this指的就是vue例項.所以除了鉤子函式和axios裡會有點影響外,其餘還好.
PS 這裡再補充說明 上下文(物件)與函式作用域的區別於聯絡:
- 上下文: 可以理解為一個物件,所有的變數都儲存在裡面.上下文環境是在函式被呼叫並被引擎執行時建立的.如果你沒呼叫,那麼就沒有上下文.
- 作用域: 除了全域性作用域,只有函式和ES6新增的let,const才能建立作用域.建立一個函式就建立了一個作用域,無論你呼叫不呼叫,函式只要建立了,它就有獨立的作用域.作用域控制著被呼叫函式中的變數訪問.
- 兩者: 作用域是基於函式的,而上下文是基於物件的。作用域涉及到所被呼叫函式中的變數訪問,並且不同的呼叫場景是不一樣的。上下文始終是this關鍵字有關, 它控制著this的引用。一個作用域下可能包含多個上下文。有可能從來沒有過上下文(函式沒有被呼叫);有可能有過,現在函式被呼叫完畢後,上下文環境被銷燬了(垃圾回收);有可能同時存在一個或多個(閉包)。
第三章 物件
3.1 語法
物件可以通過兩種形式定義:宣告(文字)形式(就是常說的物件字面量)和構造形式。
- 宣告形式(物件字面量):
var myObj = {
key: value
// ...
};
複製程式碼
- 構造形式:
var myObj = new Object();
myObj.key = value;
複製程式碼
構造形式和文字形式生成的物件是一樣的。唯一的區別是,在文字宣告中你可以新增多個 鍵 / 值對,但是在構造形式中你必須逐個新增屬性。 PS:其實我們絕大部分情況下都是使用物件字面量形式建立物件.
3.2 型別
在JavaScript中一共有6中主要型別(術語是"語言型別")
- string
- number
- boolean
- null
- undefined
- object
簡單資料型別:
其中string、boolean、number、null 和 undefined屬於簡單基本型別,並不屬於物件.
null 有時會被當作一種物件型別,但是這其實只是語言本身的一個 bug,即對 null 執行typeof null 時會返回字串 "object"。實際上,null 本身是基本型別。
PS: 原因是這樣的,不同的物件在底層都表示為二進位制,在 JavaScript 中二進位制前三位都為 0 的話會被判 斷為 object 型別,null 的二進位制表示是全 0,自然前三位也是 0,所以執行 typeof 時會返回“object”。
物件:
物件除了我們自己手動建立的,JavaScript其實內建了很多物件,也可以說是物件的一個子型別.
內建物件:
- String
- Number
- Boolean
- Object
- Function
- Array
- Date
- RegExp
- Error
在 JavaScript 中,這些內建物件實際上只是一些內建函式。這些內建函式可以當作建構函式(由 new 產生的函式呼叫——參見第 2 章)來使用.
幾點說明:
- 函式就是物件的一個子型別(從技術角度來說就是“可呼叫的物件”)。JavaScript 中的函式是“一等公民”,因為它們本質上和普通的物件一樣(只是可以呼叫),所以可以像操作其他物件一樣操作函式(比如當作另一個函式的引數)。
- 通過字面量形式建立字串,數字,布林時,引擎會自動把字面量轉換成 String 物件,Number物件,Boolean物件,所以它們是可以訪對應物件內建的問屬性和方法。
- null 和 undefined 沒有對應的構造形式,它們只有文字形式。相反,Date 只有構造,沒有文字形式。
- 對於 Object、Array、Function 和 RegExp(正規表示式)來說,無論使用文字形式還是構造形式,它們都是物件,不是字面量(這是肯定的,因為不管哪種形式一建立出來就是物件型別,不可能是其他型別,實際上是不存在字面量這一說的)。但是使用構造形式可以提供一些額外選項(內建)。
- Error 物件很少在程式碼中顯式建立,一般是在丟擲異常時被自動建立。也可以使用 new Error(..) 這種構造形式來建立,不過一般來說用不著。
3.3 內容
物件屬性:由一些儲存在特定命名位置的(任意型別的)值.
屬性名:儲存在物件容器內部的屬性的名稱.屬性值並不會存在物件內.而是通過屬性名(就像指標,從技術角度來說就是引用)來指向這些值真正的儲存位置(就像房門號一樣).
屬性名的兩種形式:
-
- 使用
.
操作符.也是我們最常用的形式.它通常被稱為"屬性訪問"..
操作符會要求屬性名滿足識別符號的命名規範.
- 使用
-
- 使用
[".."]
語法進行訪問.這個通常被稱為"鍵訪問".[".."]
語法可以接受任意UTF-8/Unicode 字串作為屬性名。並且[".."]
語法使用字串來訪問屬性,如果你的屬性名是一個變數,則可以使用書中的例子myObject[idx]
形式進行訪問.這也是最常使用"鍵訪問"的情況.但如果idx是屬性名則還是需寫成myObject["idx"]
字串形式.
- 使用
注意: 書中說在物件中,屬性名永遠都是字串。如果你使用 string(字面量)以外的其他值作為屬性名,那它首先會被轉換為一個字串。即使是數字也不例外,雖然在陣列下標中使用的的確是數字,但是在物件屬性名中數字會被轉換成字串 .
在ES6之前這段話是正確的,但是現在有了symbol. symbol也可以作為物件屬性名使用,並且symbol是不可以轉化為字串形式的!
補充: 這裡我在書中的例子基礎上進行了修改,得到這個例子:
var myObject = {
a:2,
idx:111
};
var idx="a";
console.log( myObject[idx] ); //2
console.log( myObject["idx"] ); //111
console.log( myObject[this.idx] ); // 2 此時this是指向window.[]裡的this同樣符合上一章所講的規則
//結果是否和你所想得一樣呢?
複製程式碼
3.3.1 可計算屬性名
ES6 增加了可計算屬性名,可以在文字形式中使用 [] 包裹一個表示式來當作屬性名:
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
複製程式碼
3.3.2 屬性與方法
- 我們經常把物件內部引用的函式稱為“方法”(的確如此).
- 實際上函式並不屬於該物件,它不過是對函式的引用罷了.物件屬性訪問返回的函式和其他函式沒有任何區別(除了可能發生的隱式繫結this到該物件)。
- 即使你在物件的文字形式中宣告一個函式表示式,這個函式也不會“屬於”這個物件—— 它們只是對於相同函式物件的多個引用。
3.3.3 陣列
- 陣列支援
[]
形式訪問儲存的值,其中[]
內的值預設形式為數值下標(為從0開始的整數,也就是常說的索引).例如myArray[0]
- 陣列也是物件,所以雖然每個下標都是整數,你仍然可以給陣列新增屬性.例如
myArray.baz = "baz"
.注意:新增新屬性後,雖然可以訪問,但陣列的 length 值不會改變. - 陣列可以通過
myArray[1]=11;myArray["2"]=22;
這種形式對陣列內容進行修改,新增. - 雖然陣列也可以和物件一樣通過
鍵/值 對
形式來使用.但JS已經對陣列的行為和用途進行了優化.所以還是建議使用預設的下標/值 對
形式來使用.
3.3.4 複製物件
- 複製分為淺拷貝和深拷貝.淺拷貝會對物件中的基本資料型別進行復制(在記憶體中開闢新的區域),對於物件則是繼續引用.而不是重新建立一個"一樣的"物件.深拷貝則是對其中的所有內(容包括物件)進行深層次的複製.
- 一般情況下我們可以通過JSON來複制物件.
var newObj = JSON.parse( JSON.stringify( someObj ) );
.但需要指出的是這種方法對於包含function函式或者Date型別的物件則不管用! - ES6 定義了 Object.assign(..) 方法來實現淺複製。具體用法在這就不贅述了.
3.3.5 屬性描述符
從 ES5 開始,所有的屬性都具備了屬性描述符。
- 檢視屬性描述符: 可以使用Object.getOwnPropertyDescriptor( myObject, "a" );方法檢視myObject物件裡屬性a的屬性描述符.
- 配置屬性描述符: 可以使用Object.defineProperty(..)方法對屬性的屬性描述符就像配置.舉個例子:
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
} );
myObject.a; // 2
//該方法可以配置四個屬性描述符
複製程式碼
注意: 書中關於屬性描述符也被稱為“資料描述符”
其實是不夠準確的.
物件裡目前存在的屬性描述符有兩種主要形式:資料描述符和存取描述符。資料描述符是一個具有值的屬性,該值可能是可寫的,也可能不是可寫的。存取描述符是由getter和setter函式對描述的屬性。描述符必須是這兩種形式之一;不能同時是兩者。(getter和setter是後面馬上要講到的兩個描述符)它們的關係如下:(詳情可以檢視MDN的解釋)
configurable | enumerable | value | writable | get | set | |
---|---|---|---|---|---|---|
資料描述符 | Yes | Yes | Yes | Yes | No | No |
存取描述符 | Yes | Yes | No | No | Yes | Yes |
如果一個描述符不具有value,writable,get 和 set 任意一個關鍵字,那麼它將被認為是一個資料描述符。如果一個描述符同時有(value或writable)和(get或set)關鍵字,將會產生一個異常。
value就是該屬性對應的值。預設為 undefined。下面分別介紹剩下的三個屬性描述符鍵值:
- 1. Writable 決定是否可以修改屬性的值。當被設定為false後,再對屬性值進行修改,則會靜默失敗(silently failed,修改不成功,也不報錯)了。如果在嚴格模式下,則會報出TypeError錯誤.
- 2. Configurable 決定屬性描述符是否可配置.如果為true,就可以使用 defineProperty(..) 方法來修改屬性描述符.注意:不管是不是處於嚴格模式,修改一個不可配置的屬性描述符都會出錯。並且把 configurable 修改成 false 是單向操作,無法撤銷! 但是有個例外即便屬性是 configurable:false,我們還是可以 把 writable 的狀態由 true 改為 false,但是無法由 false 改為 true。除了無法修改,configurable:false 還會禁止刪除這個屬性.
- 3. Enumerable 決定該屬性是否會出現在物件的屬性列舉中.比如說 for..in 迴圈。如果把 enumerable 設定成 false,這個屬性就不會出現在列舉中,雖然仍然可以正常訪問它。相對地,設定成 true 就會讓它出現在列舉中。
3.3.6 不變性
除了上面提到的Object.defineProperty(..),ES5還可以通過很多種方法來實現屬性或者物件的不可變.
注意: 這些所有方法都是隻能淺不變,如果目標物件引用了其他物件(陣列、物件、函式,等),其他物件的內容不受影響,仍然是可變的.類似於淺拷貝.
說明: 在 JavaScript 程式中很少需要深不可變性。 有些特殊情況可能需要這樣做,但是根據通用的設計模式,如果你發現需要密封或者凍結所有的物件,那你或許應當退一步,重新思考一下程式的設計,讓它能更好地應對物件值的改變。
方法:
- 1. 物件常量(不可改) 結合 writable:false 和 configurable:false 就可以建立一個真正的常量屬性(不可修改、重定義或者刪除)
- 2. 禁止擴充套件(不可增) 使用 Object.prevent Extensions(myObject),可以禁止一個物件新增新屬性並且保留已有屬性.在非嚴格模式下,建立屬性 b 會靜默失敗。在嚴格模式下,將會丟擲 TypeError 錯誤。
- 3. 密封(不可配置,但可修改) 使用Object.seal(..) 會建立一個“密封”的物件,這個方法實際上會在一個現有物件上呼叫Object.preventExtensions(..) 並把所有現有屬性標記為 configurable:false。密封之後不僅不能新增新屬性,也不能重新配置或者刪除任何現有屬性(雖然可以修改屬性的值)。
- 4. 凍結(不可配置,也不可修改) Object.freeze(..) 會建立一個凍結物件,這個方法實際上會在一個現有物件上呼叫 Object.seal(..) 並把所有“資料訪問”屬性標記為 writable:false,這樣就無法修改它們的值。這個方法是你可以應用在物件上的級別最高的不可變性,它會禁止對於物件本身及其任意直接屬性的修改(不過就像我們之前說過的,這個物件引用的其他物件是不受影響的)。
注意: 你可以“深度凍結”一個物件(連引用的物件也凍結),具體方法為,首先在這個物件上呼叫 Object.freeze(..), 然後遍歷它引用的所有物件並在這些物件上呼叫 Object.freeze(..)。但是一定要謹慎!因為你引用的物件可能會在其他地發也被引用.
說明: 在 JavaScript 程式中很少需要深不可變性。有些特殊情況可能需要這樣做, 但是根據通用的設計模式,如果你發現需要密封或者凍結所有的物件,那你或許應當退一步,重新思考一下程式的設計,讓它能更好地應對物件值的改變。
3.3.7 [[Get]]
var myObject = {
a: 2
};
myObject.a; // 2
複製程式碼
myObject.a是怎麼取到值2的?
myObject.a 通過物件預設內建的[[Get]] 操作(有點像函式呼叫:[Get]).首先它會在物件中查詢是否有名稱相同的屬性, 如果找到就會返回這個屬性的值。如果沒有找到名稱相同的屬性,按照 [[Get]] 演算法的定義會執行另外一種非常重要的行為。其實就是遍歷可能存在的 [[Prototype]] 鏈,也就是在原型鏈上尋找該屬性。如果仍然都沒有找到名稱相同的屬性,那 [[Get]] 操作會返回值 undefined.
注意: 如果你引用了一個當前詞法作用域中不存在的變數,並不會像物件屬性一樣返回 undefined,而是會丟擲一個 ReferenceError 異常.
3.3.8 [[Put]]
既然有可以獲取屬性值的 [[Get]] 操作,就一定有對應的 [[Put]] 來設定或者建立屬性.
[[Put]] 被觸發時的操作分為兩個情況:1. 物件中已經存在這個屬性 2. 物件中不存在這個屬性.
如果物件中已經存在這個屬性,[[Put]] 演算法大致會檢查下面這些內容:
-
- 屬性是否是訪問描述符(參見下一節)?如果是並且存在setter就呼叫setter。
-
- 屬性的資料描述符中writable是否是false?如果是,在非嚴格模式下靜默失敗,在嚴格模式下丟擲 TypeError 異常。
-
- 如果都不是,將該值設定為屬性的值。
如果物件中不存在這個屬性,[[Put]] 操作會更加複雜。會在第 5 章討論 [[Prototype]] 時詳細進行介紹。
3.3.9 Getter和Setter
物件預設的 [[Put]] 和 [[Get]] 操作分別可以控制屬性值的設定和獲取。 目前我們還無法操作[[Get]] 和 [[Put]]來改寫整個物件 ,但是在ES5中可以使用 getter 和 setter 改寫部分預設操作,只能應用在單個屬性上,無法應用在整個物件上。
注意: 書中後面說的訪問描述符
就是存取描述符
.關於屬性描述符,存取描述符及資料描述符可以檢視MDN的解釋)
getter: getter 是一個隱藏函式,會在獲取屬性值時呼叫。同時會覆蓋該單個屬性預設的 [[Get]]操作.當你設定getter時,不能同時再設定value或writable,否則就會產生一個異常.並且當你設定getter或setter時,JavaScript 會忽略它們的 value 和 writable 特性.
語法: {get prop() { ... } }
或{get [expression]() { ... } }
.其中prop
:要設定的屬性名. expression
:從 ECMAScript 2015 開始可以使用計算屬性名.
使用方式:
var myObject = {
a: 1111, //在後面會發現myObject.a為2,這是因為設定了getter所以忽略了value特性.
//方式一:在新物件初始化時定義一個getter
get a() {
return 2
}
};
Object.defineProperty(
myObject, // 目標物件
"b", // 屬性名
{
// 方式二:使用defineProperty在現有物件上定義 getter
get: function(){ return this.a * 2 },
// 確保 b 會出現在物件的屬性列表中
enumerable: true
}
);
myObject.a = 3; //因為設定了getter所以忽略了writable特性.所以這裡賦值沒成功
myObject.a; // 2
myObject.b; // 4
delete myObject.a;//可以使用delete操作符刪除
複製程式碼
setter: setter 是一個隱藏函式,會在獲取屬性值時呼叫。同時會覆蓋該單個屬性預設的 [[Put]]操作(也就是賦值操作).當你設定setter時,不能同時再設定value或writable,否則就會產生一個異常.並且當你設定getter或setter時,JavaScript 會忽略它們的 value 和 writable 特性.
語法: {set prop(val) { . . . }}
或{set [expression](val) { . . . }}
.其中prop
:要設定的屬性名. val
:用於儲存嘗試分配給prop的值的變數的一個別名。expression
:從 ECMAScript 2015 開始可以使用計算屬性名.
使用方式:
var myObject = {
//注意:通常來說 getter 和 setter 是成對出現的(只定義一個的話 通常會產生意料之外的行為):
//方式一:在新物件初始化時定義一個setter
set a(val) {
this._a_ = val * 2
},
get a() {
return this._a_
}
};
Object.defineProperty(
myObject, // 目標物件
"b", // 屬性名
{
set: function(val){ this._b_ = val * 3 },
// 方式二:使用defineProperty在現有物件上定義 setter
get: function(){ return this._b_ },
// 確保 b 會出現在物件的屬性列表中
enumerable: true
}
);
myObject.a = 2;
myObject.b = 3;
console.log(myObject.a); //4
console.log(myObject.b);//9
console.log(myObject._a_);//4
console.log(myObject._b_);//9
delete myObject.a;//可以使用delete操作符刪除
複製程式碼
3.3.10 存在性
屬性存在性: 如何判斷一個物件是否存在某個屬性(準確來說是檢查這個屬性名是否存在),這時就需要用到:
-
in
操作符 in 操作符會檢查屬性是否在物件及其 [[Prototype]] 原型鏈中(參見第 5 章)。
-
- hasOwnProperty(..) hasOwnProperty(..) 只會檢查屬性是否在 myObject 物件中,不會檢查 [[Prototype]] 鏈。
注意:
- 1.如果有的物件可能沒有連線到 Object.prototype( 通過Object. create(null) 來建立——參見第 5 章)。在這種情況下,形如myObejct.hasOwnProperty(..) 就會失敗。這時可以使用一種更加強硬的方法來進行判斷:Object.prototype.hasOwnProperty. call(myObject,"a"),它借用基礎的 hasOwnProperty(..) 方法並把它顯式繫結(參見第2章)到 myObject 上。
- 2.對於陣列來說,不要使用in操作符,因為它檢查的是屬性名,在陣列中屬性名就是索引,它並不是我們所關注的重點.對於陣列我們更關注的是它所存的值,所以對於陣列檢查某個值是否存在還是採用indexOf方法.
屬性可列舉性:
如果一個屬性存在,且它的enumerable 屬性描述符為true時.則它是可列舉的.並且可以被for..in 迴圈.
一個屬性不僅僅需要存在,還需要它的enumerable 為true才是可列舉的,才能被for...in遍歷到.
注意: for...in不適合對陣列進行遍歷,對陣列的遍歷還是使用傳統的for迴圈.
對屬性的可列舉性判斷,則需要用到以下幾種方法:
-
- propertyIsEnumerable(..) 會檢查給定的屬性名是否直接存在於物件中(而不是在原型鏈上)並且滿足 enumerable:true。
-
- Object.keys(..) 會返回一個陣列,包含所有可列舉屬性.
-
- Object.getOwnPropertyNames(..)會返回一個陣列,包含所有屬性,無論它們是否可列舉。
3.4 遍歷
關於這節我覺得還是以理清for..in和for..of為主.後面延伸的@@iterator及Symbol.iterator的使用,沒必要過於深究.注意書中123頁第二行done 是一個布林值,表示是否還有可以遍歷的值。
有個錯誤,應該改成done 是一個布林值,表示遍歷是否結束。
否則你在看後面它的說明時會感覺到自相矛盾.這裡我也是以for..in和for..of為主進行說明,也更貼近我們實際使用.
for..in
- for..in 迴圈可以用來遍歷物件的可列舉屬性列表(包括 [[Prototype]] 鏈)。
- 實際上for..in遍歷的並不是屬性值,而是屬性名(即鍵名 key).所以你想獲取屬性值還是需要手動使用obj[key]來獲取.
- 一般在遍歷物件時,推薦使用for..in.當然陣列也是可以使用for..in的.在遍歷陣列時,推薦還是使用for..of.
for..of
- ES6 增加了一種用來遍歷陣列的 for..of 迴圈語法(如果物件本身定義了迭代器的話也可以遍歷物件)
- for..of與for..in最大的不同點是,它迴圈的是屬性值,而不是屬性名.不過它只迴圈陣列裡存放的值,不會涉及到物件裡的key.(關於這個我後面的例子裡會說具體對比明的)
- for..of 迴圈首先會向被訪問物件請求一個迭代器物件,然後通過呼叫迭代器物件的next() 方法來遍歷所有返回值。陣列有內建的 @@iterator,(物件沒有,所以不能使用for..of,除非我們自己定義一個)因此 for..of 可以直接應用在陣列上。
例子比較
let arr = ['shotCat',111,{a:'1',b:'2'}]
arr.say="IG niu pi!"
//使用for..in迴圈
for(let index in arr){
console.log(arr[index]);//shotCat 111 {a:'1',b:'2'} IG niu pi!
}
//使用for..of迴圈
for(var value of arr){
console.log(value);//shotCat 111 {a:'1',b:'2'}
}
//注意 for..of並沒有遍歷得到` IG niu pi!`.原因我前面說過`它只迴圈陣列裡存放的值,不會涉及到物件裡的key.`更不用說 [[Prototype]] 鏈.(for..in則會)
複製程式碼
如何讓物件也能使用for..of ?
你可以選擇使用書中的自己通過Object.defineProperty()定義一個Symbol.iterator屬性來實現.這裡我就不贅述了.也是最接近原生使用感受的.不過我這裡要介紹一個稍微簡單點的方法來實現.就是使用上節講到的Object.keys()搭配使用.舉個例子:
var shotCat={
name:'shotCat',
age:'forever18',
info:{
sex:'true man',
city:'wuhan',
girlFriend:'新垣結衣!'
}
}
for(var key of Object.keys(shotCat)){
//使用Object.keys()方法獲取物件key的陣列
console.log(key+": "+shotCat[key]);
}
複製程式碼
3.5 小結
書中小結總結得挺全的,這裡我就搬運下
- JavaScript 中的物件有字面形式(比如 var a = { .. })和構造形式(比如 var a = new Array(..))。字面形式更常用,不過有時候構造形式可以提供更多選項。
- 物件是 6 個(或者是 7 個,取決於你的觀點)基礎型別之一。物件有包括 function 在內的子型別,不同子型別具有不同的行為,比如內部標籤 [object Array] 表示這是物件的子型別陣列。
- 物件就是鍵 / 值對的集合。可以通過 .propName 或者 ["propName"] 語法來獲取屬性值。訪問屬性時,引擎實際上會呼叫內部的預設 [[Get]] 操作(在設定屬性值時是 [[Put]]), [[Get]] 操作會檢查物件本身是否包含這個屬性,如果沒找到的話它還會查詢 [[Prototype]] 鏈(參見第 5 章)。
- 屬性的特性可以通過屬性描述符來控制,比如 writable 和 configurable。此外,可以使用 Object.preventExtensions(..)、Object.seal(..) 和 Object.freeze(..) 來設定物件(及其屬性)的不可變性級別。
- 屬性不一定包含值——它們可能是具備 getter/setter 的“訪問描述符”。此外,屬性可以是可列舉或者不可列舉的,這決定了它們是否會出現在 for..in 迴圈中。
- 可以使用 ES6 的 for..of 語法來遍歷資料結構(陣列、物件,等等)中的值,for..of 會尋找內建或者自定義的 @@iterator 物件並呼叫它的 next() 方法來遍歷資料值。
第四章 混合物件"類"
注意: 正如書中提示的那樣,整章一半以上幾乎都是講物件導向和類的概念.會讀得人云裡霧裡,給人哦,也許大概就是這樣子
的感覺.後面我還是會對那些抽象的概念找到在JavaScript裡對應的"立足點",不至於對這些概念太"飄".
4.1 類理論
說明:
- 類其是描述了一種程式碼的組織結構形式.
- 在js中類常見的就是建構函式,也可以是通過ES6提供的class關鍵字;繼承就是函式;例項化就是物件,常見的就是通過new建構函式實現的.
注意: Javascript語言不支援“類”,所謂的"類"也是模擬出的“類”。即使是ES6引入的"類"實質上也是 JavaScript 現有的基於原型的繼承的語法糖。
4.1.1 “類”設計模式
一句話:類其實也是一種設計模式!
- 類並不是必須的程式設計基礎,而是一種可選的程式碼抽象.
- 有些語言(比如 Java)並不會給你選擇的機會,類並不是可選的——萬物皆是類。
- 其他語言(比如 C/C++ 或者 PHP)會提供過程化和麵向類這兩種語法,開發者可以選擇其中一種風格或者混用兩種風格。
4.1.2 JavaScript中的“類”
JavaScript 只有一些近似類的語法元素 (比如 new 和 instanceof),不過在後來的 ES6 中新增了一些元素,比如 class 關鍵字,其實質上也是 JavaScript 現有的基於原型的繼承的語法糖。也不是真正的類.
4.2 類的機制
這部分書中的描述,我理解起來也比較費勁,主要是它提到的棧,堆與我理解中記憶體裡的棧,堆相沖突了.這裡簡單說下我的理解,如有誤,感激指正.
stack類其實是一種資料結構.它可以儲存資料,並提供一些公用的方法(這和上面提到的類很相似).但是stack類其實只是一個抽象的表示,你想對它進行操作,就需要先對它進行例項化.
4.2.1 建造
這節主要就是說明"類"和"例項"的關係. 在JavaScript裡"類"主要是建構函式,"例項"就是物件.
一個類就像一張藍圖。為了獲得真正可以互動的物件,我們必須按照類來例項化一個東西,這個東西(物件)通常被稱為例項,有需要的話,我們可以直接在例項上呼叫方法並訪問其所有公有資料屬性。
總而言之:類通過例項化得到例項物件.
4.2.2 建構函式
- 類例項是由一個特殊的類方法構造的,這個方法名通常和類名相同,被稱為建構函式。
- 例項就是由建構函式例項化的: new 建構函式.
- 建構函式大多需要用 new 來調,這樣語言引擎才知道你想要構造一個新的類例項。
- 建構函式會返回一個物件,這個物件就是例項.這個物件可以呼叫類的方法.
4.3 類的繼承
在面向類的語言中,你可以先定義一個類,然後定義一個繼承前者的類。後者通常被稱為“子類”,前者通常被稱為“父類”。子類可以繼承父類的行為,並且可以根據自己的需求,修改繼承的行為(一般並不會修改父類的行為).注意:我們討論的父類和子類並不是例項,在JavaScript裡類一般都是建構函式。
4.3.1 多型
大概你看了它的"解釋",對多型還是懵懵懂懂.這裡我再解釋下:
什麼是多型?
同一個操作,作用於不同的物件,會產生不同的結果。發出一個相同的指令後,不同的物件會對這個指令有不同的反應,故稱為多型。
說明: 書中例子中的inherited其實就是相當於super.並且注意書中的這些例子都是虛擬碼! 並不是真的在JavaScript裡就是這樣實現的.補充:這裡是關於super的mdn連結.
- 多型:
- 相對性: 其實相對性就是子類相對於父類的引用(例如使用super實現引用),並且子類對父類的引用並不會對父類的行為造成任何影響(並不會對父類自身的行為進行重新定義),例如書中例子子類對drive()的引用.
- 可重複定義: 子類繼承父類的某個方法,並可以對這個方法進行再次定義,例如書中子類對drive()中的output進行修改.
當呼叫方法時會自動選擇合適的定義
,這句話怎麼理解,當子類例項化後,執行drive()方法時,它並不會直接去執行父類的drive().而是子類上的drive().簡單來說就是例項來源於那個類,它就使用那個類的方法.
說明:
- 在 JavaScript 中“類”是屬於建構函式的(類似 Foo.prototype... 這樣的型別引用)。由於 JavaScript中父類和子類的關係只存在於兩者建構函式對應的 .prototype 物件中,因此它們的建構函式之間並不存在直接聯絡,從而無法簡單地實現兩者的相對引用(在 ES6 的類中可以通過 super來“解決”這個問題,參見附錄 A)。
- 多型並不表示子類和父類有關聯,子類得到的只是父類的一份副本。類的繼承其實就是複製。
- 其實這裡討論的這些概念其實在我們實際工作中,已經使用了無數次,只是現在你需要理解"原來你是叫這個名字啊!"
4.3.2 多重繼承
多重繼承: 一個子類可以繼承來自多個父類的方法.
多重繼承引發的問題: 多重繼承可能會出現,多個父類中方法名衝突的問題,這樣子類
到底引用哪個方法?
多重繼承與JavaScript: JavaScript本身並沒有提供多重繼承功能.但它可以通過其他方法來達到多重繼承的效果.
4.4 混入
JavaScript 中只有物件,並不存在可以被例項化的“類”。一個物件並不會被複制到其他物件,它們會被關聯起來(參見第 5 章)(其實就是引用,所以它的多型是"相對"的)。 由於在其他語言中類表現出來的都是複製行為,因此 JavaScript 開發者也想出了一個方法來模擬類的複製行為,這個方法就是混入(就是通過混入來模擬實現類的多重繼承)。
4.4.1 顯式混入
鄭重提醒: 書中這裡的類都是物件形式的.例子裡的sourceObj, targetObj,這就可能造成一個"誤導",在JavaScript裡是沒有真正的類,所謂的類也不過是我們模擬出來的"類",不過是一種語法糖(包括ES6裡的class).在JavaScript裡"所謂的類"經常是一個建構函式,你並不能這樣進行遍歷,只能對它的例項物件進行這種操作.不要被書中例子帶進去了,不要混淆,畢竟我們最終使用的是JavaScript(而不是其他物件導向的語言.),它裡面的類常常並不是一個物件!
顯式混入: 書中沒有給出明確的顯式混入的定義,但是讀完整章.基本就知道什麼是顯式混入了.顯式混入就是通過類似mixin()方法,顯式地將父物件屬性逐一複製,或者有選擇地複製(即例子中的存在性檢查)到子物件上.
顯式混入常用方法: 就是書中的例子, 首先有子物件,並對其進行特殊化(定義自己的屬性或方法).然後再通過mixin()方法將父物件有選擇地複製(即存在性檢查,過濾子物件已有的屬性,避免衝突)到子物件上.
顯式混入注意點: 顯式混入時,切記一點你要避免父物件的屬性與子物件特殊化的屬性衝突.這就是為什麼例子中要進行存在性檢查,以及後面要說的混合複製,可能存在的重寫風險.
1. 再說多型(其實說的就是js裡的多型)
顯式多型: 將父物件裡的方法通過顯式繫結到子物件上.就是顯式多型.例如書中的例子:Vehicle.drive.call( this )。顯式多型也是為了JS來模擬實現多重繼承的!
說明: 在ES6之前是沒有相對多型的機制。所以就使用call這種進行顯式繫結實現顯式動態.注意JavaScript裡實現多型的方法也被稱為"偽多型".所以不要對後面突然冒出的偽多型概念而一臉懵逼(其實整本書經常做這樣的事)
顯式多型(偽多型)的缺陷: 因為在JavaScript 中存在遮蔽(實際是函式引用的上下文不同),所以在引用的時候就需要使用顯式偽多型的方法建立一個函式關聯. 這些都會增加程式碼的複雜度和維護難度(過多的this繫結,真的會讓程式碼很難讀)。
2. 混合複製(顯式混入另一種不常用方法)
前面的顯式混入的方法是先有子物件並進行特殊化,然後再有選擇地複製父物件屬性.這個不常用的方法則是反過來的,結合書中例子,它先用一個空物件完全複製父物件的屬性,然後該物件複製特殊化物件的屬性,最後得到子物件.這種方法明顯是比第一種麻煩的,並且在複製特殊化物件時,可能會對之前重名的屬性(即複製得到的父物件屬性)進行重寫覆蓋.所以這種方法是存在風險,且效率低下的.
顯式混入的缺陷:
-
- 無法做到真正的複製: 如果複製的物件中存在對函式的引用,那麼子物件得到的是和父物件一樣的,對同一個函式的引用.如果某個子物件對函式進行了修改,那麼父物件及其他子物件都會受到影響.很明顯這是不安全的.原因是JavaScript 中的函式無法進行真正地複製,你只能複製對共享函式物件的引用.
-
- 函式名和屬性名同名: 如果混入多個物件,則可能會出現這種情況.目前現在仍沒有比較好的方式來處理函式和屬性的同名問題(提問:這種情況下誰的優先順序更高?滑稽臉)。
3. 寄生繼承
顯式混入模式的一種變體被稱為“寄生繼承”,它既是顯式的又是隱式的.
首先會複製一份父類(物件)的定義,然後混入子類(物件)的定義(如果需要的話保留到父類的特殊引用),然後用這個複合物件構建例項。
說明: 寄生繼承與混合複製是很相似的,最大的區別是寄生繼承是通過例項化建構函式(JS中的"類")來實現複製的.
4.4.2 隱式混入
隱式混入: 它與顯示混入最大的區別,就是它沒有明顯的對父類(物件)屬性進行復制的過程.它是通過在建構函式呼叫或者方法呼叫中使用顯式繫結例如: Something.cool.call( this)來實現混入(多重繼承).其本質就是通過改變this指向來實現混入.
4.5 小結
整章的重點其實就是讓你理解什麼叫類.除了最後一小節的混入和JavaScript有那麼一點點關係.其餘的小結和JavaScript都沒什麼關係.重要的是理解類這種思想和設計模式.
重點:
- 1.類意味著複製!
-
- 傳統的類被例項化時,它的行為會被複制到例項中。類被繼承時,行為也會被複制到子類中。
-
- 多型(在繼承鏈的不同層次名稱相同但是功能不同的函式)看起來似乎是從子類引用父類,但是本質上引用的其實是複製的結果。
-
- JavaScript 並不會(像類那樣)自動建立物件的副本。(你只能自己手動複製,而且複製的還不徹底!)
-
- 混入模式(無論顯式還是隱式)可以用來模擬類的複製行為,但是通常會產生醜陋並且脆弱的語法,比如顯式偽多型(OtherObj.methodName.call(this, ...)),這會讓程式碼更加難 懂並且難以維護。
-
- 顯式混入實際上無法完全模擬類的複製行為,因為物件(和函式!別忘了函式也是物件)只能複製引用,無法複製被引用的物件或者函式本身。忽視這一點會導致許多問題。
-
- 在 JavaScript 中模擬類是得不償失的,雖然能解決當前的問題,但是可能會埋下更多的隱患。(但實際,我們用得卻很多)
第五章 原型
注意:本章的前提是你已經比較熟悉原型及原型鏈.不太熟或者不知道的可以,通過這篇文章熟悉下.
5.1 [[Prototype]]
JavaScript 中的物件有一個特殊的 [[Prototype]] 內建屬性,其實就是對於其他物件的引用(一般就是其建構函式prototype屬性的引用)。幾乎所有的物件在建立時 [[Prototype]] 屬性都會被賦予一個非空的值。
吐槽: 書中有這樣一句話 "注意:很快我們就可以看到,物件的 [[Prototype]] 連結可以為空,雖然很少見。"我前前後後看了三遍都沒找到它所說的物件的 [[Prototype]] 連結可以為空.
的情況!應該是作者寫忘記了.ok,這裡我來說下物件的 [[Prototype]] 連結可以為空
的情況.就是通過Object.create(null)得到的物件.它的 [[Prototype]] 是為空的.應該說它的所有都是空的.為什麼?因為null是原型鏈的頂端.它是沒有[[Prototype]]的.對應的可以對比下console.log(Object.create({}))
和console.log(Object.create(null))
[[Prototype]]有什麼用?
我原以為作者會說可以作為存放例項物件的公共屬性,然後像類一樣講得更深刻點.不過這次只是說了它表明的作用.
作用: 就是存放哪些不在物件自身的屬性. 當我們訪問一個物件的屬性時,此時物件的內部預設操作[[Get]],首先會檢查物件本身是否有這個屬性,如果有的話就使用它。如果沒有的話,[[Get]] 就會繼續訪問物件的 [[Prototype]] 鏈.([[Prototype]]其實就是其建構函式的prototype屬性.也是一個物件.)如果找到,就返回該屬性值.如果沒有就繼續尋找下一個[[Prototype]]鏈.直到找完整條[[Prototype]]鏈.還是沒有的話,[[Get]] 就會返回undefined.
補充:
- 使用 for..in 遍歷物件時 任何可以通過原型鏈訪問到 (並且是 enumerable:true)的屬性都會被列舉。(其實這個在第三章裡我說過)
- 使用 in 操作符 同樣會查詢物件的整條原型鏈(無論屬性是否可列舉)
5.1.1 Object.prototype
所有普通的 [[Prototype]] 鏈最終都會指向內建的 Object.prototype。(Object.prototype的[[Prototype]] 最終會指向null.null就是最後的終點). 這個 Object.prototype 物件,包含了 JavaScript 中許多通用的功能,例如:toString() , valueOf(), hasOwnProperty(..)和 isPrototypeOf(..)。
5.1.2 屬性設定和遮蔽
說明: 看完本節時,切記不要對myObject.foo = "bar"
這種簡單的物件屬性賦值產生顧慮和疑惑.這種賦值絕對不會對原型鏈產生任何影響!基本也不會出現賦值不成功的情況.如果有人敢在團隊專案裡修改物件的屬性描述符,早就被拖出去打死了!!! 這部分可以看做補充知識,知道有這些奇葩設定就行.其實這節更應該關注的是myObject.foo
的返回值.
注意: 書中提到的動詞遮蔽
其實指的就是在物件上建立同名屬性(原型鏈上已有該屬性).注意不要被繞暈了.還有++就相當於myObject.a=myObject.a+1,注意分解就行,不存在什麼特別需要當心的.
5.2 “類”
- JavaScript裡只有物件,沒有類!
- JavaScript不需要通過類來抽象物件.而是自己直接建立物件,並定義物件的行為.
5.2.1 “類”函式
吐槽:模仿類竟然被說成奇怪的無恥濫用!?不這樣做,js那些高階用法怎麼實現?怎麼會有現在前端的百花齊放(輪子滿地跑)?這也是冒得辦法的辦法啊!畢竟當時js只是小眾,不指望它有多大能耐.畢竟只是一個人用7天"借鑑"出來的東西.
"類"函式: JavaScript用來模仿類的函式就被稱為類函式,其實就是我們常說的建構函式.
"類"函式模擬類的關鍵: 所有的函式預設都會擁有一個名為 prototype 的公有並且不可列舉(參見第 3 章)的屬性,它會指向另一個物件.當我們通過new 函式(建構函式)來得到例項物件時,此時new會給例項物件一個內部的 [[Prototype]]屬性,例項物件內部的[[Prototype]]屬性與建構函式的prototype屬性都指向同一個物件.那JS的這個特性怎麼模擬類呢?首先類的本質就是複製!.明白這點後,我們就需要實現偽複製.我們可以將類裡的屬性,放在函式的prototype屬性裡.這樣該函式的例項物件就可以通過[Prototype]訪問這些屬性.我們也經常把這種行為稱為原型繼承(作者後面會瘋狂吐槽這個稱呼,我後面再解釋為什麼吐槽).這樣就實現了偽"複製". 可以達到和類相似的效果.
注意: 雖然說所有的函式預設都會擁有一個名為 prototype屬性.但也有特殊的時候.就不是預設的情況.就是通過bind()硬繫結時.所返回的繫結函式,它是沒有prototype屬性的!
圖解真正的類與JS的模擬類:
關於原型繼承這個名字的瘋狂吐槽: 作者的吐槽主要集中在"繼承"兩個字,原因是在面向類的語言中,"繼承"意味著複製,但在JavaScript裡原型繼承卻根本不是這個意思,它並沒有複製,而是用原型鏈來實現.所以瘋狂吐槽其誤導.
什麼是差異繼承? 我根本沒聽過這個術語,初次看作者所謂的解釋,這是啥?他想說啥?後來讀了好多遍,終於大概理解了.如果你也看不懂作者想在表達什麼,就pass這部分.沒必要理解.反而會把你看得更迷惑. 好了,我來解釋下什麼叫差異繼承.差異繼承就是原型繼承的一個不常用的別名.我們知道物件可以通過原型鏈繼承一部分屬性,但我們仍可以給物件設定其他有差異不同的屬性.這也就可以稱為差異繼承.
5.2.2 “建構函式”
建構函式之所以是建構函式,是因為它被new呼叫,如果沒被new呼叫,它就是一個普通函式.實際上,new會劫持所有普通函式並用構造物件的形式來呼叫它,並且無論如何都會構造返回一個物件.
5.2.3 技術
關於兩種“面向類”的技巧,我這就不說明了,理解了這部分第一第二章關於this的使用,就很簡單了.
prototype.constructor: 為了正確理解constructor.我特意在標題上加上prototype.是想強調:一個物件訪問constructor時,會預設訪問其原型物件上的constructor屬性.
注意:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 有時候我們會需要建立一個新原型物件,因此也不會有預設的constructor屬性指向建構函式
// 需要在 Foo.prototype 上“修復”丟失的 .constructor 屬性
// 關於 defineProperty(..),參見第 3 章
Object.defineProperty( Foo.prototype, "constructor" , {
enumerable: false,//不可列舉
writable: true,
configurable: true,
value: Foo // 讓 .constructor 指向 Foo
} );
//上面這種方法是比較嚴謹,也比較麻煩的.並且使用Object.defineProperty()風險是很大的.
//所以我們實際是這樣修改的
Foo.prototype.constructor=Foo; //直接將其賦值Foo 唯一要注意的是此時constructor是可列舉的.會被例項物件的for..in..遍歷到.
複製程式碼
5.3 (原型)繼承
原型物件到原型物件的繼承: 例如:Bar.prototype 到 Foo.prototype 的委託關係, 正確的JavaScript中“原型風格”:
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
function Bar(name,label) {
Foo.call( this, name );
this.label = label;
}
// 我們建立了一個新的 Bar.prototype 物件,並且它的[[Prototype]] 關聯Foo.prototype
Bar.prototype = Object.create( Foo.prototype );
// 注意!Object.create()是返回一個新的物件,所以現在沒有 Bar.prototype.constructor 了
// 如果你需要這個屬性的話可能需要手動修復一下它
Bar.prototype.myLabel = function() {
return this.label;
};
var a = new Bar( "a", "obj a" );
a.myName(); // "a"
a.myLabel(); // "obj a"
複製程式碼
錯誤用法:
- 1,
Bar.prototype = Foo.prototype;
此時並不會建立一個關聯到 Bar.prototype 的新物件,它只是讓 Bar.prototype 直接引用 Foo.prototype 物件。 因此當你執行類似 Bar.prototype. myLabel = ... 的賦值語句時會直接修改 Foo.prototype 物件本身。 - 2,
Bar.prototype = new Foo();
它使用 了 Foo(..) 的“建構函式呼叫”,如果函式 Foo 有一些其他操作的話,尤其是與this有關的的話,就會影響到 Bar() 的“後代”,後果不堪設想。
結論: 要建立一個合適的關聯物件,我們需使用 Object.create(..) 而不是使用具有副作用的 Foo(..)。這樣做唯一的缺點就是需要建立一個新物件然後把舊物件拋棄掉(主要是需要手動設定constructor),不能直接修改已有的預設物件。
檢查"類"關係
- instanceof 操作符: 驗證左邊的普通物件的整條[[prototype]]鏈是否有指向右邊函式的prototype,例如:
a instanceof Foo
。 - isPrototypeOf(..) 方法: 驗證在物件 a 的整條 [[Prototype]] 鏈中是否出現過 原型物件b.例如:
b.isPrototypeOf( a );
注意: 如果使用內建的 .bind(..) 函式來生成一個硬繫結函式(參見第 2 章)的話, 該函式是沒有 .prototype 屬性的。如果硬繫結函式instanceof 的話,則其bind的 目標函式的prototype會成為硬繫結函式的prototype.
關於__proto__:
我們知道函式可以直接通過prototype屬性直接訪問原型物件.那物件怎麼訪問呢?我們知道是通過[[prototype]]鏈.怎麼訪問呢?
在ES5之中的標準方法:通過Object.getPrototypeOf( )方法來獲取物件原型.Object.getPrototypeOf( a ) === Foo.prototype; // true
,
另一種方法:在 ES6 之前並不是標準,但卻被絕大多數瀏覽器支援的一種方法,可以訪問內部[[prototype]]物件.那就是__proto__
.例如:a.__proto__ === Foo.prototype; // true
.你甚至可以通過.__proto__.__ptoto__...
來訪問整個原型鏈. .__proto__
實際上並不存在於你正在使用的物件中.並且它看起來很像一個屬性,但是實際上它更像一個 getter/setter(見第三章).
5.4 物件關聯
[[Prototype]] 機制就是存在於物件中的一個內部連結,它會引用其他物件。
這個連結的作用是:如果在物件上沒有找到需要的屬性或者方法引用,引擎就會繼續在 [[Prototype]] 關聯的物件上進行查詢。同理,如果在後者中也沒有找到需要的 引用就會繼續查詢它的 [[Prototype]],以此類推。這一系列物件的連結被稱為原型鏈。
5.4.1 建立關聯
問:"我們已經明白了為什麼 JavaScript 的 [[Prototype]] 機制和類不一樣,也明白了它如何建立物件間的關聯。"
答: 類的機制是複製,JavaScript裡原型鏈的機制是引用.
問:"那 [[Prototype]] 機制的意義是什麼呢?為什麼 JavaScript 開發者費這麼大的力氣(模擬類)在程式碼中建立這些關聯呢?"
答: 意義就是模擬類,JavaScript不需要複製(我覺得這不是個優點)而通過原型鏈實現"例項"對"類"的"繼承(其實就是引用)".這樣就達到了例項物件對某些屬性(即原型物件裡的屬性)的複用.
Object.create(..)
這個方法其實我們在前面已經使用過很多次."Object.create(..) 會建立一個新物件(bar)並把它關聯到我們指定的物件(foo),這樣我們就可以充分發揮 [[Prototype]] 機制的威力(委託)並且避免不必要的麻煩(比如使用 new 的建構函式呼叫會生成 .prototype 和 .constructor 引用)。"實際上這個方法就是建立返回一個新物件,這個新物件的原型([[Prototype]])會繫結為我們輸入的引數物件foo.並且由於不是通過建構函式的形式,所以不需要為函式單獨設定prototype.雖然Object.create(..)很好,但實際我們使用的更多的還是建構函式形式.
注意: Object.create(..) 的第二個引數指定了需要新增到新物件中的屬性名以及這些屬性的屬性描述符(參見第 3 章)。
Object.create(null)
這個方法其實我們在前面也講解過幾次."Object.create(null) 會建立一個擁有空(或者說null)[[Prototype]] 連結的物件,這個物件無法進行委託。由於這個物件沒有原型鏈,所以 instanceof 操作符(之前解釋過)無法進行判斷,因此總是會返回 false。 這些特殊的空 [[Prototype]] 物件通常被稱作“字典”,它們完全不會受到原型鏈的干擾,因此非常適合用來儲存資料。"
"Object.create()的polyfill程式碼."這部分我就不做解讀了,因為現在都8102年,es6早就普及了,你幾乎不可能再用到es5之前的語法了.所以這部分大家瞭解下即可.
5.4.2 關聯關係是備用
[[Prototype]] 的本質作用: 書中提到了一個觀點"處理“缺失”屬性或者方法時的一種備用選項。"(即備用設計模式).但隨後進行了否定"但是這在 JavaScript 中並不是很常見。所以如果你使用的是這種模式,那或許應當退後一步並重新思考一下這種模式是否合適。" 作者給出的觀點是:"進行委託設計模式,即例子中的內部委託(就是在物件裡套了個殼再引用了一遍,為的是將委託進行隱藏).這樣可以使我們的API設計得更加清晰."文中的清晰是指,當我們需要引用原型物件的屬性方法時,我們在物件內部設定對應專門的屬性(例子中的doCool),進行內部委託(其實就是套個殼進行隱藏).這樣我們物件的屬性就是"完整"的.
在實際工作中,我們常常就是把原型物件作為存放物件的公共屬性方法的地方.對於一般比較重要的操作才會在物件裡進行內部委託(隱藏委託)!
5.5 小結
總結得很好很全面,這裡我還是直接摘抄了,不是偷懶哦!
- 如果要訪問物件中並不存在的一個屬性,[[Get]] 操作(參見第 3 章)就會查詢物件內部[[Prototype]] 關聯的物件。 這個關聯關係實際上定義了一條“原型鏈”(有點像巢狀的作用域鏈),在查詢屬性時會對它進行遍歷。
- 所有普通物件都有內建的 Object.prototype, 指向原型鏈的頂端(比如說全域性作用域),如 果在原型鏈中找不到指定的屬性就會停止。toString()、valueOf() 和其他一些通用的功能 都存在於 Object.prototype 物件上,因此語言中所有的物件都可以使用它們。
- 關聯兩個物件最常用的方法是使用 new 關鍵詞進行函式呼叫,在呼叫的 4 個步驟(第 2 章)中會建立一個關聯其他物件的新物件。
- 使用 new 呼叫函式時會把新物件的 .prototype 屬性關聯到“其他物件”(就是建構函式prototype所指的物件)。帶 new 的函式呼叫 通常被稱為“建構函式呼叫”,儘管它們實際上和傳統面向類語言中的類建構函式不一樣。
- JavaScript 中的機制有一個核心區別,那就是不會進行復制,物件之間是通過內部的 [[Prototype]] 鏈關聯的。
- “委託”是一個更合適的術語,因為物件之間的關係不是複製而是委託。(意思就是原先繼承應該改為原先委託?)
第六章 行為委託
第 5 章的結論:[[Prototype]] 機制就是指物件中的一個內部連結引用另一個物件。換句話說,JavaScript 中這個機制的本質就是物件之間的關聯關係。在第六章又被稱為委託. PS:前面在講原型的時候我就習慣用父物件指代原型物件(類似"父類"),用子物件指代其例項物件(類似"子類").本章也將採用這種稱呼,故下面不再說明.(其實我覺得用父物件和子物件稱呼更形象)
6.1 面向委託的設計
一句話:[[Prototype]]機制是面向委託的設計,是不同於面向類的設計. 下面將分別介紹類理論和委託理論.
6.1.1 類理論
類理論設計方法: 首先定義一個通用父(基)類,在 父類類中定義所有任務都有(通用)的行為。接著定義子類 ,它們都繼承自 父類並且會新增一些特殊的行為來處理對應的任務,並且在繼承時子類可以使用方法重寫(和多型)父類的行為.
類理論中許多行為可以先“抽象”到父類然後再用子類進行特殊化(重寫)。 ps:這部分了解即可,著重理解下面JavaScript用到的委託.
6.1.2 委託理論
類理論設計方法: 首先你會定義一個"父"物件(相當於上節中的父類),它會包含所有任務都可以使用(委託)的具體行為。接著,對於每個任務你都可以定義一個物件("子"物件)來儲存對應的資料和行為。你可以把特定的任務物件都關聯到父物件上,讓它們在需要的時候可以進行委託。 (其實我們一般都是用父物件來定義通用的方法,子物件進行委託.然後子物件自身個性的屬性方法就寫在子物件本身,並避免與父物件的屬性名衝突)
ps: 這節書中這段話但是我們並不需要把這些行為放在一起,**通過類的複製**,我們可以把它們分別放在各自獨立 的物件中,需要時可以允許 XYZ 物件委託給 Task。
有個錯誤."通過類的複製"應該改為"通過"[[Prototype]]機制".這裡應該是作者的手誤.
在 JavaScript 中,[[Prototype]] 機制會把物件關聯到其他物件。無論你多麼努力地說服自 己,JavaScript 中就是沒有類似“類”的抽象機制。(其實主要原因還是是JavaScript沒有完整的複製機制)
委託理論的使用建議:
PS:書中這裡寫了3條,其實只有2條,第三條不過是對第一條的說明,這裡我進行了合併.
-
- 通常來說,在 [[Prototype]] 委託中最好把狀態儲存在委託者(子物件)而不是委託目標(父物件)上。那怎麼實現呢,就是通過"this 的隱式繫結".在委託目標(父物件)上的函式裡通過this定義儲存狀態.當委託者(子物件)引用該函式方法時,此時的this就自動繫結到委託者上了.
-
- 在委託中我們會盡量避免在 [[Prototype]] 鏈的不同級別中使用相同的命名,否則就需要使用笨拙並且脆弱的語法來消除引用歧義(參見第 4 章)。
- 補充: 3. 在 API 介面的設計中,委託最好在內部實現,不要直接暴露出去。 這麼做更多的是出於安全和介面穩定的考慮.建議子物件將所有引用父物件的方法都套個函式隱藏起來,並取一個語義化的屬性名.
委託理論的使用注意:
-
- 禁止兩個物件互相委託:當你將第二個物件反向委託給前一個物件時,就會報錯.
-
- 除錯: 這個瞭解下就行.知道不同瀏覽器和工具對委託的解析結果並不一定相同.(吐槽:看了半天到後面出現
實際上,在編寫本書時,這個行為被認定是 Chrome 的一個 bug, 當你讀到此書時,它可能已經被修復了。
我只想說WTF! 好吧,我知道chrome以前可能出現過這個"bug"了=.=)
- 除錯: 這個瞭解下就行.知道不同瀏覽器和工具對委託的解析結果並不一定相同.(吐槽:看了半天到後面出現
6.1.3 比較思維模型
這節主要是比較了"通過建構函式(模擬類)實現原型繼承"與"通過物件關聯(委託形式,Object.create( ... ))實現原型繼承"兩種方式的區別.
結論: 通過物件關聯,委託形式,更加簡潔,更加清晰易懂.
PS:這裡我原本自己對例子畫出原型示意圖.但是發現是真的複雜,並且和書中簡潔後的示意圖是差不多的,所以這裡就不展示了,免得讓讀者看得更頭大.這裡建議,讀者自己在草稿紙上畫出原型示意圖.
6.2 類與物件
其實這節講得還是"通過建構函式(模擬類)實現原型繼承"與"通過物件關聯(委託形式,Object.create( ... ))實現原型繼承"兩種方式的區別.不過這次主要是以前端實際使用場景進行講解.
6.2.1 控制元件“類”
這裡我就不以書中的例子進行講解了,而是直接站在更高的角度對這種"類"風格的程式碼進行講解.
最大特點: 1是通過建構函式進行模擬類,2是通過顯式偽多型(硬繫結函式)關聯兩個函式.
注意:
- 不管是類還是物件.這兩種形式一般都需要定義兩種資料.第一種就是例項物件要用到的"初始儲存的資料";第二種就是通用行為的定義,包括對例項物件資料的增刪改查.
- 下面提到的顯式偽多型(詳見第四章),其實指的就是使用call()方法這種硬繫結.
- 注意ES6 class模擬類的寫法我就沒具體列出了.實際上class 仍然是通過 [[Prototype]] 機制實現的,不過是個表面簡潔的語法糖.
雖然書中對顯式偽多型稱為"醜陋的",還用了一個語氣動詞"呸!".雖然這樣不好,但有時用call真的很方便,所以用得也很多.
6.2.2 委託控制元件物件
最大特點: 通過物件載體來模擬父子,並通過Object,create(...)來對兩個物件進行關聯.並通過委託的形式進行引用.與上節中提到的類形式還有一個區別:物件foo構建後,需要手動呼叫setUp方法進行初始化.故物件的構建與初始化是分開的.而建構函式形式則是在new 建構函式時, 同時進行了物件構建與初始化.(關於這點我下面還會再說明的)
關於書中這句使用類建構函式的話,你需要(並不是硬性要求,但是強烈建議)在同一個步驟中實現構造和初始化。然而,在許多情況下把這兩步分開(就像物件關聯程式碼一樣)更靈活。
的理解:使用類建構函式形式,當我們使用new 建構函式
時,其實是在一步實現物件的構建和物件資料的初始化(通過建構函式裡的call) ;使用這種委託形式,我們是分別通過Object.create( ... );
構建物件和foo.setUp( ...);
來初始化的.即我們是分兩步實現的.這樣分開的話其實是更加靈活,也更符合程式設計中的關注分離原則.
6.3 更簡潔的設計
這節也是一樣通過兩者的對比來突顯委託設計模式的各種優點.這裡我就不再對書中的例子進行解讀.如果你真正理解了類和委託的話,其實是很簡單的.如果覺得複雜的話,可以在紙上理一下函式和物件之間的關係,下面我就只總結下這裡提到委託設計模式的優點,當然核心是更簡潔.
簡潔體現在:
- 1, 委託重點在於只需要兩個實體(兩個物件相互關聯),而之前的"類"模式需要三個(父"類",子"類",例項物件)其實可以這麼理解:委託模式將"子類"和"例項物件"合為一個物件了。
- 2, 不需要基類(父類)來"共享"兩個實體之間的行為.不需要例項化類,也不需要合成.其實這第二條就是對第一條這種結果的說明.
- 額外補充強調:在使用建構函式模擬類時,子類通常會對父類的行為進行重寫(屬性名相同);但委託模式則不會,它會重新取個屬性名,再引用父物件上的行為.
6.4 更好的語法
這節主要是介紹ES6提供的2個簡潔寫法與其中的隱患.
語法:
- 在 ES6 中我們可以在任意物件的字面形式中使用簡潔方法宣告,例如:
var Foo = {
bar() { /*..*/ },//字面形式宣告
};
複製程式碼
- 在 ES6 中我們可以用 Object. setPrototypeOf(..) 來修改物件的 [[Prototype]],具體用法可以檢視MDN例如:
// 使用更好的物件字面形式語法和簡潔方法
var AuthController = {
errors: [],
checkAuth() {
// ...
},
server(url,data) {
// ...
}
// ...
};
// 現在把 AuthController 關聯到 LoginController
Object.setPrototypeOf( AuthController, LoginController );
複製程式碼
弊端:
- 物件字面形式語法:實際上就是一個匿名函式表示式.匿名函式會導致3個缺點:1. 除錯棧更難追蹤;2. 自我引用(遞迴、事件(解除)繫結,等等)更難; 3. 程式碼(稍微)更難理解。(其實我覺得這3個缺點還好,影響不是那麼大).但是這種簡潔語法很特殊,會給對應的函式物件設定一個內部的 name 屬性,這樣理論上可以用在追 蹤棧中。所以實際上唯一的弊端就只剩第2條了.終極建議就是:如果你需要自我引用的話,那最好使用傳統的具名函式表示式來定義對應的函式,不要使用簡潔方法。
- Object. setPrototypeOf(..) 這個是書中沒有提的,我覺得有必要進行補充下.首先,Object. setPrototypeOf(..)可能會帶來效能問題,如果關心效能,則應該使用Object.create()替代.Object. setPrototypeOf(..)與Object.create()的主要區別: Object. setPrototypeOf(..)會直接修改現有物件的[[prototype]],Object.create()則是返回一個新物件.所以你需要手動設定一下丟失的的constructor屬性(如果你需要的話).而使用setPrototypeOf(..)則不需要.
6.5 內省
吐槽: 縱觀整本書,作者關於JavaScript中模擬類和繼承"的批評,說它們具有很大誤導性!更是嗤之以鼻!就差爆粗口了,JavaScript就像一個異教徒,應該綁在十字架上被燒死!但是他這樣的觀點,都是站在其他語言的角度來看待時,產生的.我想更多的讀者可能是隻接觸過JavaScript.那麼他其實是沒有這些疑惑的!!!你反而給他們講這一大堆其他語言的"正確"含義,有時候會時得其反!讓讀者更加困惑,如果是理解不透徹的,反而會懷疑自己本來寫的是對的程式碼!所以讀者應該做一個可以理解作者意圖,並且擁有自我見解和觀點立場!
什麼是內省(自省)?
首先,本節需要弄懂一個問題,什麼是內省,或者是自省。書中的解釋是自省就是檢查例項的型別。類例項的自省主要目的是通過建立方式來判斷物件的結構和功能。
我這裡再更通俗地解釋下:當我們構建得到一個例項物件時,有時候我們是不太清除它的屬性和方法的.尤其是第三方庫.有時候貿然使用會導致很多錯誤(例如呼叫的方法不存在,或者報錯等).這個時候我們就需要通過自省.其實就是通過一系列操作,來確認例項是不是我們想要的那個,例項的方法是不是我們想要的(存在且可用).
內省的方法:
- 1.通過 instanceof 語法:
function Foo() {
// ...
}
Foo.prototype.something = function(){
// ...
}
var a1 = new Foo();
// 假設我們不知道上面的過程,只知道得到例項物件a1
//我們想知道a1是不是我所希望的函式Foo所構建的
if (a1 instanceof Foo) {
a1.something();
}
複製程式碼
例子中我們有一個例項物件a1,但是我們不知道a1是不是我們所希望的函式Foo所構造的,此時就可以通過instanceof
進行判斷. instanceof
比較適合判斷例項物件和建構函式之間的關係.
缺陷: 但是如果我們想判斷函式A是不是函式B的"子類"時,則會稍微麻煩點,我們需要像這樣A.prototype instanceof B
進行判斷.並且也不能直接判斷兩個物件是否關聯.
- 2.通過 "鴨子型別": 為什麼會叫這個名字?看了作者的解釋,還是不太能接受.不太理解外國人的腦回路.你在國內和別人說"鴨子型別",估計也是一臉懵逼.其實很簡單,所謂的"鴨子型別"其實也是我們實際工作中常用的:
//如果a1的something存在的話,則我們可以進行呼叫
if ( a1.something) {
a1.something();
}
複製程式碼
其實這種方法是非常常用的,排除了在不知道存在性情況下,貿然呼叫的風險.
缺陷: 關於書中提到的缺點,四個字概括就是"以偏概全" .書中關於Promise的例子,就是以偏概全的例子.所以我們在使用時,在if判斷a1.something存在時,才會在後面使用something方法.不要直接使用anotherthing,這種沒確認過的方法.
- 3.如果使用物件關聯時: 則可以比較簡單的使用
Object.getPrototypeOf(..)
進行判斷.例如Object.getPrototypeOf(a)===A
其中a,A都是物件.如果為true,則說明a的原型鏈上含有物件A.
6.6 小結
- 除了類和繼承設計模式,行為委託其實是更強大,更值得推廣的模式(本觀點僅代表作者的觀點!)
- 行為委託認為物件之間是兄弟關係,互相委託,而不是父類和子類的關係。JavaScript 的 [[Prototype]] 機制本質上就是行為委託機制。(我覺得還是父子物件關係.我的解說裡也都是父子相稱)
- 當你只用物件來設計程式碼時,不僅可以讓語法更加簡潔,而且可以讓程式碼結構更加清晰。
- 物件關聯(物件之前互相關聯)是一種編碼風格,它倡導的是直接建立和關聯物件,不把它們抽象成類。物件關聯可以用基於 [[Prototype]] 的行為委託非常自然地實現
後續更新
後面關於<你不知道的JavaScript>中和下.還在寫作當中,手頭上還有一篇webpack完全指北的文章,目前寫了一半2w字,也都是面向新手,真正的全面地由淺入深.最近,空降一個新專案組,開發到測試只有一個月,還要帶新人,更新會很慢.不過我會爭取年前全部放出.如果大家喜歡的話,可以關注我一下,文章首發還是在掘金的.
最後求一個內推,目前筆者在一家軟體國企(半養老型).年底或者明年初就會考慮離職.希望進入一家比較好的網際網路企業.如果有小夥伴有好的機會可以發我郵箱:bupabuku@foxmail.com.謝謝!
目前暫時優先考慮武漢(房子,盆友,東西都在這邊,去外地太不方便了-.-)
百度網盤下載
為了方便大家下載到本地檢視,目前已經將MD檔案上傳到百度網盤上了.
連結: pan.baidu.com/s/1ylKgPCw3… 提取碼: jags
(相信進去後,你們還會回來點讚的! =.=)