Stories

Detail Return Return

TinyVue表格重構5大關鍵幀一次曝光!內存節省 27%,JS 執行時間減少 43% ,FPS 提升 110% - Stories Detail

本文由體驗技術團隊岑灌銘原創。

前言

表格作為組件庫高頻使用的組件,它作為承載、展示和交互數據的核心載體,每一次卡頓都可能意味着時間的浪費與耐心的消磨。
然而有小夥伴反饋説,表格組件樹表數據滾動場景卡頓,偶爾會出現白屏現象,甚至會出現表頭和表體滾動不同步的情況。
後來據瞭解是小夥伴的機器性能較為普通,加上表格樹表大數據虛擬滾動確實存在較大的性能瓶頸,存在大量的計算與dom操作。
問題在性能較好的機器上被“屏蔽”了。

  1. 打開 performance 面板
  2. 點擊面板內右上角設置按鈕
  3. 在 CPU 設置選項中點擊彈出下拉麪板
  4. 選擇需要降低的 CPU 倍率

在降低 CPU 性能 4x 減速後,再使用官網中的樹表大數據demo體驗滾動,用户反饋的問題也成功地“輕鬆”復現了。

白屏與滾動不同步的表格滾動體驗實在是太差了,因此我們決定針對表格滾動場景做一個專項優化,解決這一大痛點。

使用performance工具分析滾動場景下任務耗時,發現其中hasRowChangerenderColumn比較耗時。

其中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 標籤,一起參與開源貢獻\~

user avatar guochenglong Avatar u_17470194 Avatar soroqer Avatar yuzhihui Avatar ccVue Avatar kongsq Avatar yulong1992 Avatar huangmingji Avatar gomi Avatar abcdxj555 Avatar snowwolfarden Avatar evilboy Avatar
Favorites 47 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.