遇到數萬到數十萬筆資料的超大表格,當代瀏覽器及電腦的效能或許還足以應付,顯示沒問題,但遇上畫面捲動或資料動態更新等操作,便不免出現畫面停滯、短暫空白、延遲顯示等狀況。

例如以下這個有三萬兩千六百筆、七個欄位的股價表,進入網頁要等兩三秒;捲動會有短暫空白,內容幾秒後出現;而點收盤價排序,則要等個三四秒才會刷新結果。線上展示

更慘的是,多操作幾下,瀏覽器有時會原地休克...

面對這類挑戰,有個簡單解法是改成分頁顯示逃避問題。每次只顯示一頁資料,反正使用者一次能閱讀的資料數頂多數十上百筆,讀完再切下一頁。如此,瀏覽器每次只需渲染 (Render) 幾十筆頂多上百筆 <TR>,輕鬆自在,很難出現效能問題。

但分頁操作的有重大缺點:點一次換一頁,無法快速線性移動,流暢性體驗遠不及捲軸。想像一下,如果有個捲軸中間的滑塊(Thumb)不能拖拉,只讓你按上下箭頭(Button)捲動,一定難用到靠北吧?而分頁採用的正是這等操作概念。


圖片來源:捲軸樣式

更甭提,現在大家的滑鼠都有滾輪,不能拖拉捲動、不能用滾輪捲動,分頁操作介面常被嫌棄到不行。

於是,無比內捲的前端設計界就有人想出了「虛擬捲動 (Virtual Scrolling)」這個點子,用來解決大量數千甚至數萬筆資料畫面渲染時的效能瓶頸。核心想法是:永遠只渲染目前在瀏覽器可視範圍(Viewport)內可見的資料項目,看不到的部分根本不用在 DOM 裡出現,省去 DOM 處理與渲染的成本。使用者捲動畫面時,程式動態改變渲染的資料對象,確保可見區域的內容都如實顯示即可,使用者感覺自己在操作一個完整的超長列表,實際上 DOM 只有可視範圍那區的少量元素,完美!

以下 TABLE 的例子,可視區域的高度只能看到五列,抓上下兩列緩衝(以免一次捲動距離過大,上下出現空白),只需產生九列的資料就夠。上下方則各放一列空白,其高度設定省略掉沒產生資料筆數的高度。例如,九列內容上方其實有 100 列,則上方填充列的 CSS height 就設成 100 * 每列高度,下方的填充列比照辦理。加入上面填充列,滑塊所在位置可正確反應當前看到資料是在全部資料的上段、中段還是下段的大約 100% 位置,操作上更直覺。

虛擬捲動的詳細原理網路上有不少圖文並茂的教學(推這篇:Build your Own Virtual Scroll by klein),而想當然爾,現成程式庫或元件自然也是滿坑滿谷,例如 Kendo UI Gridvue-virtual-scroller,因此我們在開發設計網頁時,可以透過虛擬捲動提供更優質的操作體驗,我們下次再見。(揮手下降)

電影散場還在的各位,彩蛋來了。

如果你跟我一樣,在一堆人歡慶這輩子都不必再寫他 X 的程式的 AI 年代,仍想享受自己寫程式徒手刻出虛擬捲動效果的樂趣,以下是我的心得。

先看成品:線上展示

清單表格生成、排序及關鍵字篩選是用 Vue.js 實作,原本因為一次要顯示三萬多筆,操作上嚴重卡頓,有時還會因不明原因瀏覽器凍結。加入虛擬捲動技術後,每次只需要產生不超過 100 筆 <TR>,不管一開始的顯示、捲動、排序都沒有半點延遲,但整體感覺就是在瀏覽一個超大 <TABLE>,體驗十分良好。

const app = Vue.createApp({
    data() {
        return {
            data: [],
            sortedData: [],
            sortMode: 0, // 0: 無排序, 1: 股價由低至高, 2: 股價由高到低
            keywd: '', // 關鍵字

            // 虛擬化捲動相關變數
            rowHeight: 28,
            visibleItems: [],
            startIndex: 0,
            endIndex: 0,
            bufferSize: 5,
            viewportHeight: 0,
            scrollTop: -1,
            isScrolling: false,
        }
    },
    watch: {
        keywd(newVal) {
            this.updateVisibleItems();
        }
    },
    computed: {
        filteredData() { // 關鍵字篩選
            if (!this.keywd) return this.sortedData;
            const keyword = this.keywd.toLowerCase();
            return this.sortedData.filter(item => item.Name.toLowerCase().includes(keyword) || item.Code.includes(keyword));
        },
    },
    methods: {
        sortData() {
            // ... 資料排序 (略)
        },
        toggleOrder() {
            // ... 排序切換 (略)
        },
        loadData() {
            // 股價資料來源:https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL
            let idx = 1;
            fetch('./STOCK_DAY_ALL.json')
                .then(response => response.json())
                .then(data => {
                    // 過濾無收盤價或收盤價 < 5 的資料
                    data = data.filter(item => item.ClosingPrice && parseFloat(item.ClosingPrice) >= 5);
                    
                    // ...資料增量十倍 (略)
                    
                    // 稍後執行虛擬捲動初始化
                    this.$nextTick(() => {
                        this.initializeVirtualScroll();
                    });
                })
        },

        // 虛擬捲動初始化
        initializeVirtualScroll() {
            // 測量 Row 高度 (用 setTimeout 技巧,等 Vue 生成內容後再測量)
            setTimeout(() => { 
                const sampleRow = document.querySelector('.stk-item');
                if (sampleRow) {
                    this.rowHeight = sampleRow.offsetHeight;
                }
            }, 50);
            // 更新可視項目
            this.updateVisibleItems();
        },
        // 使用者捲動時,重算可視項目
        handleScroll(event) {
            this.updateVisibleItems();
        },
        updateVisibleItems() {
            const container = document.querySelector('.stk-list');
            // 使用者
            if (!container || this.isScrolling) return;
            this.isScrolling = true;
            const containerHeight = container.clientHeight;
            // 取得目前捲動位置
            let scrollTop = container.scrollTop;
            
            const data = this.filteredData;
            // 顯示範圍的開始筆數(向上抓 bufferSize 筆)
            this.startIndex = Math.floor(scrollTop / this.rowHeight) - this.bufferSize;
            this.startIndex = Math.max(0, this.startIndex);
            // 顯示範圍筆數 = 容器高度 / 列高 + 2 倍 bufferSize 筆
            const visibleCount = Math.ceil(containerHeight / this.rowHeight) + 2 * this.bufferSize;
            // 顯示範圍的結束筆數 (不可大於總筆數)
            this.endIndex = Math.min(this.startIndex + visibleCount, data.length);

            if (this.startIndex >= this.endIndex) {
                this.startIndex = Math.max(0, this.endIndex - visibleCount);
            }

            // 上下各放一列佔位,依範圍外的筆數多寬計算高度
            this.topSpacerHeight = this.startIndex * this.rowHeight;
            this.bottomSpacerHeight = (data.length - this.endIndex) * this.rowHeight;

            // 從資料陣列取出可視範圍的項目
            this.visibleItems = data.slice(this.startIndex, this.endIndex);
            this.$nextTick(() => {
                this.isScrolling = false;
                container.scrollTop = scrollTop; // 確保捲動位置不變
            });
        }
    }
});

const vm = app.mount('#app');
vm.loadData();
// 視窗縮放時可能改變可視範圍大小,觸發重算,但加上 debounce 以避免縮放過程連續觸發
let debounceTimer;
window.addEventListener('resize', () => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
        vm.updateVisibleItems();
    }, 100);
});

HTML 部分如下:

<div class="stk-list" @scroll="handleScroll">
    <table class="stk-table">
        <thead>
            <tr>
                <td width="100">
                    股票代碼
                </td>
                <td width="200">名稱</td>
                <td width="80">
                    <button @click="toggleOrder()">
                        收盤價
                        <span>
                            <span v-if="sortMode == 1">▲</span>
                            <span v-else-if="sortMode == 2">▼</span>
                            <span v-else>⇳</span>
                        </span>
                    </button>
                </td>
                <td width="80">開盤價</td>
                <td width="80">最高價</td>
                <td width="80">最低價</td>
                <td width="100">成交量</td>
            </tr>
        </thead>
        <tbody>
            <tr class="table-body-spacer" :style="{ height: topSpacerHeight + 'px' }"></tr>
            <tr v-for="stock in visibleItems" class="stk-item">
                <td>{{ stock.Code }}</td>
                <td>{{ stock.Name }}</td>
                <td class="num">{{ stock.ClosingPrice }}</td>
                <td class="num">{{ stock.OpeningPrice }}</td>
                <td class="num">{{ stock.HighestPrice }}</td>
                <td class="num">{{ stock.LowestPrice }}</td>
                <td class="num">{{ stock.TradeVolume }}</td>
            </tr>
            <tr class="table-body-spacer" :style="{ height: bottomSpacerHeight + 'px' }"></tr>
        </tbody>
    </table>
</div>

相關說明有加成註解,希望解釋夠清楚。完整程式碼在 Github 有需要請自取,最後補充這次學到的心得:

  1. scroll 事件會被連續觸發,使用者點一次捲軸上下箭頭可能會觸發多次,而拖拉滑塊或是滾滑鼠滾輪更是會持續發送,故我加了 isScrolling 旗標防止重複執行。
  2. 發現修改上下填充列高度,元素高度異動會改變可視範圍容器的 scrollTop 觸發 scroll 事件,接重算可視範圍及填充列高度,渲染後 scrollTop 改變再次觸發 scroll 事件... 形成永動機捲到地老天荒。 最後我找到的解法是在更新可視範圍及填充列高度後,覆寫容器的 scrollTop 維持不變才克服。
  3. setTimeout 與 this.$nextTick() 的不同:Vue 的 $nextTick() 會在 Vue 的 DOM 更新完成後、瀏覽器渲染之前觸發。故要測量 rowHight 要用 setTimeout 等 DOM 渲染完成才準確、而覆寫 container.scrollTop = scrollTop 要趕在 DOM 渲染之前防止 scroll 事件。

我被拉捲軸捲成永動機的問題卡住,花了很久時間才研究出解法。問 AI 指出了正確方向,但沒一次改對,靠著加 Log 觀察及理解問題根源的古典除錯技術,最後試出了可行做法,解掉問題瞬間的興奮是靠 AI 改 Code 所沒有的。

面對沒學過的應用需求,AI 寫的程式直接可用,賺到時間;AI 生成方向正確但不 Work 的程式,搞懂原理並修到好,則賺到知識及樂趣。怎麼都不會虧,哈!

The blog explores virtual scrolling as a solution to handle large datasets in web applications. By rendering only visible items in the viewport, it improves performance and user experience compared to traditional pagination. The post shares insights and a personal implementation using Vue.js.


Comments

# by 小黑

受用

# by Raven

這樣是不是內建的搜尋就沒有作用了

# by Jeffrey

to Raven, 想了一下,如果硬要實現用瀏覽器內建搜尋文字找到正確位置,得建立高度結構一致的 DOM,一個解法是不可見範圍還是 Render 出一筆資料一列,但合併成一欄放純文字就好,若筆數很多還是會衝擊效能。真有類似需求,我會選擇自己實作搜尋。

Post a comment