《你不知道的JavaScript》系列叢書給出了很多顛覆以往對JavaScript認知的點, 讀完上卷山橄,受益匪淺恨狈,于是對其精華的知識點進行了梳理访忿。
什么是作用域
作用域是一套規(guī)則啤覆,用于確定在何處以及如何查找變量面睛。
編譯原理
JavaScript是一門編譯語言脑溢。在傳統(tǒng)編譯語言的流程中雀彼,程序中一段源代碼在執(zhí)行之前會經(jīng)歷三個步驟县踢,統(tǒng)稱為“編譯”瓷蛙。
- 分詞/詞法分析
將字符串分解成有意義的代碼塊悼瓮,代碼塊又稱詞法單元。比如程序var a = 2;
會被分解為var艰猬、a横堡、=、2冠桃、;
- 解析/語法分析
將詞法單元流轉(zhuǎn)換成一個由元素逐級嵌套所組成的代表了程序語法接口的書命贴,又稱“抽象語法樹”。 - 代碼生成
將抽象語法樹轉(zhuǎn)換為機器能夠識別的指令食听。
理解作用域
作用域 分別與編譯器胸蛛、引擎進行配合完成代碼的解析
- 引擎執(zhí)行時會與作用域進行交流,確定RHS與LHS查找具體變量樱报,如果查找不到會拋出異常葬项。
- 編譯器負責語法分析以及生成代碼。
- 作用域?負責收集并維護所有變量組成的一系列查詢迹蛤,并確定當前執(zhí)行的代碼對這些變量的訪問權(quán)限民珍。
對于 var a = 2
這條語句,首先編譯器會將其分為兩部分笤受,一部分是 var a
穷缤,一部分是 a = 2
。編譯器會在編譯期間執(zhí)行 var a箩兽,然后到作用域中去查找 a 變量津肛,如果 a 變量在作用域中還沒有聲明,那么就在作用域中聲明 a 變量汗贫,如果 a 變量已經(jīng)存在身坐,那就忽略 var a 語句。然后編譯器會為 a = 2 這條語句生成執(zhí)行代碼落包,以供引擎執(zhí)行該賦值操作部蛇。所以我們平時所提到的變量提升,無非就是利用這個先聲明后賦值的原理而已咐蝇!
異常
對于 var a = 10
這條賦值語句涯鲁,實際上是為了查找變量 a, 并且將 10 這個數(shù)值賦予它,這就是 LHS
查詢抹腿。 對于 console.log(a)
這條語句岛请,實際上是為了查找 a 的值并將其打印出來,這是 RHS
查詢警绩。
為什么區(qū)分 LHS
和 RHS
是一件重要的事情崇败?
在非嚴格模式下,LHS 調(diào)用查找不到變量時會創(chuàng)建一個全局變量肩祥,RHS 查找不到變量時會拋出 ReferenceError后室。 在嚴格模式下,LHS 和 RHS 查找不到變量時都會拋出 ReferenceError混狠。
作用域的工作模式
作用域共有兩種主要的工作模型岸霹。第一種是最為普遍的,被大多數(shù)編程語言所采用的詞法作用域( JavaScript 中的作用域就是詞法作用域)檀蹋。另外一種是動態(tài)作用域松申,仍有一些編程語言在?使用(比如Bash腳本、Perl中的一些模式等)俯逾。
詞法作用域
詞法作用域是一套關(guān)于引擎如何尋找變量以及會在何處找到變量的規(guī)則。詞法作用域最重要的特征是它的定義過程?發(fā)生在代碼的書寫階段(假設(shè)沒有使用 eval() 或 with )舅逸。來看示例代碼:
function foo() {
console.log(a); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar()
詞法作用域讓foo()中的a通過RHS引用到了全局作用域中的a桌肴,因此會輸出2。
動態(tài)作用域
而動態(tài)作用域只關(guān)心它們從何處調(diào)用琉历。換句話說坠七,作用域鏈是基于調(diào)用棧的,而不是代碼中的作用域嵌套旗笔。因此彪置,如果 JavaScript 具有動態(tài)作用域,理論上蝇恶,下面代碼中的 foo() 在執(zhí)行時將會輸出3拳魁。
function foo() {
console.log(a); // 3
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar()
函數(shù)?作用域
匿名與具名
?對于函數(shù)表達式一個最熟悉的場景可能就是???回調(diào)函數(shù)了,比如
setTimeout( function() {
console.log("I waited 1 second!")
}, 1000 )
這叫作?匿名函數(shù)表達式
撮弧。?函數(shù)表達式可以匿名潘懊,而函數(shù)聲明則不可以省略函數(shù)名。匿名函數(shù)表達式書寫起來簡單快捷,很多庫和工具也傾向鼓勵使用這種風格的代碼贿衍。但它也有幾個缺點需要考慮授舟。
- 匿名函數(shù)在棧追蹤中不會顯示出有意義的函數(shù)名,使得調(diào)試很困難。
- 如果沒有函數(shù)名,當函數(shù)需要引用自身時只能使用已經(jīng)過期的 arguments.callee 引用,比如在遞歸中贸辈。另一個函數(shù)需要引用自身的例子,是在事件觸發(fā)后事件監(jiān)聽器需要解綁自身释树。
- 匿名函數(shù)省略了對于代碼可讀性 / 可理解性很重要的函數(shù)名。一個描述性的名稱可以讓代碼不言自明。
?始終給函數(shù)表達式命名是一個最佳實踐:
setTimeout( function timeoutHandler() { // 我?有名字了
console.log("I waited 1 second!")
}, 1000 )
提升
先有聲明還是先有賦值
考慮以下代碼:
a = 2;
var a;
console.log(a); // 2
考慮另外一段代碼
console.log(a); // undefined
var a = 2;
我們習慣將 var a = 2; 看作一個聲明奢啥,而實際上 JavaScript 引擎并不這么認為署浩。它將 var a 和 a = 2 當作兩個單獨的聲明,第一個是編譯階段的任務(wù)扫尺,而第二個是執(zhí)行階段的任務(wù)筋栋。
這意味著無論作用域中的聲明出現(xiàn)在什么地方,都將在代碼本身被執(zhí)行前首先進行處理正驻”兹粒可以將這個過程形象地想象成所有的聲明(變量和函數(shù))都會被“移動”到各自作用域的最頂端,這個過程?稱為提升姑曙。
可以看出襟交,先有聲明后有賦值。
再來看以下代碼:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};
這個代碼片段經(jīng)過提升后伤靠,實際上會被理解為以下形式:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
};
這段程序中的變量標識符 foo() 被提升并分配給全局作用域捣域,因此 foo() 不會導致 ReferenceError。但是 foo 此時并?沒有賦值(如果它是一個函數(shù)聲明而不是函數(shù)表達式就會賦值
)宴合。foo()由于對 undefined 值進行函數(shù)調(diào)用而導致非法操作焕梅,因此拋出 TypeError 異常。另外即時是具名的函數(shù)表達式卦洽,名稱標識符(這里是 bar )在賦值之前也無法在所在作用域中使用贞言。
閉包
之前寫過關(guān)于閉包?的一篇文章深入淺出JavaScript之閉包(Closure)
循環(huán)和閉包
要說明閉包,for 循環(huán)是最常見的例子阀蒂。
for (var i = 1; i <= 5; i++) {
setTimeout( function timer() {
console.log(i);
}, i*1000 )
}
正常情況下该窗,我們對這段代碼行為的預期是分別輸出數(shù)字 1~5,每秒一次蚤霞,每次一個酗失。但實際?上,這段代碼在運行時會以每秒一次的?頻率輸出五次6昧绣。
它的缺陷在于?:根據(jù)作用域的工作原理规肴,?盡管循環(huán)中的五個函數(shù)是在?各個迭代中分別定義的,但是它們都被封閉在一個共享的全局作用域中滞乙,?因此實際上只有一個i奏纪。因此我們需要更多的閉包作用域。我們知道IIFE會通過聲明并立即執(zhí)行一個函數(shù)來創(chuàng)建作用域斩启,我們來進行改進:
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);
}
在迭代內(nèi)使用 IIFE 會為每個迭代都生成一個新的作用域序调,使得延遲函數(shù)的回調(diào)可以將新的作用域封閉在每個迭代內(nèi)部,每個迭代中都會含有一個具有正確值的變量供我們訪問兔簇。
重返塊作用域
我們使用 IIFE 在每次迭代時?都創(chuàng)建一個新的作用域发绢。換句話說硬耍,每次迭代我們都需要一個塊作用域。我們知道 let 聲明可以用來?劫持塊作用域边酒,那我們可以進行這樣改:
for (var i = 1; i <= 5; i++) {
let j = i;
setTimeout( function timer() {
console.log(j);
}, j*1000 )
}
本質(zhì)上這是將一個塊轉(zhuǎn)換成一個可以被關(guān)閉的作用域经柴。
?此外,for循環(huán)頭部的 let 聲明還會有一個特殊行為墩朦。這個行為指出每個迭代都會使用上一個迭代結(jié)束時的值來初始化這個變量坯认。
for (let i = 1; i <= 5; i++) {
setTimeout( function timer() {
console.log(i);
}, i*1000 )
}
this全面解析
之前寫過一篇深入淺出JavaScript之this。我們知道this是在運行時進行綁定的氓涣,并不是在?編寫時綁定牛哺,它的上下文取決于函數(shù)調(diào)用時的各種條件。this的綁定和函數(shù)聲明的位置沒有任何關(guān)系劳吠,只取決于函數(shù)的調(diào)用方式引润。
this詞法
來看下面這段代碼的問題:
var obj = {
id: "awesome",
cool: function coolFn() {
console.log(this.id);
}
};
var id = "not awesome";
obj.cool(); // awesome
setTimeout( obj.cool, 100); // not awesome
obj.cool() 與 setTimeout( obj.cool, 100 ) 輸出結(jié)果不一樣的原因在于 cool() 函數(shù)丟失了同 this 之間的綁定。解決方法最常用的是 var self = this;
var obj = {
count: 0,
cool: function coolFn() {
var self = this;
if (self.count < 1) {
setTimeout( function timer(){
self.count++;
console.log("awesome?");
}, 100)
}
}
}
obj.cool(); // awesome?
這里用到的知識點是我們非常熟悉的詞法作用域痒玩。self 只是一個可以通過詞法作用域和閉包進行引用的標識符淳附,不關(guān)心 this 綁定的過程中發(fā)生了什么。
?ES6 中的箭頭函數(shù)引人了一個叫作 this 詞法的行為:
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( () => {
this.count++;
console.log("awesome?");
}, 100)
}
}
}
obj.cool(); // awesome?
箭頭函數(shù)棄用了所有普通 this 綁定規(guī)則蠢古,取而代之的是用當前的詞法作用域覆蓋了 this 本來的值奴曙。因此,這個代碼片段中的箭頭函數(shù)只是"繼承"了 cool() 函數(shù)的 this 綁定便瑟。
但是箭頭函數(shù)的缺點就是因為其是匿名的缆毁,上文已介紹過具名函數(shù)比匿名函數(shù)更可取的原因。而且箭頭函數(shù)將程序員們經(jīng)常犯的一個錯誤給標準化了:混淆了 this 綁定規(guī)則和詞法作用域規(guī)則到涂。?
箭頭函數(shù)不僅僅意味著可以少寫代碼。本書的作者?認為使用 bind() 是更靠得住的方式颁督。
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( () => {
this.count++;
console.log("more awesome");
}.bind( this ), 100)
}
}
}
obj.cool(); // more awesome
綁定規(guī)則
函數(shù)在執(zhí)行的過程中践啄,可以根據(jù)下面這4條綁定規(guī)則來判斷 this 綁定到哪。
- 默認綁定
- 獨立函數(shù)調(diào)用
- 隱?式綁定
- ?當函數(shù)?引用有上下文對象時沉御,隱?式綁定規(guī)則會把函數(shù)調(diào)用中的 this 綁定到這個?上下文對象
- 顯示綁定
- call/apply
- bind(本質(zhì)是對call/apply函數(shù)的封裝
fn.apply( obj, arguments )
) - 第三方庫的許多函數(shù)都提供了一個可選的參數(shù)(上下文)屿讽,其作用和 bind() 一樣,確狈婉桑回調(diào)函數(shù)?使用指定的 this
- new 綁定
- JavaScript 中的 new 機制實際上和面向類的語言完全不同
- 實際上并不存在所謂的“構(gòu)造函數(shù)”伐谈,只有對于函數(shù)的“構(gòu)造調(diào)用”
書中對4條綁定?規(guī)則的優(yōu)先級進行了驗證,得出以下的順序優(yōu)先級:
- 函數(shù)是否在 new 中調(diào)用(new 綁定)试疙?如果是的話 this 綁定的是新創(chuàng)建的對象诵棵。
- 函數(shù)是否通過 call、apply(顯式綁定)或者硬綁定(bind)調(diào)用祝旷?如果是的話履澳,this 綁定的是指定對象嘶窄。
- 函數(shù)是否在某個上下文對象中調(diào)用(隱式綁定)?如果是的話距贷,this 綁定的是那個上下文對象柄冲。
- 如果都不是的話,使用默認綁定忠蝗。在嚴格模式下现横,綁定到 undefined,否則綁定到全局對象阁最。
被忽略的 this
如果你把 null 或者 undefined 作為 this 的綁定對象傳入 call戒祠、apply 或者 bind,這些值在調(diào)用時會被忽略闽撤,實際應(yīng)用的是默認規(guī)則得哆。
什么時候?會傳入 null/undefined 呢?一種非常常見的做法是用 apply(..) 來“展開”一個數(shù)組哟旗,并當作參數(shù)傳入一個函數(shù)贩据。類似地,bind(..) 可以對參數(shù)進行柯里化(預先設(shè)置一些參數(shù)),如下代碼:
function foo(a, b) {
console.log( "a:" + a + ", b:" + b );
}
// 把數(shù)組"展開"成參數(shù)
foo.apply(null, [2, 3]); // a:2, b:3
// 使用 bind(..) 進行柯里化
var bar = foo.bind( null, 2);
bar(3); // a:2, b:3
其中?? ES6 中闸餐,可以用 ... 操作符代替? apply(..) 來“展開”數(shù)組饱亮,但是 ES6 中沒有柯里化的相關(guān)語法,因此還是需要使用 bind(..)舍沙。
使用 null 來忽略 this 綁定可能產(chǎn)生一些副作用近上。如果某個?函數(shù)(比如第三庫中的某個函數(shù))確實使用了 this ,默認綁定規(guī)則會把 this 綁定到全局對象拂铡,這將導致不可預計的后果壹无。更安全的做法是傳入一個特殊的對象,一個 “DMZ” 對象感帅,一個空的非委托對象斗锭,即 Object.create(null)。
function foo(a, b) {
console.log( "a:" + a + ", b:" + b );
}
?var ? = Object.create(null);
// 把數(shù)組"展開"成參數(shù)
foo.apply( ?, [2, 3]); // a:2, b:3
// 使用 bind(..) 進行柯里化
var bar = foo.bind( ?, 2);
bar(3); // a:2, b:3
對象
JavaScript中的對象有字面?形式(比如var a = { .. }
)和構(gòu)造形式(比如var a = new Array(..)
)失球。字面形式更常用岖是,不過有時候構(gòu)造形式可以提供更多選擇。
作者認為“JavaScript中萬物都是對象”的觀點是不對的实苞。因為?對象只是 6 個基礎(chǔ)類型( string豺撑、number、boolean黔牵、null聪轿、undefined、object )之一荧止。對象有包括 function 在內(nèi)的子對象屹电,不同子類型具有不同的行為阶剑,比如內(nèi)部標簽 [object Array] 表示這是對象的子類型數(shù)組。
復制對象
思考一下這個對象:
function anotherFunction() { /*..*/ }
var anotherObject = {
c: true
};
var anotherArray = [];
var myObject = {
a: 2,
b: anotherObject, // 引用危号,不是復本牧愁!
c: anotherArray, // 另一個引用!
d: anotherFunction
};
anotherArray.push( myObject )
如何準確地表示 myObject 的復制呢外莲?
這里有一個知識點猪半。
- 淺復制。復制出的新對象中 a 的值會復制舊對象中 a 的值偷线,也就是 2磨确,但是新對象中 b、c声邦、d 三個屬性其實只是三個引用乏奥。
- 深復制。除了復制 myObject 以外還會復制 anotherArray亥曹。這時問題就來了邓了,anotherArray 引用了 myObject,? 所以又需要復制 myObject,這樣就會由于循環(huán)引用導致死循環(huán)媳瞪。
對于 JSON 安全的對象(就是能用 JSON.stringify 序列號的字符串)來說骗炉,有一種巧妙的復制方法:
var newObj = JSON.parse( JSON.stringify(someObj) )
我認為這種方法就是深復制。相比于深復制蛇受,?淺復制非常易懂并且問題要少得多句葵,ES6 定義了 Object.assign(..) 方法來實現(xiàn)淺復制。 Object.assign(..) 方法的第一個參數(shù)是目標對象兢仰,之后還可以跟一個或多個源對象乍丈。它會遍歷一個或多個源對象的所有可枚舉的自由鍵并把它們復制到目標對象,最后返回目標對象把将,就像這樣:
var newObj = Object.assign( {}, myObject );
newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true
類
JavaScript 有一些近似類的語法元素(比如 new 和 instanceof), 后來的 ES6 中新增了一些如 class 的關(guān)鍵字诗赌。但是 JavaScript 實際上并沒有類。類是一種設(shè)計模式秸弛,JavaScript 的機制?其實和類完全不同。
- 類的繼承(委托)其實就是復制洪碳,但和其他語言中類的表現(xiàn)不同(其他語言類表現(xiàn)出來的都是復制行為)递览,JavaScript 中的多態(tài)(在繼承鏈中不同層次名稱相同,但是功能不同的函數(shù))并不表示子類和父類有關(guān)聯(lián)瞳腌,子類得到的只是父類的一份復本绞铃。
- JavaScript 通過顯示混入和隱式混入 call() 來模擬其他語言類的表現(xiàn)。此外嫂侍,顯示混入實際上無法完全模擬類的復制行為儿捧,因為對象(和函數(shù)荚坞!別忘了函數(shù)也是對象)只能復制引用,無法復制被引用的?對象或者函數(shù)本身菲盾。
檢查“類”關(guān)系
思考下面的代碼:
function Foo() {
// ...
}
Foo.prototype.blah = ...;
var a = new Foo();
我們?nèi)绾握页? a 的“祖先”(委托關(guān)系)呢颓影?
- 方法一:
a instanceof Foo; // true
(對象 instanceof 函數(shù)) - 方法二:
Foo.prototype.isPrototypeOf(a); // true
(對象 isPrototypeOf 對象) - 方法三:
Object.getPrototypeOf(a) === Foo.prototype; // true
(Object.getPrototypeOf() 可以獲取一個對象的 [[Prototype]]) 鏈; - 方法四:
a.__proto__ == Foo.prototype; // true
構(gòu)造函數(shù)
- 函數(shù)不是構(gòu)造函數(shù),而是當且僅當使用 new 時懒鉴,函數(shù)調(diào)用會變成“構(gòu)造函數(shù)調(diào)用”诡挂。
- 使用 new 會在 prototype 生成一個 constructor 屬性,指向構(gòu)造調(diào)用的函數(shù)临谱。
- constructor 并不表示被構(gòu)造璃俗,而且 constructor 屬性并不是一個不可變屬性,它是不可枚舉的悉默,但它是可以被修改的城豁。
對象關(guān)聯(lián)
來看下面的代碼:
var foo = {
something: function() {
console.log("Tell me something good...");
}
};
var bar = Object.create(foo);
bar.something(); // Tell me something good...
Object.create(..)會創(chuàng)建一個新對象 (bar) 并把它關(guān)聯(lián)到我們指定的對象 (foo),這樣我們就可以充分發(fā)揮 [[Prototype]] 機制的為例(委托)并且避免不必要的麻煩 (比如使用 new 的構(gòu)造函數(shù)調(diào)用會生成 .prototype 和 .constructor 引用)抄课。
Object.create(null) 會創(chuàng)建一個?擁有空鏈接的對象唱星,這個對象無法進行委托。由于這個對象沒有原型鏈剖膳,所以 instanceof 操作符無法進行判斷魏颓,因此總是會返回 false 。這些特殊的空對象通常被稱作“字典”吱晒,它們完全不會受到原型鏈的干擾甸饱,因此非常適合用來存儲數(shù)據(jù)。
我們并不需要類來?創(chuàng)建兩個對象之間的關(guān)系仑濒,只需要通過委托來關(guān)聯(lián)對象就足夠了叹话。而Object.create(..)?不包含任何“類的詭計”,所以它可以完美地創(chuàng)建我們想要的關(guān)聯(lián)關(guān)系墩瞳。
此書的第二章第6部分就把面對類和繼承
和行為委托
兩種設(shè)計模式進行了對比驼壶,我們可以??看到行為委托?是一種更加簡潔的設(shè)計模式,在這種設(shè)計模式中能感受到Object.create()
的強大喉酌。
?ES6中的Class
來看一段 ES6中Class 的例子
class Widget {
constructor(width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
render($where){
if (this.$elem) {
this.$elem.css({
width: this.width + "px"热凹,
height: this.height + "px"
}).appendTo($where);
}
}
}
class Button extends Widget {
constructor(width, height, label) {
super(width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label)
}
render($where) {
super($where);
this.$elem.click(this.onClick.bind(this));
}
onClick(evt) {
console.log("Button '" + this.label + "' clicked!")
}
}
除了語法更好看之外,ES6還有以下優(yōu)點
- 基本上不再引用雜亂的 .prototype 了泪电。
- Button 聲明時直接 “繼承” 了 Widget般妙。
- 可以通過 super(..)來?實現(xiàn)相對多態(tài),這樣任何方法都可以引用原型鏈上層的同名方法相速。
- class 字面語法不能聲明屬性(只能聲明方法)碟渺。這是一種限制,但是它會排除掉許多不好的情況突诬。
- 可以通過 extends 很自然地擴展對象(子)類型苫拍。
但是 class 就是完美的嗎芜繁?在傳統(tǒng)面向類的語言中,類定義之后就不會進行修改绒极,所以類的設(shè)計模式就不支持修改骏令。但JavaScript 最強大的特性之一就是它的動態(tài)性,在使用 class 的有些時候還是會?用到 .prototype 以及碰到 super (期望動態(tài)綁定然而靜態(tài)綁定) 的問題集峦,class 基本?上都沒有提供解決方案伏社。
這也是本書作者希望我們思考的問題。