成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

一頓操作,我把 Table 組件性能提升了十倍

開發 前端
Table 表格組件在 Web 開發中的應用隨處可見,不過當表格數據量大后,伴隨而來的是性能問題:渲染的 DOM 太多,渲染和交互都會有一定程度的卡頓。

  [[423502]]

背景

Table 表格組件在 Web 開發中的應用隨處可見,不過當表格數據量大后,伴隨而來的是性能問題:渲染的 DOM 太多,渲染和交互都會有一定程度的卡頓。

通常,我們有兩種優化表格的方式:一種是分頁,另一種是虛擬滾動。這兩種方式的優化思路都是減少 DOM 渲染的數量。在我們公司的項目中,會選擇分頁的方式,因為虛擬滾動不能正確的讀出行的數量,會有 Accessibility 的問題。

記得 19 年的時候,我在 Zoom 已經推行了基于 Vue.js 的前后端分離的優化方案,并且基于 ElementUI 組件庫開發了 ZoomUI。其中我們在重構用戶管理頁面的時候使用了 ZoomUI 的 Table 組件替換了之前老的用 jQuery 開發的 Table 組件。

因為絕大部分場景 Table 組件都是分頁的,所以并不會有性能問題。但是在某個特殊場景下:基于關鍵詞的搜索,可能會出現 200 * 20 條結果且不分頁的情況,且表格是有一列是帶有 checkbox 的,也就是可以選中某些行進行操作。

當我們去點選其中一行時,發現過了好久才選中,有明顯的卡頓感,而之前的 jQuery 版本卻沒有這類問題,這一比較令人大跌眼鏡。難道好好的技術重構,卻要犧牲用戶體驗嗎?

Table 組件第一次優化嘗試

既然有性能問題,那么我們的第一時間的思路應該是要找出產生性能問題的原因。

列展示優化

首先,ZoomUI 渲染的 DOM 數量是要多于 jQuery 渲染的 Table 的,因此第一個思考方向是讓 Table 組件 盡可能地減少 DOM 的渲染數量 。

20 列數據通常在屏幕下是展示不全的,老的 jQuery Table 實現很簡單,底部有滾動條,而 ZoomUI 在這種列可滾動的場景下,支持了左右列的固定,這樣在左右滑動過程中,可以固定某些列一直展示,用戶體驗更好,但這樣的實現是有一定代價的。

想要實現這種固定列的布局,ElementUI 用了 6 個 table 標簽來實現,那么為什么需要 6 個 table 標簽呢?

首先,為了讓 Table 組件支持豐富的表頭功能,表頭和表體都是各自用一個 table 標簽來實現。因此對于一個表格來說,就會有 2 個 table 標簽,那么再加上左側 fixed 的表格,和右側 fixed 的表格,總共有 6 個 table 標簽。

在 ElementUI 實現中,左側 fixed 表格和右側 fixed 表格從 DOM 上都渲染了完整的列,然后從樣式上控制它們的顯隱:

但這么實現是有性能浪費的,因為完全不需要渲染這么多列,實際上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。 ZoomUI 就是這么實現的,效果如下:

當然,僅僅減少 fixed 表格渲染的列,性能的提升還不夠明顯,有沒有辦法在列的渲染這個維度繼續優化呢?

這就是從業務層面的優化了,對于一個 20 列的表格,往往關鍵的列并沒有多少,那么我們可不可以初次渲染僅僅渲染關鍵的列,其它列通過配置方式的渲染呢?

根據上述需求,我給 Table 組件添加了如下功能:

Table 組件新增一個 initDisplayedColumn 屬性,通過它可以配置初次渲染的列,同時當用戶修改了初次渲染的列,會在前端存儲下來,便于下一次的渲染。

通過這種方式,我們就可以少渲染一些列。顯然,列渲染少了,表格整體渲染的 DOM 數就會變少,對性能也會有一定的提升。

更新渲染的優化

當然,僅僅通過優化列的渲染還是不夠的,我們遇到的問題是當點選某一行引起的渲染卡頓,為什么會引起卡頓呢?

為了定位該問題,我用 Table 組件創建了一個 1000 * 7 的表格,開啟了 Chrome 的 Performance 面板記錄 checkbox 點選前后的性能。

在經過幾次 checkbox 選擇框的點選后,可以看到如下火焰圖:

其中黃色部分是 Scripting 腳本的執行時間,紫色部分是 Rendering 所占的時間。我們再截取一次更新的過程:

然后觀察 JS 腳本執行的 Call Tree,發現時間主要花在了 Table 組件的更新渲染上 

我們發現組件的 render to vnode 花費的時間約 600ms; vnode patch to DOM 花費的時間約 160ms。

為什么會需要這么長時間呢,因為點選了 checkbox ,在組件內部修改了其維護的選中狀態數據,而整個組件的 render 過程中又訪問了這個狀態數據,因此當這個數據修改后,會引發整個組件的重新渲染。

而又由于有 1000 * 7 條數據,因此整個表格需要循環 1000 * 7 次去創建最內部的 td ,整個過程就會耗時較長。

那么循環的內部是不是有優化的空間呢?對于 ElementUI 的 Table 組件,這里有非常大的優化空間。

其實優化思路主要參考我之前寫的 《揭秘 Vue.js 九個性能優化技巧》 其中的 Local variables 技巧。舉個例子,在 ElementUI 的 Table 組件中,在渲染每個 td 的時候,有這么一段代碼:

  1. const data = { 
  2.   store: this.store, 
  3.   _self: this.context || this.table.$vnode.context, 
  4.   column: columnData, 
  5.   row, 
  6.   $index 

這樣的代碼相信很多小伙伴隨手就寫了,但卻忽視了其內部潛在的性能問題。

由于 Vue.js 響應式系統的設計,在每次訪問 this.store 的時候,都會觸發 響應式數據內部的  getter  函數,進而執行 它的依賴收集,當這段代碼被循環了 1000 * 7 次,就會執行 this.store 7000 次的依賴收集,這就造成了性能的浪費,而真正的依賴收集只需要執行一次就足夠了。

解決這個問題其實也并不難,由于 Table 組件中的 TableBody 組件是用 render 函數寫的,我們可以在組件 render 函數的入口處定義一些局部變量:

  1. render(h) { 
  2.   const { store /*...*/} = this 
  3.   const context = this.context ||  this.table.$vnode.context 

然后在渲染整個 render 的過程中,把局部變量當作內部函數的參數傳入,這樣在內部渲染 td 的渲染中再次訪問這些變量就不會觸發依賴收集了:

  1. rowRender({store, context, /* ...其它變量 */}) { 
  2.   const data = { 
  3.     store: store, 
  4.     _self: context, 
  5.     column: columnData, 
  6.     row, 
  7.     $index, 
  8.     disableTransition, 
  9.     isSelectedRow 
  10.   } 

通過這種方式,我們把類似的代碼都做了修改,就實現了 TableBody 組件渲染函數內部訪問這些響應式變量,只觸發一次依賴收集的效果,從而優化了 render 的性能。

來看一下優化后的火焰圖:

從面積上看似乎 Scripting 的執行時間變少了,我們再來看它一次更新所需要的 JS 執行時間:

我們發現組件的 render to vnode 花費的時間約 240ms; vnode patch to DOM 花費的時間約 127ms。

可以看到,ZoomUI Table 組件的 render 的時間和 update 的時間都要明顯少于 ElementUI 的 Table 組件。 render 時間減少是由于響應式變量依賴收集的時間大大減少, update 的時間的減少是因為 fixed 表格渲染的 DOM 數量減少。

從用戶的角度來看,DOM 的更新除了 Scripting 的時間,還有 Rendering 的時間,它們是共享一個線程的,當然由于 ZoomUI Table 組件渲染的 DOM 數量更少,執行 Rendering 的時間也更短。

手寫 benchmark

僅僅從 Performance 面板的測試并不是一個特別精確的 benchmark,我們可以針對 Table 組件手寫一個 benchmark。

我們可以先創建一個按鈕,去模擬 Table 組件的選中操作:

  1. <div> 
  2.   <zm-button @click="toggleSelection(computedData[1]) 
  3. ">切換第二行選中狀態 
  4.   </zm-button> 
  5. </div> 
  6. <div> 
  7.   更新所需時間: {{ renderTime }} 
  8. </div> 

然后實現這個 toggleSelection 函數:

  1. methods: { 
  2.  toggleSelection(row) { 
  3.    const s = window.performance.now() 
  4.    if (row) { 
  5.      this.$refs.table.toggleRowSelection(row) 
  6.    } 
  7.    setTimeout(() => { 
  8.      this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms' 
  9.    }) 
  10.  } 

我們在點擊事件的回調函數中,通過 window.performance.now() 記錄起始時間,然后在 setTimeout 的回調函數中,再去通過時間差去計算整個更新渲染需要的時間。

由于 JS 的執行和 UI 渲染占用同一線程,因此在一個宏任務執行過程中,會執行這倆任務,而 setTimeout 0 會把對應的回調函數添加到下一個宏任務中,當該回調函數執行,說明上一個宏任務執行完畢,此時做時間差去計算性能是相對精確的。

基于手寫的 benchmark 得到如下測試結果:

ElementUI Table 組件一次更新的時間約為 900ms 。

ZoomUI Table 組件一次更新的時間約為 280ms,相比于 ElementUI 的 Table 組件, 性能提升了約三倍 。

v-memo 的啟發

經過這一番優化,基本解決了文章開頭提到的問題,在 200 * 20 的表格中去選中一列,已經并無明顯的卡頓感了,但相比于 jQuery 實現的 Table,效果還是要差了一點。

雖然性能優化了三倍,但我還是有個心結:明明只更新了一行數據的選中狀態,卻還是重新渲染了整個表格,仍然需要在組件 render 的過程中執行多次的循環,在 patch 的過程中通過 diff 算法來對比更新。

最近我研究了 Vue.js 3.2 v-memo 的實現,看完源碼后,我非常激動,因為發現這個優化技巧似乎可以應用到 ZoomUI 的 Table 組件中,盡管我們的組件庫是基于 Vue 2 版本開發的。

我花了一個下午的時間,經過一番嘗試,果然成功了,那么具體是怎么做的呢?先不著急,我們從 v-memo 的實現原理說起。

v-memo 的實現原理

v-memo 是 Vue.js 3.2 版本新增的指令,它可以用于普通標簽,也可以用于列表,結合 v-for 使用,在官網文檔中,有這么一段介紹:

v-memo 僅供性能敏感場景的針對性優化,會用到的場景應該很少。渲染 v-for 長列表 (長度大于 1000) 可能是它最有用的場景:

  1. <div v-for="item in list" :key="item.id" v-memo="[item.id === selected]"
  2.   <p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p> 
  3.   <p>...more child nodes</p> 
  4. </div> 

當組件的 selected 狀態發生變化時,即使絕大多數 item 都沒有發生任何變化,大量的 VNode 仍將被創建。此處使用的 v-memo 本質上代表著“僅在 item 從未選中變為選中時更新它,反之亦然”。這允許每個未受影響的 item 重用之前的 VNode,并完全跳過差異比較。注意,我們不需要把 item.id 包含在記憶依賴數組里面,因為 Vue 可以自動從 item 的 :key 中把它推斷出來。

其實說白了 v-memo 的核心就是復用 vnode ,上述模板借助于在線模板編譯工具,可以看到其對應的 render 函數:

  1. import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue" 
  2.  
  3. const _hoisted_1 = /*#__PURE__*/_createElementVNode("p"null"...more child nodes", -1 /* HOISTED */
  4.  
  5. export function render(_ctx, _cache, $props, $setup, $data, $options) { 
  6.   return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => { 
  7.     const _memo = ([item.id === _ctx.selected]) 
  8.     if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached 
  9.     const _item = (_openBlock(), _createElementBlock("div", { 
  10.       key: item.id 
  11.     }, [ 
  12.       _createElementVNode("p"null"ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */), 
  13.       _hoisted_1 
  14.     ])) 
  15.     _item.memo = _memo 
  16.     return _item 
  17.   }, _cache, 0), 128 /* KEYED_FRAGMENT */)) 

基于 v-for 的列表內部是通過 renderList 函數來渲染的,來看它的實現:

  1. function renderList(source, renderItem, cache, index) { 
  2.   let ret 
  3.   const cached = (cache && cache[index]) 
  4.   if (isArray(source) || isString(source)) { 
  5.     ret = new Array(source.length) 
  6.     for (let i = 0, l = source.length; i < l; i++) { 
  7.       ret[i] = renderItem(source[i], i, undefined, cached && cached[i]) 
  8.     } 
  9.   } 
  10.   else if (typeof source === 'number') { 
  11.     // source 是數字 
  12.   } 
  13.   else if (isObject(source)) { 
  14.     // source 是對象 
  15.   } 
  16.   else { 
  17.     ret = [] 
  18.   } 
  19.   if (cache) { 
  20.     cache[index] = ret 
  21.   } 
  22.   return ret 

我們只分析 source ,也就是列表 list 是數組的情況,對于每一個 item ,會執行 renderItem 函數來渲染。

從生成的 render 函數中,可以看到 renderItem 的實現如下:

  1. (item, __, ___, _cached) => { 
  2.     const _memo = ([item.id === _ctx.selected]) 
  3.     if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached 
  4.     const _item = (_openBlock(), _createElementBlock("div", { 
  5.       key: item.id 
  6.     }, [ 
  7.       _createElementVNode("p"null"ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */), 
  8.       _hoisted_1 
  9.     ])) 
  10.     _item.memo = _memo 
  11.     return _item 
  12.   } 

在 renderItem 函數內部,維護了一個 _memo 變量,它就是用來判斷是否從緩存里獲取 vnode 的條件數組;而第四個參數 _cached 對應的就是 item 對應緩存的 vnode 。接下來通過 isMemoSame 函數來判斷 memo 是否相同,來看它的實現:

  1. function isMemoSame(cached, memo) { 
  2.   const prev = cached.memo 
  3.   if (prev.length != memo.length) { 
  4.     return false 
  5.   } 
  6.   for (let i = 0; i < prev.length; i++) { 
  7.     if (prev[i] !== memo[i]) { 
  8.       return false 
  9.     } 
  10.   } 
  11.   // ... 
  12.   return true 

isMemoSame 函數內部會通過 cached.memo 拿到緩存的 memo ,然后通過遍歷對比每一個條件來判斷和當前的 memo 是否相同。

而在 renderItem 函數的結尾,就會把 _memo 緩存到當前 item 的 vnode 中,便于下一次通過   isMemoSame 來判斷這個 memo 是否相同,如果相同,說明該項沒有變化,直接返回上一次緩存的 vnode 。

那么這個緩存的 vnode 具體存儲到哪里呢,原來在初始化組件實例的時候,就設計了渲染緩存:

  1. const instance = { 
  2.   // ... 
  3.   renderCache: [] 

然后在執行 render 函數的時候,把這個緩存當做第二個參數傳入:

  1. const { renderCache } = instance 
  2. result = normalizeVNode( 
  3.   render.call( 
  4.     proxyToUse, 
  5.     proxyToUse, 
  6.     renderCache, 
  7.     props, 
  8.     setupState, 
  9.     data, 
  10.     ctx 
  11.   ) 

然后在執行 renderList 函數的時候,把 _cahce 作為第三個參數傳入:

  1. export function render(_ctx, _cache, $props, $setup, $data, $options) { 
  2.   return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => { 
  3.     // renderItem 實現 
  4.   }, _cache, 0), 128 /* KEYED_FRAGMENT */)) 

所以實際上列表緩存的 vnode 都保留在 _cache 中,也就是 instance.renderCache 中。

那么為啥使用緩存的 vnode 就能優化 patch 過程呢,因為在 patch 函數執行的時候,如果遇到新舊 vnode 相同,就直接返回,什么也不用做了。

  1. const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => { 
  2.   if(n1 === n2) { 
  3.     return 
  4.   } 
  5.   // ... 

顯然,由于使用緩存的 vnode ,它們 指向同一個對象引用 ,直接返回,節約了后續執行 patch 過程的時間。

在 Table 組件的應用

v-memo 的優化思路很簡單,就是復用緩存的 vnode ,這是一種空間換時間的優化思路。

那么,前面我們提到在表格組件中選擇狀態沒有變化的行,是不是也可以從緩存中獲取呢?

順著這思路,我給 Table 組件設計了 useMemo 這個 prop ,它其實是專門用于有選擇列的場景。

然后在 TableBody 組件的 created 鉤子函數中,創建了用于緩存的對象:

  1. created() { 
  2.   if (this.table.useMemo) { 
  3.     if (!this.table.rowKey) { 
  4.       throw new Error('for useMemo, row-key is required.'
  5.     } 
  6.     this.vnodeCache = [] 
  7.   } 

這里之所以把 vnodeCache 定義到 created 鉤子函數中,是因為它并不需要變成響應式對象。

另外注意,我們會根據每一行的 key 作為緩存的 key ,因此 Table 組件的 rowKey 屬性是必須的。

然后在渲染每一行的過程中,添加了 useMemo 相關的邏輯:

  1. function rowRender({ /* 各種變量參數 */}) { 
  2.   let memo 
  3.   const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey }) 
  4.   let cached 
  5.   if (useMemo) { 
  6.     cached = this.vnodeCache[key] 
  7.     const currentSelection = store.states.selection 
  8.     if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) { 
  9.       return cached 
  10.     } 
  11.     memo = currentSelection.slice() 
  12.   } 
  13.   // 渲染 row,返回對應的 vnode 
  14.   const ret = rowVnode 
  15.   if (useMemo && columns.length) { 
  16.     ret.memo = memo 
  17.     this.vnodeCache[key] = ret 
  18.    } 
  19.    return ret 

這里的 memo 變量用于記錄已選中的行數據,并且它也會在函數最后存儲到 vnode 的 memo ,便于下一次的比對。

在每次渲染 row 的 vnode 前,會根據 row 對應的 key 嘗試從緩存中?。蝗绻彺嬷写嬖?,再通過 isRowSelectionChanged 來判斷行的選中狀態是否改變;如果沒有改變,則直接返回緩存的 vnode 。

如果沒有命中緩存或者是行選擇狀態改變,則會去重新渲染拿到新的 rowVnode ,然后更新到 vnodeCache 中。

當然,這種實現相比于 v-memo 沒有那么通用,只去對比行選中的狀態而不去對比其它數據的變化。你可能會問,如果這一行某列的數據修改了,但選中狀態沒變,再走緩存不就不對了嗎?

確實存在這個問題,但是在我們的使用場景中,遇到數據修改,是會發送一個異步請求到后端,然獲取新的數據再來更新表格數據。因此我只需要觀測表格數據的變化清空 vnodeCache 即可:

  1. watch: { 
  2.   'store.states.data'() { 
  3.     if (this.table.useMemo) { 
  4.       this.vnodeCache = [] 
  5.     } 
  6.   } 

此外,我們支持列的可選則渲染功能,以及在窗口發生變化時,隱藏列也可能發生變化,于是在這兩種場景下,也需要清空 vnodeCache :

  1. watch:{ 
  2.   'store.states.columns'() { 
  3.     if (this.table.useMemo) { 
  4.       this.vnodeCache = [] 
  5.     } 
  6.   }, 
  7.   columnsHidden(newVal, oldVal) { 
  8.     if (this.table.useMemo && !valueEquals(newVal, oldVal)) { 
  9.       this.vnodeCache = [] 
  10.     } 
  11.   } 

以上實現就是基于 v-memo 的思路實現表格組件的性能優化。我們從火焰圖上看一下它的效果:

我們發現黃色的 Scripting 時間幾乎沒有了,再來看它一次更新所需要的 JS 執行時間:

我們發現組件的 render to vnode 花費的時間約 20ms ; vnode patch to DOM  花費的時間約 1ms ,整個更新渲染過程, JS 的執行時間大幅減少。

另外,我們通過 benchmark 測試,得到如下結果:

優化后,ZoomUI Table 組件一次更新的時間約為 80ms ,相比于 ElementUI 的 Table 組件, 性能提升了約十倍 。

這個優化效果還是相當驚人的,并且從性能上已經不輸 jQuery Table 了,我兩年的心結也隨之解開了。

總結

Table 表格性能提升主要是三個方面:減少 DOM 數量、優化 render 過程以及復用 vnode 。有些時候,我們還可以從業務角度思考,去做一些優化。

雖然 useMemo 的實現還比較粗糙,但它目前已滿足我們的使用場景了,并且當數據量越大,渲染的行列數越多,這種優化效果就越明顯。如果未來有更多的需求,更新迭代就好。

由于一些原因,我們公司仍然在使用 Vue 2,但這并不妨礙我去學習 Vue 3,了解它一些新特性的實現原理以及設計思想,能讓我開拓不少思路。

從分析定位問題到最終解決問題,希望這篇文章能給你在組件的性能優化方面提供一些思路,并應用到日常工作中。

 

 

 

責任編輯:張燕妮 來源: 老黃的前端私房菜
相關推薦

2022-04-21 07:51:51

場景JavaSQL

2023-09-07 11:29:36

API開發

2025-05-26 00:02:00

TypeScriptGo 語言前端

2024-10-29 08:21:05

2017-09-26 14:56:57

MongoDBLBS服務性能

2022-09-21 17:43:29

Kafka底層網絡

2024-07-17 08:25:44

2020-03-29 08:56:07

文件系統磁盤Java

2025-05-27 01:55:00

TypeScript開發者項目

2021-02-02 15:38:19

Disruptor緩存Java

2025-03-13 11:59:00

2017-12-06 08:06:47

IBMGPU機器學習

2025-06-05 04:22:00

SQL性能索引

2023-06-13 13:52:00

Java 7線程池

2021-04-13 14:25:41

架構運維技術

2021-12-27 06:57:40

Maven工具性能

2009-12-15 21:49:05

2024-06-27 11:22:34

2023-02-06 07:17:22

2022-09-27 18:19:32

Java數據結構
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 欧美中文字幕一区二区三区亚洲 | 在线伊人网| 国产精品国产精品国产专区不片 | 一级做a爰片性色毛片16美国 | 台湾a级理论片在线观看 | 久久综合爱| 中文一区二区视频 | 久在线观看 | av黄色在线观看 | 五月婷婷激情 | 欧美精品1区 | 在线中文字幕av | 51ⅴ精品国产91久久久久久 | 九色一区 | 国产四区 | 91精品国产一区二区三区 | 日韩三级 | 一区日韩 | 国产91 在线播放 | 成人av在线网站 | 欧美福利视频 | 国产中文一区二区三区 | 国产91在线 | 亚洲 | 精品视频99| 亚洲午夜精品视频 | 国产一级网站 | 久久国产视频播放 | 色婷婷一区二区三区四区 | 久久久久久久久久久福利观看 | 久久6| 欧美激情综合色综合啪啪五月 | 国产美女一区二区 | av官网在线 | 久久久精品久久久 | 亚洲精品一区二区三区免 | 福利一区二区在线 | 9久9久9久女女女九九九一九 | 日韩精品一区二区三区在线观看 | 综合九九 | 97色在线视频 | 91视频导航 |