騰訊文檔:大型前端項目內(nèi)存優(yōu)化總結(jié)
戳藍字「TianTianUp」關(guān)注我們哦!
在騰訊文檔表格中,如果用戶打開表格的內(nèi)容非常多,比如有幾萬行或者幾十萬個單元格。內(nèi)存占用會居高不下,在使用的過程中非常容易出現(xiàn)崩潰,卡頓等問題,在進行一系列的優(yōu)化之后,寫下這篇文章,總結(jié)下期間用到的一些優(yōu)化方案,希望可以或多或少幫助或者啟發(fā)一下他人。
怎么找出內(nèi)存問題
在內(nèi)存這里,我們一般是借助 Google 瀏覽器的開發(fā)者工具,然后獲取下頁面的內(nèi)存快照,然后分享具體占用過大內(nèi)存的對象。


除了查看內(nèi)存占用過大的對象之外,也可以切換到 Containment 這里查看一些占用內(nèi)存過大的全局對象。

常見的內(nèi)存優(yōu)化方案
按需加載
按需加載可能是能夠想到的最簡單的內(nèi)存優(yōu)化的方式,比如當某一個功能沒有使用的時候,不加載這個功能模塊,也不進行初始化。舉個例子,比如帳號管理模塊,在沒有點擊右上角頭像部分的時候,是不需要加載的,這個時候就可以改成按需加載。

在騰訊文檔表格的優(yōu)化中,部分改造為按需加載的模塊使用率:
Account 組件改成按需加載 用戶使用率:0.8%
QuickFill 組件改成按需加載 用戶使用率:1.2%
優(yōu)化了這些模塊之后,內(nèi)存占用大概降低了 10M 左右,優(yōu)化效果并不明顯。老板說:


對象池
對象池模式在初始化的時候會創(chuàng)建固定數(shù)量的緩存,并且每個對象都可以復用,對象沒有更新的需求。

對象池這種模式主要用于游戲或者數(shù)據(jù)庫連接池等場景。因為騰訊文檔表格優(yōu)化的時候沒有使用到對象池的方式,這里就不詳細描述了,有需要的可以自行 Google。
享元
享元模式的特點:
屬性全部只讀或者私有化
提供創(chuàng)建對象實例的工廠方法
修改時重新創(chuàng)建對象實例
使用 key 緩存對象,key 相同直接返回緩存對象實例
舉個例子:假設(shè)目前有三個變量 ABC,引用的都是同一個內(nèi)存中的對象。

假設(shè)這個時候需要修改對象 A 的屬性,則需要使用新的屬性參數(shù),創(chuàng)建一個新的對象,講 A 指向新創(chuàng)建的對象,此時內(nèi)存當中的對象應該是這樣的:

這個時候,內(nèi)存當中就有兩個對象了,BC 還是指向的之前的對象,但是這個時候的 A 已經(jīng)指向新創(chuàng)建的對象了。
下圖是在騰訊文檔表格中,部分使用享元模式優(yōu)化之后的內(nèi)存對比,可以發(fā)現(xiàn)優(yōu)化效果還是比較明顯的。

享元遇到的問題
以為用享元就能解決問題了?

直到看到這個圖:

用戶在操作設(shè)置數(shù)據(jù)格式之后,前端提交到數(shù)據(jù)后臺,落盤失敗!

在做享元優(yōu)化的時候,遇到一個特殊的問題,這里也記錄下,方便大家后面使用享元的時候注意。我們有一個對象,用來描述單元格的值,以及值的類型。如下所示:

type 表示類型,value 就是存儲的值。
type 的類型有如下幾種

這個對象的 key=type+,+value

假設(shè)此時實用如下參數(shù)創(chuàng)建一個對象:
{type: 1,value: 123,}
此時生成的 key='1,123'
很明顯,這個時候的 value 和 type 的類型對應不上,但是由于我們沒有任何校驗,導致錯誤的創(chuàng)建了一個對象,并且以 key='1,123' 被緩存起來了。
當下次創(chuàng)建的一個對象的時候,傳入如下的參數(shù):
{type: 1,value: '123',}
這里的參數(shù),跟上面的區(qū)別是 value 和 type 是對應上的,但是注意,這個時候生存的 key='1,123'。嗯?發(fā)現(xiàn)這個 key 在之前已經(jīng)創(chuàng)建過了,并且緩存了一個對象。這個時候,不會再去創(chuàng)建一個新的對象,而是將上面的對象直接返回給到使用方,然后報錯了。
這個情況在騰訊文檔表格中就出現(xiàn)過,有的業(yè)務模塊創(chuàng)建了臨時的 ExtenedValue 對象,在傳參數(shù)的時候,類型和值對應不上,導致數(shù)據(jù)層在提交數(shù)據(jù)的時候報錯。
那如何解決這個問題呢? 在創(chuàng)建 key 的時候,獲取到 value 的真實類型,將真實類型作為 key 的一部分,這樣,上述的兩個參數(shù)創(chuàng)建的就會是兩個不同的對象了。
進階版內(nèi)存優(yōu)化方案
對象存儲優(yōu)化
一個 demo

上圖中的代碼,占用內(nèi)存 4.8M。

上圖中的代碼,占用內(nèi)存 3.6M。

對比兩者的內(nèi)存快照發(fā)現(xiàn)相差的就是 1.2M。這是為什么呢?
對象在 v8 中的存儲方式

首先來看下對象在 v8 中的存儲方式,在 v8 中,js 的對象使用 JSObject 對象來描述。這個對象有下面幾個主要屬性:
HiddenClass 用來存儲對象屬性存儲的位置信息
properties 用來存儲具名屬性
elements 用來存儲有順序的屬性,類似數(shù)組 在這種情況下,存儲在 properties 中的屬性,都是快屬性模式(使用數(shù)組的形式存儲)。
那針對上面的問題,猜測可能是因為
this.a = null初始化屬性 a 的時候,HiddenClass 的結(jié)構(gòu)會變化,從而增加內(nèi)存。接著使用 v8 的調(diào)試工具 d8 來驗證下:


從上面的 debug 信息中可以看出,初始化的時候,如果賦值為 null,會在 HiddenClass 的 DescriptorArray 中添加記錄,用來描述屬性 a 的初始值。 所以,在初始化屬性的時候,如果參數(shù)沒有傳遞該屬性的值,那就不要初始化為 null,因為這樣會占用內(nèi)存。
優(yōu)化驗證
針對上述的情況,我們優(yōu)化了騰訊文檔表格的單元格對象 CellData 的初始化過程。

可以發(fā)現(xiàn),優(yōu)化的效果非常明顯。30w 單元格的表格,CellData 的內(nèi)存占用從 24M 減少到了 12M。
優(yōu)化到到這里的時候,老板再問我能達到預期目標的時候:

清除 delete 操作
一個 demo
有 delete 操作


沒有 delete 操作


從上面的圖中可以看出,沒有 delete 操作會比有 delete 操作的內(nèi)存占用少非常多。這又是為什么呢?
v8 HiddenClass 的轉(zhuǎn)化過程
v8 在初始化對象,給對象添加屬性的時候,會變更 hiddenClass 指針的指向,將其指向最新的節(jié)點。先看一個 v8 官方網(wǎng)站的圖:

在這個圖中,當我們給對象 o 添加屬性的時候,hiddenClass 的指針一直在變化,也就是說每次給對象添加新的屬性,都會生成一個新的 HiddenClass 節(jié)點,所有的 HiddenClass 節(jié)點組成了一個的鏈表(其實準確的說應該是 tree ),然后對象中的 hiddenClass 指針指向最新的節(jié)點。
上面說的所有的 HiddenClass 節(jié)點其實組成了一棵樹,在 v8 里面叫做 transition tree。例如:假設(shè)這個時候,新創(chuàng)建一個對象,并且添加了一個新的屬性:

這樣看是不是就知道為什么是一棵樹了。
那這些跟 delete 操作有什么關(guān)系呢?
delete 操作導致 v8 的對象存儲模式退化為字典模式
當對屬性進行 delete 操作的時候,會將上面所說的鏈表結(jié)構(gòu)打破,并且對象在 v8 內(nèi)的存儲方式也會發(fā)生變化。變成如下所示的結(jié)構(gòu):

或者直接看 v8 官方網(wǎng)站提供的圖:

可以看到,如果你對一個對象的屬性進行 delete 操作,就會導致對象的存儲方式退化到字典模式(慢屬性模式)。相對于之前的快屬性模式,這種存儲方式更加消耗內(nèi)存。所以這也是為什么 delete 操作會導致對象內(nèi)存占用增加的根本原因。
驗證:
const obj = {a:1,b: 2,}%DebugPrint(obj);delete obj.a;%DebugPrint(obj);

刪除屬性 a 之后,退化到字典模式:

再看一下這個圖,我們發(fā)現(xiàn)在 HiddenClass 節(jié)點也有一個 back pointer ,指向上一個節(jié)點。

所以,如果你是按照對象添加屬性的反方向刪除屬性的話,對象并不會退化到慢屬性模式,或者對象的內(nèi)存占用并不會增加。這里更多詳細的描述可以參考 v8 的官方文檔或者 superzheng 大佬在知乎的專欄。
如何替換掉 delete 操作?
賦值給 undefined
假設(shè)有如下代碼:
const data = {a: '1',b: 'c',}// do something...delete data.c;
如果沒有特殊的要求,建議直接修改為:
const data = {a: '1',b: 'c',}// do something...data.c = undefined;
使用如下代碼測試:
const startTime = +new Date();for (let i = 0; i < 1000000; i++) {const a = {a: '1',b: '2',c: '3',};// a.b = undefined;delete a.b;}const endTime = +new Date();console.log(endTime - startTime);
賦值給 undefined 比直接 delete 刪除,要快幾十倍,甚至上百倍。
使用 Map 替代
假設(shè)你的對象必須就是要刪除,那么還可以使用 Map 來替代對象。
const data = {a: '1',b: 'c',}// do something...delete data.c;
可以修改為如下的形式:
const data = new Map<string, string>();data.set('a', '1');data.set('b', 'c');// do something...// 使用 Map 對象提供的 delete 方法刪除元素data.delete('a');
性能監(jiān)控(保護優(yōu)化成果)
內(nèi)存監(jiān)控
內(nèi)存優(yōu)化的手段固然重要,但是如果在優(yōu)化之后一段時間,隨著需求的增加,由于一些新代碼的引入,或者寫代碼時候不注意,就有可能導致內(nèi)存暴增。這個時候,就在想是否能夠在發(fā)布之前,就可以自動化的檢測到騰訊文檔的特征表格的內(nèi)存占用,然后跟現(xiàn)網(wǎng)環(huán)境的內(nèi)存占用對比。如果發(fā)現(xiàn)內(nèi)存占用比現(xiàn)網(wǎng)的要高很多,則可以在發(fā)布階段終止發(fā)布,接著進入代碼 review 階段,分析哪里導致的內(nèi)存占用增加。解決了問題之后,再重新進行發(fā)布流程。
針對這個自動化測試,騰訊文檔這邊目前也實現(xiàn)了一個可以自動化獲取頁面內(nèi)存快照和大小的工具,可以接入到流水線當中,作為一個性能檢測的方式集成到發(fā)布流水線當中,保護內(nèi)存優(yōu)化成功。
參考資料
https://flaviocopes.com/how-to-remove-object-property-javascript/ https://v8.dev/blog/fast-properties https://zhuanlan.zhihu.com/p/28872382 https://v8.dev/docs/build
最后,如果你對性能優(yōu)化感興趣,騰訊文檔歡迎你的加入。
關(guān)于AlloyTeam
AlloyTeam 是國內(nèi)影響力最大的前端團隊之一,核心成員來自前 WebQQ 前端團隊。 AlloyTeam負責過WebQQ、QQ群、興趣部落、騰訊文檔等大型Web項目,積累了許多豐富寶貴的Web開發(fā)經(jīng)驗。 這里技術(shù)氛圍好,領(lǐng)導nice、錢景好,無論你是身經(jīng)百戰(zhàn)的資深工程師,還是即將從學校步入社會的新人,只要你熱愛挑戰(zhàn),希望前端技術(shù)和我們飛速提高,這里將是最適合你的地方。 加入我們,請將簡歷發(fā)送至 [email protected],或直接在公眾號留言~ 期待您的回復??
最近文章:
END


“分享、點贊、在看” 支持一波 
