第七章:元編程 2

特別說(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ǔ)在targetSymbol.for("[[Prototype]]")位置中的對(duì)象引用剩瓶。

這種模式的一個(gè)重要優(yōu)點(diǎn)是驹溃,在obj1obj2之間建立循環(huán)關(guān)系幾乎沒(méi)有入侵它們的定義。雖然前面的代碼段為了簡(jiǎn)短而將所有的步驟交織在一起延曙,但是如果你仔細(xì)觀察豌鹤,代理處理器的邏輯完全是范用的(不具體地知道obj1obj2)。所以枝缔,這段邏輯可以抽出到一個(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è)置為多重委托到obj1obj2笼平。在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è)匹配痛悯。

obj1obj2代表obj3的兩個(gè)平行的[[Prototype]]鏈余黎。obj1和/或obj2自身可以擁有委托至其他對(duì)象的普通[[Prototype]],或者自身也可以是多重委托的代理(就像obj3一樣)载萌。

正如先前的循環(huán)[[Prototype]]的例子一樣惧财,obj1obj2obj3的定義幾乎完全與處理多重委托的范用代理邏輯相分離扭仁。定義一個(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è)順序是:

  1. 首先鞭达,以數(shù)字上升的順序,枚舉所有數(shù)字索引的直屬屬性皇忿。
  2. 然后畴蹭,以創(chuàng)建順序枚舉剩下的直屬字符串屬性名。
  3. 最后鳍烁,以創(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_ENABLEDtrue還是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 + 1bar(..)調(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)中:

  1. 首先拧廊,我們將return _foo ..這一行包裝進(jìn)函數(shù)表達(dá)式return partial() {..晋修。
  2. 然后我們將_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ù)使用在變量xacc上的閉包來(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ò)作用域中的變量xacc來(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ò)將xacc留在_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í)候了曲尸!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末赋续,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子另患,更是在濱河造成了極大的恐慌纽乱,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,576評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件昆箕,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡鹏倘,警方通過(guò)查閱死者的電腦和手機(jī)敛熬,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,515評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)第股,“玉大人应民,你說(shuō)我怎么就攤上這事。” “怎么了诲锹?”我有些...
    開封第一講書人閱讀 168,017評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵繁仁,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我归园,道長(zhǎng)黄虱,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,626評(píng)論 1 296
  • 正文 為了忘掉前任庸诱,我火速辦了婚禮捻浦,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘桥爽。我一直安慰自己朱灿,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,625評(píng)論 6 397
  • 文/花漫 我一把揭開白布钠四。 她就那樣靜靜地躺著盗扒,像睡著了一般。 火紅的嫁衣襯著肌膚如雪缀去。 梳的紋絲不亂的頭發(fā)上侣灶,一...
    開封第一講書人閱讀 52,255評(píng)論 1 308
  • 那天,我揣著相機(jī)與錄音缕碎,去河邊找鬼褥影。 笑死,一個(gè)胖子當(dāng)著我的面吹牛咏雌,可吹牛的內(nèi)容都是我干的伪阶。 我是一名探鬼主播,決...
    沈念sama閱讀 40,825評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼处嫌,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了斟湃?” 一聲冷哼從身側(cè)響起熏迹,我...
    開封第一講書人閱讀 39,729評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎凝赛,沒(méi)想到半個(gè)月后注暗,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,271評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡墓猎,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,363評(píng)論 3 340
  • 正文 我和宋清朗相戀三年捆昏,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片毙沾。...
    茶點(diǎn)故事閱讀 40,498評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡骗卜,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情寇仓,我是刑警寧澤举户,帶...
    沈念sama閱讀 36,183評(píng)論 5 350
  • 正文 年R本政府宣布,位于F島的核電站遍烦,受9級(jí)特大地震影響俭嘁,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜服猪,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,867評(píng)論 3 333
  • 文/蒙蒙 一供填、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧罢猪,春花似錦近她、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,338評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至备闲,卻和暖如春晌端,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背恬砂。 一陣腳步聲響...
    開封第一講書人閱讀 33,458評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工咧纠, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人泻骤。 一個(gè)月前我還...
    沈念sama閱讀 48,906評(píng)論 3 376
  • 正文 我出身青樓漆羔,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親狱掂。 傳聞我的和親對(duì)象是個(gè)殘疾皇子演痒,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,507評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容

  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券趋惨,享受所有官網(wǎng)優(yōu)惠鸟顺,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 6,554評(píng)論 3 22
  • 特別說(shuō)明,為便于查閱器虾,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS...
    殺破狼real閱讀 164評(píng)論 0 0
  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持讯嫂,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠兆沙,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 3,660評(píng)論 2 27
  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持欧芽,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠葛圃,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 3,002評(píng)論 4 14
  • 現(xiàn)在是星期五晚上千扔,不知道從什么時(shí)候開始憎妙,我對(duì)星期五晚上有了一種特別的情愫。盡管我不用朝九晚五的工作昏鹃,但是每天我在思...
    水伊兒閱讀 559評(píng)論 0 0