一頓操作,我把 Table 組件性能提升了十倍
背景
Table
表格組件在 Web 開發(fā)中的應(yīng)用隨處可見,不過當(dāng)表格數(shù)據(jù)量大后,伴隨而來的是性能問題:渲染的 DOM 太多,渲染和交互都會有一定程度的卡頓。
通常,我們有兩種優(yōu)化表格的方式:一種是分頁,另一種是虛擬滾動。這兩種方式的優(yōu)化思路都是減少 DOM 渲染的數(shù)量。在我們公司的項目中,會選擇分頁的方式,因為虛擬滾動不能正確的讀出行的數(shù)量,會有 Accessibility 的問題。
記得 19 年的時候,我在 Zoom 已經(jīng)推行了基于 Vue.js 的前后端分離的優(yōu)化方案,并且基于 ElementUI 組件庫開發(fā)了 ZoomUI。其中我們在重構(gòu)用戶管理頁面的時候使用了 ZoomUI 的 Table
組件替換了之前老的用 jQuery 開發(fā)的 Table
組件。
因為絕大部分場景 Table
組件都是分頁的,所以并不會有性能問題。但是在某個特殊場景下:基于關(guān)鍵詞的搜索,可能會出現(xiàn) 200 * 20 條結(jié)果且不分頁的情況,且表格是有一列是帶有 checkbox
的,也就是可以選中某些行進行操作。
當(dāng)我們?nèi)c選其中一行時,發(fā)現(xiàn)過了好久才選中,有明顯的卡頓感,而之前的 jQuery 版本卻沒有這類問題,這一比較令人大跌眼鏡。難道好好的技術(shù)重構(gòu),卻要犧牲用戶體驗嗎?
Table 組件第一次優(yōu)化嘗試
既然有性能問題,那么我們的第一時間的思路應(yīng)該是要找出產(chǎn)生性能問題的原因。
列展示優(yōu)化
首先,ZoomUI 渲染的 DOM 數(shù)量是要多于 jQuery 渲染的 Table
的,因此第一個思考方向是讓 Table
組件 盡可能地減少 DOM 的渲染數(shù)量 。
20 列數(shù)據(jù)通常在屏幕下是展示不全的,老的 jQuery Table 實現(xiàn)很簡單,底部有滾動條,而 ZoomUI 在這種列可滾動的場景下,支持了左右列的固定,這樣在左右滑動過程中,可以固定某些列一直展示,用戶體驗更好,但這樣的實現(xiàn)是有一定代價的。
想要實現(xiàn)這種固定列的布局,ElementUI 用了 6 個 table
標(biāo)簽來實現(xiàn),那么為什么需要 6 個 table
標(biāo)簽?zāi)兀?/p>
首先,為了讓 Table
組件支持豐富的表頭功能,表頭和表體都是各自用一個 table
標(biāo)簽來實現(xiàn)。因此對于一個表格來說,就會有 2 個 table
標(biāo)簽,那么再加上左側(cè) fixed
的表格,和右側(cè) fixed
的表格,總共有 6 個 table
標(biāo)簽。
在 ElementUI 實現(xiàn)中,左側(cè) fixed
表格和右側(cè) fixed
表格從 DOM 上都渲染了完整的列,然后從樣式上控制它們的顯隱:
但這么實現(xiàn)是有性能浪費的,因為完全不需要渲染這么多列,實際上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。 ZoomUI 就是這么實現(xiàn)的,效果如下:
當(dāng)然,僅僅減少 fixed
表格渲染的列,性能的提升還不夠明顯,有沒有辦法在列的渲染這個維度繼續(xù)優(yōu)化呢?
這就是從業(yè)務(wù)層面的優(yōu)化了,對于一個 20 列的表格,往往關(guān)鍵的列并沒有多少,那么我們可不可以初次渲染僅僅渲染關(guān)鍵的列,其它列通過配置方式的渲染呢?
根據(jù)上述需求,我給 Table
組件添加了如下功能:
Table
組件新增一個 initDisplayedColumn
屬性,通過它可以配置初次渲染的列,同時當(dāng)用戶修改了初次渲染的列,會在前端存儲下來,便于下一次的渲染。
通過這種方式,我們就可以少渲染一些列。顯然,列渲染少了,表格整體渲染的 DOM 數(shù)就會變少,對性能也會有一定的提升。
更新渲染的優(yōu)化
當(dāng)然,僅僅通過優(yōu)化列的渲染還是不夠的,我們遇到的問題是當(dāng)點選某一行引起的渲染卡頓,為什么會引起卡頓呢?
為了定位該問題,我用 Table
組件創(chuàng)建了一個 1000 * 7 的表格,開啟了 Chrome 的 Performance 面板記錄 checkbox
點選前后的性能。
在經(jīng)過幾次 checkbox
選擇框的點選后,可以看到如下火焰圖:
其中黃色部分是 Scripting
腳本的執(zhí)行時間,紫色部分是 Rendering
所占的時間。我們再截取一次更新的過程:
然后觀察 JS 腳本執(zhí)行的 Call Tree,發(fā)現(xiàn)時間主要花在了 Table
組件的更新渲染上 :
我們發(fā)現(xiàn)組件的 render to vnode
花費的時間約 600ms; vnode patch to DOM
花費的時間約 160ms。
為什么會需要這么長時間呢,因為點選了 checkbox
,在組件內(nèi)部修改了其維護的選中狀態(tài)數(shù)據(jù),而整個組件的 render
過程中又訪問了這個狀態(tài)數(shù)據(jù),因此當(dāng)這個數(shù)據(jù)修改后,會引發(fā)整個組件的重新渲染。
而又由于有 1000 * 7 條數(shù)據(jù),因此整個表格需要循環(huán) 1000 * 7 次去創(chuàng)建最內(nèi)部的 td
,整個過程就會耗時較長。
那么循環(huán)的內(nèi)部是不是有優(yōu)化的空間呢?對于 ElementUI 的 Table
組件,這里有非常大的優(yōu)化空間。
其實優(yōu)化思路主要參考我之前寫的 《揭秘 Vue.js 九個性能優(yōu)化技巧》 其中的 Local variables
技巧。舉個例子,在 ElementUI 的 Table
組件中,在渲染每個 td
的時候,有這么一段代碼:
- const data = {
- store: this.store,
- _self: this.context || this.table.$vnode.context,
- column: columnData,
- row,
- $index
- }
這樣的代碼相信很多小伙伴隨手就寫了,但卻忽視了其內(nèi)部潛在的性能問題。
由于 Vue.js 響應(yīng)式系統(tǒng)的設(shè)計,在每次訪問 this.store
的時候,都會觸發(fā) 響應(yīng)式數(shù)據(jù)內(nèi)部的 getter
函數(shù),進而執(zhí)行 它的依賴收集,當(dāng)這段代碼被循環(huán)了 1000 * 7 次,就會執(zhí)行 this.store 7000 次的依賴收集,這就造成了性能的浪費,而真正的依賴收集只需要執(zhí)行一次就足夠了。
解決這個問題其實也并不難,由于 Table
組件中的 TableBody
組件是用 render
函數(shù)寫的,我們可以在組件 render
函數(shù)的入口處定義一些局部變量:
- render(h) {
- const { store /*...*/} = this
- const context = this.context || this.table.$vnode.context
- }
然后在渲染整個 render
的過程中,把局部變量當(dāng)作內(nèi)部函數(shù)的參數(shù)傳入,這樣在內(nèi)部渲染 td
的渲染中再次訪問這些變量就不會觸發(fā)依賴收集了:
- rowRender({store, context, /* ...其它變量 */}) {
- const data = {
- store: store,
- _self: context,
- column: columnData,
- row,
- $index,
- disableTransition,
- isSelectedRow
- }
- }
通過這種方式,我們把類似的代碼都做了修改,就實現(xiàn)了 TableBody
組件渲染函數(shù)內(nèi)部訪問這些響應(yīng)式變量,只觸發(fā)一次依賴收集的效果,從而優(yōu)化了 render
的性能。
來看一下優(yōu)化后的火焰圖:
從面積上看似乎 Scripting
的執(zhí)行時間變少了,我們再來看它一次更新所需要的 JS 執(zhí)行時間:
我們發(fā)現(xiàn)組件的 render to vnode
花費的時間約 240ms; vnode patch to DOM
花費的時間約 127ms。
可以看到,ZoomUI Table
組件的 render
的時間和 update
的時間都要明顯少于 ElementUI 的 Table
組件。 render
時間減少是由于響應(yīng)式變量依賴收集的時間大大減少, update
的時間的減少是因為 fixed
表格渲染的 DOM 數(shù)量減少。
從用戶的角度來看,DOM 的更新除了 Scripting
的時間,還有 Rendering
的時間,它們是共享一個線程的,當(dāng)然由于 ZoomUI Table
組件渲染的 DOM 數(shù)量更少,執(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>
然后實現(xiàn)這個 toggleSelection
函數(shù):
- 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'
- })
- }
- }
我們在點擊事件的回調(diào)函數(shù)中,通過 window.performance.now()
記錄起始時間,然后在 setTimeout
的回調(diào)函數(shù)中,再去通過時間差去計算整個更新渲染需要的時間。
由于 JS 的執(zhí)行和 UI 渲染占用同一線程,因此在一個宏任務(wù)執(zhí)行過程中,會執(zhí)行這倆任務(wù),而 setTimeout 0
會把對應(yīng)的回調(diào)函數(shù)添加到下一個宏任務(wù)中,當(dāng)該回調(diào)函數(shù)執(zhí)行,說明上一個宏任務(wù)執(zhí)行完畢,此時做時間差去計算性能是相對精確的。
基于手寫的 benchmark 得到如下測試結(jié)果:
ElementUI Table
組件一次更新的時間約為 900ms 。
ZoomUI Table
組件一次更新的時間約為 280ms,相比于 ElementUI 的 Table
組件, 性能提升了約三倍 。
v-memo 的啟發(fā)
經(jīng)過這一番優(yōu)化,基本解決了文章開頭提到的問題,在 200 * 20 的表格中去選中一列,已經(jīng)并無明顯的卡頓感了,但相比于 jQuery 實現(xiàn)的 Table,效果還是要差了一點。
雖然性能優(yōu)化了三倍,但我還是有個心結(jié):明明只更新了一行數(shù)據(jù)的選中狀態(tài),卻還是重新渲染了整個表格,仍然需要在組件 render
的過程中執(zhí)行多次的循環(huán),在 patch
的過程中通過 diff
算法來對比更新。
最近我研究了 Vue.js 3.2 v-memo
的實現(xiàn),看完源碼后,我非常激動,因為發(fā)現(xiàn)這個優(yōu)化技巧似乎可以應(yīng)用到 ZoomUI 的 Table 組件中,盡管我們的組件庫是基于 Vue 2 版本開發(fā)的。
我花了一個下午的時間,經(jīng)過一番嘗試,果然成功了,那么具體是怎么做的呢?先不著急,我們從 v-memo
的實現(xiàn)原理說起。
v-memo 的實現(xiàn)原理
v-memo
是 Vue.js 3.2 版本新增的指令,它可以用于普通標(biāo)簽,也可以用于列表,結(jié)合 v-for
使用,在官網(wǎng)文檔中,有這么一段介紹:
v-memo
僅供性能敏感場景的針對性優(yōu)化,會用到的場景應(yīng)該很少。渲染 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>
當(dāng)組件的 selected
狀態(tài)發(fā)生變化時,即使絕大多數(shù) item
都沒有發(fā)生任何變化,大量的 VNode 仍將被創(chuàng)建。此處使用的 v-memo
本質(zhì)上代表著“僅在 item 從未選中變?yōu)檫x中時更新它,反之亦然”。這允許每個未受影響的 item
重用之前的 VNode,并完全跳過差異比較。注意,我們不需要把 item.id
包含在記憶依賴數(shù)組里面,因為 Vue 可以自動從 item
的 :key
中把它推斷出來。
其實說白了 v-memo
的核心就是復(fù)用 vnode
,上述模板借助于在線模板編譯工具,可以看到其對應(yīng)的 render
函數(shù):
- 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
的列表內(nèi)部是通過 renderList
函數(shù)來渲染的,來看它的實現(xiàn):
- 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 是數(shù)字
- }
- else if (isObject(source)) {
- // source 是對象
- }
- else {
- ret = []
- }
- if (cache) {
- cache[index] = ret
- }
- return ret
- }
我們只分析 source
,也就是列表 list
是數(shù)組的情況,對于每一個 item
,會執(zhí)行 renderItem
函數(shù)來渲染。
從生成的 render
函數(shù)中,可以看到 renderItem
的實現(xiàn)如下:
- (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
函數(shù)內(nèi)部,維護了一個 _memo
變量,它就是用來判斷是否從緩存里獲取 vnode
的條件數(shù)組;而第四個參數(shù) _cached
對應(yīng)的就是 item
對應(yīng)緩存的 vnode
。接下來通過 isMemoSame
函數(shù)來判斷 memo
是否相同,來看它的實現(xiàn):
- 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
函數(shù)內(nèi)部會通過 cached.memo
拿到緩存的 memo
,然后通過遍歷對比每一個條件來判斷和當(dāng)前的 memo
是否相同。
而在 renderItem
函數(shù)的結(jié)尾,就會把 _memo
緩存到當(dāng)前 item
的 vnode
中,便于下一次通過 isMemoSame
來判斷這個 memo
是否相同,如果相同,說明該項沒有變化,直接返回上一次緩存的 vnode
。
那么這個緩存的 vnode
具體存儲到哪里呢,原來在初始化組件實例的時候,就設(shè)計了渲染緩存:
- const instance = {
- // ...
- renderCache: []
- }
然后在執(zhí)行 render
函數(shù)的時候,把這個緩存當(dāng)做第二個參數(shù)傳入:
- const { renderCache } = instance
- result = normalizeVNode(
- render.call(
- proxyToUse,
- proxyToUse,
- renderCache,
- props,
- setupState,
- data,
- ctx
- )
- )
然后在執(zhí)行 renderList
函數(shù)的時候,把 _cahce
作為第三個參數(shù)傳入:
- export function render(_ctx, _cache, $props, $setup, $data, $options) {
- return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
- // renderItem 實現(xiàn)
- }, _cache, 0), 128 /* KEYED_FRAGMENT */))
- }
所以實際上列表緩存的 vnode
都保留在 _cache
中,也就是 instance.renderCache
中。
那么為啥使用緩存的 vnode
就能優(yōu)化 patch
過程呢,因為在 patch
函數(shù)執(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 組件的應(yīng)用
v-memo
的優(yōu)化思路很簡單,就是復(fù)用緩存的 vnode
,這是一種空間換時間的優(yōu)化思路。
那么,前面我們提到在表格組件中選擇狀態(tài)沒有變化的行,是不是也可以從緩存中獲取呢?
順著這思路,我給 Table
組件設(shè)計了 useMemo
這個 prop
,它其實是專門用于有選擇列的場景。
然后在 TableBody
組件的 created
鉤子函數(shù)中,創(chuàng)建了用于緩存的對象:
- created() {
- if (this.table.useMemo) {
- if (!this.table.rowKey) {
- throw new Error('for useMemo, row-key is required.')
- }
- this.vnodeCache = []
- }
- }
這里之所以把 vnodeCache
定義到 created
鉤子函數(shù)中,是因為它并不需要變成響應(yīng)式對象。
另外注意,我們會根據(jù)每一行的 key
作為緩存的 key
,因此 Table
組件的 rowKey
屬性是必須的。
然后在渲染每一行的過程中,添加了 useMemo
相關(guān)的邏輯:
- function rowRender({ /* 各種變量參數(shù) */}) {
- 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,返回對應(yīng)的 vnode
- const ret = rowVnode
- if (useMemo && columns.length) {
- ret.memo = memo
- this.vnodeCache[key] = ret
- }
- return ret
- }
這里的 memo
變量用于記錄已選中的行數(shù)據(jù),并且它也會在函數(shù)最后存儲到 vnode
的 memo
,便于下一次的比對。
在每次渲染 row
的 vnode
前,會根據(jù) row
對應(yīng)的 key
嘗試從緩存中?。蝗绻彺嬷写嬖?,再通過 isRowSelectionChanged
來判斷行的選中狀態(tài)是否改變;如果沒有改變,則直接返回緩存的 vnode
。
如果沒有命中緩存或者是行選擇狀態(tài)改變,則會去重新渲染拿到新的 rowVnode
,然后更新到 vnodeCache
中。
當(dāng)然,這種實現(xiàn)相比于 v-memo
沒有那么通用,只去對比行選中的狀態(tài)而不去對比其它數(shù)據(jù)的變化。你可能會問,如果這一行某列的數(shù)據(jù)修改了,但選中狀態(tài)沒變,再走緩存不就不對了嗎?
確實存在這個問題,但是在我們的使用場景中,遇到數(shù)據(jù)修改,是會發(fā)送一個異步請求到后端,然獲取新的數(shù)據(jù)再來更新表格數(shù)據(jù)。因此我只需要觀測表格數(shù)據(jù)的變化清空 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 = []
- }
- }
- }
以上實現(xiàn)就是基于 v-memo
的思路實現(xiàn)表格組件的性能優(yōu)化。我們從火焰圖上看一下它的效果:
我們發(fā)現(xiàn)黃色的 Scripting
時間幾乎沒有了,再來看它一次更新所需要的 JS 執(zhí)行時間:
我們發(fā)現(xiàn)組件的 render to vnode
花費的時間約 20ms ; vnode patch to DOM
花費的時間約 1ms ,整個更新渲染過程, JS 的執(zhí)行時間大幅減少。
另外,我們通過 benchmark
測試,得到如下結(jié)果:
優(yōu)化后,ZoomUI Table
組件一次更新的時間約為 80ms
,相比于 ElementUI 的 Table
組件, 性能提升了約十倍 。
這個優(yōu)化效果還是相當(dāng)驚人的,并且從性能上已經(jīng)不輸 jQuery Table 了,我兩年的心結(jié)也隨之解開了。
總結(jié)
Table
表格性能提升主要是三個方面:減少 DOM 數(shù)量、優(yōu)化 render
過程以及復(fù)用 vnode
。有些時候,我們還可以從業(yè)務(wù)角度思考,去做一些優(yōu)化。
雖然 useMemo
的實現(xiàn)還比較粗糙,但它目前已滿足我們的使用場景了,并且當(dāng)數(shù)據(jù)量越大,渲染的行列數(shù)越多,這種優(yōu)化效果就越明顯。如果未來有更多的需求,更新迭代就好。
由于一些原因,我們公司仍然在使用 Vue 2,但這并不妨礙我去學(xué)習(xí) Vue 3,了解它一些新特性的實現(xiàn)原理以及設(shè)計思想,能讓我開拓不少思路。
從分析定位問題到最終解決問題,希望這篇文章能給你在組件的性能優(yōu)化方面提供一些思路,并應(yīng)用到日常工作中。