javascript-渲染后端传来的10w条数据(分页/懒加载/虚拟列表)

loading 2023年02月01日 146次浏览

经常在网上看到这道题目,写一篇博客记录一下。

后端传数据和前端接收数据的代码都省略了,直接看重点:怎么将这些数据渲染出来?

1. 常规方案

最常规的方案就是接收到数据之后在容器中逐条创建:

        const renderList = async () => {
            // 调用函数获取到10w条数据
            const list = await getList();

            // 逐条读取并插入到容器中
            list.forEach(item => {
                const div = document.createElement('div')
                div.className = 'flex'
                div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
                container.appendChild(div)
            });
        }

毫无疑问这种方法是不可取的,数据量小还好,数据量一大起来那耗费的时间和资源是巨大的。

2. 优化一 前端分页

思路是把10w条数据分成10w/200 = 500页,通过setTimeout每次只渲染一页,速度大幅提升。

        const renderList = async () => {
            const list = await getList();
            // 总数据条数
            const total = list.length;
            // 每页数据条数
            const limit = 200;
            // 总页数
            const totalPage = Math.ceil(total / limit);
            // 用于遍历的页数
            const page = 0;

            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 = 'flex'
                        div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
                        container.appendChild(div)
                    }
                    render(page + 1)
                }, 0)
            }
            render(page);
        }

3. 优化二 requestAnimationFrame

别的全部没区别,就把setTimeout改成requestAnimationFrame,减少了重排的次数,极大提高了性能。

                requestAnimationFrame(() => {
                    // 逐页渲染
                    for (let i = page * limit; i < page * limit + limit; i++) {
                        const item = list[i];
                        const div = document.createElement('div')
                        div.className = 'flex'
                        div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
                        container.appendChild(div)
                    }
                    render(page + 1)
                })

什么是requestAnimationFrame?

window.requestAnimationFrame() 告诉浏览器:

  • 你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
  • 该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
  • 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

上面概念太抽象了,那到底和setTimeout区别在哪?

用法其实跟 setTimeout 完全一致,只不过当前的时间间隔是跟着系统的绘制频率走,是固定的

优点在于:

  • 减少DOM操作
    requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率
  • 资源节能
    requestAnimationFrame 是由浏览器专门为动画提供的 API,在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了 CPU 开销
  • 函数节流
    在高频率事件( resize, scroll 等)中,为了防止在一个刷新间隔内发生多次函数执行,RequestAnimationFrame可保证每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销,一个刷新间隔内函数执行多次时没有意义的,因为多数显示器每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来。

setTimeout执行动画容易出现卡顿、抖动的现象。原因是:

  • settimeout任务被放入异步队列,只有当主线程任务执行完后才会执行队列中的任务,因此实际执行时间总是比设定时间要晚
  • settimeout的固定时间间隔不一定与屏幕刷新间隔时间相同,会引起丢帧。

requestAnimationFrame 采用 浏览器时间间隔 ,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,消耗性能;也不会因为间隔时间太长,造成动画卡顿不流畅

4. 优化三 DocumentFragments

之前的代码中每创建一个div就立刻插入到container中:

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

那么是否可以改成一次性添加到container中呢?通过DocumentFragments来实现:

        const renderList = async () => {
            const list = await getList();
            // 总数据条数
            const total = list.length;
            // 每页数据条数
            const limit = 200;
            // 总页数
            const totalPage = Math.ceil(total / limit);
            // 用于遍历的页数
            const page = 0;

            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 = 'flex'
                        div.innerHTML = `<img src="${item.src}" /><span>${item.text}</span>`
                        // 将div插入到文档片段
                        fragment.appendChild(div)
                    }
                    // 将文档片段插入到dom树
                    container.appendChild(fragment)
                    render(page + 1)
                })
            }
            render(page);
        }

DocumentFragments,这个又是啥?

  • DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。
  • 在DOM树中,文档片段被其所有的子元素所代替。因为文档片段存在于内存中,并不在DOM树中。
  • 所以将子元素插入到文档片段时不会引起回流,因此,使用文档片段通常会带来更好的性能。

5. 优化四 懒加载

别的博客有写~

不过那篇博客主要涉及的是组件懒加载,这种内容懒加载主要还是通过getBoundingClientRect或者IntersectionObserver
来监听位置,当内容进入到可视区后再加载。
具体实现之后有空补充,重点学习下面的虚拟列表。

6. 优化五 虚拟列表

6.1 子项定高

相对来说最合理的优化方法:只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能,虚拟列表其实是按需显示的一种实现。(参考)

和懒加载的区别就体现在当内容出了可视区后会被销毁,毕竟懒加载迟早还是会把所有内容加载下来,可能还是会导致交互的卡顿。

这里首先把完整的实现代码贴上来然后分析:

App.jsx

import VList from "./components/vlist";

let data = [];
for (let id = 0; id < 1000; id++) {
  data.push(id);
}

export default function App() {
  return (
    <div className="App">
      <VList list={data}></VList>
    </div>
  );
}

主页面,引入虚拟列表组件并且模拟1000条数据,将模拟的数据通过prop传递过去。

核心代码 Vlist.jsx

import React, { useMemo, useRef, useState } from "react";
import "./vlist.css";

export default function Vlist(props) {
    const { list = [] } = props;
    const viewport = useRef(null); // 可视区域
    const listArea = useRef(null); // 渲染区域
    const phantom = useRef(null); // 占位区域,列表总高度

    // 每项列表的高度
    const itemSize = 100;
    // 列表总高度
    const phantomHeight = list.length * itemSize;
    // 渲染数量
    const viewCount = 10;
    // 开始index
    const [startIndex, setstartIndex] = useState(0);
    // 结束index
    const endIndex = useMemo(() => startIndex + viewCount, [startIndex]);
    // 偏移量
    const [startOffset, setstartOffset] = useState(0);

    // 获取startIndex
    const getStartIndex = (scrollTop) => {
        return Math.floor(scrollTop / itemSize);
    };

    // 获取startOffset
    const getStartOffset = (startIndex) => {
        return startIndex * itemSize;
    };

    // 是否在可视区域内
    const isBetweenViewRanges = (index) => {
        return index >= startIndex && index <= endIndex;
    };

    /**
     * 获取滚动距离 scrollTop
     * 根据 scrollTop 和 itemSize 计算出 startIndex 和 endIndex
     * 根据 scrollTop 和 itemSize 计算出 startOffset
     * 显示startIndex 和 endIndex之间的元素
     * 设置listArea的偏移量为startOffset
     */
    const onScroll = () => {
        const scrollTop = viewport.current.scrollTop; // 滚动距离
        const startIndex = getStartIndex(scrollTop);
        setstartIndex(startIndex);

        const startOffset = getStartOffset(startIndex);
        setstartOffset(startOffset);
    };

    return (
        // 可视区
        <div className="viewport" ref={viewport} onScroll={onScroll}>
            {/* 占位区 用于显示滚动条 需脱离文档流 */}
            <div
                className="list-phantom"
                ref={phantom}
                style={{ height: `${phantomHeight}px` }}
            ></div>
            {/* 列表区 */}
            <div
                className="list-area"
                ref={listArea}
                style={{ transform: `translate3d(0,${startOffset}px,0)` }}
            >
                {list.map((item, index) =>
                    isBetweenViewRanges(index) && (
                        <div
                            key={item.id}
                            className="list-item"
                            style={{ height: itemSize + "px", lineHeight: itemSize + "px" }}
                        >
                            {item.id}
                        </div>
                    )
                )}
            </div>
        </div>
    );
}

具体逻辑看参考文章,这里写一下几个需要理解的点:

  1. 占位区的作用: 如果只渲染出可视区内的item那显然不足以把滚动条撑开实现持续滚动,需要有一个盒子将整个区域撑开,高度设置为列表总高度,便于模拟“滚动”的过程(实际上可视区内容是计算出来的)。但是这个盒子必须是“隐形”的,否则会占据文档流空间,因此通过设置绝对定位使得占位区盒子脱离文档流即可。
  2. translate3d: mdn,这里的作用就是移动y坐标而已(那么为啥不用translate呢,其实都一样,写法区别而已)
  3. startOffset: 这个偏移量的作用是让可视窗口移动到被渲染出来的数据的位置。因为实际上当前窗口中展示的数据内容是通过startIndex和endIndex被计算出来的,而非像传统的滚动那样滚出来的。因此必须要让可视窗口也移动到被计算出来的数据所在的位置。

6.2 子项不定高

不定高分为两种处理方式,其一是提前计算好子项的所有高度,然后当做定高来处理;其二是采取动态获取高度的方式来计算,也就是滚到某个位置时,动态计算其中子项的高度。

具体逻辑见参考文章,已经说得很清楚了,这里还是上代码然后分析:

VList.js

import React, { useEffect, useMemo, useRef, useState } from "react";

export default function Vlist(props) {
    const { list = [] } = props;
    const viewport = useRef(null); // 可视区域
    const listArea = useRef(null); // 渲染区域
    const phantom = useRef(null); // 占位区域,列表总高度

    // 预估高度
    const defaultItemSize = 100;
    // 记录列表项的位置信息
    const [positions, setpositions] = useState(
        list.map((item, index) => {
            return {
                index,
                height: defaultItemSize,
                top: index * defaultItemSize,
                bottom: (index + 1) * defaultItemSize
            };
        })
    );
    window.positions = positions;

    // 列表总高度
    const [phantomHeight, setphantomHeight] = useState(
        positions.reduce((total, item) => total + item.height, 0)
    );

    const viewCount = 10; // 渲染数量
    const [startIndex, setstartIndex] = useState(0); // 开始index
    // 结束index
    const endIndex = useMemo(() => startIndex + viewCount, [startIndex]);
    const [startOffset, setstartOffset] = useState(0); // 偏移量

    useEffect(() => {
        if (positions?.length) {
            const totalHeight = positions.reduce(
                (total, item) => total + item.height,
                0
            );
            setphantomHeight(totalHeight);
        }
    }, [positions]);

    // 测量高度
    const measure = (index, height) => {
        // 如果没有传入height,主动进行测量
        if (height === undefined) {
            height =
                listArea.current.querySelector(`[index="${index}"]`)?.clientHeight ||
                defaultItemSize;
        }

        positions.forEach((item) => {
            if (item.index === index) {
                let oldHeight = item.height;
                let dHeight = oldHeight - height;

                // 向下更新
                if (dHeight) {
                    item.height = height;
                    item.bottom = item.bottom - dHeight;

                    for (let k = index + 1; k < positions.length; k++) {
                        positions[k].top = positions[k - 1].bottom;
                        positions[k].bottom = positions[k].bottom - dHeight;
                    }
                }
            }
        });
        setpositions(positions);
    };

    // 获取startIndex 二分查找法
    const getStartIndex = (scrollTop) => {
        let item = positions.find((i) => i && i.bottom > scrollTop);
        return item.index;
    };

    // 获取startOffset
    const getStartOffset = (startIndex) => {
        return startIndex >= 1 ? positions[startIndex - 1].bottom : 0;
    };

    /**
     * 获取滚动距离 scrollTop
     * 根据 scrollTop 和 itemSize 计算出 startIndex 和 endIndex
     * 根据 scrollTop 和 itemSize 计算出 startOffset
     * 显示startIndex 和 endIndex之间的元素
     * 设置listArea的偏移量为startOffset
     */
    const onScroll = () => {
        const scrollTop = viewport.current.scrollTop; // 滚动距离
        const startIndex = getStartIndex(scrollTop);
        setstartIndex(startIndex);

        const startOffset = getStartOffset(startIndex);
        setstartOffset(startOffset);
    };

    return (
        <div className="viewport" ref={viewport} onScroll={onScroll}>
            <div
                className="list-phantom"
                ref={phantom}
                style={{ height: `${phantomHeight}px` }}
            ></div>
            <div
                className="list-area"
                ref={listArea}
                style={{ transform: `translate3d(0,${startOffset}px,0)` }}
            >
                {list.map(
                    (item, index) =>
                        index >= startIndex &&
                        index <= endIndex &&
                        props.children({
                            index,
                            item,
                            measure
                        })
                )}
            </div>
        </div>
    );
}

说一下和上面定高子项不同的点:

  1. 引入列表项的position这个state,position存储了每个字项的下标,高度,顶部和底部的位置。
  2. 由于子项不定高,因此列表总高度也必须在useEffect中依赖子项高度计算出来,并分配给占位div。
  3. 在measure函数中实现动态测量高度的功能,首先通过传入的高度参数/clientHeight/defaultItemSize得到子项的高度。随后判断该子项高度是否大于原本高度(注意上面代码里的dHeight显然是负数,所以bottom才会去减dHeight”,相当于bottom值增加,也就是下移),同时更新自身bottom的值与下方所有子项的top和bottom。
  4. 最后模版处的区别,这里在列表区留下了插槽,插槽装的内容下文讲解。

item.jsx

import React, { useEffect, useRef } from "react";

export default function Item(props) {
    const { index, measure } = props;
    const element = useRef(null);

    useEffect(() => {
        measureItem(index);
    }, []);

    // 这里负责纯文本的渲染 直接传clientHeight进去
    const measureItem = (index) => {
        const item = element.current;
        if (item?.clientHeight) {
            measure(index, item.clientHeight);
        }
    };

    return (
        <div index={index} className="list-item" ref={element}>
            {props.children}
        </div>
    );
}

这里主要负责文本内容的渲染,然后留下了一个插槽,插槽内容见下文。

App.jsx

import "./styles.css";
import VList from "./components/vlist";
import Item from "./components/item";
import faker from "faker";

let data = [];
for (let id = 0; id < 100; id++) {
  const item = {
    id,
    value: faker.lorem.paragraphs() // 长文本
  };

  if (id % 5 === 1) {
    item.src = faker.image.image();
  }
  data.push(item);
}

export default function App() {
  // 开启图片
  const enableImag = true;

  return (
    <div className="App">
      <VList list={data}>
        {({ index, item, measure }) => (
          <Item index={index} key={item.id} item={item} measure={measure}>
            <>
              {item.value}
              {enableImag && item.src && (
                <img src={item.src} onLoad={() => measure(index)} alt="" />
              )}
            </>
          </Item>
        )}
      </VList>
    </div>
  );
}

除了基本的插入id和长文本外,还通过依赖随机生成了图片,而图片则是放到刚刚item组件的插槽中,并且会动态计算出高度。

6.3 优化

6.3.1 IntersectionObserver

传统方法是通过监听scroll事件来判断子项的位置。然而太多的事件监听会导致性能和交互体验等各方面的问题,因此考虑通过IntersectionObserver这个api来进行子项的位置监听。

IntersectionObserver可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。

6.3.2 ResizeObserver

如果列表项中包含图片,并且列表高度由图片撑开,由于图片会发送网络请求,此时无法保证我们在获取列表项真实高度时图片是否已经加载完成,从而造成计算不准确的情况。
这种情况下,最好主动去监听子项的高度,这里用到ResizeObserver这个api。

// 监听高度变化
const observe = () => {
    const resizeObserver = new ResizeObserver(() => {
        // 获取当前列表项的高度
        const el = element.current;
        if (el && el.offsetHeight) {
            // 触发更新
            measure(index, el.offsetHeight);
        }
    });
    resizeObserver.observe(element.current);

    return () => resizeObserver.disconnect();
};

6.3.3 缓冲区

这个比较好理解,就是计算的时候上上下下多算几条作为缓冲,避免划太快的时候白屏。至于缓冲区的大小怎么规定就要看具体问题具体决定了。

  const endIndex = useMemo(
    () => Math.min(startIndex + viewCount + buffered, list.length),
    [startIndex, list.length]
  );

6.3.4 骨架屏

采用骨架屏代替原先不渲染的白屏部分,提升用户体验。

6.3.5 二分查找startIndex

显然positions数组是有序数组,因此可以通过二分查找来优化时间复杂度。先来看之前的,是直接通过find方法来寻找:

const getStartIndex = (scrollTop) => {
    let item = positions.find((i) => i && i.bottom > scrollTop);
    return item.index;
};

优化后可以通过二分算法直接寻找出index(二分算法这里就不展开了):

const getStartIndex = (scrollTop) => {
  let item = binarySearch(positions, scrollTop);
  return Math.max(0, item - buffered);
};

6.4 实习项目中的实际应用

有了上面的理论铺垫,实际应用起来其实就没那么难了,但是实际中和案例还是有一些区别:

  1. 上面的例子中是通过一个占位区div来撑开整个列表高度的,表格中显然不能用这个方法,因此需要固定表头,才能使得表格的内容形成滚动条。(固定表头时要注意自己调节列的宽度)

  2. 那么此时的startOffset也是直接加到表单数据区(可视区)即可