记一次卡顿的性能优化经历实操
本篇的性能優化不是八股文類的優化方案,而是針對具體場景,具體分析,從排查卡頓根因到一步步尋找解決方案,甚至是規避等方案來最終解決性能問題的經歷實操
所以,解決方案可能不通用,不適用于你的場景,但這個解決過程是如何一步步去處理的,解決思路是怎么樣的,應該還是可以提供一些參考、借鑒意義的
當然,也許你還有更好的解決方案,也歡迎評論教一下,萬分感謝
問題現象
我基于 twaver.js 庫實現了一個園區內網絡設備的拓撲呈現,連線表示設備間的拓撲關系,線路上支持流動動畫、告警動畫、鏈路信息等呈現,如:
但當呈現的節點數量超過 1000 后,動畫開始有點丟幀,操作有點點滯后感
超過 5000 個節點后,頁面就非常的卡頓,難以操作
所以,就開始了性能優化之路
猜測&驗證
猜測 1:Vue 框架的響應式處理導致的性能瓶頸
之所以有這個猜測是因為,我在官方給的 demo 上體驗時,上萬個節點時都不卡頓,更何況是一千個節點而已
而我的項目跟官方 demo 的差異有兩塊:
- 我用 vue 框架開發,官方 demo 用的純 html + js
- 我功能已經開發完,所以實際上還參雜了其他各種實現的代碼,官方 demo 很簡單的純節點和鏈路
為了驗證這個猜想,我另外搞了個空項目,純粹就只是把官方 demo 的代碼遷移到 vue 上運行起來而已,如:
【10000 個節點,20000 條連線,twaver 官方 demo 耗時 250ms,不卡頓】
【10000 個節點,20000 條連線,vue 實現的 demo 耗時 11500ms,操作上有 0.5s 的滯后感】
同樣的代碼,同樣的數據量,區別僅僅是一個用純 js 實現,一個用 vue 實現,但兩邊的耗時差異將近 45 倍
所以就開始思考了,Vue 框架能影響到性能問題的是什么?
無非不就是響應式處理,內部會自動對復雜對象深度遍歷去配置 setter, getter 來攔截對象屬性的讀寫
而 twaver 的對象結構又非常復雜,就導致了一堆無效的響應式處理耗時資源:
看到沒有,twaver 的兩個變量 box 和 network,內部結構非常復雜,N 多的內嵌對象,全部都被響應式處理,這占用的資源是非常恐怖的
(注:Vue2.x 版本可以直接在開發者工具面板上查看對象內部是否有 setter 和 getter 就知道這個對象是否有被響應式處理)
但我們其實又不需要它能夠響應式,我們只是想使用 twaver 對象的一些 api 而已
那么該怎么來避免 Vue 對這些數據進行的響應式處理呢?
下一章節里再具體介紹解法,至少到這里已經明確了卡頓的根因之一是 Vue 對 twaver 的數據對象進行了響應式處理而引發的性能瓶頸
猜測 2:動畫太多導致的性能瓶頸
這個應該是顯而易見的根因之一了,每條鏈路上都會有各種動畫,而實現上又是每條鏈路內部自己維護自己的動畫管理器(twaver.Animate)
簡單去撈了下 twaver 內部源碼實現,動畫管理器用了 requestAnimationFrame 來實現動畫幀,用了 setTimeout 來實現動畫的延遲執行
那么當節點成千上萬時,肯定會卡頓,畢竟這么多異步任務
而之所以會這么實現,原因之一是官方給的鏈路動畫 demo 就是這么做的,當初做的時候直接用 demo 方案來實現了
而 demo 顯然只是介紹鏈路動畫怎么實現而已,不會給你考慮到極端場景的性能瓶頸問題
那么怎么解決呢?不難,無非就是抽離復用 + 按需刷新思路而已,具體也是下面講解
猜測 3:一次性呈現的節點鏈路太多導致的性能瓶頸
這也是顯而易見的根因之一,就像長列表問題一樣,一次性呈現的節點鏈路太多了,必然會導致性能瓶頸問題
也不需要去驗證了,思考解決方案就行
但這跟長列表實現上有點不太一樣,因為 twaver 內部是用 canvas 來繪制節點和鏈路的,并不是用 dom 繪制,所以虛擬列表那種思路在這里行不通
但本質上的解決都一個樣,無非就是一次性沒必要呈現這么多節點,因為一屏內又顯示不了,沒有意義
所以,按照這種思路去尋找解決方案,具體也下面講講
猜測 4:dom 節點太多導致的性能瓶頸
雖然 twaver 內部是用 canvas 繪制的節點和鏈路,但當節點畢竟復雜時,比如:
這種時候用 canvas 畫不出來,只能用 div 繪制,twaver 也支持 HTMLNode 類型節點,這就意味著也會存在 dom 過多的場景
而 dom 導致的性能問題包括 dom 元素過多,頻繁操作 dom
因此解決方案上就是盡量避免創建過多的 dom 元素以及避免頻繁操作 dom 即可,具體也下面講
解決方案
繞過 Vue 的自動對數據模型進行的響應式處理
Vue2.x 框架內部會自動將聲明在 data 里的變量進行響應式處理,第一個想到的是嘗試用 Object.freeze 來凍結對象,例如:
this.box = Object.freeze(new twaver.ElementBox());
但有兩個問題:
- Object.freeze 是淺凍結,不是深度凍結,內嵌的對象好像還是會被響應式處理
- 可能會引發功能異常,因為沒法確認三方庫內部是否有用到對象的枚舉、遍歷、擴展等能力
那么還有其他什么方案嗎?
如果是 Vue3.x 的話,因為響應式處理是顯示調用,就沒有這些煩惱了。
至于 Vue2.x,內部自動進行了響應式處理,因此我們需要去源碼里看看有沒有什么辦法可以繞過響應式處理。
注:下面是 Vue 2.7.16 版本的源碼
源碼里給 data 數據進行響應式處理是在 core/instance/state.ts#initData()
// core/instance/state.ts
function initData(vm: Component) {
let data: any = vm.$options.data;
data = vm._data = isFunction(data) ? getData(data, vm) : data || {};
// 省略判斷 data 為對象的代碼
// ...
const keys = Object.keys(data);
const props = vm.$options.props;
const methods = vm.$options.methods;
let i = keys.length;
while (i--) {
const key = keys[i];
// 省略判斷 data 的字段與 props 或 methods 是否有同名的場景
// ...
// 判斷變量命名是否是 _ 或 $ 為前綴
if (!isReserved(key)) {
proxy(vm, `_data`, key); // 這里是關鍵之一,把 data 里的對象掛載到外部 vue 組件上
}
}
const ob = observe(data); // 響應式處理 data 數據
ob && ob.vmCount++;
}
上面的源碼里我省略了一些無關的代碼,然后有兩個關鍵點,一個是通過 isReserved(key) 判斷變量命名是否是以 _ 或 $ 開頭的代理處理,另一個是 observe(data) 處理響應式的 data 數據
第一點等會再講,先來看看是怎么對 data 數據進行響應式處理的:
// core/observer/index.ts
export function observe(
value: any,
shallow?: boolean,
ssrMockReactivity?: boolean
): Observer | void {
// 如果該對象已經響應式處理過了,就跳過,沒必要再次處理
if (value && hasOwn(value, "__ob__") && value.__ob__ instanceof Observer) {
return value.__ob__;
}
// 當滿足下面條件時,對對象進行響應式處理
if (
shouldObserve && // 總開關
(ssrMockReactivity || !isServerRendering()) && // 非服務端渲染場景
(isArray(value) || isPlainObject(value)) && // 數組或對象
Object.isExtensible(value) && // 支持擴展(即動態增刪字段)
!value.__v_skip /* ReactiveFlags.SKIP */ && // 是否跳過響應式處理
!isRef(value) && // // 是否是響應式對象
!(value instanceof VNode) // 是否是 VNode 對象
) {
// 內部遍歷對象的屬性,調用 defineReactive() 來對屬性進行 setter, getter 攔截
// 而 setter 里又重新調用 observe() 處理屬性值,從而達到深度遞歸處理內嵌對象屬性的響應式效果
return new Observer(value, shallow, ssrMockReactivity);
}
}
所以,我們其實是有辦法來繞過響應式處理的,比如給對象增加一個要跳過響應式處理的標志 __v_skip,如:
const box = new twaver.ElementBox();
box.__v_skip = true; // 這個是關鍵
this.box = box;
const network = new twaver.vector.Network(this.box);
network.__v_skip = true; // 這個是關鍵
this.network = network;
注意:__v_skip是 Vue2.7.x 版本后加入的邏輯,在 Vue2.6 及之前版本里,并沒有該邏輯,相反只有一個 _isVue 標志位判斷
有人說,不用這么麻煩,把變量命名改成 _ 為前綴,也能繞過響應式處理,這是真的嗎?畢竟源碼里好像沒有看到相關的代碼
別急,還記得我上面介紹 initData() 源碼里的兩個關鍵點之一的 ``
// core/instance/state.ts
function initData(vm: Component) {
// 省略其他無關代碼
// ...
while (i--) {
// 省略其他無關代碼
// ...
// 判斷變量命名是否是 _ 或 $ 為前綴
if (!isReserved(key)) {
// 把 data 里的對象掛載到外部 vue 組件上
proxy(vm, `_data`, key);
}
}
// 省略其他無關代碼
// ...
}
這里會遍歷 data 里的各個屬性字段,然后把里面非 _ 或 $ 為前綴的變量都掛到外部 Vue 組件實例上,這樣我們代碼里才可以直接用 this.xxx 來操作這些變量
由于我們命名了 _box,_network 變量,這些以 _ 開頭的變量就沒有被掛到 Vue 組件實例上,而后續我們代碼里使用 this._box = xxx 這樣來賦值變量,其實本質上是動態的往 Vue 組件實例上增加了一個 _box 變量,由于 Vue2.x 不支持對動態添加的屬性進行響應式處理,因此這才能達到繞過響應式處理的效果
所以把變量命名改成 _ 為前綴,其實是誤打誤撞的剛好繞過了響應式處理
Vue 官方文檔里其實也有解釋說了:
Properties that start with _ or $ will not be proxied on the Vue instance because they may conflict with Vue’s internal properties and API methods. You will have to access them as vm.$data._property
大意就是,Vue 內部變量命名就是以 _ 和 $ 為前綴命名,因此不會把 data 里以 _ 和 $ 開頭的變量掛到外部上來,防止變量命名沖突覆蓋掉內部變量而引起異常。因此當 data 里有這些變量時,使用時應該要 this.$data._xxx 的方式來操作這些變量
雖然是誤打誤撞的繞過了響應式處理,但這種方案不會讓代碼更繁瑣,使用上還算方便,就是需要放開 eslint 的 vue/no-reserved-keys 校驗規則
【舉一反三】
當用到其他一些三方庫,三方庫變量又不是全局而是當前組件內的局部變量 data 內部時,都會存在被 Vue 響應式處理的問題。
如果你也有遇到這種場景,不防往這方面去考慮看看如果繞過響應式處理
共同復用全局的動畫管理器 + 按需刷新
【原實現方案】
每條鏈路的動畫由各自內部實現:
export default function FlowLink() {
FlowLink.superClass.constructor.apply(this, arguments);
this._animate = this.getAnimate();
}
twaver.Util.ext(FlowLink, twaver.Link, {
play: function (options) {
this._animate.play();
return this._animate;
},
getAnimate: function (options) {
// 內部自己的動畫管理器
this._animate = new twaver.Animate(
Object.assign(
{
from: 0,
to: 1,
repeat: Number.POSITIVE_INFINITY,
reverse: false,
delay: 200, // 動畫延遲
dur: 5000, // 動畫時才
easing: "linear", // 線性動畫
onUpdate: (value) => {
// 更新動畫進度
this.setClient("anim.percent", value);
},
},
options
)
);
return this._animate;
},
});
而每條鏈路都是獨立的 FlowLink 實例對象,當達到成千上萬條鏈路時,資源就被撐爆了,很卡
【復用全局動畫管理器思想】
其實,每條鏈路內部的動畫管理器是一模一樣的,那我們其實可以實現一個全局的統一動畫管理器,這樣不管鏈路有多少條,我們的動畫管理器都只有 1 個
但動畫管理器就要有種途徑來找到各個鏈路,這樣才能觸發鏈路的刷新,以便它們內部根據最新動畫進度來進行渲染
【按需刷新思想】
既然動畫管理器內部需要撈取到鏈路來刷新,那干脆,只撈取屏幕可視范圍內的鏈路進行刷新,屏幕外部的鏈路就不通知刷新
這樣不就更節省性能損耗了
export default function FlowLink() {
FlowLink.superClass.constructor.apply(this, arguments);
}
twaver.Util.ext(FlowLink, twaver.Link, {
play: function () {
// 鏈路內部不維護動畫管理器了,只需要加個動畫開關即可
this.setClient("anim.enable", true);
},
});
export default class GLobalAnimation {
constructor(network) {
this._network = network; // 與動畫關聯的拓撲畫布
this._linkAnimation = null; // 鏈路動畫實例
this._linkAnimPercent = 0; // 鏈路動畫進度
}
playLinkAnimation() {
if (!this._linkAnimation) {
this._linkAnimation = this._initLinkAnimation();
this._linkAnimation.play();
}
}
_initLinkAnimation() {
return new twaver.Animate({
from: 0,
to: 1,
repeat: Number.POSITIVE_INFINITY,
reverse: false,
delay: 200, // 動畫延遲
dur: 5000, // 動畫時才
easing: "linear", // 線性動畫
onUpdate: (value) => {
// 只重繪可視范圍內的鏈路
try {
const state = this._network.state || {};
// 滑動、縮放、布局過程中,都沒必要更新UI
const isReady = !state.zooming && !state.panning && !state.layouting;
if (isReady) {
// 獲取經過縮放后的可視范圍
const viewRect = this._getZoomRect(this._network.getViewRect());
// 根據可視范圍,獲取范圍內的鏈路對象
const nodes = this._network.getElementsAtRect(viewRect, true);
nodes.forEach((node) => {
// 刷新指定鏈路節點
this._network.invalidateElementUI(node, false);
});
}
} catch (error) {
console.error("[GlobalAnimation]", error);
}
},
});
}
_getZoomRect(rect) {
const zoom = this._network.getZoom() || 1;
const offset = 200;
return {
x: (rect.x - offset) / zoom,
y: (rect.y - offset) / zoom,
width: (rect.width + offset * 2) / zoom,
height: (rect.height + offset * 2) / zoom,
};
}
}
這種思路有點像一開始只站在局部角度來思考代碼實現,優化后則是站在全局角度上來進行的思考
而解決思路則是萬能的復用,萬能的懶加載,按需使用思想
交互上進行規避,如增加默認折疊、展開處理
由于節點是直接借助 twaver 內部的 canvas 實現,因此節點數量太多導致的性能瓶頸問題是 twaver 庫本身就存在的問題,雖然 twaver 已經做到 1W 級別的節點的絲滑呈現,但當數量繼續加上去,達到 5W,10W 級別時,也會開始出現操作滯后感,卡頓等性能瓶頸
也許你會說,簡單,跟上個問題一樣,按需加載不就行了,只繪制屏幕可視范圍內的節點,其余節點不繪制
理論上可行,但實現上難度很大
因為上一個問題是節點鏈路已經繪制完畢的基礎上,來進行刷新范圍的過濾,所以只需要根據坐標點信息的計算就能達到訴求
但現在場景是還沒繪制,你沒法獲知任何信息
你不知道經過縮放、拖拽后的當前視圖里,到底應該呈現哪些節點
而且,twaver 是付費框架,源碼是混淆的,你不知道內部它是怎么實現的,無法參與節點的排版過程,也導致你很難下手去實現所謂的按需繪制問題
再者,我們還有搜索定位的交互需求,就算你上面問題都解決了,那當搜索的節點是沒繪制的節點,你如何去定位到該節點真實的位置
基于以上種種原因,考慮到投入成本的性價比,我們最終決定采用從非技術角度去優化:從交互上進行規避
- 增加節點的默認折疊處理方案,當超過一定數量時,默認把子孫節點折疊起來,這樣能夠避免一次性渲染太多節點
- 同時增加展開/折疊全部節點的快捷操作
- 由于孤點沒有樹形結構,因此當超過一定數量孤點時,需要另外處理折疊邏輯
- 搜索節點時,發現節點處于折疊狀態的話,要自動進行展開處理
簡單來說就是會設定一個閾值,當節點超過這個數量時,都折疊起來,等用戶手動去展開再呈現,相當于分頁呈現的思想
dom 節點的懶創建 + 緩存和復用
有些復雜節點的場景無法用 twaver 的默認節點樣式呈現,也就用不了 canvas 實現,只能自己用 html 方式來實現
但也不可能用純 html + js 實現,還是依賴于 vue 框架,這就涉及到 vue 組件的手動創建、掛載、銷毀
這種復雜節點過多時,就會涉及到 dom 元素的反復創建、銷毀以及渲染過多的性能瓶頸問題
那么解決方案上,一樣也是懶加載,但為了組件可以復用,增加了緩存和復用處理,避免相同組件要重復創建
具體做法則是:
- 重寫了 twaver 繪制 dom 元素的方法邏輯,改造成懶加載方式
- 即當節點不在頁面可視范圍內的話,不掛載 dom 到界面上,避免一次性渲染太多 dom
- 收集緩存所有的 dom 組件
- 當反復使用時,直接復用緩存
- 當銷毀時,手動觸發 vue 的 destroy,及時銷毀資源
小結
其實,大多數的性能問題本質上都是大同小異的原因:
- 無意義的內存占用過高,如 Vue 對 twaver 數據對象的響應式處理
- 一次性處理的東西過多,如渲染上萬個節點
- 短時間內頻繁執行某些其實沒意義的操作,如實時刷新即使在屏幕外的動畫
- 反復創建、銷毀行為,如 dom 節點的反復創建
所以性能優化的難點之一在于排查根因,找到問題所在后,才能去著手思考對應的解決方案
而解決思路無外乎也是大同小異:
- 按需使用、懶加載、分頁
- 緩存和復用
- 規避方法
總結
以上是生活随笔為你收集整理的记一次卡顿的性能优化经历实操的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ChatGPT的中转站(欧派API) o
- 下一篇: Javac多模块化编译