特別說(shuō)明黄绩,為便于查閱饮六,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS
代理黑入 [[Prototype]]
鏈
[[Get]]
操作是[[Prototype]]
機(jī)制被調(diào)用的主要渠道其垄。當(dāng)一個(gè)屬性不能在直接對(duì)象上找到時(shí),[[Get]]
會(huì)自動(dòng)將操作交給[[Prototype]]
對(duì)象卤橄。
這意味著你可以使用一個(gè)代理的get(..)
機(jī)關(guān)來(lái)模擬或擴(kuò)展這個(gè)[[Prototype]]
機(jī)制的概念绿满。
我們將考慮的第一種黑科技是創(chuàng)建兩個(gè)通過(guò)[[Prototype]]
循環(huán)鏈接的對(duì)象(或者說(shuō),至少看起來(lái)是這樣?咂恕)喇颁。你不能實(shí)際創(chuàng)建一個(gè)真正循環(huán)的[[Prototype]]
鏈,因?yàn)橐鎸?huì)拋出一個(gè)錯(cuò)誤嚎货。但是代理可以假冒它橘霎!
考慮如下代碼:
var handlers = {
get(target,key,context) {
if (Reflect.has( target, key )) {
return Reflect.get(
target, key, context
);
}
// 假冒循環(huán)的 `[[Prototype]]`
else {
return Reflect.get(
target[
Symbol.for( "[[Prototype]]" )
],
key,
context
);
}
}
},
obj1 = new Proxy(
{
name: "obj-1",
foo() {
console.log( "foo:", this.name );
}
},
handlers
),
obj2 = Object.assign(
Object.create( obj1 ),
{
name: "obj-2",
bar() {
console.log( "bar:", this.name );
this.foo();
}
}
);
// 假冒循環(huán)的 `[[Prototype]]` 鏈
obj1[ Symbol.for( "[[Prototype]]" ) ] = obj2;
obj1.bar();
// bar: obj-1 <-- 通過(guò)代理假冒 [[Prototype]]
// foo: obj-1 <-- `this` 上下文環(huán)境依然被保留
obj2.foo();
// foo: obj-2 <-- 通過(guò) [[Prototype]]
注意: 為了讓事情簡(jiǎn)單一些,在這個(gè)例子中我們沒(méi)有代理/轉(zhuǎn)送[[Set]]
殖属。要完整地模擬[[Prototype]]
兼容姐叁,你會(huì)想要實(shí)現(xiàn)一個(gè)set(..)
處理器,它在[[Prototype]]
鏈上檢索一個(gè)匹配得屬性并遵循它的描述符的行為(例如洗显,set外潜,可寫性)。參見本系列的 this與對(duì)象原型墙懂。
在前面的代碼段中橡卤,obj2
憑借Object.create(..)
語(yǔ)句[[Prototype]]
鏈接到obj1
。但是要?jiǎng)?chuàng)建反向(循環(huán))的鏈接损搬,我們?cè)?code>obj1的symbol位置Symbol.for("[[Prototype]]")
(參見第二章的“Symbol”)上創(chuàng)建了一個(gè)屬性碧库。這個(gè)symbol可能看起來(lái)有些特別/魔幻,但它不是的巧勤。它只是允許我使用一個(gè)被方便地命名的屬性嵌灰,這個(gè)屬性在語(yǔ)義上看來(lái)是與我進(jìn)行的任務(wù)有關(guān)聯(lián)的。
然后颅悉,代理的get(..)
處理器首先檢查一個(gè)被請(qǐng)求的key
是否存在于代理上沽瞭。如果每個(gè)有,操作就被手動(dòng)地交給存儲(chǔ)在target
的Symbol.for("[[Prototype]]")
位置中的對(duì)象引用剩瓶。
這種模式的一個(gè)重要優(yōu)點(diǎn)是驹溃,在obj1
和obj2
之間建立循環(huán)關(guān)系幾乎沒(méi)有入侵它們的定義。雖然前面的代碼段為了簡(jiǎn)短而將所有的步驟交織在一起延曙,但是如果你仔細(xì)觀察豌鹤,代理處理器的邏輯完全是范用的(不具體地知道obj1
或obj2
)。所以枝缔,這段邏輯可以抽出到一個(gè)簡(jiǎn)單的將它們連在一起的幫助函數(shù)中布疙,例如setCircularPrototypeOf(..)
。我們將此作為一個(gè)練習(xí)留給讀者。
現(xiàn)在我們看到了如何使用get(..)
來(lái)模擬一個(gè)[[Prototype]]
鏈接灵临,但讓我們將這種黑科技推動(dòng)的遠(yuǎn)一些截型。與其制造一個(gè)循環(huán)[[Prototype]]
,搞一個(gè)多重[[Prototype]]
鏈接(也就是“多重繼承”)怎么樣儒溉?這看起來(lái)相當(dāng)直白:
var obj1 = {
name: "obj-1",
foo() {
console.log( "obj1.foo:", this.name );
},
},
obj2 = {
name: "obj-2",
foo() {
console.log( "obj2.foo:", this.name );
},
bar() {
console.log( "obj2.bar:", this.name );
}
},
handlers = {
get(target,key,context) {
if (Reflect.has( target, key )) {
return Reflect.get(
target, key, context
);
}
// 假冒多重 `[[Prototype]]`
else {
for (var P of target[
Symbol.for( "[[Prototype]]" )
]) {
if (Reflect.has( P, key )) {
return Reflect.get(
P, key, context
);
}
}
}
}
},
obj3 = new Proxy(
{
name: "obj-3",
baz() {
this.foo();
this.bar();
}
},
handlers
);
// 假冒多重 `[[Prototype]]` 鏈接
obj3[ Symbol.for( "[[Prototype]]" ) ] = [
obj1, obj2
];
obj3.baz();
// obj1.foo: obj-3
// obj2.bar: obj-3
注意: 正如在前面的循環(huán)[[Prototype]]
例子后的注意中提到的宦焦,我們沒(méi)有實(shí)現(xiàn)set(..)
處理器,但對(duì)于一個(gè)將[[Set]]
模擬為普通[[Prototype]]
行為的解決方案來(lái)說(shuō)睁搭,它將是必要的赶诊。
obj3
被設(shè)置為多重委托到obj1
和obj2
笼平。在obj2.baz()
中园骆,this.foo()
調(diào)用最終成為從obj1
中抽出foo()
(先到先得,雖然還有一個(gè)在obj2
上的foo()
)寓调。如果我們將連接重新排列為obj2, obj1
锌唾,那么obj2.foo()
將被找到并使用。
同理夺英,this.bar()
調(diào)用沒(méi)有在obj1
上找到bar()
晌涕,所以它退而檢查obj2
,這里找到了一個(gè)匹配痛悯。
obj1
和obj2
代表obj3
的兩個(gè)平行的[[Prototype]]
鏈余黎。obj1
和/或obj2
自身可以擁有委托至其他對(duì)象的普通[[Prototype]]
,或者自身也可以是多重委托的代理(就像obj3
一樣)载萌。
正如先前的循環(huán)[[Prototype]]
的例子一樣惧财,obj1
,obj2
和obj3
的定義幾乎完全與處理多重委托的范用代理邏輯相分離扭仁。定義一個(gè)setPrototypesOf(..)
(注意那個(gè)“s”?逯浴)這樣的工具將是小菜一碟,它接收一個(gè)主對(duì)象和一組模擬多重[[Prototype]]
鏈接用的對(duì)象乖坠。同樣搀突,我們將此作為練習(xí)留給讀者。
希望在這種種例子之后代理的力量現(xiàn)在變得明朗了熊泵。代理使得許多強(qiáng)大的元編程任務(wù)成為可能仰迁。
Reflect
API
Reflect
對(duì)象是一個(gè)普通對(duì)象(就像Math
),不是其他內(nèi)建原生類型那樣的函數(shù)/構(gòu)造器顽分。
它持有對(duì)應(yīng)于你可以控制的各種元編程任務(wù)的靜態(tài)函數(shù)徐许。這些函數(shù)與代理可以定義的處理器方法(機(jī)關(guān))一一對(duì)應(yīng)。
這些函數(shù)中的一些看起來(lái)與在Object
上的同名函數(shù)很相似:
Reflect.getOwnPropertyDescriptor(..)
Reflect.defineProperty(..)
Reflect.getPrototypeOf(..)
Reflect.setPrototypeOf(..)
Reflect.preventExtensions(..)
Reflect.isExtensible(..)
這些工具一般與它們的Object.*
對(duì)等物的行為相同。但一個(gè)區(qū)別是,Object.*
對(duì)等物在它們的第一個(gè)參數(shù)值(目標(biāo)對(duì)象)還不是對(duì)象的情況下请契,試圖將它強(qiáng)制轉(zhuǎn)換為一個(gè)對(duì)象啡氢。Reflect.*
方法在同樣的情況下僅簡(jiǎn)單地拋出一個(gè)錯(cuò)誤激才。
一個(gè)對(duì)象的鍵可以使用這些工具訪問(wèn)/檢測(cè):
-
Reflect.ownKeys(..)
:返回一個(gè)所有直屬(不是“繼承的”)鍵的列表裸违,正如被Object.getOwnPropertyNames(..)
和Object.getOwnPropertySymbols(..)
返回的那樣胸遇。關(guān)于鍵的順序問(wèn)題评汰,參見“屬性枚舉順序”一節(jié)村缸。 -
Reflect.enumerate(..)
:返回一個(gè)產(chǎn)生所有(直屬和“繼承的”)非symbol祠肥、可枚舉的鍵的迭代器(參見本系列的 this與對(duì)象原型)。 實(shí)質(zhì)上梯皿,這組鍵與在for..in
循環(huán)中被處理的那一組鍵是相同的仇箱。關(guān)于鍵的順序問(wèn)題,參見“屬性枚舉順序”一節(jié)东羹。 -
Reflect.has(..)
:實(shí)質(zhì)上與用于檢查一個(gè)屬性是否存在于一個(gè)對(duì)象或它的[[Prototype]]
鏈上的in
操作符相同剂桥。例如,Reflect.has(o,"foo")
實(shí)質(zhì)上實(shí)施"foo" in o
属提。
函數(shù)調(diào)用和構(gòu)造器調(diào)用可以使用這些工具手動(dòng)地實(shí)施权逗,與普通的語(yǔ)法(例如,(..)
和new
)分開:
-
Reflect.apply(..)
:例如冤议,Reflect.apply(foo,thisObj,[42,"bar"])
使用thisObj
作為foo(..)
函數(shù)的this
來(lái)調(diào)用它斟薇,并傳入?yún)?shù)值42
和"bar"
。 -
Reflect.construct(..)
:例如恕酸,Reflect.construct(foo,[42,"bar"])
實(shí)質(zhì)上調(diào)用new foo(42,"bar")
堪滨。
對(duì)象屬性訪問(wèn),設(shè)置蕊温,和刪除可以使用這些工具手動(dòng)實(shí)施:
-
Reflect.get(..)
:例如袱箱,Reflect.get(o,"foo")
會(huì)取得o.foo
。 -
Reflect.set(..)
:例如寿弱,Reflect.set(o,"foo",42)
實(shí)質(zhì)上實(shí)施o.foo = 42
犯眠。 -
Reflect.deleteProperty(..)
:例如,Reflect.deleteProperty(o,"foo")
實(shí)質(zhì)上實(shí)施delete o.foo
症革。
Reflect
的元編程能力給了你可以模擬各種語(yǔ)法特性的程序化等價(jià)物筐咧,暴露以前隱藏著的抽象操作。例如噪矛,你可以使用這些能力來(lái)擴(kuò)展 領(lǐng)域特定語(yǔ)言(DSL)的特性和API量蕊。
屬性順序
在ES6之前,羅列一個(gè)對(duì)象的鍵/屬性的順序沒(méi)有在語(yǔ)言規(guī)范中定義艇挨,而是依賴于具體實(shí)現(xiàn)的残炮。一般來(lái)說(shuō),大多數(shù)引擎會(huì)以創(chuàng)建的順序來(lái)羅列它們缩滨,雖然開發(fā)者們已經(jīng)被強(qiáng)烈建議永遠(yuǎn)不要依仗這種順序势就。
在ES6中泉瞻,羅列直屬屬性的屬性是由[[OwnPropertyKeys]]
算法定義的(ES6語(yǔ)言規(guī)范,9.1.12部分)苞冯,它產(chǎn)生所有直屬屬性(字符串或symbol)袖牙,不論其可枚舉性。這種順序僅對(duì)Reflect.ownKeys(..)
有保證()舅锄。
這個(gè)順序是:
- 首先鞭达,以數(shù)字上升的順序,枚舉所有數(shù)字索引的直屬屬性皇忿。
- 然后畴蹭,以創(chuàng)建順序枚舉剩下的直屬字符串屬性名。
- 最后鳍烁,以創(chuàng)建順序枚舉直屬symbol屬性叨襟。
考慮如下代碼:
var o = {};
o[Symbol("c")] = "yay";
o[2] = true;
o[1] = true;
o.b = "awesome";
o.a = "cool";
Reflect.ownKeys( o ); // [1,2,"b","a",Symbol(c)]
Object.getOwnPropertyNames( o ); // [1,2,"b","a"]
Object.getOwnPropertySymbols( o ); // [Symbol(c)]
另一方面,[[Enumeration]]
算法(ES6語(yǔ)言規(guī)范老翘,9.1.11部分)從目標(biāo)對(duì)象和它的[[Prototype]]
鏈中僅產(chǎn)生可枚舉屬性芹啥。它被用于Reflect.enumerate(..)
和for..in
∑糖停可觀察到的順序是依賴于具體實(shí)現(xiàn)的,語(yǔ)言規(guī)范沒(méi)有控制它汽纠。
相比之下卫键,Object.keys(..)
調(diào)用[[OwnPropertyKeys]]
算法來(lái)得到一個(gè)所有直屬屬性的列表。但是虱朵,它過(guò)濾掉了不可枚舉屬性莉炉,然后特別為了JSON.stringify(..)
和for..in
而將這個(gè)列表重排,以匹配遺留的碴犬、依賴于具體實(shí)現(xiàn)的行為絮宁。所以通過(guò)擴(kuò)展,這個(gè)順序 也 與Reflect.enumerate(..)
的順序像吻合服协。
換言之绍昂,所有四種機(jī)制(Reflect.enumerate(..)
,Object.keys(..)
偿荷,for..in
窘游,和JSON.stringify(..)
)都同樣將與依賴于具體實(shí)現(xiàn)的順序像吻合,雖然技術(shù)上它們是以不同的方式達(dá)到的同樣的效果跳纳。
具體實(shí)現(xiàn)可以將這四種機(jī)制與[[OwnPropertyKeys]]
的順序相吻合忍饰,但不是必須的。無(wú)論如何寺庄,你將很可能從它們的行為中觀察到以下的排序:
var o = { a: 1, b: 2 };
var p = Object.create( o );
p.c = 3;
p.d = 4;
for (var prop of Reflect.enumerate( p )) {
console.log( prop );
}
// c d a b
for (var prop in p) {
console.log( prop );
}
// c d a b
JSON.stringify( p );
// {"c":3,"d":4}
Object.keys( p );
// ["c","d"]
這一切可以歸納為:在ES6中艾蓝,根據(jù)語(yǔ)言規(guī)范Reflect.ownKeys(..)
力崇,Object.getOwnPropertyNames(..)
,和Object.getOwnPropertySymbols(..)
保證都有可預(yù)見和可靠的順序赢织。所以依賴于這種順序來(lái)建造代碼是安全的餐曹。
Reflect.enumerate(..)
,Object.keys(..)
敌厘,和for..in
(擴(kuò)展一下的話還有JSON.stringify(..)
)繼續(xù)互相共享一個(gè)可觀察的順序台猴,就像它們往常一樣。但這個(gè)順序不一定與Reflect.ownKeys(..)
的相同俱两。在使用它們依賴于具體實(shí)現(xiàn)的順序時(shí)依然應(yīng)當(dāng)小心饱狂。
特性測(cè)試
什么是特性測(cè)試?它是一種由你運(yùn)行來(lái)判定一個(gè)特性是否可用的測(cè)試宪彩。有些時(shí)候休讳,這種測(cè)試不僅是為了判定存在性,還是為判定對(duì)特定行為的適應(yīng)性 —— 特性可能存在但有bug尿孔。
這是一種元編程技術(shù) —— 測(cè)試你程序?qū)⒁\(yùn)行的環(huán)境然后判定你的程序應(yīng)當(dāng)如何動(dòng)作俊柔。
在JS中特性測(cè)試最常見的用法是檢測(cè)一個(gè)API的存在性,而且如果它不存在活合,就定義一個(gè)填補(bǔ)(見第一章)雏婶。例如:
if (!Number.isNaN) {
Number.isNaN = function(x) {
return x !== x;
};
}
在這個(gè)代碼段中的if
語(yǔ)句就是一個(gè)元編程:我們探測(cè)我們的程序和它的運(yùn)行時(shí)環(huán)境,來(lái)判定我們是否和如何進(jìn)行后續(xù)處理白指。
但是如何測(cè)試一個(gè)涉及新語(yǔ)法的特性呢留晚?
你可能會(huì)嘗試這樣的東西:
try {
a = () => {};
ARROW_FUNCS_ENABLED = true;
}
catch (err) {
ARROW_FUNCS_ENABLED = false;
}
不幸的是,這不能工作告嘲,因?yàn)槲覀兊腏S程序是要被編譯的错维。因此,如果引擎還沒(méi)有支持ES6箭頭函數(shù)的話橄唬,它就會(huì)在() => {}
語(yǔ)法的地方熄火赋焕。你程序中的語(yǔ)法錯(cuò)誤會(huì)阻止它的運(yùn)行,進(jìn)而阻止你程序根據(jù)特性是否被支持而進(jìn)行后續(xù)的不同相應(yīng)仰楚。
為了圍繞語(yǔ)法相關(guān)的特性進(jìn)行特性測(cè)試的元編程隆判,我們需要一個(gè)方法將測(cè)試與我們程序?qū)⒁ㄟ^(guò)的初始編譯步驟隔離開。舉例來(lái)說(shuō)缸血,如果我們能夠?qū)⑦M(jìn)行測(cè)試的代碼存儲(chǔ)在一個(gè)字符串中蜜氨,之后JS引擎默認(rèn)地將不會(huì)嘗試編譯這個(gè)字符串中的內(nèi)容,直到我們要求它這么做捎泻。
你的思路是不是跳到了使用eval(..)
飒炎?
別這么著急“驶恚看看本系列的 作用域與閉包 來(lái)了解一下為什么eval(..)
是一個(gè)壞主意郎汪。但是有另外一個(gè)缺陷較少的選項(xiàng):Function(..)
構(gòu)造器赤赊。
考慮如下代碼:
try {
new Function( "( () => {} )" );
ARROW_FUNCS_ENABLED = true;
}
catch (err) {
ARROW_FUNCS_ENABLED = false;
}
好了,現(xiàn)在我們判定一個(gè)像箭頭函數(shù)這樣的特性是否 能 被當(dāng)前的引擎所編譯來(lái)進(jìn)行元編程煞赢。你可能會(huì)想知道抛计,我們要用這種信息做什么?
檢查API的存在性照筑,并定義后備的API填補(bǔ)吹截,對(duì)于特性檢測(cè)成功或失敗來(lái)說(shuō)都是一條明確的道路。但是對(duì)于從ARROW_FUNCS_ENABLED
是true
還是false
中得到的信息來(lái)說(shuō)凝危,我們能對(duì)它做什么呢波俄?
因?yàn)槿绻娌恢С忠环N特性,它的語(yǔ)法就不能出現(xiàn)在一個(gè)文件中蛾默,所以你不能在這個(gè)文件中定義使用這種語(yǔ)法的函數(shù)懦铺。
你所能做的是,使用測(cè)試來(lái)判定你應(yīng)當(dāng)加載哪一組JS文件支鸡。例如冬念,如果在你的JS應(yīng)用程序中的啟動(dòng)裝置中有一組這樣的特性測(cè)試,那么它就可以測(cè)試環(huán)境來(lái)判定你的ES6代碼是否可以直接加載運(yùn)行牧挣,或者你是否需要加載一個(gè)代碼的轉(zhuǎn)譯版本(參見第一章)急前。
這種技術(shù)稱為 分割投遞。
事實(shí)表明浸踩,你使用ES6編寫的JS程序有時(shí)可以在ES6+瀏覽器中完全“原生地”運(yùn)行叔汁,但是另一些時(shí)候需要在前ES6瀏覽器中運(yùn)行轉(zhuǎn)譯版本。如果你總是加載并使用轉(zhuǎn)譯代碼检碗,即便是在新的ES6兼容環(huán)境中,至少是有些情況下你運(yùn)行的也是次優(yōu)的代碼码邻。這并不理想折剃。
分割投遞更加復(fù)雜和精巧,但對(duì)于你編寫的代碼和你的程序所必須在其中運(yùn)行的瀏覽器支持的特性之間像屋,它代表一種更加成熟和健壯的橋接方式怕犁。
FeatureTests.io
為所有的ES6+語(yǔ)法以及語(yǔ)義行為定義特性測(cè)試,是一項(xiàng)你可能不想自己解決的艱巨任務(wù)己莺。因?yàn)檫@些測(cè)試要求動(dòng)態(tài)編譯(new Function(..)
)奏甫,這會(huì)產(chǎn)生不幸的性能損耗。
另外凌受,在每次你的應(yīng)用運(yùn)行時(shí)都執(zhí)行這些測(cè)試可能是一種浪費(fèi)阵子,因?yàn)槠骄鶃?lái)說(shuō)一個(gè)用戶的瀏覽器在幾周之內(nèi)至多只會(huì)更新一次,而即使是這樣胜蛉,新特性也不一定會(huì)在每次更新中都出現(xiàn)挠进。
最終色乾,管理一個(gè)對(duì)你特定代碼庫(kù)進(jìn)行的特性測(cè)試列表 —— 你的程序?qū)⒑苌儆玫紼S6的全部 —— 是很容易失控而且易錯(cuò)的。
“https://featuretests.io”的“特性測(cè)試服務(wù)”為這種挫折提供了解決方案领突。
你可以將這個(gè)服務(wù)的庫(kù)加載到你的頁(yè)面中暖璧,而它會(huì)加載最新的測(cè)試定義并運(yùn)行所有的特性測(cè)試。在可能的情況下君旦,它將使用Web Worker的后臺(tái)處理中這樣做澎办,以降低性能上的開銷。它還會(huì)使用LocalStorage持久化來(lái)緩存測(cè)試的結(jié)果 —— 以一種可以被所有你訪問(wèn)的使用這個(gè)服務(wù)的站點(diǎn)所共享的方式金砍,這將及大地降低測(cè)試需要在每個(gè)瀏覽器實(shí)例上運(yùn)行的頻度局蚀。
你可以在每一個(gè)用戶的瀏覽器上進(jìn)行運(yùn)行時(shí)特性測(cè)試,而且你可以使用這些測(cè)試結(jié)果動(dòng)態(tài)地向用戶傳遞最適合他們環(huán)境的代碼(不多也不少)捞魁。
另外至会,這個(gè)服務(wù)還提供工具和API來(lái)掃描你的文件以判定你需要什么特性,這樣你就能夠完全自動(dòng)化你的分割投遞構(gòu)建過(guò)程谱俭。
對(duì)ES6的所有以及未來(lái)的部分進(jìn)行特性測(cè)試奉件,以確保對(duì)于任何給定的環(huán)境都只有最佳的代碼會(huì)被加載和運(yùn)行 —— FeatureTests.io使這成為可能。
尾部調(diào)用優(yōu)化(TCO)
通常來(lái)說(shuō)昆著,當(dāng)從一個(gè)函數(shù)內(nèi)部發(fā)起對(duì)另一個(gè)函數(shù)的調(diào)用時(shí)县貌,就會(huì)分配一個(gè) 棧幀 來(lái)分離地管理這另一個(gè)函數(shù)調(diào)用的變量/狀態(tài)。這種分配不僅花費(fèi)一些處理時(shí)間凑懂,還會(huì)消耗一些額外的內(nèi)存煤痕。
一個(gè)調(diào)用棧鏈從一個(gè)函數(shù)到另一個(gè)再到另一個(gè),通常至多擁有10-15跳接谨。在這些場(chǎng)景下摆碉,內(nèi)存使用不太可能是某種實(shí)際問(wèn)題。
然而脓豪,當(dāng)你考慮遞歸編程(一個(gè)函數(shù)頻繁地調(diào)用自己) —— 或者使用兩個(gè)或更多的函數(shù)相互調(diào)用而構(gòu)成相互遞歸 —— 調(diào)用棧就可能輕易地到達(dá)上百巷帝,上千,或更多層的深度扫夜。如果內(nèi)存的使用無(wú)限制地增長(zhǎng)下去楞泼,你可能看到了它將導(dǎo)致的問(wèn)題。
JavaScript引擎不得不設(shè)置一個(gè)隨意的限度來(lái)防止這樣的編程技術(shù)耗盡瀏覽器或設(shè)備的內(nèi)存笤闯。這就是為什么我們會(huì)在到達(dá)這個(gè)限度時(shí)得到令人沮喪的“RangeError: Maximum call stack size exceeded”堕阔。
警告: 調(diào)用棧深度的限制是不由語(yǔ)言規(guī)范控制的。它是依賴于具體實(shí)現(xiàn)的颗味,而且將會(huì)根據(jù)瀏覽器和設(shè)備不同而不同超陆。你絕不應(yīng)該帶著可精確觀察到的限度的強(qiáng)烈臆想進(jìn)行編碼,因?yàn)樗鼈冞€很可能在每個(gè)版本中變化脱衙。
一種稱為 尾部調(diào)用 的特定函數(shù)調(diào)用模式侥猬,可以以一種避免額外的棧幀分配的方法進(jìn)行優(yōu)化。如果額外的分配可以被避免鹃锈,那么就沒(méi)有理由隨意地限制調(diào)用棧的深度瞧预,這樣引擎就可以讓它們沒(méi)有邊界地運(yùn)行下去屎债。
一個(gè)尾部調(diào)用是一個(gè)帶有函數(shù)調(diào)用的return
語(yǔ)句,除了返回它的值盆驹,函數(shù)調(diào)用之后沒(méi)有任何事情需要發(fā)生滩愁。
這種優(yōu)化只能在strict
模式下進(jìn)行躯喇。又一個(gè)你總是應(yīng)該用strict
編寫所有代碼的理由!
這個(gè)函數(shù)調(diào)用 不是 在尾部:
"use strict";
function foo(x) {
return x * 2;
}
function bar(x) {
// 不是一個(gè)尾部調(diào)用
return 1 + foo( x );
}
bar( 10 ); // 21
在foo(x)
調(diào)用完成后必須進(jìn)行1 + ..
硝枉,所以那個(gè)bar(..)
調(diào)用的狀態(tài)需要被保留妻味。
但是下面的代碼段中展示的foo(..)
和bar(..)
都是位于尾部,因?yàn)樗鼈兌际窃谧陨泶a路徑上(除了return
以外)發(fā)生的最后一件事:
"use strict";
function foo(x) {
return x * 2;
}
function bar(x) {
x = x + 1;
if (x > 10) {
return foo( x );
}
else {
return bar( x + 1 );
}
}
bar( 5 ); // 24
bar( 15 ); // 32
在這個(gè)程序中焦履,bar(..)
明顯是遞歸雏逾,但foo(..)
只是一個(gè)普通的函數(shù)調(diào)用。這兩個(gè)函數(shù)調(diào)用都位于 恰當(dāng)?shù)奈膊课恢?/em>价脾。x + 1
在bar(..)
調(diào)用之前被求值笛匙,而且不論這個(gè)調(diào)用何時(shí)完成妹孙,所有將要放生的只有return
获枝。
這些形式的恰當(dāng)尾部調(diào)用(Proper Tail Calls —— PTC)是可以被優(yōu)化的 —— 稱為尾部調(diào)用優(yōu)化(TCO)—— 于是額外的棧幀分配是不必要的。與為下一個(gè)函數(shù)調(diào)用創(chuàng)建新的棧幀不同嚣崭,引擎會(huì)重用既存的棧幀雹舀。這能夠工作是因?yàn)橐粋€(gè)函數(shù)不需要保留任何當(dāng)前狀態(tài) —— 在PTC之后的狀態(tài)下不會(huì)發(fā)生任何事情。
TCO意味著調(diào)用椥橐鳎可以有多深實(shí)際上是沒(méi)有限度的签财。這種技巧稍稍改進(jìn)了一般程序中的普通函數(shù)調(diào)用,但更重要的是它打開了一扇大門:可以使用遞歸表達(dá)程序邦鲫,即使它的調(diào)用棧深度有成千上萬(wàn)層。
我們不再局限于單純地在理論上考慮用遞歸解決問(wèn)題了神汹,而是可以在真實(shí)的JavaScript程序中使用它!
作為ES6慎冤,所有的PTC都應(yīng)該是可以以這種方式優(yōu)化的蚁堤,不論遞歸與否。
重寫尾部調(diào)用
然而撬即,障礙是只有PTC是可以被優(yōu)化的剥槐;非PTC理所當(dāng)然地依然可以工作宪摧,但是將造成往常那樣的棧幀分配几于。如果你希望優(yōu)化機(jī)制啟動(dòng),就必須小心地使用PTC構(gòu)造你的函數(shù)朽砰。
如果你有一個(gè)沒(méi)有用PTC編寫的函數(shù),你可能會(huì)發(fā)現(xiàn)你需要手動(dòng)地重新安排你的代碼漆弄,使它成為合法的TCO撼唾。
考慮如下代碼:
"use strict";
function foo(x) {
if (x <= 1) return 1;
return (x / 2) + foo( x - 1 );
}
foo( 123456 ); // RangeError
對(duì)foo(x-1)
的調(diào)用不是一個(gè)PTC备绽,因?yàn)樵?code>return之前它的結(jié)果必須被加上(x / 2)
肺素。
但是,要使這段代碼在一個(gè)ES6引擎中是合法的TCO猴伶,我們可以像下面這樣重寫它:
"use strict";
var foo = (function(){
function _foo(acc,x) {
if (x <= 1) return acc;
return _foo( (x / 2) + acc, x - 1 );
}
return function(x) {
return _foo( 1, x );
};
})();
foo( 123456 ); // 3810376848.5
如果你在一個(gè)實(shí)現(xiàn)了TCO的ES6引擎中運(yùn)行前面這個(gè)代碼段他挎,你將會(huì)如展示的那樣得到答案3810376848.5
捡需。然而站辉,它仍然會(huì)在非TCO引擎中因?yàn)?code>RangeError而失敗。
非TCO優(yōu)化
有另一種技術(shù)可以重寫代碼殊霞,讓調(diào)用棧不隨每次調(diào)用增長(zhǎng)绷蹲。
一個(gè)這樣的技術(shù)稱為 蹦床顾孽,它相當(dāng)于讓每一部分結(jié)果表示為一個(gè)函數(shù)若厚,這個(gè)函數(shù)要么返回另一個(gè)部分結(jié)果函數(shù),要么返回最終結(jié)果。然后你就可以簡(jiǎn)單地循環(huán)直到你不再收到一個(gè)函數(shù)乞封,這時(shí)你就得到了結(jié)果∶考慮如下代碼:
"use strict";
function trampoline( res ) {
while (typeof res == "function") {
res = res();
}
return res;
}
var foo = (function(){
function _foo(acc,x) {
if (x <= 1) return acc;
return function partial(){
return _foo( (x / 2) + acc, x - 1 );
};
}
return function(x) {
return trampoline( _foo( 1, x ) );
};
})();
foo( 123456 ); // 3810376848.5
這種返工需要一些最低限度的改變來(lái)將遞歸抽出到trampoline(..)
中的循環(huán)中:
- 首先拧廊,我們將
return _foo ..
這一行包裝進(jìn)函數(shù)表達(dá)式return partial() {..
晋修。 - 然后我們將
_foo(1,x)
包裝進(jìn)trampoline(..)
調(diào)用墓卦。
這種技術(shù)之所以不受調(diào)用棧限制的影響,是因?yàn)槊總€(gè)內(nèi)部的partial(..)
函數(shù)都只是返回到trampoline(..)
的while
循環(huán)中睁本,這個(gè)循環(huán)運(yùn)行它然后再一次循環(huán)迭代呢堰。換言之凡泣,partial(..)
并不遞歸地調(diào)用它自己问麸,它只是返回另一個(gè)函數(shù)。棧的深度維持不變席舍,所以它需要運(yùn)行多久就可以運(yùn)行多久来颤。
蹦床表達(dá)的是稠肘,內(nèi)部的partial()
函數(shù)使用在變量x
和acc
上的閉包來(lái)保持迭代與迭代之間的狀態(tài)项阴。它的優(yōu)勢(shì)是循環(huán)的邏輯可以被抽出到一個(gè)可重用的trampoline(..)
工具函數(shù)中,許多庫(kù)都提供這個(gè)工具的各種版本略荡。你可以使用不同的蹦床算法在你的程序中重用trampoline(..)
多次汛兜。
當(dāng)然,如果你真的想要深度優(yōu)化(于是可復(fù)用性不予考慮)肛根,你可以摒棄閉包狀態(tài)派哲,并將對(duì)acc
的狀態(tài)追蹤哟玷,與一個(gè)循環(huán)一起內(nèi)聯(lián)到一個(gè)函數(shù)的作用域內(nèi)巢寡。這種技術(shù)通常稱為 遞歸展開:
"use strict";
function foo(x) {
var acc = 1;
while (x > 1) {
acc = (x / 2) + acc;
x = x - 1;
}
return acc;
}
foo( 123456 ); // 3810376848.5
算法的這種表達(dá)形式很容易閱讀,而且很可能是在我們探索過(guò)的各種形式中性能最好的(嚴(yán)格地說(shuō))一個(gè)树叽。很明顯它看起來(lái)是一個(gè)勝利者题诵,而且你可能會(huì)想知道為什么你曾嘗試其他的方式层皱。
這些是為什么你可能不想總是手動(dòng)地展開遞歸的原因:
與為了復(fù)用而將彈簧(循環(huán))邏輯抽出去相比叫胖,我們內(nèi)聯(lián)了它瓮增。這在僅有一個(gè)這樣的例子需要考慮時(shí)工作的很好,但只要你在程序中有五六個(gè)或更多這樣的東西時(shí)拳恋,你將很可能想要一些可復(fù)用性來(lái)將讓事情更簡(jiǎn)短砸捏、更易管理一些。
-
這里的例子為了展示不同的形式而被故意地搞得很簡(jiǎn)單鸳谜。在現(xiàn)實(shí)中,遞歸算法有著更多的復(fù)雜性芭挽,比如相互遞歸(有多于一個(gè)的函數(shù)調(diào)用它自己)袜爪。
你在這條路上走得越遠(yuǎn),展開 優(yōu)化就變得越復(fù)雜和越依靠手動(dòng)俺陋。你很快就會(huì)失去所有可讀性的認(rèn)知價(jià)值腊状。遞歸缴挖,甚至是PTC形式的遞歸的主要優(yōu)點(diǎn)是焚辅,它保留了算法的可讀性同蜻,并將性能優(yōu)化的任務(wù)交給引擎。
如果你使用PTC編寫你的算法瘫析,ES6引擎將會(huì)實(shí)施TCO來(lái)使你的代碼運(yùn)行在一個(gè)定長(zhǎng)深度的棧中(通過(guò)重用棧幀)颁股。你將在得到遞歸的可讀性的同時(shí)傻丝,也得到性能上的大部分好處與無(wú)限的運(yùn)行長(zhǎng)度葡缰。
元?
TCO與元編程有什么關(guān)系滤愕?
正如我們?cè)谠缦鹊摹疤匦詼y(cè)試”一節(jié)中講過(guò)的间影,你可以在運(yùn)行時(shí)判定一個(gè)引擎支持什么特性魂贬。這也包括TCO,雖然判定的過(guò)程相當(dāng)粗暴宣谈∥懦螅考慮如下代碼:
"use strict";
try {
(function foo(x){
if (x < 5E5) return foo( x + 1 );
})( 1 );
TCO_ENABLED = true;
}
catch (err) {
TCO_ENABLED = false;
}
在一個(gè)非TCO引擎中嗦嗡,遞歸循環(huán)最終將會(huì)失敗牙言,拋出一個(gè)被try..catch
捕獲的異常咱枉。否則循環(huán)將由TCO輕易地完成蚕断。
討厭,對(duì)吧硝拧?
但是圍繞著TCO特性進(jìn)行的元編程(或者障陶,沒(méi)有它)如何給我們的代碼帶來(lái)好處聊训?簡(jiǎn)單的答案是你可以使用這樣的特性測(cè)試來(lái)決定加載一個(gè)你的應(yīng)用程序的使用遞歸的版本带斑,還是一個(gè)被轉(zhuǎn)換/轉(zhuǎn)譯為不需要遞歸的版本。
自我調(diào)整的代碼
但這里有另外一種看待這個(gè)問(wèn)題的方式:
"use strict";
function foo(x) {
function _foo() {
if (x > 1) {
acc = acc + (x / 2);
x = x - 1;
return _foo();
}
}
var acc = 1;
while (x > 1) {
try {
_foo();
}
catch (err) { }
}
return acc;
}
foo( 123456 ); // 3810376848.5
這個(gè)算法試圖盡可能多地使用遞歸來(lái)工作敢靡,但是通過(guò)作用域中的變量x
和acc
來(lái)跟蹤這個(gè)進(jìn)程啸胧。如果整個(gè)問(wèn)題可以通過(guò)遞歸沒(méi)有錯(cuò)誤地解決幔虏,很好所计。如果引擎在某一點(diǎn)終止了遞歸团秽,我們簡(jiǎn)單地使用try..catch
捕捉它习勤,然后從我們離開的地方再試一次图毕。
我認(rèn)為這是一種形式的元編程,因?yàn)槟阍谶\(yùn)行時(shí)期間探測(cè)著引擎是否能(遞歸地)完成任務(wù)的能力予颤,并繞過(guò)了任何可能制約你的(非TCO的)引擎的限制蛤虐。
一眼(或者是兩眼2低ァ)看上去,我打賭這段代碼要比以前的版本難看許多蹲堂。它運(yùn)行起來(lái)還相當(dāng)?shù)芈恍ㄔ谝粋€(gè)非TCO環(huán)境中長(zhǎng)時(shí)間運(yùn)行的情況下)柒竞。
它主要的優(yōu)勢(shì)是能犯,除了在非TCO引擎中也能完成任意棧大小的任務(wù)外,這種對(duì)遞歸棧限制的“解法”要比前面展示的蹦床和手動(dòng)展開技術(shù)靈活得多执泰。
實(shí)質(zhì)上术吝,這種情況下的_foo()
實(shí)際上是任意遞歸任務(wù)排苍,甚至是相互遞歸的某種替身学密。剩下的內(nèi)容是應(yīng)當(dāng)對(duì)任何算法都可以工作的模板代碼腻暮。
唯一的“技巧”是為了能夠在達(dá)到遞歸限制的事件發(fā)生時(shí)繼續(xù)運(yùn)行哭靖,遞歸的狀態(tài)必須保存在遞歸函數(shù)外部的作用域變量中。我們是通過(guò)將x
和acc
留在_foo()
函數(shù)外面這樣做的筝蚕,而不是像早先那樣將它們作為參數(shù)值傳遞給_foo()
起宽。
幾乎所有的遞歸算法都可以采用這種方法工作燎含。這意味著它是在你的程序中屏箍,進(jìn)行最小的重寫就能利用TCO遞歸的最廣泛的可行方法橘忱。
這種方式仍然使用一個(gè)PTC钝诚,意味著這段代碼將會(huì) 漸進(jìn)增強(qiáng):從在一個(gè)老版瀏覽器中使用許多次循環(huán)(遞歸批處理)來(lái)運(yùn)行,到在一個(gè)ES6+環(huán)境中完全利用TCO遞歸疹鳄。我覺得這相當(dāng)酷瘪弓!
復(fù)習(xí)
元編程是當(dāng)你將程序的邏輯轉(zhuǎn)向關(guān)注它自身(或者它的運(yùn)行時(shí)環(huán)境)時(shí)進(jìn)行的編程禽最,要么為了調(diào)查它自己的結(jié)構(gòu)川无,要么為了修改它懦趋。元編程的主要價(jià)值是擴(kuò)展語(yǔ)言的普通機(jī)制來(lái)提供額外的能力仅叫。
在ES6以前惑芭,JavaScript已經(jīng)有了相當(dāng)?shù)脑幊棠芰陶遥荅S6使用了幾個(gè)新特性及大地提高了它的地位婴渡。
從對(duì)匿名函數(shù)的函數(shù)名推斷边臼,到告訴你一個(gè)構(gòu)造器是如何被調(diào)用的元屬性柠并,你可以前所未有地在程序運(yùn)行期間來(lái)調(diào)查它的結(jié)構(gòu)臼予。通用Symbols允許你覆蓋固有的行為,比如將一個(gè)對(duì)象轉(zhuǎn)換為一個(gè)基本類型值的強(qiáng)制轉(zhuǎn)換窄锅。代理可以攔截并自定義各種在對(duì)象上的底層操作入偷,而且Reflect
提供了模擬它們的工具疏之。
特性測(cè)試,即便是對(duì)尾部調(diào)用優(yōu)化這樣微妙的語(yǔ)法行為冠摄,將元編程的焦點(diǎn)從你的程序提升到JS引擎的能力本身河泳。通過(guò)更多地了解環(huán)境可以做什么拆挥,你的程序可以在運(yùn)行時(shí)將它們自己調(diào)整到最佳狀態(tài)纸兔。
你應(yīng)該進(jìn)行元編程嗎汉矿?我的建議是:先集中學(xué)習(xí)這門語(yǔ)言的核心機(jī)制是如何工作的洲拇。一旦你完全懂得了JS本身可以做什么,就是開始利用這些強(qiáng)大的元編程能力將這門語(yǔ)言向前推進(jìn)的時(shí)候了曲尸!