长列表渲染浅析

450 阅读5分钟

前言

实际工作中,大概率会碰到大量数据渲染,且无法进行分页的场景,称之为长列表。例如日志记录、交易流水、股票持仓。无论是从传统的 jQuery 到如今的框架繁荣,社区都已经有成熟的方案,然而停留在使用层面并不够,需要更深入的了解内部机制,才()能(yào)重复造轮子。

全量渲染弊端

即使面对海量数据,最初的抉择依然是全量渲染,将所有职责压在浏览器上,优点自然是实现简单,没有维护成本,简易实现如下:

  function render() {
    const list = new Array(10000).fill("");

    list.forEach(() => {
      const ele = document.createElement("div");

      ele.setAttribute("class", "alert alert-primary");
      // 文字内容使用  `fakerjs` 生成,暂且忽略
      ele.textContent = faker.lorem.words(5);

      contaienr.appendChild(ele);
    });
  }

一次性将 10000 条数据插入页面。操作过于粗暴,浏览器表现如下:

virtual-full-10000

初次插入操作,整体耗时 3749ms ,绝大部分事件消耗在 Layout 阶段,期间无法响应其他用户事件,用户体验极差。

即使能够容忍初始化阶段的长耗时,高节点数量,后续性能表现亦不佳:

image-20200811105916865

优化初始化阶段用户体验,考虑分批插入全量数据,将时间成本分散到不同帧:

  function renderByFrame() {
    list.slice(0, 20).forEach(() => {
      const ele = document.createElement("div");

      ele.setAttribute("class", "alert alert-primary");
      ele.textContent = faker.lorem.words(5);

      contaienr.appendChild(ele);
    });

    list = list.slice(0);

    if (list.length > 0) {
      requestAnimationFrame(renderByFrame);
    }
  }

分批量插入,Layout 成本分摊到单帧,执行期间依然可以响应用户操作。节点较少时,单帧耗时 20ms

virtual-request-animate-good

节点较多时,单帧耗时突破 70ms。

virtual-request-animate-worse

采取分批渲染方式,只能保证初始性能, 随插入节点增多,流畅度逐渐降低,大量节点对性能压力较大。使用不同的 API 执行插入操作,对性能影响非常有限,可以自行验证。

长列表实现的核心要点,在于控制节点数量,且不影响用户的正常操作。

可视区域渲染

可视区域渲染,意为只渲染用户可感知的区域,节点数量完全可控。本质上来说,可视区域渲染就是仿真,只要更新的足够快,用户并不知道你用了多少个节点,也没必要知道。

理论上来说,可视区域渲染能够支持不同数据,不同展现形式,实际开发中,一般需要满足以下条件:

  • 数据结构相似(渲染呈现相似)
  • 加载数据量较大

核心要点非常简单:选择合适的时机执行删除、插入操作。后续代码基于 Intersection Observer 实现。

固定高度场景

举例说明,假定条目高度固定为 100px,方便计算,创建节点实现如下:

export function renderItem(record: Record) {
  const ele = document.createElement('div');

  ele.setAttribute('class', 'alert alert-primary');

  ele.dataset.index = record.index;
  // 初始化触发标记
  ele.dataset.visible = 'pending';
  // 原始数据控制长度,保证容器高度固定
  ele.textContent = record.textContent;

  return ele;
}

用户操作包含上下滚动,处于可滚动状态时,可视区域外需要保留部分节点,作为滚动缓冲,不至于出现暂时空白。缓冲范围之外的节点,便可以直接删除。极限条件下,渲染数量比可见数量 +2 即可,实际开发中,建议适当增大。固定高度场景下,插入、删除节点可以同时进行,因此关键帧选用节点离开缓冲区事件。

本文缓冲节点设定为 1,方便画示意图。初始化时,需要插入足够节点满足初始条件:

export function bootstrap() {
  // 首次渲染,初始数量为可视数量加缓冲节点
  const length = Math.ceil(window.innerHeight / height) + buffer * 2;
  // slice 不包含尾标
  const frames = FixedRecords.slice(0, length + 1);

  // 更新范围标记
  head = 0;
  tail = length;

  frames.forEach((frame) => {
    container.appendChild(renderItem(frame));
  });
}

采用静态布局,增删节点,相对位置不发生变化,草图示意如下:

image-20200811100123947

可视区域边界以外,设置为 缓冲区回收区,条目自下向上完全离开缓冲后,删除该节点,并向后插入新节点。条目自上而下完全离开缓冲区后,向头部插入新节点,删除尾部节点。循环往复,实际表现贴近全量渲染。

  const bufferHeight = buffer * height;
  const options = {
    root: container,
    rootMargin: `${bufferHeight}px 0px`,
    threshold: 0,
  };

  const observer = new IntersectionObserver(observeCallback, options);
  const elements = document.querySelectorAll('.alert');

  elements.forEach((element) => {
    observer.observe(element);
  });

回调逻辑需要排除初始化干扰,且判断方向,完整逻辑实现如下:

const observeCallback: IntersectionObserverCallback = (entries, observer) => {
  // 初始化阶段过滤干扰
  const pristines = entries.filter(
    (entry) => (entry.target as HTMLElement).dataset.status === 'pending'
  );

  const changes = entries
    // 排除初始化干扰
    .filter((entry) => (entry.target as HTMLElement).dataset.status === 'ready')
    // 仅关注元素离开
    .filter((entry) => entry.intersectionRatio === 0);

  changes.forEach((entry) => {
    const direction =
      entry.boundingClientRect.bottom < entry.rootBounds!.top ? 'BTA' : 'ATB';

    // 从下至上离开观察区域,尾部插入节点,头部回收节点
    if (direction === 'BTA') {
      // 确认后续存在条目
      if (tail < lastIndex) {
        // 更新数据范围标记
        head += 1;
        tail += 1;

        const element = renderItem(FixedRecords[tail]);
        // 尾部插入新节点
        container.appendChild(element);
        // 新节点纳入观察
        observer.observe(element);

        // 取消观察待删除节点
        observer.unobserve(entry.target);
        // 删除旧节点
        container.removeChild(entry.target);
      }
    }
    // 从上至下离开观察区域,头部插入节点,尾部回收节点
    else {
      if (head > 0) {
        // 更新数据范围标记
        head -= 1;
        tail -= 1;

        const element = renderItem(FixedRecords[head]);
        // 尾部插入新节点
        container.insertBefore(element, container.firstChild);
        // 新节点纳入观察
        observer.observe(element);

        // 取消观察待删除节点
        observer.unobserve(entry.target);
        // 删除旧节点
        container.removeChild(entry.target);
      }
    }
  });

  pristines.forEach((entry) => {
    (entry.target as HTMLElement).dataset.status = 'ready';
  });
};

方案中包含 DOM 节点新建与删除,可以进一步优化:初始化之后,不再创建任何新节点,完全复用现存节点,将 DOM 节点新建与删除操作,调整为 DOM 节点移动与更新操作。

节点更新代码如下:

function patchItem(item, element) {
  element.dataset.index = item.index;
  element.textContent = item.textContent;

  return element;
}

移动逻辑相较于删除插入逻辑,改动部分如下:

    // 从下至上离开观察区域
    if (direction === 'BTA') {
      // 确认后续存在条目
      if (tail < lastIndex) {
        // 更新数据范围标记
        head += 1;
        tail += 1;

        // 回收头部节点到尾部
        container.appendChild(
          patchItem(FixedRecords[tail], container.firstChild as HTMLElement)
        );
      }
    }
    // 从上至下离开观察区域
    else {
      if (head > 0) {
        // 更新数据范围标记
        head -= 1;
        tail -= 1;

        // 回收尾部节点到头部
        container.insertBefore(
          patchItem(FixedRecords[head], container.lastChild as HTMLElement),
          container.firstChild
        );
      }
    }

效果如下:

Virtual List

关键帧如下:

image-20200805163426631

整体性能表现如下:

image-20200805163547574

高度固定条件下,实现虚拟列表逻辑较为简单。代码实现完全基于理想状况,距离生产环境使用还有提升空间。

可变高度场景

进一步考虑高度不固定场景,临界条件相似却有不同。固定高度场景下,插入删除可以同时进行,一增一补即可保持平衡。动态高度场景,一增一补无法保持平衡,插入删除执行时机不一致,需要分离逻辑,且缓冲区高度必须显式传入。

使用双观察者模式, Intersection Observer 参数重新标定:

  // 可视区域观察者,处理新增节点
  observerVisible = new IntersectionObserver(observeVisibleCallback, {
    root: container,
    threshold: 0,
  });

  // 缓冲区观察者,处理删除节点
  observerBeyond = new IntersectionObserver(observeBeyondCallback, {
    root: container,
    rootMargin: `${BUFFER_AREA_HEIGHT}px 0px`,
    threshold: 0,
  });

节点进入可视区域,逻辑尾部增添新节点,需要节点时进入方向,核心代码如下:

const observeVisibleCallback: IntersectionObserverCallback = (entries) => {
  enters.forEach((entry) => {
    // 从下而上进入缓冲区,尾部补充节点
    if (
      entry.boundingClientRect.top <= entry.rootBounds!.bottom &&
      entry.boundingClientRect.bottom >= entry.rootBounds!.bottom
    ) {
      replenishTailNodes();
    }
    // 从上而下进入缓冲区,头部补充节点
    else if (
      entry.boundingClientRect.top <= entry.rootBounds!.top &&
      entry.boundingClientRect.bottom >= entry.rootBounds!.top
    ) {
      replenishHeadNodes();
    } else {
      // 手速太快,不知如何处理
      // 保守估计,双重插入
      replenishHeadNodes();
      replenishTailNodes();
    }
  });

插入操作代码类似,以尾部插入节点为例:

function replenishTailNodes() {
  // 确认后续存在条目
  if (tail < lastIndex) {
    // 更新数据范围标记
    tail += 1;

    const element = renderItem(DynamicRecords[tail]);
    // 新节点纳入观察
    observerVisible.observe(element);
    observerBeyond.observe(element);
    // 尾部插入新节点
    container.appendChild(element);
  }
}

节点彻底离开缓冲区,则删除非必要节点。对于删除操作,不能仅关注 entry.target 节点,必须删除 entry.target 逻辑前置节点,通过 leave 事件状态,判断逻辑前置节点,核心代码如下:

const observeBeyondCallback: IntersectionObserverCallback = (entries) => {
  // 离开缓冲区立刻删除
  leaves.forEach((entry) => {
    const direction =
      entry.boundingClientRect.bottom <= entry.rootBounds!.top ? 'BTA' : 'ATB';

    if (direction === 'BTA') {
      recycleAboveNodes(entry);
    } else {
      recycleBelowNodes(entry);
    }
  });
};

删除头部操作核心代码如下:

function recycleAboveNodes(entry: IntersectionObserverEntry): void {
  const element = entry.target as HTMLElement;
  const elements: HTMLElement[] = [];

  let cursor = element.previousSibling as HTMLElement;

  while (cursor) {
    // queue
    elements.unshift(cursor);

    // loop
    cursor = cursor.previousSibling as HTMLElement;
  }

  elements.forEach((ele) => {
    // 更新游标
    head += 1;
    // 取消观察
    observerVisible.unobserve(ele);
    observerBeyond.unobserve(ele);

    if (container.contains(ele)) {
      // 删除节点
      container.removeChild(ele);
    }
  });
}

运行表现如下:

image-20200810171651455

此处采用直接增删操作,不建议采用节点回收方式,不再赘述。

细节说明

选用 Intersection Observer 并未如预期理想,尤其快速滚动场景下,需要特别考虑:**元素 enter 方向不确定, leave 方向确定,**以 threshold: 0 为例。

理想情况下,节点与观察区开始接触,事件低延迟触发,示意如下:

Enter Illustrate

实践中,滚动过快时,则无法判断节点从上方还是下方进入,依赖前后节点数量并不靠谱,依靠观察区边界距离判断亦不可靠,除非观察区远大于节点高度,事件触发最大延迟内,节点偏移距离小于一半观察区高度,可以考虑延时批量处理,根据后续可信事件,推定当前方向。关于事件触发延迟,暂未找到确切说明。

函数 recycleAboveNodes 内部循环,删除事件节点前置节点,而不能删除事件节点,示意草图如下:

Recycle Leaf

基于 Intersection Observer 存在其他思路,可能存在其他边界条件未考虑在内,引发潜在致命缺陷,生产环境建议使用成熟第三方库。文章涉及所有代码,github.com/huang-xiao-…

参考链接