[前端] 定高虚拟列表

154 阅读2分钟
问题

像一些新闻、文章论坛列表都会涉及到一些大量数据的渲染的情况,当后端响应大量的数据,前端直接拿来渲染到列表上时,会造成页面的严重卡顿,常见的处理就是使用分片渲染和虚拟列表渲染的方式,这里简单实现定高的虚拟列表

虚拟列表 对需要展示的数据添加进展示列表,在页面发生滚动时,动态的添加当前屏幕可视区位置需要展示的列表,因为初始渲染无需渲染所有数据(只渲染可视区数据),导致列表展示效率非常高。

比较原始渲染和虚拟渲染

这里以渲染8000条数据为例

渲染速度 QQ录屏20230406161922.gif 渲染数据量 QQ录屏20230406162013.gif

渲染速度上虚拟列表渲染比原始渲染速度上要快很多,并且随着列表的滚动,发现虚拟列表渲染的数据长度是基本固定的。

拆解虚拟列表

9c745ab2f66e389415991b1e0a3d60ad.png

实现思路

1、获取元素视口的高度,获取列表元素的高度,得到视口内可展示的元素数量,设置头尾缓冲元素数量

 // 每条数据的高度
const itemHeight = 120;
// 可视区高度
const viewPointHeight = 640;
// 页面展示条数
const limitSize = Math.ceil(viewPointHeight / itemHeight);

// 可视区 头部+尾部索引
let startIndex = 0;
let endIndex = 0;
//头部+尾部 缓冲偏移
const startOffset = 3,
      endOffset = 3;

2、设置render方法, 进行初始化渲染(传入可视区第一个元素索引,和最后一个元素索引,方法内会调用createlist方法生成结构,并动态添加可视区,缓冲区,留白区[具体的render,createlist见完整代码])

 // 初次渲染
endIndex = limitSize;
render(startIndex, limitSize, true);

3、监听页面滚动,根据scrollTop动态生成可视区首尾元素索引,调用render,createlist生成可视区,缓冲区,留白区元素。

// 监听滚动
view.addEventListener('scroll', e => {
    // 获取最新可视区头部元素索引
    let start = Math.floor(e.target.scrollTop / itemHeight);

    // 当滚动距离超过元素高度时触发
    if (start !== startIndex) {
        startIndex = start;
        // 生成虚拟列表
        render(startIndex, startIndex + limitSize);
    }
})
最后

完整代码(定高)

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>渲染大量数据 createDocumentFragment和requestAnimationFrame</title>

    <style>
        .popup-box {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100vh;
            display: none;
            box-sizing: border-box;
            background-color: rgba(0, 0, 0, 0.3);
        }
        
        .popup-box .view {
            width: 360px;
            height: 640px;
            overflow-x: hidden;
            overflow-y: auto;
            background-color: azure;
            margin: 100px auto;
            padding: 10px;
            box-sizing: inherit;
        }

        .popup-box .view .popup {
            width: 100%;
        }

        .popup-box .view::-webkit-scrollbar {
            display: none;
        }

        .popup-box .view .popup .item-box {
            width: 100%;
            height: 120px;
            border-radius: 10px;
            border: 1px solid #666;
        }
    </style>
</head>
<body>
<button class="nativeListBtn">大数据列表</button>

<button class="virtualListBtn">虚拟数据列表(定高)</button>

<div class="popup-box">
    <div class="view"
        <div class="popup"></div>
    </div>
</div>
</body>
<script>
    let allList = [];

    ;(() => {
        // 获取按钮
        const nativeBtn = document.querySelector('.nativeListBtn');
        const virtualBtn = document.querySelector('.virtualListBtn');
        // 获取容器
        const popup = document.querySelector('.popup');
        const popupBox = document.querySelector('.popup-box');
        const view = document.querySelector('.view');

        // 每条数据的高度
        const itemHeight = 120;
        // 可视区高度
        const viewPointHeight = 640;
        // 页面展示条数
        const limitSize = Math.ceil(viewPointHeight / itemHeight);

        // 可视区 头部+尾部索引
        let startIndex = 0;
        let endIndex = 0;
        //头部+尾部 缓冲偏移
        const startOffset = 3,
            endOffset = 3;

        // 原始渲染
        nativeBtn.addEventListener('click', () => {
            console.time('native');
            popupBox.style.display = 'block';
            let elementList = createList(allList);
            popup.append(...elementList);
            console.timeEnd('native');
        });

        // 虚拟列表渲染
        virtualBtn.addEventListener('click', () => {
            console.time('virtual');
            popupBox.style.display = 'block';

            // 初次渲染
            endIndex = limitSize;
            render(startIndex, limitSize, true);
            console.timeEnd('virtual');
            // 监听滚动
            view.addEventListener('scroll', e => {
                // 获取卷去了几个元素高度
                let start = Math.floor(e.target.scrollTop / itemHeight);

                if (start !== startIndex) {
                    startIndex = start;
                    // 生成虚拟列表
                    render(startIndex, startIndex + limitSize);
                }
            })
        });

        /*
        * @description 用于创建元素
        * @param {number} result 数据列表
        * @returns {HTMLDivElementList[]} element[]
        * */
        function createList(result) {
            return result.map(child => {
                let div = document.createElement('div');
                let p = document.createElement('p');
                let span = document.createElement('span');
                div.className = 'item-box';
                p.className = 'item-msg';
                span.className = 'item-time';
                p.innerText = child.msg;
                span.innerText = child.date + '/' + child.now;
                div.append(p);
                div.append(span);
                return div;
            })
        }

        /*
         * @description 用于生成可视区 + 缓冲区 + 留白区内容
         * @param {number} startIndex 可视区头部元素索引
         * @param {number} endIndex 可视区尾部元素索引
         * @returns {void}
         * */
        function render(startIndex, endIndex) {
            // 可视区域元素渲染列表
            let elementList = createList(allList.slice(startIndex, endIndex));
            // 头部缓冲区域渲染列表
            let startOffsetList = createList(allList.slice(startIndex - startOffset, startIndex));
            // 尾部缓冲区域渲染列表
            let endOffsetList = createList(allList.slice(endIndex, endIndex + endOffset));
            // 设置头部空白占位
            popup.style.paddingTop = (startIndex - startOffset > 0 ? startIndex - startOffset : 0) * itemHeight + 'px';
            // 设置尾部空白占位
            popup.style.paddingBottom = (allList.length - endIndex) * itemHeight + 'px';
            // 清空缓冲 + 可视区内容
            [...popup.children].forEach(child => {
                popup.removeChild(child);
            })
            // 动态添加节点
            popup.append(...startOffsetList, ...elementList, ...endOffsetList);
        }
    })();
</script>

<script>
    ;(() => {
        const xhr = new XMLHttpRequest();

        xhr.open('get', 'http://localhost:4800/api/list?count=8000');

        xhr.setRequestHeader("Authorization", window.localStorage.getItem('TOKEN'));

        xhr.addEventListener('load', () => {
            if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                allList = JSON.parse(xhr.response).data;
            }
        });

        xhr.send();
    })();
</script>
</html>

express

router.get('/list', async (req, res, next) => {
  try {
    const {count} = req.query;
    if (!count) {
      res.status(400).json({
        code: 400,
        message: 'params error',
        data: [],
      });

      return;
    }

    let i = 0;
    const arr = [];

    while (i < count) {
      arr.push({
        id: i,
        msg: `这是数据${i + 1}`,
        // eslint-disable-next-line max-len
        date: `${(new Date()).getMonth()+1}${(new Date()).getDate()}${(new Date()).getHours()}${(new Date()).getMinutes()}分`,
        now: `${Date.now()}`,
      });
      i++;
    }

    res.status(200).json({
      code: 200,
      message: 'Ok',
      data: arr,
    });
  } catch (error) {
    next(error);
  }
});