著前端的飛速發展,在瀏覽器端完成複雜的計算,支配並處理大量資料已經屢見不鮮。那麼,如何在最小化記憶體消耗的前提下,高效優雅地完成複雜場景的處理,越來越考驗開發者功力,也直接決定了程式的效能。
本文展現了一個完全在控制檯就能模擬體驗的例項,通過一步步優化,實現了生產並操控多個1000000(百萬級別)物件的場景。
導讀:這篇文章涉及到 javascript 中 陣列各種操作、原型原型鏈、ES6、classes 繼承、設計模式、控制檯分析 等內容。
要求閱讀者具有 js 物件導向紮實的基礎知識。如果你是初級前端開發者,很容易被較為複雜的邏輯繞的雲裡霧裡,“從入門到放棄”,不過建議先收藏。如果你是“老司機”,本文提供的解決思路希望對你有所啟發,拋磚引玉。
場景和初級感知
具體來說,我們需要一個建構函式,或者說類似 factory 模式,例項化1000000個以上物件例項。
先來感知一下具體實現:
Step1
開啟你的瀏覽器控制檯,仔細觀察並複製貼上以下程式碼,觸發執行。
1 |
a = new Array(1e6).fill(0); |
我們建立了一個長度為1000000的陣列,陣列的每一項元素都為0。
Step2
在陣列 a 的基礎上,再生產一個長度為1000000的陣列 b,陣列的每一項元素都是一個普通 javascript object,擁有 id 屬性,並且其 id 屬性值為其在元素中的 index 值;
1 |
b = a.map((val, ix) => ({id: ix})) |
Step3
接下來,在 b 的基礎上,再生產一個長度為1000000的陣列 c ,類似於 b,同時我們增加一些其它屬性,使得陣列元素物件更加複雜一些:
1 |
c = a.map((val, ix) => ({id: ix, shape: 'square', size: 10.5, color: 'green'})) |
語義上,我們可以更直觀的理解:c 就是包含了1000000個元素的陣列,每一項都是一個綠色的、size 為10.5的小方塊。
深層探究
你也許會想,這麼大的資料量,記憶體佔用會是什麼樣的情況呢?
好,我來帶你看看,點選控制檯 Profiles,選擇 Take Shapshot。在Window->Window 目錄下,根據記憶體進行篩選,你會得到:
很明顯,我們看到:
- a陣列:8MB;
- b陣列:40MB;
- c陣列:64MB
也許在實際場景中,除了1000000個綠色的、size為10.5的小方塊,我們還需要很多不同顏色,不同 size 的形狀。之前,這樣“變態”的需求常見於遊戲應用中。但是現在,複雜專案中類似場景,也許距離你並不遙遠。
ES6 Classes處理需求
簡單“熱身”之後,我們瞭解了實際需求。接下來,我們考察一下 ES6 Classes 處理這個問題的情況。請重新重新整理瀏覽器 tab,複製執行以下程式碼。
1 2 3 4 5 6 7 8 9 10 |
class Shape { constructor (id, shape = 'square', size = 10.5, color = 'green') { this.x = x; // 座標x軸 this.y = y; // 座標y軸 Object.assign(this, {id, shape, size, color}) } } a = new Array(1e6).fill(0); b = a.map((val, ix) => new Shape(ix)); |
很明顯,此時 b 陣列由100000個形狀組成,佔據記憶體:80MB,超過了先前陣列的記憶體消耗。也許這並不出乎意料,此時的b陣列畢竟又多了兩個屬性。
優化設計:Two-Headed Classes
我們先來分析一下上面的實現,熟悉原型鏈、原型概念的同學也許會明白,之前的方案產生的例項,順著原型鏈上溯,具有三層原型屬性:
第一層:[id, shape, size, color, x, y]; 這一層屬性的 hasOwnproperty 為 true; 屬性存在於例項本身。
第二層:[Shape]; 順著原型鏈上溯,這一層 instance.proto === Constructor.prototype; ( proto 左右兩邊 __ 被編輯器吃掉了,請見諒,下同)
第三層:[Object]; 這一層: instance.proto.__proto__ === Object.prototype; 如果在向上追溯,就為 null 了。
這樣的情況下,實際業務資料層只有一層,即為第一層。
但是,請仔細思考,如果有大量的不同顏色,不同size,不同形狀的情況下。單一資料層,是難以滿足我們需求的。 我們需要,再新增一層資料層,構成所謂的 Two-Headed Classes!同時,還需要對於預設的屬性,實現共享,以節省記憶體的佔用。
什麼什麼?沒聽明白,那就請看具體操作吧。
如何實現?
我們可以使用 Object.create 方法,這樣使得生產得到的例項的 proto 指向 b 陣列的元素,然後在最頂層設計一個 id 屬性。
也許這樣說過於晦澀,那就直接參考程式碼吧,請注意,這是本篇文章最難以理解的地方,請務必仔細揣摩:
1 2 3 |
two = Object.create(b[0]); // two.__proto__ === b[0] two.id = 1; |
還記得 b 陣列是什麼嘛?參考上文,它由
1 |
b = a.map((val, ix) => new Shape(ix)); |
得到。這樣子的話,對於每一個例項,我們有如下關係:
第一層:[id]; 這一層例項的 hasOwnproperty 為 true;
第二層:[id, shape, size, color, x, y]; 這一層 instance.proto === Constructor.prototype;
第三層:[Shape];
第四層:[Object]; 這一層的再頂層,就為null了。
我們將 Shape 的一個例項作為一個新的 object 的原型,並複寫了 id 屬性,原有的 id 屬性將作為預設 id。
當然,上邊的程式碼只是“個案”,我們進行“生產化”:
1 2 3 4 5 6 7 |
proto = new Shape(0); function newTwoHeaded (ix) { const obj = Object.create(proto); obj.id = ix; return obj } c = a.map((val, ix) => newTwoHeaded(ix)); |
這表明:我們從80MB的b,優化得到了64MB的c! 原因當然就在於雖然多加了一層原型結構,但是第二層變成了“共享”。
當然,如果到這裡你還沒有暈的話,可能要問:那第二層諸如 shape, size, color 這些屬性變成共享的之後,存在互相干擾怎麼破解呢?
好問題,我先不解答,先給大家看一下最後的 final product:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
class ShapeMaker { constructor () { Object.assign(this, ShapeMaker.defaults()) } static defaults () { return { id: null, x: 0, y: 0, shape: 'square', size: 0.5, color: 'red', strokeColor: 'yellow', hidden: false, label: null, labelOffset: [0, 0], labelFont: '10px sans-serif', labelColor: 'black' } } newShape (id, x, y) { const obj = Object.create(this); return Object.assign(obj, {id, x, y}) } setDefault (name, value) { this[name] = value; } getDefault (name) { return this[name] } } |
在例項化的時候,我們便可以這樣使用:
1 2 |
shapeProto = new ShapreMaker(); d = a.map((val, ix) => shapeProto.newShape(ix, ix/10, -ix/10)) |
像上面所說的,初始化例項時,我們初始化了 id, x, y 這麼三個引數。作為該例項本身的資料層。這個例項的原型上,也有類似的引數,來保證預設值。這些原型上的屬性,對於例項陣列中的每個例項,都是共享的。
為了更好的對比,如果設計是這樣子:
1 2 3 4 5 |
function fatShape (id, x, y) { const a = new shapeMaker(); return Object.assign(a, {id, x, y}) } e = a.map((val, ix) => fatShape(ix, ix/10, -ix/10)) |
那麼所有屬性無法共享,而是各自拷貝了一份。在記憶體的佔用上,將是我們給出方案的三倍之多!
阿喀琉斯之踵
阿喀琉斯,是凡人珀琉斯和美貌仙女忒提斯的寶貝兒子。忒提斯為了讓兒子煉成“金鐘罩”,在他剛出生時就將其倒提著浸進冥河,遺憾的是,乖兒被母親捏住的腳後跟卻不慎露在水外,全身留下了惟一一處“死穴”。後來,阿喀琉斯被帕里斯一箭射中了腳踝而死去。 後人常以“阿喀琉斯之踵”譬喻這樣一個道理:即使是再強大的英雄,他也有致命的死穴或軟肋。
就像我們剛才提的到解決方案一樣,也有一些“不足”。問題其實在之前我也已經丟擲:“第二層諸如:shape, size, color 這些屬性變成共享的之後,存在互相干擾怎麼破解呢?”
這個問題的答案其實也隱藏在上面的程式碼中,很簡單,就是我們在例項的自身屬性上,進行復寫,而避免更改原型上的屬性造成汙染。
如果你看的雲裡霧裡,不要緊,馬上看一下我下面的程式碼說明:
1 |
.every((item) => item.shape === 'square') // true |
列印為 true,是因為 d 陣列中的每個例項的 shape 屬性,都在原型上,且初始值都為’square’;
現在我們呼叫 setDefault 方法,實現對預設 shape 的改寫。
1 2 |
shapeProto.setDefault('shape', 'circle'); d.every((item) => item.shape === 'square'); // false |
因為此時所有例項的 shape 都在原型上,並共享這個原型。更改之後,我們有:
1 |
<span class="nx">d</span><span class="p">.</span><span class="nx">every</span><span class="p">((</span><span class="nx">item</span><span class="p">)</span> <span class="o">=></span> <span class="nx">item</span><span class="p">.</span><span class="nx">shape</span> <span class="o">===</span> <span class="s1">'circle'</span><span class="p">);</span> <span class="c1">// true</span> |
但是,我只想把第一個例項的 shape 設定為 triangle,其他的不變,該怎麼辦呢?只需要在第一個例項上,增加一個 shape 屬性,進行重寫:
1 2 |
d[0].shape = 'triangle'; d.every((item) => item.shape === 'circle'); // false |
好吧,嘗試完畢之後,我們在變回來。
1 |
d[0].shape = 'circle'; |
這時候,自然有:
1 |
d.every((item) => item.shape === 'circle'); // true |
同時,再折騰一下:
1 2 |
d[0].shape = 'triangle'; d.every((item) => item.shape === 'triangle'); // false |
相信下面的也不難理解了:
1 2 |
hapeProto.setDefault('shape', 'triangle'); d.every((item) => item.shape === 'triangle'); // true |
這種模式其實比單純使用ES6 Classes要靈活的多,同時也節省了記憶體。所有的靜態屬性都是共享的,但是共享的靜態屬性又都是可變的,可複寫的。
總結
這篇文章,我們在開頭部分了解到了在大量資料的情況下,記憶體的佔用是如何一步一步變的沉重。同時,我們提供了一種,在傳統的 Classes 之上增加一個資料層的方法,有效地解決了這個問題。解決方案充分利用了 Object.create 等手段。
當然,理解這些內容並不簡單,需要讀者有比較紮實的 javascript 基礎。在您閱讀過程當中,有任何問題,歡迎與我討論。