如何處理後端一次性返回的十萬條資料

語言: CN / TW / HK

丟擲問題:後端未做分頁處理,一次性返回十萬條資料,作為前端開發工程師,應該如何應對呢?

準備工作

首先,我們來寫個測試案例,模擬後端返回十萬條資料,來看一下頁面渲染效果。

首先我們用node.js建立一個本地伺服器。模擬與後端通訊。

const http = require('http');

const port = 8000;



let list = [];

let num = 0;



    // create 100,000 records

    for (let i = 0; i < 100000; i++) {

        num++

        list.push({
            src: 'https://a.a.com/data',
               text: `第 ${num}條資料`,
            tid: num
        })

    }
     http.createServer(function (req, res) {

    // for Cross-Origin Resource Sharing (CORS)

    res.writeHead(200, {

        'Access-Control-Allow-Origin': '*',

        "Access-Control-Allow-Methods": "DELETE,PUT,POST,GET,OPTIONS",

        'Access-Control-Allow-Headers': 'Content-Type'

    })



    res.end(JSON.stringify(list));

}).listen(port, function () {

    console.log('server is listening on port ' + port);

})

然後呼叫node,啟動該服務

node server

封裝一個html文字,作為渲染資料的介面:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
    <div id="container">
    </div>
    <script src="./index.js"></script>
</body>
</html>

接下來就是解決效能問題了

初級前端開發-直接渲染

const getList = () => {
    return new Promise((resolve, reject) => {
        var ajax = new XMLHttpRequest();
        ajax.open('get', 'http://127.0.0.1:8000');
        ajax.send();
        ajax.onreadystatechange = function () {
            if (ajax.readyState == 4 && ajax.status == 200) {
                resolve(JSON.parse(ajax.responseText))
            }
        }
    })
}
const container = document.getElementById('container');

const renderList = async() => {

    const list = await getList()



        list.forEach(item => {

        const div = document.createElement('div')

            div.className = 'sunshine'

            div.innerHTML = `<img src="${item.src}" ><span>${item.text}</span>`

            container.appendChild(div)

    })

}
renderList()

通過Google控制檯,我們可以清晰的看到頁面呈現所需要花費的時間。可以看到,頁面載入花了接近10秒,對於使用者來說,是種極不友好的體驗。

中級前端開發-呼叫setTimeout進行分頁渲染

原理:簡單的實現快速渲染出效果,原理是使用者看到的頁面內容只是一部分,後面仍然還在載入中

const renderList = async () => {



    const list = await getList()



    const total = list.length

    const page = 0

    const limit = 200

    const totalPage = Math.ceil(total  limit)



    const render = (page) => {

        if (page >= totalPage) return

        setTimeout(() => {

            for (let i = page * limit; i < page * limit + limit; i++) {

                const item = list[i]

                const div = document.createElement('div')

                div.className = 'sunshine'

                div.innerHTML = `<img src="${item.src}" ><span>${item.text}</span>`

                container.appendChild(div)

            }

            render(page + 1)

        }, 0)

    }

    render(page)

}

通過呼叫setTimeout將任務拆分成一個個獨立的小任務,達到第一次載入頁面快速渲染。我們來看一下總體載入時間。網路花費時間沒有減少多少,但是指令碼執行時間減少了快三秒。但是在這裡渲染時間以及繪製時間都大量提升了,原因是拆分成一個個任務難以避免的又引起了頁面重排重繪導致時間快速的上升,所以相比較直接渲染時間只下降了兩秒多。

進階前端開發-requestAnimationFrame函式

requestAnimationFramesetTimeout 用法類似,都是起到一個定時器的作用,但是相比較於 setTimeout 而言,它更加適合處理DOM的操作,總結一下它的相比較 setTimeout 的優點。

  • requestAnimationFrame 會把每一幀中的所有DOM操作集中起來,在一次重繪或迴流中就完成,並且重繪或迴流的時間間隔緊緊跟隨瀏覽器的重新整理頻率,一般來說,這個頻率為每秒60幀。

  • 在隱藏或不可見的元素中,requestAnimationFrame將不會進行重繪或迴流,這當然就意味著更少的的cpu,gpu和記憶體使用量。

  • requestAnimationFrame是由瀏覽器專門為動畫提供的API,在執行時瀏覽器會自動優化方法的呼叫,並且如果頁面不是啟用狀態下的話,動畫會自動暫停,有效節省了CPU開銷。

前面的 setTimeout 之所以渲染時間花費太多,就是因為在一幀上處理了太多次的頁面重排重繪,而這次我們改一下程式碼呼叫 requestAnimationFrame 試試。

const renderList = async () => {
    const list = await getList()



    const total = list.length

    const page = 0

    const limit = 200

    const totalPage = Math.ceil(total  limit)



    const render = (page) => {

        if (page >= totalPage) return

        requestAnimationFrame(() => {

            for (let i = page * limit; i < page * limit + limit; i++) {

                const item = list[i]

                const div = document.createElement('div')

                div.className = 'sunshine'

                div.innerHTML = `<img src="${item.src}" ><span>${item.text}</span>`

                container.appendChild(div)

            }

            render(page + 1)

        })

    }

    render(page)

    }

看一下執行時間,我們可以看到指令碼執行時間也降低了,但是不多可以暫時忽略。重點是渲染繪製兩者得到了大幅度的下降。所以總體時間相比較setTimeOut下降了兩秒多,這是一個很大的進步。

const renderList = async () => {
    const list = await getList()
    console.log(list)
    const total = list.length
    const page = 0
    const limit = 200
    const totalPage = Math.ceil(total  limit)


    const render = (page) => {
        if (page >= totalPage) return
        requestAnimationFrame(() => {


            const fragment = document.createDocumentFragment()
            for (let i = page * limit; i < page * limit + limit; i++) {
                const item = list[i]
                const div = document.createElement('div')
                div.className = 'sunshine'
                div.innerHTML = `<img src="${item.src}" ><span>${item.text}</span>`


                fragment.appendChild(div)
            }
            container.appendChild(fragment)
            render(page + 1)
        })
    }
    render(page)
}

因為瀏覽器快取的存在,會導致兩者時間會越來越低,我們重點看渲染和繪製時間,可以看到兩者又下降了一秒多,所以這種方案是非常可行的。

文件片段

前面的手段是將指令碼拆分成一個個子任務去執行,現在我們注意到,每次我們都是新建一個div元素,然後通過appenChild將div插入到元素中。而appenChild是一個昂貴的操作,而如果通過文件片段的話,我們先將生成的div新增到文件片段中,然後操作完成後新增到容器中,這樣就只需要做一次重排重繪操作即可。修改程式碼如下。

高階前端開發-虛擬滾動

其實虛擬滾動才是解決這些效能瓶頸最好的辦法,我們可以看到在載入時間和指令碼執行時間一樣的時候,頁面載入的時候所花費的大頭全部是在渲染以及繪製上,所以解決渲染繪製才是王道。

在上面我們可以看到,我們是實打實的生成了十萬個div,生成這十萬個div需要花費時間,而div的 appenchild 操作又會觸發重新渲染,這又是個花費時間的點。而如果我們只生成固定的div,只去修改動態的資料會怎麼樣呢。其實這就是虛擬滾動的思想。

我們知道,瀏覽器的視覺化是一個固定的高度寬度。我們計算出視覺化高度寬度,得到我們需要生成的列表,然後通過 transform 開啟硬體加速,這個屬性並不會引起重排以及重繪。我們來看一下案例

<!DOCTYPE html>
<html>
    
<head>
    <meta charset="UTF-8">
    <!-- import CSS -->
    <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.13/vue.min.js"></script>
    <!-- import JavaScript -->
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
    <title>虛擬滾動原理</title>
</head>
 
<body>
    <div id="app">
        <el-row :gutter="10">
            <el-col :xs="6" :sm="6" :md="5" :lg="4" :xl="2">
                <el-button type="danger" @click="virtualScrolling(20)">20條</el-button>
            </el-col>
            <el-col :xs="6" :sm="6" :md="5" :lg="4" :xl="2">
                <el-button type="primary" @click="virtualScrolling(100)">一百條</el-button>
            </el-col>
            <el-col :xs="6" :sm="6" :md="5" :lg="4" :xl="2">
                <el-button type="success" @click="virtualScrolling(1000)">一千條</el-button>
            </el-col>
            <el-col :xs="6" :sm="6" :md="5" :lg="4" :xl="2">
                <el-button @click="virtualScrolling(100000)">十萬條</el-button>
            </el-col>
        </el-row>
        <div class="wrap" @scroll="liScroll">
            <ul class="ul_wrap" :style="`height:${ulHei}px`">
                <li class="li_item" :style="`height:${liHei}px;transform:translateY(${ScroolNum}px)`"
                    v-for="item in liList" :key="item">
                    {{item}}
                </li>
            </ul>
        </div>
    </div>
</body>
<style>
    .wrap {
        height: 400px;
        background-color: #fff;
        overflow: scroll;
        margin-top: 20px;
    }
 
    .li_item {
        border: 1px red solid;
        line-height: 50px;
 
    }
</style>
<script>
    new Vue({
        el: '#app',
        data(){
            return {
                liHei: 50,//li的高度
                ulHei: 480,//ul的高度
                liList: [],//真實展示的列表
                scrollHei:0,//@scroll事件滾動的top值
                ScroolNum: 0,//scrollHei能被li高度取餘數的整數值。ScroolNum=scrollHei-(scrollHei%liHei)
                showList: 0,//真實展示的條數
                tableData: [],//全部資料的集合
                lastTime:0,//最後時間
            }
        },
        mounted () {
            this.virtualScrolling(100000)
            
        },
        methods: {
             /**滾動監聽 */
            liScroll (e) {
                if(new Date().getTime()-this.lastTime>40){//設定時間間隔,防止滾動事件高頻觸發消耗記憶體資源
                this.ele = e;//儲存元素,方便重置scrollTop值
                this.scrollHei = e.target.scrollTop;//儲存滾動條scrollTop值
                this.ScroolNum = this.scrollHei - (this.scrollHei % this.liHei);//獲取已滾動到頁面上方不可見的li元素的總高度(translateY的偏移高度)
                let len = this.ScroolNum / this.liHei;//計算已經有多少個li滾動到頁面上方(檢視上方使用者不可見的數量)
                this.liList = this.tableData.slice(len, len + this.showList);//每次滾動事件後重新計算展示內容(擷取的內容對應全部資料集的部分內容)
                this.lastTime=new Date().getTime();//記錄最後一次更新時間
                }
                
            },
           /**初始化資料*/
            virtualScrolling (num) {
                let arr = [];//初始化陣列
                for (let i = 0; i < num; i++) {//計算給定資料量
                    arr.push(i+1)
                }
                this.tableData = arr;//全部資料集
                this.showList = Math.floor(this.ulHei / this.liHei) + 4;//計算真實渲染的列表數量
                this.liList = this.tableData.slice(0, this.showList);//初始化可視列表的內容
                this.lastTime=new Date().getTime();//記錄最後一次更新時間
                this.$message({
                    message: `當前資料為${num}條`,
                    type: 'success'
                    });
               
                if (!!this.ele) {//判斷監聽元素是否儲存到ele欄位中
                    this.ele.target.scrollTop = 0;//如果元素存在ele中則將scrollTop初始化為0;
                    this.ScroolNum=0;//初始化translateY的偏移高度
                }
               
            },
        }
    })
</script>
 
</html>

實際上只生成了13個 li 標籤,相比較其他方案動輒生成10萬個div,當然是這種速度更快,並且我們可以發現,前面那種情況頁面是非常卡頓的,畢竟塞了這麼多個div,而採用虛擬滾動,可以應付百萬級別的資料量都不卡頓。