Published on

渲染

Authors
  • avatar
    Name
    Reeswell
    Twitter

一次性渲染大量数据不卡顿

原理: 时间换空间,使用 requestIdleCallback 或者 requestAnimationFrame 调度渲染。 缺点: 大量dom需要占用较多内存

<body>
  <ul id="list"></ul>

  <script>
    function render() {
      const FRAME_TIME = 1000 / 60; // 定义每帧的时长
      const TOTAL_ITEMS = 100000; // 总条数
      const ITEMS_PER_RENDER = 20; // 每次渲染20条
      const RENDER_COUNT = TOTAL_ITEMS / ITEMS_PER_RENDER;
      const list = document.getElementById('list');
      let renderedCount = 0;

      function renderItem(deadline) {
        if (deadline ? deadline.timeRemaining() > 0 : true) {
          // 1. 使用文档碎片优化一次插入优化性能
          const fragment = document.createDocumentFragment();
          for (let i = 0; i < ITEMS_PER_RENDER; i++) {
            const item = document.createElement('li');
            item.innerText = Math.floor(Math.random() * TOTAL_ITEMS);
            fragment.appendChild(item);
          }
          list.appendChild(fragment);
          renderedCount += 1;
        }
        loop();
      }

      function loop() {
        //  使用 requestIdleCallback 或者 requestAnimationFrame 函数循环渲染 li 元素,避免卡顿
        if (renderedCount < RENDER_COUNT) {
          // 2. 兼容性处理,使用 requestIdleCallback 或 requestAnimationFrame
          if ('requestIdleCallback' in window) {
            window.requestIdleCallback(renderItem);
          } else {
            scheduleAnimationTask(renderItem);
          }
        }
      }

      function run() {
        if ('requestIdleCallback' in window) {
          window.requestIdleCallback(renderItem);
        } else {
          scheduleAnimationTask(renderItem);
        }
      }
      function scheduleAnimationTask(callback) {
        let startTime = Date.now();
        requestAnimationFrame(() => {
          if (Date.now() - startTime < FRAME_TIME) {
            callback();
          } else {
            scheduleAnimationTask(callback);
          }
        });
      }
      run();
    }

    render()

  </script>
</body>

虚拟列表实现

原理:按需渲染, 只渲染视口范围内可见的元素。 步骤:

  • 初始化:每一项高度预估一个默认值,用一个元素如这里的scrollHold元素撑开滚动条高度。
  • 根据滚动高度确定视口元素的起始位置start,计算视口的区间元素位置positions[start,end]
  • 根据start计算滚动内容容器的偏移量currentOffset利用css的transform设置滚动内容容器的偏移量
  • 渲染后计算实际内容高度记录下来,更新虚拟滚动条内容高度。

TODO:

  • 使用骨架屏作为缓冲区。
  • 算法上的优化缓存高度等。
  • 滚动获取数据,请求过程中显示骨架屏。数据渲染显示数据隐藏骨架屏。
  <style>
    /* 列表项样式 */
    .list-item {
      list-style: none;
      background-color: #fc4838;
      padding: 10px 20px;
      color: #fff;
      height: 50px;
      display: flex;
      justify-content: center;
      box-sizing: border-box;
      align-items: center;
      margin: 10px 24px;
      font-weight: bold;
      border-radius: 10px;
    }

    .app {
      height: 80vh;
      padding: 0;
      margin-top: 50px;
      overflow: hidden;
    }

    .list-box {
      position: fixed;
      left: 0;
      top: 60px;
      right: 0;
      bottom: 0;
    }

    /* 滚动占位元素样式 */
    .scroll-hold {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
    }

    .scroll-box {
      height: 70vh;
      overflow-y: auto;
      position: relative;
    }
  
  </style>

  <body>
  <div class="app" id="box">
    <div id="scroll" class="scroll-box" onscroll="handleScroll()">
      <div class="scroll-hold" id="scrollHold"></div>
      <div class="skeleton-box" id="skeletonBox"></div> <!--  TODO: -->
      <div class="context" id="context"></div>
    </div>
  </div>
  <script>
    let dataList = []; // 数据列表
    let position = [0, 0]; // 当前渲染列表项的起始位置和结束位置
    const scroll = document.getElementById('scroll'); // 滚动容器
    const context = document.getElementById('context'); // 实际内容
    const scrollHold = document.getElementById('scrollHold'); // 滚动内容容器,用来撑开滚动条
    const scrollInfo = { // 滚动相关信息
      height: 500, // 滚动容器高度
      bufferCount: 4, // 缓冲区数量
      defaultItemSize: 60, // 列表项高度
      renderCount: 0, // 当前渲染的列表项数量
    };
    function run() {
      const height = scroll.offsetHeight; // 获取滚动容器高度
      const { defaultItemSize, bufferCount } = scrollInfo;
      const renderCount = Math.ceil(height / defaultItemSize) + bufferCount; // 计算每次渲染的列表项数量
      scrollInfo.renderCount = renderCount;
      let length = 1000
      dataList = new Array(length).fill(1).map((item, index) => {
        return {
          id: index + 1,
          height: defaultItemSize,
        }
      }); // 初始化数据列表
      const initScrollHold = dataList.length * (defaultItemSize)

      scrollHold.style.height = `${initScrollHold}px`; // 设置滚动内容容器的高度
      setDataList(dataList); // 设置数据列表
      setPosition([0, renderCount]); // 设置当前渲染的列表项的位置
    }

    function handleScroll() {
      const { scrollTop } = scroll; // 获取滚动条距离滚动容器顶部的距离
      const { defaultItemSize, renderCount } = scrollInfo;
      let start = 0
      let accumulatedHeight = 0
      for (let i = 0; i < dataList.length; i++) {
        accumulatedHeight += dataList[i].height;
        if (accumulatedHeight > scrollTop) {
          start = i;
          break
        }
      }
      let currentOffset = 0
      for (let j = 0; j < start; j++) {
        currentOffset += dataList[j].height;
      }
      context.style.transform = `translate3d(0, ${currentOffset}px, 0)`; // 设置滚动内容容器的偏移量
      const end = Math.min(start + renderCount + 1, dataList.length)
      if (end !== position[1] || start !== position[0]) { // 判断当前渲染的列表项位置是否发生改变
        setPosition([start, end]); // 设置渲染列表项的起始位置和结束位置
      }
    }

    function renderList() {
      const [start, end] = position;
      const renderList = dataList.slice(start, end); // 获取需要渲染的列表项
      context.innerHTML = renderList
        .map((item, index) => `<div class="list-item" key=${item.id} style="height: ${getRandomHeight()}px">${item.id} Item </div>`) // 渲染列表项
        .join('');
      // 渲染后 获取每个列表项的实际高度
      const listItems = document.getElementsByClassName('list-item');
      for (let i = start; i < end; i++) {
        const height = listItems[i - start].offsetHeight
        dataList[i].height = height;
      }
      updateData()
    }
    function updateData() {
      const height= dataList.reduce((pre,cur)=> pre+cur.height,0)
      scrollHold.style.height = `${height}px`; // 设置虚拟滚动内容容器的高度
    }
    function getRandomHeight() {
      return Math.floor(Math.random() * 31) + 30
    }
    function setDataList(data) {
      dataList = data;
    }
    function setPosition(pos) {
      position = pos;
      renderList();
    }
    run();
  </script>
</body>