本文由體驗技術團隊岑灌銘原創。
前言
表格作為組件庫高頻使用的組件,它作為承載、展示和交互數據的核心載體,每一次卡頓都可能意味着時間的浪費與耐心的消磨。
然而有小夥伴反饋説,表格組件樹表數據滾動場景卡頓,偶爾會出現白屏現象,甚至會出現表頭和表體滾動不同步的情況。
後來據瞭解是小夥伴的機器性能較為普通,加上表格樹表大數據虛擬滾動確實存在較大的性能瓶頸,存在大量的計算與dom操作。
問題在性能較好的機器上被“屏蔽”了。
- 打開 performance 面板
- 點擊面板內右上角設置按鈕
- 在 CPU 設置選項中點擊彈出下拉麪板
- 選擇需要降低的 CPU 倍率
在降低 CPU 性能 4x 減速後,再使用官網中的樹表大數據demo體驗滾動,用户反饋的問題也成功地“輕鬆”復現了。
白屏與滾動不同步的表格滾動體驗實在是太差了,因此我們決定針對表格滾動場景做一個專項優化,解決這一大痛點。
使用performance工具分析滾動場景下任務耗時,發現其中hasRowChange和renderColumn比較耗時。
其中hasRowChange是動態計算表格中單元格有無發生數據變化。
renderColumn 就是渲染表格行和列了,展開後發現是其中vue更新組件耗時,其中涉及dom元素的創建和銷燬,事件的處理等。
針對已有分析的問題,主要對錶格做了如下重構:
主要重構點
單元格事件委託
重構前:
在之前的實現中,每個單元格(cell)都單獨綁定了自己的事件監聽器,並在單元格內容更新或頁面滾動導致單元格變化時,頻繁地進行事件監聽器的綁定和銷燬操作。這種做法在處理大數據量表格時,尤其是在滾動過程中動態更新單元格內容的情況下,佔用了大量的資源。
重構後:
為了優化這一情況,採用了事件委託的技術方案;將所有單元格的事件處理器統一綁定到外層表格(table)上,利用事件冒泡機制,僅保留一個全局的事件處理器。無論表格中有多少個單元格,或是單元格如何動態變化,都不需要再對每個單元格單獨管理事件監聽器。相反,通過在外層表格上捕獲並分發事件,我們可以顯著減少因頻繁綁定和銷燬事件監聽器帶來的資源消耗,從而提升整體應用性能
關鍵代碼修改:
將單元格事件處理邏輯使用composition-api抽離到packages\vue\src\grid\src\composable\useCellEvent.ts中
詳細的處理邏輯可以查看具體文件
const bindMouseEvents = (target) => {
on(target, 'mouseenter', handleMouseEnter, true)
on(target, 'mouseleave', handleMouseLeave, true)
on(target, 'mousedown', handleMouseDown, true)
on(target, 'click', handleClick, true)
on(target, 'dblclick', handleDoubleClick, true)
}
// ...省略其他代碼
hooks.watch(table, (table, old) => {
if (isBound && old) {
unbindMouseEvents(old)
isBound = false
}
if (!isBound && table) {
bindMouseEvents(table)
isBound = true
}
})
// ...省略其他代碼
空數據展示優化
重構前:
之前空數據居中展示,需要通過複雜的JS邏輯計算,保存表格的表體高度(表格整體高度減去表頭高度),再通過js給空數據賦值對應高度,使其能夠適配表體高度。期間會讀取到clientHeight觸發不必要的迴流。
重構後:
重構後的空數據居中展示,使用純css方案,使用sticky粘性定位加高度自適應實現,避免JS邏輯計算。
關鍵代碼修改:
// packages\vue\src\grid\src\table\src\methods.ts
// 刪除updateTableBodyHeight方法
updateTableBodyHeight() {
if (!this.tasks.updateTableBodyHeight) {
this.tasks.updateTableBodyHeight = () => {
fastdom.measure(() => {
const tableBodyElem = this.elemStore['main-body-wrapper']
this.tableBodyHeight = tableBodyElem ? tableBodyElem.clientHeight : 0
})
}
}
this.tasks.updateTableBodyHeight()
},
// packages\theme\src\grid\table.less
// 改用sticky定位
& &__empty-block {
height: 100%;
min-height: 60px;
padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
position: sticky;
left: 0;
flex: auto;
flex-direction: column;
}
表頭、表體、表尾三合一。
重構前:
在過去的版本中,為了實現表頭固定顯示在頂部,將表頭、表體、表尾拆分為了三個表格,使滾動條僅出現在表體中從未達到表頭固定展示的效果。當滾動橫向滾動條時,通過js邏輯同步設置表頭。表尾的滾動位置,達到三者同步效果。在性能不佳的機器上,大數據滾動場景會出現表頭表尾不同步的問題。
重構後:
最新版本的表格目前將表頭、表體、表尾都合併到了一個table中,同樣使用sticky定位去實現表頭固定顯示在頂部,刪除滾動同步邏輯。由於三者位於同一滾動容器中,因此不會出現滾動不同步問題。
關鍵代碼修改:
// packages\vue\src\grid\src\header\src\header.ts
function renderHeaderTable(args) {
// ...省略其他代碼
return h(
'table',
{
class: 'tiny-grid__header',
style: { tableLayout },
attrs: { cellspacing: 0, cellpadding: 0, border: 0 },
ref: 'table'
},
[
// 列寬
renderTableColgroup(tableColumn),
// 頭部
renderTableThead(args1)
]
)
}
// packages\vue\src\grid\src\body\src\body.tsx
function renderTable({ $table, _vm, tableColumn, tableData, tableLayout }) {
return h(
'table',
{
class: 'tiny-grid__body',
style: { tableLayout },
attrs: { cellspacing: 0, cellpadding: 0, border: 0 },
ref: 'table'
},
[
// 渲染colgroup標籤,設置表格列寬度,保證表頭的表格和表體的表格每列寬相同
h(
'colgroup',
{ ref: 'colgroup' },
tableColumn.map((column, columnIndex) => h('col', { attrs: { name: column.id }, key: columnIndex }))
),
h('tbody', { ref: 'tbody' }, renderRows({ h, _vm, $table, $seq: '', rowLevel: 0, tableData, tableColumn }))
]
)
}
// packages\vue\src\grid\src\footer\src\footer.ts
render()
// ...省略其他代碼
return h(
'div',
{
class: ['tiny-grid__footer-wrapper', 'body__wrapper'],
on: { scroll: this.scrollEvent }
},
[
h('div', { class: 'tiny-grid-body__x-space', ref: 'xSpace' }),
typeof renderFooter === 'function'
? renderFooter(renderParams, h)
: h(
'table',
{
class: 'tiny-grid__footer',
style: { tableLayout },
attrs: tableAttrs,
ref: 'table'
},
[
// 列寬
colgroupVNode,
// 底部
tfootVNode
]
)
]
)
},
// packages\vue\src\grid\src\body\src\body.tsx
// heder、body、footer三者再同一個table中
const tableVnode = (
<table
ref="table"
class="tiny-grid__body"
style={{ tableLayout, width: bodyTableWidth ? `${bodyTableWidth}px` : undefined }}
cellspacing={0}
cellpadding={0}
border={0}
data-tableid={$table.id}>
{[
// 列分組(用於指定列寬)
<colgroup ref="colgroup">
{columnPool.map(({ id, item: column, used }) => {
return (
<col
key={id}
name={column.id}
width={String(column.renderWidth)}
style={{ display: used ? undefined : 'none' }}
/>
)
})}
</colgroup>,
// 表頭
$table.showHeader ? <thead ref="thead">{renderHeaderRows(_vm)}</thead> : null,
// 表體內容
<tbody ref="tbody">{renderRows(_vm)}</tbody>,
// 表尾
$table.showFooter && !isNoData && typeof $table.renderFooter !== 'function' ? (
<tfoot ref="tfoot">{renderFooterRows(_vm)}</tfoot>
) : null
]}
</table>
)
增加數據緩存,以空間換時間
重構前:
在之前的實現中,每次滾動時都會對每個單元格的狀態進行重新計算,例如判斷單元格數據是否發生變化(dirty check),這些操作涉及大量的計算資源,進一步加重了主線程的負擔。頻繁的重新計算和大對象查詢導致了滾動等其他邏輯執行緩慢,引起卡頓問題。
重構後:
引入了緩存機制。在第一次渲染時,將每個單元格的 dirty 狀態存儲在一個小型緩存表中。這樣,在後續的滾動過程中,無需再進行重複的狀態計算或從原始大對象中查詢數據,而是直接在緩存表中快速查找所需信息。通過這種方式,以空間換時間,減少不必要的計算開銷。
關鍵代碼修改:
將單元格狀態相關邏輯使用composition-api抽離到packages\vue\src\grid\src\composable\useCellStatus.ts中
將數據相關緩存抽離到packages\vue\src\grid\src\composable\useData.ts中
// packages\vue\src\grid\src\composable\useCellStatus.ts
export const getCellStatus = ($table, row, column) => {
const cellKey = getCellKey($table, row, column)
const map = $table.cellStatus
if (map.has(cellKey)) {
return map.get(cellKey)
} else {
return { isDirty: false }
}
}
// packages\vue\src\grid\src\composable\useData.ts
const structure = ({ array, stack, tiled, map, customMappings, getID, childrenKey, sizeKey }) => {
if (!Array.isArray(array)) {
return
}
const level = stack.length
const nodes = []
for (let i = 0; i < array.length; i++) {
const item = array[i]
const node = {
id: getID(item) || ++nid,
payload: item,
path: [...stack, item],
level,
parentNode: level > 0 ? map.get(stack[stack.length - 1]) : undefined,
childNodes: undefined,
space: { originDistance: 0, size: item[sizeKey] || 36 },
mappings: customMappings ? Object.assign({}, customMappings({ payload: item, viewIndex: tiled.length })) : {}
}
tiled.push(node)
map.set(item, node)
nodes.push(node)
if (childrenKey) {
stack.push(item)
node.childNodes = structure({
array: item[childrenKey],
stack,
tiled,
map,
customMappings,
getID,
childrenKey,
sizeKey
})
stack.pop()
}
}
return nodes
}
其他更新
- 特性增強:表格支持跨凍結列合併
- 優化渲染機制,減少表格內組件重新 render 次數
- 優化列配置收集,列配置收集完成後再渲染真實表格,解決表格初始化渲染高度過大問題。
驗證性能提升
測試環境信息:
- 瀏覽器:chrome 版本 138.0.7204.158 無痕模式
- 操作系統: win10 專業版
- 處理器: Intel(R) Xeon(R) Gold 6278C CPU
- 內存: 32GB
本次性能測試的 demo 是官網中已有的樹表虛擬滾動:https://opentiny.design/tiny-vue/zh-CN/os-theme/components/gr...
先來看看直觀感受
重構前:
可以明顯的看到,當縱向和橫向快速滾動時,都會出現白屏現象。且滾動條也較為卡頓。
重構後:
重構後,無論是橫向亦或是縱向快速滾動,都不會出現白屏現象,滾動效果也相對絲滑。
利用 performance 記錄一下大數據樹表虛擬滾動初始化的情況。
重構前的內存佔用約為 37.2M (832KB 應為 chrome 初始化必需內存),代碼執行時間為 981ms。
重構後的內存佔用約為 27M,代碼執行時間為 552ms。
小結:
大數據樹表虛擬滾動初始化場景,內存節省27%, js 執行時間減少43%,另外渲染和繪製時間也有小幅提升。
再來記錄一下滾動場景:
對 Demo 進行以下改造,點擊按鈕後, 在 3 秒內對錶格進行橫向滾動6000px
// template add code
<tiny-button @click="handleScroll">開始滾動</tiny-button>
// ...省略其他代碼
// script add code
const grid = ref();
const handleScroll = () => {
const now = Date.now();
let frameCount = 0;
const doScroll = () => {
const time = Date.now() - now;
requestAnimationFrame(() => {
frameCount++;
if (time <= 3000) {
grid.value.scrollTo(time * 2);
doScroll();
} else {
console.log(`滾動結束:平均幀率為${frameCount / 3}FPS`);
}
});
};
doScroll();
};
先使用 chrome performance 工具記錄,然後再點擊按鈕
重構前後數據對比如下:
重構前:FPS 約為20FPS,js 執行時間為2288ms
重構後:FPS 約為44FPS,js 執行時間為1302ms
重構後單個任務中,已經消除了狀態計算的邏輯,renderColumn任務也從40ms降低為15ms
小結:
大數據樹表虛擬滾動橫向場景下,FPS 提升110%, js 執行時間減少43%
總結
主要優化點:
- 事件委託:將單元格事件統一綁定到表格外層,減少事件監聽器頻繁綁定/銷燬
- CSS 優化:空數據展示改用純 CSS 方案,避免 JS 計算和迴流
- 結構簡化:表頭、表體、表尾合併為單一表格,使用 sticky 定位,消除滾動同步問題
- 緩存機制:增加數據緩存,以空間換時間,減少重複計算
- 滾動優化:分離滾動處理與數據剪切邏輯,提高響應速度
中等配置電腦下性能提升如下:
初始化場景:內存節省 27%,JS 執行時間減少 43%
滾動場景:FPS 提升 110%(20FPS → 44FPS),JS 執行時間減少 43%
關於 OpenTiny
歡迎加入 OpenTiny 開源社區。添加微信小助手:opentiny-official 一起參與交流前端技術~
OpenTiny 官網:https://opentiny.design\
OpenTiny 代碼倉庫:https://github.com/opentiny\
TinyVue 源碼:https://github.com/opentiny/tiny-vue\
TinyEngine 源碼: https://github.com/opentiny/tiny-engine\
歡迎進入代碼倉庫 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor\~
如果你也想要共建,可以進入代碼倉庫,找到 good first issue 標籤,一起參與開源貢獻\~