如何处理后端一次性返回的十万条数据

语言: 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,而采用虚拟滚动,可以应付百万级别的数据量都不卡顿。