第一部分: 作用域和閉包
一、作用域
1. 作用域:存儲並查找變量的規則
2. 源代碼在執行之前(編譯)會經歷三個步驟:
- 分詞/此法分析:將代碼字符串分解成有意義的代碼塊(詞法單元)
- 解析/語法分析:將詞法單元流轉換成抽象語法樹(AST)
- 代碼生成:將抽象語法樹轉換成可執行代碼
3. LHS查詢: 變量出現在賦值操作左側, RHS查詢: 變量出現在賦值操作右側
4. 作用域嵌套:在當前作用域中無法找到某個變量的時候,引擎就會在外層的嵌套作用域中繼續查找,直到找到該變量,或抵達最外層的作用域
5. 當引擎執行 LHS 查詢時,如果在頂層(全局作用域)中也無法找到目標變量,全局作用域中就會創建一個具有該名稱的變量,並將其返還給引擎,前提是程序運行在非 “嚴格模式”下
6. 不成功的 RHS 引用會導致拋出 ReferenceError 異常。不成功的 LHS 引用會導致自動隱式 地創建一個全局變量(非嚴格模式下),該變量使用 LHS 引用的目標作為標識符,或者拋出 ReferenceError 異常(嚴格模式下)。
function foo(a) { console.log( a + b ); b = a; // b is not defined
} foo(2);
【解析】
第一次對 b 進行 RHS 查詢時是無法找到該變量的。也就是說,這是一個“未聲明”的變 量,因為在任何相關的作用域中都無法找到它。
如果 RHS 查詢在所有嵌套的作用域中遍尋不到所需的變量,引擎就會拋出 ReferenceError 異常。值得注意的是,ReferenceError 是非常重要的異常類型。
相較之下,當引擎執行 LHS 查詢時,如果在頂層(全局作用域)中也無法找到目標變量,全局作用域中就會創建一個具有該名稱的變量,並將其返還給引擎,前提是程序運行在非 “嚴格模式”下。
二、詞法作用域
1. 在多層的嵌套作用域中可以定義同名的標識符,這叫作“遮蔽效應”(內部的標識符“遮蔽”了外部的標識符)。
拋開遮蔽效應, 作用域查找始終從運行時所處的最內部作用域開始,逐級向外或者說向上進行,直到遇見 第一個匹配的標識符為止。
2. 全局變量會自動成為全局對象(比如瀏覽器中的 window 對象)的屬性,因此 可以不直接通過全局對象的詞法名稱,而是間接地通過對全局對象屬性的引用來對其進行訪問。
三、函數作用域和塊作用域
1. 函數作用域
屬於這個函數的全部變量都可以在整個函數的范圍內使用及復 用(事實上在嵌套的作用域中也可以使用)
2. 函數聲明
function foo () {}
函數表達式
var foo = function () {}
匿名函數表達式
function () {} // 在setTimeout 中使用
3. 立即執行函數表達式(IIFE): Immediately Invoked Function Expression
var a = 2; (function foo() { var a = 3; console.log( a ); // 3 })(); console.log( a ); // 2 //另一種形式:(function(){ .. }()),功能上是一致的。選擇哪個全憑個人喜好
4. 塊作用域
for (var i=0; i<10; i++) { console.log( i ); }
【解析】 for 循環的頭部直接定義了變量 i,通常是因為只想在 for 循環內部的上下文中使用 i,而忽略了 i 會被綁定在外部作用域(函數或全局)中的事實。
這就是塊作用域的用處。變量的聲明應該距離使用的地方越近越好,並最大限度地本地化。
if 和 for 中使用 var 聲明變量時,它寫在哪里都是一樣的,因為它們最終都會屬於外部作用域。這樣寫只是為了風格更易讀而偽裝出的形式上的塊作用域。
5. let
let 關鍵字可以將變量綁定到所在的任意作用域中, let 為其聲明的變量隱式地了所在的塊作用域。
var foo = true; if (foo) { let bar = foo * 2; console.log( bar ); } console.log( bar ); // ReferenceError
6. 提升
聲明會被視為存在於其所出現的作用域的整個范圍內。使用 let 進行的聲明不會在塊作用域中進行提升。聲明的代碼被運行之前,聲明並不“存在”。
{ console.log( bar ); // ReferenceError! let bar = 2; }
四、提升
1. 包括變量和函數在內的所有聲明都會在任何代碼被執行前首先被處理。
無論作用域中的聲明出現在什么地方,都將在代碼本身被執行前首先進行處理。 可以將這個過程形象地想象成所有的聲明(變量和函數)都會被“移動”到各自作用域的 最頂端,這個過程被稱為提升。
var a = 2 // JavaScript 實際上會將其看成兩個聲明: // var a; // a = 2; // 第一個定義聲明是在編譯階段進行的。第二個賦值聲明會被留在 原地等待執行階段。
a = 2; var a; console.log( a ); // 2 console.log(a); var a = 2; // undefined
2. 函數優先
多個不重復聲明的代碼中,函數會首先被提升,然后才是變量。
foo(); // 1 var foo; function foo() { console.log(1); } foo = function() { console.log(2); };
會被引擎理解為如下形式:
function foo() { console.log(1); } foo(); // 1 foo = function() { console.log(2); };
五、作用域閉包
1. 定義
當函數可以記住並訪問所在的詞法作用域,即使函數是在當前詞法作用域之外執行,這時就產生了閉包。
function foo() { var a = 2; function bar() { console.log(a); } return bar; } var baz = foo(); baz(); // 2
【解析】
bar() 的詞法作用域能夠訪問 foo() 的內部作用域。然后我們將 bar() 函數本身當作 一個值類型進行傳遞,bar() 在自己定義的詞法作用域以外的地方執行。
在 foo() 執行后,因為 bar() 本身在使用內部作用域,因此內部作用域依然存在。——bar() 依然持有對該作用域的引用,而這個引用就叫作閉包。
【結論】
無論通過何種手段將內部函數傳遞到所在的詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函數都會使用閉包。
2. 循環和閉包
for (var i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, i*1000 ); } // 6, 6, 6, 6, 6
【解析】
延遲函數的回調會在循環結束時才執行。事實上, 當定時器運行時即使每個迭代中執行的是setTimeout(.., 0),所有的回調函數依然是在循 環結束后才會被執行,因此會每次輸出一個 6 出來。
缺陷是我們試圖假設循環中的每個迭代在運行時都會給自己“捕獲”一個 i 的副本。但是根據作用域的工作原理,實際情況是盡管循環中的五個函數是在各個迭代中分別定義的,但是它們都被封閉在一個共享的全局作用域中,因此實際上只有一個 i。
【改進】
for (var i=1; i<=5; i++) { (function() { var j = i; setTimeout( function timer() { console.log( j ); }, j*1000 ); })(); }
或者:
for (var i=1; i<=5; i++) { (function(j) { setTimeout(function timer() { console.log( j ); }, j*1000 ); })(i); }
迭代內使用 IIFE 會為每個迭代都生成一個新的作用域,使得延遲函數的回調可以將新的作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變量供我們訪問。
3. 塊作用域和閉包
for (var i=1; i<=5; i++) { let j = i; // 是的,閉包的塊作用域! setTimeout( function timer() { console.log( j ); }, j*1000 ); }
【解析】
let 聲明,可以用來劫持塊作用域,並且在這個塊作用域中聲明一個變量。
本質上這是將一個塊轉換成一個可以被關閉的作用域
for (let i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, i*1000 ); }
【解析】
for 循環頭部的 let 聲明還會有一 個特殊的行為。這個行為指出變量在循環過程中不止被聲明一次,每次迭代都會聲明。隨 后的每個迭代都會使用上一個迭代結束時的值來初始化這個變量
第二部分 this 和對象原型
一、關於 this
1. 誤解:指向自身
function foo () { this.num = 0 } foo() console.log(foo.num) // undefined console.log(window.num); // console.log(global.num) // 0
【解析】
執行 foo.count = 0 時,的確向函數對象 foo 添加了一個屬性 count。但是函數內部代碼 this.count 中的 this 並不是指向那個函數對象。
這段代碼在 無意中創建了一個全局變量 count,它的值為 NaN
2. this 到底是什么?
this 是在運行時進行綁定的,並不是在編寫時綁定,它的上下文取決於函數調 用時的各種條件。this 的綁定和函數聲明的位置沒有任何關系,只取決於函數的調用方式
【補充】
函數調用的三種方式:
- thisObj.myFunc()
- myFunc.call(thisObj, ...arg)
- myFunc.apply(thisObj, [...arg])
obj.call(thisObj, ...):把 obj 的 this 綁定到 thisObj,從而使 thisObj 具備 obj 中的屬性和方法
A.call(B, ...args): A 打電話給 B, B 把參數 args 給 A,A 接受參數把結果返回給 B
二、this 全面解析
1. 調用位置
分析調用棧,調用位置就在當前正在執行的函數的前一個調用中。
2. 綁定規則
(1)默認綁定
純粹的函數調用,函數內 this 指向全局對象(window / global)
注意:只有函數運行在非嚴格模式下,默認綁定才能綁定到全局對象。
function foo() { "use strict"; console.log(this.a); } var a = 2; foo(); // TypeError: this is undefined
(2)隱式綁定
當函數引 用有上下文對象時,隱式綁定規則會把函數調用中的 this 綁定到這個上下文對象。
function foo() { console.log(this.a); } var obj2 = { a: 42, foo: foo }; var obj1 = { a: 2, obj2: obj2 }; obj1.obj2.foo(); // 42
【注意】對象屬性引用鏈中只有最頂層或者說最后一層會影響調用位置
(3)顯示綁定:
function foo() { console.log(this.a); } var obj = { a:2 }; foo.call( obj ); // 2
通過 foo.call(..),我們可以在調用 foo 時強制把它的 this 綁定到 obj 上。
(4)作為構造函數調用
函數內的 this 指向通過構造函數生成的對象實例
【補充】
實際上並不存在所謂的“構造函數”,只有對於函數的“構造調用”。
使用 new 來調用函數,或者說發生構造函數調用時,會自動執行下面的操作。
- 創建(或者說構造)一個全新的對象。
- 這個新對象會被執行[[原型]]連接。
- 這個新對象會綁定到函數調用的this。
- 如果函數沒有返回其他對象,那么new表達式中的函數調用會自動返回這個新對象。
3. 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 綁定規則:
(1) 由new調用?綁定到新創建的對象。
(2) 由call或者apply(或者bind)調用?綁定到指定的對象。
(3) 由上下文對象調用?綁定到那個上下文對象。
(4) 默認:在嚴格模式下綁定到undefined,否則綁定到全局對象。
4. 箭頭函數
箭頭函數不使用 this 的四種標准規則,而是根據外層(函數或者全局)作用域來決 定 this。
function foo() { // 返回一個箭頭函數 return (a) => { // this 繼承自 foo() console.log(this.a ); }; } var obj1 = { a:2 }; var obj2 = { a:3 }; var bar = foo.call( obj1 ); bar.call( obj2 ); // 2, 不是 3 ! // foo() 內部創建的箭頭函數會捕獲調用時 foo() 的 this。由於 foo() 的 this 綁定到 obj1, bar(引用箭頭函數)的 this 也會綁定到 obj1,箭頭函數的綁定無法被修改。(new 也不 行!)
箭頭函數最常用於回調函數中,例如事件處理器或者定時器:
function foo() { setTimeout(() => { // 這里的 this 在此法上繼承自 foo() console.log(this.a); },100); } var obj = { a:2 }; foo.call( obj ); // 2
三、對象
1. JavaScript 中的六種主要類型
- undefined
- null
- number
- boolean
- string
- object
前五個是基本類型,但是typeof null === object,why?
不同的對象在底層都表示為二進制,在 JavaScript 中二進制前三位都為 0 的話會被判 斷為 object 類型,null 的二進制表示是全 0,自然前三位也是 0,所以執行 typeof 時會返回“object”
2. 對象的組成
obj = {
key: value
}
存儲在對象容器內部的是這些屬性的名稱,它們就像指針(從技術角度 來說就是引用)一樣,指向這些值真正的存儲位置
var myObject = { a: 2 }; myObject.a; // 2 myObject["a"]; // 2 // obj.['key']:鍵訪問 // obj.key:屬性訪問(屬性名滿足標識符的命名規范,形如'super-fun' 則不能使用屬性訪問)
屬性名永遠是字符串,如果你使用 string(字面量)以外的其他值作為屬性 名,那它首先會被轉換為一個字符串
3. 數組
數組也是對象,所以雖然每個下標都是整數,你仍然可以給數組添加屬性。
var myArray = [ "foo", 42, "bar" ]; myArray.baz = "baz"; myArray.length; // 3 myArray.baz; // "baz" // 雖然添加了命名屬性(無論是通過 . 語法還是 [] 語法),數組的 length 值並未發 生變化
【注意】
如果你試圖向數組添加一個屬性,但是屬性名“看起來”像一個數字,那它會變成 一個數值下標(因此會修改數組的內容而不是添加一個屬性):
var myArray = [ "foo", 42, "bar" ]; myArray["3"] = "baz"; myArray.length; // 4 myArray[3]; // "baz"
4. 復制對象:
var newObj = JSON.parse( JSON.stringify(someObj));
對於 JSON 安全(也就是說可以被序列化為一個 JSON 字符串並且可以根據這個字符串解 析出一個結構和值完全一樣的對象)的對象適用
var newObj = Object.assign({}, myObject); Object.assign(target, [...source])
遍歷一個或多個源對象的所有可枚舉的自有鍵(owned key) 並把它們復制 (使用 = 操作符賦值) 到目標對象,最后返回目標對象
5. 屬性描述符
獲取
Object.getOwnPropertyDescriptor(myObject, key) var myObject = { a:2 }; Object.getOwnPropertyDescriptor(myObject, "a"); // { // value: 2, // writable: true, // enumerable: true, // configurable: true // }
添加或修改
// Object.defineProperty(myObject, key, {...}) var myObject = {}; Object.defineProperty( myObject, "a", { value: 2, writable: true, configurable: true, enumerable: true, }); myObject.a; // 2 //注意:即便屬性是 configurable:false,我們還是可以 把 writable 的狀態由 true 改為 false,但是無法由 false 改為 true。
6. 不變性
淺不變性:
const obj = { a: 1, b: 2, } obj.a = 3 console.log(obj) // { a: 3, b:2 }
如何讓obj的屬性也不可變?
1. 對象常量
結合 writable:false 和 configurable:false 就可以創建一個真正的常量屬性(不可修改、 重定義或者刪除)
var myObject = {}; Object.defineProperty( myObject, "FAVORITE_NUMBER", { value: 42, writable: false, configurable: false });
2. 禁止擴展
Object.prevent Extensions(..) var myObject = { a:2 }; Object.preventExtensions( myObject ); myObject.b = 3; myObject.b; // undefined
3. 密封
Object.seal(obj)
創建一個“密封”的對象,這個方法實際上會在一個現有對象上調用 Object.preventExtensions(..) 並把所有現有屬性標記為 configurable:false。
4. 凍結
Object.freeze(obj)
創建一個凍結對象,這個方法實際上會在一個現有對象上調用 Object.seal(..) 並把所有“數據訪問”屬性標記為 writable:false
【補充】
深度凍結:這個對象上調用 Object.freeze(..), 然后遍歷它引用的所有對象並在這些對象上調用 Object.freeze(..)
凍結的邏輯
Object.freeze(obj) => Object.seal(obj) => Object.preventExtensions(obj) => { configurable: false }
7. 對象屬性的訪問
[[get]] [[put]] [[getter]] [[setter]]
8. 存在性
針對可能的情況:
var myObject = { a: undefined }; myObject.a; // undefined myObject.b; // undefined
僅根據返回值無法判斷出到底變量的值為 undefined 還是變量不存在,可以在不訪問屬性值的情況下判斷對象中是否存在此屬性:
var myObject = { a:2 }; ("a" in myObject); // true ("b" in myObject); // false myObject.hasOwnProperty( "a" ); // true myObject.hasOwnProperty( "b" ); // false
in 操作符會檢查屬性是否在對象及其 [[Prototype]] 原型鏈中。相比之下, hasOwnProperty(..) 只會檢查屬性是否在 myObject 對象中,不會檢查 [[Prototype]] 鏈。
【注意】雖然數組也是對象,但是 in 操作符在數組中檢查的是數組的屬性名,而不是值
4 in [2, 4, 6] // false
所有的普通對象都可以通過對於 Object.prototype 的委托來訪問 hasOwnProperty(..), 但是有的對象可能沒有連接到 Object.prototype ( 通 過 Object. create(null) )。在這種情況下,形如 myObejct.hasOwnProperty(..) 就會失敗。
這時可以使用一種更加強硬的方法來進行判斷:Object.prototype.hasOwnProperty. call(myObject,"a"),它借用基礎的 hasOwnProperty(..) 方法並把它顯式綁定到 myObject 上。
獲取對象所有的屬性
Object.keys(..) // 會返回一個數組,包含所有可枚舉屬性。
類似地:
for (key in obj) { console.log(key) console.log(obj[key]) // 不能寫成 obj.key }
Object.getOwnPropertyNames(..) // 返回一個數組,包含所有屬性,無論它們是否可枚舉。
in 和 hasOwnProperty(..) 的區別在於是否查找 [[Prototype]] 鏈,然而,Object.keys(..) 和 Object.getOwnPropertyNames(..) 都只會查找對象直接包含的屬性。
並沒有內置的方法可以獲取 in 操作符使用的屬性列表(對象本身的屬性以 及 [[Prototype]] 鏈中的所有屬性)。不過你可以遞歸遍歷某個對象的整條 [[Prototype]] 鏈並保存每一層中使用 Object.keys(..) 得到的屬性列表——只包含可枚舉屬性。
【注意】遍歷對象屬性時的順序是不確定的,遍歷數組下標時采用的是數字順序,如何直接遍歷值而不是數組下標(或者對象屬性)呢
var myArray = [ 1, 2, 3 ]; for (var v of myArray) { console.log(v); } // 1 // 2 // 3
四、混合對象"類"
- JavaScript 中的 "類"
- 構造函數和類
- 類的繼承
- 多態
- 多重繼承
- 顯示混入
- 隱式混入
五、原型
1. [[prototype]] __proto__
var anotherObject = { a:2 }; // 創建一個關聯到 anotherObject 的對象 var myObject = Object.create( anotherObject ); myObject.a; // 2 // Object.create(...) 會創建一個 對象並把這個對象的 [[Prototype]] 關聯到指定的對象。
2. Object.prototype
所有普通的 [[Prototype]] 鏈最終都會指向內置的 Object.prototype,這個 Object.prototype 對象,所以它包含 JavaScript 中許多通用的功能。
3. 屬性設置和屏蔽
屬性設置
obj.foo = 'bar'
(1)obj 中包含 foo,則直接修改
(2)obj 中存在 foo,且在 obj.__proto__ 上也存在 foo,則會發生屬性屏蔽,即:myObject 中包含的 foo 屬性會屏蔽原型鏈上層的所有 foo 屬性,因為 myObject.foo 總是會選擇原型鏈中最底層的 foo 屬性
(3)obj 中不存在 foo,但存在於 obj.__proto__ 原型鏈上,則會出現三種情況:
- 如果在[[Prototype]]鏈上層存在名為 foo 的普通數據訪問屬性並且沒有被標記為只讀(writable:false),那就會直接在 myObject 中添加一個名為 foo 的新 屬性,它是屏蔽屬性。
- 如果在[[Prototype]]鏈上層存在foo,但是它被標記為只讀(writable:false),那么 無法修改已有屬性或者在 myObject 上創建屏蔽屬性。如果運行在嚴格模式下,代碼會 拋出一個錯誤。否則,這條賦值語句會被忽略。總之,不會發生屏蔽。
- 如果在[[Prototype]]鏈上層存在 foo 並且它是一個 setter,那就一定會 調用這個 setter。foo 不會被添加到(或者說屏蔽於)myObject,也不會重新定義 foo 這個 setter。
4. "類"
function Foo(name) { this.name = name } Foo.prototype.getName = function () { return this.name } var a = new Foo('a'); var b = new Foo('b'); a.getName() // 'a' b.getName() // 'b' Object.getPrototypeOf( a ) === Foo.prototype; // true
new Foo() 會生成一個新對象(我們稱之為 a),這個新對象的內部鏈接 [[Prototype]] 關聯 的是 Foo.prototype 對象。
最后我們得到了兩個對象,它們之間互相關聯,就是這樣。我們並沒有初始化一個類,實 際上我們並沒有從“類”中復制任何行為到一個對象中,只是讓兩個對象互相關聯。
創建的過程中,a 和 b 的內部 [[Prototype]] 都會關聯到 Foo.prototype 上。當 a 和 b 中無法找到 myName 時,它會(通過委托,參見第 6 章)在 Foo.prototype 上找到。
5. 原型鏈
如果在對象上沒有找到需要的屬性或者方法引用,引擎就 會繼續在 [[Prototype]] 關聯的對象上進行查找。同理,如果在后者中也沒有找到需要的 引用就會繼續查找它的 [[Prototype]],以此類推。這一系列對象的鏈接被稱為“原型鏈”。
// Object.create(...) var foo = { something: function() { console.log( "Tell me something good..." ); } }; var bar = Object.create( foo ); bar.something(); // Tell me something good...
Object.create(..) 會創建一個新對象(bar)並把它關聯到我們指定的對象(foo),這樣 我們就可以充分發揮 [[Prototype]] 機制的威力(委托)並且避免不必要的麻煩(比如使 用 new 的構造函數調用會生成 .prototype 和 .constructor 引用)。
【補充】
Object.create(null) 會 創 建 一 個 擁 有 空( 或 者 說 null)[[Prototype]] 鏈接的對象,這個對象無法進行委托。由於這個對象沒有原型鏈,所以 instanceof 操作符(之前解釋過)無法進行判斷,因此總是會返回 false。 這些特殊的空 [[Prototype]] 對
象通常被稱作“字典”,它們完全不會受到原 型鏈的干擾,因此非常適合用來存儲數據。
Object.create() 的 polyfill 代碼
if (!Object.create) { Object.create = function(o) { function F() {} F.prototype = o; return new F(); }; }
可以發現,本質上還是使用構造函數
6. 類構造函數的本質
這些 JavaScript 機制和傳統面向類語言中的“類初始化”和“類繼承”很相似,但 是 JavaScript 中的機制有一個核心區別,那就是不會進行復制,對象之間是通過內部的 [[Prototype]] 鏈關聯的。
六、行為委托
var anotherObject = { cool: function() { console.log( "cool!" ); } }; var myObject = Object.create( anotherObject ); myObject.doCool = function() { this.cool(); // 內部委托! }; myObject.doCool(); // "cool!"
調用的 myObject.doCool() 是實際存在於 myObject 中的,這可以讓我們的 API 設 計更加清晰。從內部來說,我們的實現遵循的是委托設計模式,通過 [[Prototype]] 委托到 anotherObject.cool()。
換句話說,內部委托比起直接委托可以讓 API 接口設計更加清晰。
委托行為意味着某些對象(XYZ)在找不到屬性或者方法引用時會把這個請求委托給另一個對象(Task)。
這是一種極其強大的設計模式,和父類、子類、繼承、多態等概念完全不同。在你的腦海中對象並不是按照父類到子類的關系垂直組織的,而是通過任意方向的委托關聯並排組織的。
以上就是 《你不知道的 JavaScript 上卷》的學習筆記,下周開始看下卷。