<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

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

          共 16166字,需瀏覽 33分鐘

           ·

          2021-09-20 18:12

          背景

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

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

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

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

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

          Table 組件第一次優(yōu)化嘗試

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

          列展示優(yōu)化

          首先,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 表格渲染的列,性能的提升還不夠明顯,有沒有辦法在列的渲染這個維度繼續(xù)優(yōu)化呢?

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

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

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

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

          更新渲染的優(yōu)化

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

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

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

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

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

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

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

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

          那么循環(huán)的內部是不是有優(yōu)化的空間呢?對于 ElementUI 的 Table 組件,這里有非常大的優(yōu)化空間。

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

          const data = {
            storethis.store,
            _selfthis.context || this.table.$vnode.context,
            column: columnData,
            row,
            $index
          }

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

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

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

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

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

          rowRender({store, context, /* ...其它變量 */}) {
            const data = {
              store: store,
              _self: context,
              column: columnData,
              row,
              $index,
              disableTransition,
              isSelectedRow
            }
          }

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

          來看一下優(yōu)化后的火焰圖:

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

          我們發(fā)現組件的 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 數量更少,執(zhí)行 Rendering 的時間也更短。

          手寫 benchmark

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

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

          <div>
            <zm-button @click="toggleSelection(computedData[1])
          "
          >
          切換第二行選中狀態(tài)
            </zm-button>
          </div>
          <div>
            更新所需時間: {{ renderTime }}
          </div>

          然后實現這個 toggleSelection 函數:

          methods: {
           toggleSelection(row) {
             const s = window.performance.now()
             if (row) {
               this.$refs.table.toggleRowSelection(row)
             }
             setTimeout(() => {
               this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
             })
           }
          }

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

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

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

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

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

          v-memo 的啟發(fā)

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

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

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

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

          v-memo 的實現原理

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

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

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

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

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

          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"

          const _hoisted_1 = /*#__PURE__*/_createElementVNode("p"null"...more child nodes"-1 /* HOISTED */)

          export function render(_ctx, _cache, $props, $setup, $data, $options{
            return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
              const _memo = ([item.id === _ctx.selected])
              if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
              const _item = (_openBlock(), _createElementBlock("div", {
                key: item.id
              }, [
                _createElementVNode("p"null"ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
                _hoisted_1
              ]))
              _item.memo = _memo
              return _item
            }, _cache, 0), 128 /* KEYED_FRAGMENT */))
          }

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

          function renderList(source, renderItem, cache, index{
            let ret
            const cached = (cache && cache[index])
            if (isArray(source) || isString(source)) {
              ret = new Array(source.length)
              for (let i = 0, l = source.length; i < l; i++) {
                ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
              }
            }
            else if (typeof source === 'number') {
              // source 是數字
            }
            else if (isObject(source)) {
              // source 是對象
            }
            else {
              ret = []
            }
            if (cache) {
              cache[index] = ret
            }
            return ret
          }

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

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

          (item, __, ___, _cached) => {
              const _memo = ([item.id === _ctx.selected])
              if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
              const _item = (_openBlock(), _createElementBlock("div", {
                key: item.id
              }, [
                _createElementVNode("p"null"ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
                _hoisted_1
              ]))
              _item.memo = _memo
              return _item
            }

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

          function isMemoSame(cached, memo{
            const prev = cached.memo
            if (prev.length != memo.length) {
              return false
            }
            for (let i = 0; i < prev.length; i++) {
              if (prev[i] !== memo[i]) {
                return false
              }
            }
            // ...
            return true
          }

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

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

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

          const instance = {
            // ...
            renderCache: []
          }

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

          const { renderCache } = instance
          result = normalizeVNode(
            render.call(
              proxyToUse,
              proxyToUse,
              renderCache,
              props,
              setupState,
              data,
              ctx
            )
          )

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

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

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

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

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

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

          在 Table 組件的應用

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

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

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

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

          created() {
            if (this.table.useMemo) {
              if (!this.table.rowKey) {
               throw new Error('for useMemo, row-key is required.')
              }
              this.vnodeCache = []
            }
          }

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

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

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

          function rowRender(/* 各種變量參數 */}{
            let memo
            const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
            let cached
            if (useMemo) {
              cached = this.vnodeCache[key]
              const currentSelection = store.states.selection
              if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
                return cached
              }
              memo = currentSelection.slice()
            }
            // 渲染 row,返回對應的 vnode
            const ret = rowVnode
            if (useMemo && columns.length) {
              ret.memo = memo
              this.vnodeCache[key] = ret
             }
             return ret
          }

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

          在每次渲染 rowvnode 前,會根據 row 對應的 key 嘗試從緩存中取;如果緩存中存在,再通過 isRowSelectionChanged 來判斷行的選中狀態(tài)是否改變;如果沒有改變,則直接返回緩存的 vnode

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

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

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

          watch: {
            'store.states.data'() {
              if (this.table.useMemo) {
                this.vnodeCache = []
              }
            }
          }

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

          watch:{
            'store.states.columns'() {
              if (this.table.useMemo) {
                this.vnodeCache = []
              }
            },
            columnsHidden(newVal, oldVal) {
              if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
                this.vnodeCache = []
              }
            }
          }

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

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

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

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

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

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

          總結

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

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

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

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


          ?? 看完兩件事

          如果你覺得這篇內容對你挺有益,我想邀請你幫我兩個小忙:

          1. 點個「在看」,讓更多的人也能看到這篇內容

          2. 關注公眾號「前端keep」,每周學習新技術


          瀏覽 57
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  日本驲屄视频在线高潮视频 | xjgggyxgs.com高价收liang,请涟系@qdd2000 | 免费久久一级欧美特大黄 | 国产精品无套久久久久 | 在线激情视频 |