寫這篇《你不知道的javascript讀書筆記》是因為最近找到了一本非常好的js的書,叫《你不知道的javascript》,講的非常通俗易懂,但是又非常深入原理,豆瓣上評分9.4,可以說是非常高了。這本書有三本,而且比較新,裡面有許多ES6的內容,所以寫一篇讀書筆記,推薦給大家,共勉。
如果每次遇到 JavaScript 中出乎意料的行為時,你的反應就是把它加入黑名單(很多人都是這麼做的),那用不了多久你就會把 JavaScript 語言真正的多樣性全部排除。剩下的部分就是非常著名的“好的部分”(Good Parts),但是親愛的讀者們,我懇請你們把它稱作“簡單的部分”、“安全的部分”甚至“不完整的部分”。“你不知道的 JavaScript”系列叢書要做的事恰好相反:學習並且深入理解整個 JavaScript,尤其是那些“難的部分”。
作用域
LHS和RHS
當變數出現在賦值操作的左側時進行 LHS 查詢,出現在右側時進行 RHS 查詢。講得更準確一點,RHS 查詢與簡單地查找某個變數的值別無二致,而 LHS 查詢則是試圖找到變數的容器本身,從而可以對其賦值。從這個角度說,RHS 並不是真正意義上的“賦值操作的右側”,更準確地說是“非左側”。
考慮以下代碼:
console.log( a );
其中對 a 的引用是一個 RHS 引用,因為這裡 a 並沒有賦予任何值。相應地,需要查找並取得 a 的值,這樣才能將值傳遞給 console.log(..)
。
相比之下,例如:
a = 2;
這裡對 a 的引用則是 LHS 引用,因為實際上我們並不關心當前的值是什麼,只是想要為 =2 這個賦值操作找到一個目標。
作用域嵌套
我們說過,作用域是根據名稱查找變數的一套規則。實際情況中,通常需要同時顧及幾個作用域。當一個塊或函式嵌套在另一個塊或函式中時,就發生了作用域的嵌套。因此,在當前作用域中無法找到某個變數時,引擎就會在外層嵌套的作用域中繼續查找,直到找到該變數,或抵達最外層的作用域(也就是全局作用域)為止。
考慮以下代碼:
function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4
對 b 進行的 RHS 引用無法在函式 foo 內部完成,但可以在上一級作用域(在這個例子中就是全局作用域)中完成。
嚴格模式
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
第一次對 b 進行 RHS 查詢時是無法找到該變數的。也就是說,這是一個“未聲明”的變數,因為在任何相關的作用域中都無法找到它。如果 RHS 查詢在所有嵌套的作用域中遍尋不到所需的變數,引擎就會拋出 ReferenceError異常。值得注意的是,ReferenceError 是非常重要的異常類型。
ES5 中引入了“嚴格模式”。同正常模式,或者說寬鬆 / 懶惰模式相比,嚴格模式在行為上有很多不同。其中一個不同的行為是嚴格模式禁止自動或隱式地創建全局變數。因此,在嚴格模式中 LHS 查詢失敗時,並不會創建並返回一個全局變數,引擎會拋出同 RHS 查詢失敗時類似的 ReferenceError 異常。接下來,如果 RHS 查詢找到了一個變數,但是你嘗試對這個變數的值進行不合理的操作,比如試圖對一個非函式類型的值進行函式調用,或着引用 null 或 undefined 類型的值中的屬性,那麼引擎會拋出另外一種類型的異常,叫作 TypeError。ReferenceError 同作用域判別失敗相關,而 TypeError 則代表作用域判別成功了,但是對結果的操作是非法或不合理的。
欺騙詞法
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(..) 的詞法作用域進行了修改。
事實上,和前面提到的原理一樣,這段代碼實際上在 foo(..) 內部創建了一個變數 b,並遮蔽了外部(全局)作用域中的同名變數。當 console.log(..) 被執行時,會在 foo(..) 的內部同時找到 a 和 b,但是永遠也無法找到外部的 b。因此會輸出“1, 3”而不是正常情況下會輸出的“1, 2”。
with()
with 通常被當作重複引用同一個對象中的多個屬性的快捷方式,可以不需要重複引用對象本身。
比如:
var obj = {
a: 1,
b: 2,
c: 3
};
// 單調乏味的重複 "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 簡單的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}
但實際上這不僅僅是為了方便地訪問對象屬性。考慮如下代碼:
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 和 o2 兩個對象。其中一個具有 a 屬性,另外一個沒有。foo(..) 函式接受一個 obj 參數,該參數是一個對象引用,並對這個對象引用執行了 with(obj) {..}
。
在 with 塊內部,我們寫的代碼看起來只是對變數 a 進行簡單的詞法引用,實際上就是一個LHS 引用(檢視第 1 章),並將 2 賦值給它。當我們將 o1 傳遞進去,a=2 賦值操作找到了 o1.a 並將 2 賦值給它,這在後面的 console.log(o1.a)
中可以體現。而當 o2 傳遞進去,o2 並沒有 a 屬性,因此不會創建這個屬性,o2.a 保持 undefined。
函式中的作用域
首先需要研究一下函式作用域及其背後的一些內容。
考慮下面的代碼:
function foo(a) {
var b = 2;
// 一些代碼
function bar() {
// ...
}
// 更多的代碼
var c = 3;
}
由於標識符 a、b、c 和 bar 都附屬於 foo(..) 的作用域氣泡,因此無法從 foo(..) 的外部對它們進行訪問。也就是說,這些標識符全都無法從全局作用域中進行訪問,因此下面的代碼會導致 ReferenceError 錯誤:
bar(); // 失敗
console.log( a, b, c ); // 三個全都失敗
但是,這些標識符(a、b、c、foo 和 bar)在 foo(..) 的內部都是可以被訪問的,同樣在bar(..) 內部也可以被訪問(假設 bar(..) 內部沒有同名的標識符聲明)。
函式作用域
我們已經知道,在任意代碼片段外部添加包裝函式,可以將內部的變數和函式定義“隱藏”起來,外部作用域無法訪問包裝函式內部的任何內容。
例如:
var a = 2;
function foo() { // <-- 添加這一行
var a = 3;
console.log( a ); // 3
} // <-- 以及這一行
foo(); // <-- 以及這一行
console.log( a ); // 2
雖然這種技術可以解決一些問題,但是它並不理想,因為會導致一些額外的問題。首先,必須聲明一個具名函式 foo(),意味着 foo 這個名稱本身“污染”了所在作用域(在這個例子中是全局作用域)。
其次,必須顯式地通過函式名(foo())調用這個函式才能運行其中的代碼。如果函式不需要函式名(或者至少函式名可以不污染所在作用域),並且能夠自動運行,這將會更加理想。
幸好,JavaScript 提供了能夠同時解決這兩個問題的方案。
var a = 2;
(function foo(){ // <-- 添加這一行
var a = 3;
console.log( a ); // 3
})(); // <-- 以及這一行
console.log( a ); // 2
函式聲明和函式表達式之間最重要的區別是它們的名稱標識符將會綁定在何處。比較一下前面兩個代碼片段。
第一個片段中 foo 被綁定在所在作用域中,可以直接通過foo() 來調用它。
第二個片段中 foo 被綁定在函式表達式自身的函式中而不是所在作用域中。換句話說,(function foo(){ .. })
作為函式表達式意味着 foo 只能在 .. 所代表的位置中被訪問,外部作用域則不行。foo 變數名被隱藏在自身中意味着不會非必要地污染外部作用域。
對象
可計算屬性名
ES6 增加了可計算屬性名,可以在文字形式中使用 [] 包裹一個表達式來當作屬性名
根據這個特性,便可以很方便的構造動態的屬性。
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
可計算屬性名最常用的場景可能是 ES6 的符號(Symbol),它們是一種新的基礎數據類型,包含一個不透明且無法預測的值(從技術角度來說就是一個字符串)。
比如:
var myObject = {
[Symbol.Something]: "hello world"
}
陣列的屬性訪問
陣列也支持[]的訪問形式,陣列也支持 [] 訪問形式,不過就像我們之前提到過的,陣列有一套更加結構化的值存儲機制(不過仍然不限制值的類型)。
正常的字符串屬性訪問可以儲存值,但是不會被計算到length中。
var myArray = [ "foo", 42, "bar" ];
myArray.length; // 3
myArray[0]; // "foo"
myArray[2]; // "bar"
// 陣列也是對象,所以雖然每個下標都是整數,你仍然可以給陣列添加屬性:
var myArray = [ "foo", 42, "bar" ];
myArray.baz = "baz";
myArray.length; // 3
myArray.baz; // "baz"
你完全可以把陣列當作一個普通的鍵 / 值對象來使用,並且不添加任何數值索引,但是這並不是一個好主意。陣列和普通的對象都根據其對應的行為和用途進行了優化,所以最好只用對象來存儲鍵 / 值對,只用陣列來存儲數值下標 / 值對。
但屬性的值如果是數字的話,情況便會發生變化,看似很像“屬性”的值在這裡卻變成了下標。看代碼:
var myArray = [ "foo", 42, "bar" ];
myArray["3"] = "baz";
myArray.length; // 4
myArray[3]; // "baz"
可以看到,這裡的length發生了變化,說明[“3”]已經變成了下標。
複製對象
我自己的理解中,淺拷貝中新對象的值只是對原對象的一種引用,並不是真真的拷貝,而像是一個快捷方式。
那麼,該如何簡單的完成對象的深拷貝呢?
許多 JavaScript 框架都提出了自己的解決辦法,
對於 JSON 安全(也就是說可以被序列化為一個 JSON 字符串並且可以根據這個字符串解
析出一個結構和值完全一樣的對象)的對象來說,有一種巧妙的複製方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );
當然,這種方法需要保證對象是 JSON 安全的,所以只適用於部分情況。
相比深複製,淺複製非常易懂並且問題要少得多,所以 ES6 定義了 Object.assign(..) 方法來實現淺複製。Object.assign(..) 方法的第一個參數是目標對象,之後還可以跟一個或多個源對象。它會遍歷一個或多個源對象的所有可枚舉(enumerable,參見下面的代碼)的自有鍵(owned key,很快會介紹)並把它們複製(使用 = 操作符賦值)到目標對象,最後返回目標對象,就像這樣:
var newObj = Object.assign( {}, myObject );
newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true
存在性
如 myObject.a 的屬性訪問返回值可能是 undefined,但是這個值有可能是屬性中存儲的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]] 鏈。
所有的普通對象都可以通過對於Object.prototype
的委託來訪問hasOwnProperty(..)
,但是有的對象可能沒有連接到 Object.prototype(通過 Object.create(null) 來創建)。在這種情況下,形如myObejct.hasOwnProperty(..)
就會失敗。
這時可以使用一種更加強硬的方法來進行判斷:
object.prototype.hasOwnProperty.call(myObject,"a")
它借用基礎的 hasOwnProperty(..)
方法並把它顯式綁定到 myObject 上。
枚舉
var myObject = { };
Object.defineProperty(
myObject,
"a",
// 讓 a 像普通屬性一樣可以枚舉
{
enumerable: true, value: 2
}
);
Object.defineProperty(
myObject,
"b",
// 讓 b 不可枚舉
{
enumerable: false, value: 3
}
);
我們可以用循環的方法來實際測試一下該屬性目前是否可以枚舉:
myObject.b; // 3
("b" in myObject); // true
myObject.hasOwnProperty( "b" ); // true
// .......
for (var k in myObject) {
console.log( k, myObject[k] );
}
// "a" 2
或者,我們可以調用對象的預設的方法來直接檢視是否可以枚舉:
myObject.propertyIsEnumerable( "a" ); // true
myObject.propertyIsEnumerable( "b" ); // false
Object.keys( myObject ); // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]
propertyIsEnumerable(..)
會檢查給定的屬性名是否直接存在於對象中(而不是在原型鏈
上)並且滿足enumerable:true。Object.keys(..)
會返回一個陣列,包含所有可枚舉屬性,Object.getOwnPropertyNames(..)
會返回一個陣列,包含所有屬性,無論它們是否可枚舉。in
和hasOwnProperty(..)
的區別在於是否查找 [[Prototype]] 鏈,然而,Object.keys(..)
和Object.getOwnPropertyNames(..)
都只會查找對象直接包含的屬性。(目前)並沒有內置的方法可以獲取 in 操作符使用的屬性列表(對象本身的屬性以及 [[Prototype]] 鏈中的所有屬性,參見第 5 章)。不過你可以遞歸遍歷某個對象的整條[[Prototype]] 鏈並儲存每一層中使用Object.keys(..)
得到的屬性列表——只包含可枚舉屬性。
for of 原理解析
陣列有內置的 @@iterator,因此 for..of 可以直接應用在陣列上。我們使用內置的 @@iterator 來手動遍曆數組,看看它是怎麼工作的:
var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();
it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }
如你所見,調用迭代器的 next() 方法會返回形式為 { value: .. , done: .. } 的值,
value 是當前的遍歷值,done 是一個布爾值,表示是否還有可以遍歷的值
var myObject = {
a: 2,
b: 3
};
Object.defineProperty(myObject, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function () {
var o = this;
var idx = 0;
var ks = Object.keys(o);
return {
next: function () {
return {
value: o[ks[idx++]],
done: (idx > ks.length)
};
}
};
}
});
// 手動遍歷 myObject
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }
// 用 for..of 遍歷 myObject
for (var v of myObject) {
console.log(v);
}
// 2
// 3
原型
[[Prototype]]
先看這樣一段代碼:
var anotherObject = {
a:2
};
// 創建一個關聯到 anotherObject 的對象
var myObject = Object.create( anotherObject );
myObject.a; // 2
現在 myObject 對象的 [[Prototype]]
關聯到了 anotherObject。顯然 myObject.a 並不存在,但是儘管如此,屬性訪問仍然成功地(在 anotherObject 中)找到了值 2。但是,如果 anotherObject 中也找不到 a 並且 [[Prototype]]
鏈不為空的話,就會繼續查找下去。這個過程會持續到找到匹配的屬性名或者查找完整條 [[Prototype]]
鏈。如果是後者的話,[[Get]] 操作的返回值是 undefined。
使用 for..in 遍歷對象時原理和查找 [[Prototype]] 鏈類似,任何可以通過原型鏈訪問到(並且是 enumerable,參見第 3 章)的屬性都會被枚舉。使用 in 操作符來檢查屬性在對象中是否存在時,同樣會查找對象的整條原型鏈(無論屬性是否可枚舉):
var anotherObject = {
a:2
};
// 創建一個關聯到 anotherObject 的對象
var myObject = Object.create( anotherObject );
for (var k in myObject) {
console.log("found: " + k);
}
// found: a
("a" in myObject); // true
因此,當你通過各種語法進行屬性查找時都會查找 [[Prototype]]
鏈,直到找到屬性或者查找完整條原型鏈。
Object.prototype
但是到哪裡是 [[Prototype]]
的“盡頭”呢?
所有普通的 [[Prototype]]
鏈最終都會指向內置的 Object.prototype
。由於所有的“普通”(內置,不是特定主機的擴展)對象都“源於”(或者說把 [[Prototype]]
鏈的頂端設置為)這個 Object.prototype
對象,所以它包含 JavaScript 中許多通用的功能。有些功能你應該已經很熟悉了,比如說 .toString()
和 .valueOf()
,第3章還介紹過.hasOwnProperty(..)
。稍後我們還會介紹 .isPrototypeOf(..)
,這個你可能不太熟悉。
目前到:page 159
非常非常有用