Next.js中使用时间分片、虚拟列表,提高大数据量(如6000000)情况下的渲染速度

1,945 阅读7分钟

引言

在前端开发中,性能优化尤为重要,尤其是在需要渲染大量数据的场景下。如果一次性渲染几十万条数据,可能会导致页面卡顿甚至崩溃。那么,如何高效地渲染这些数据呢?本篇文章将通过 时间分片虚拟列表 两种解决方案,帮助你轻松应对类似问题。


基础知识

JavaScript 是单线程的,代码的执行是按照 事件循环 的机制进行的:

  1. 同步代码:立即执行的代码。

  2. 异步代码:稍后再执行,比如定时器和请求。

  3. 事件循环顺序

    • 执行同步代码。
    • 检查异步任务队列,执行所有微任务。
    • 如果有需要,渲染页面。
    • 开始执行下一轮宏任务。

微任务:例如 Promise.then()async/awaitMutationObserver 等。

宏任务:包括 setTimeoutsetIntervalUI 渲染 等。


方法一:时间分片

核心思想:将一个大任务拆成许多小任务,分多次渲染,避免主线程阻塞。

实现方式:使用 setTimeout

每次只渲染一部分数据,通过定时器将任务分批处理。

代码示例(Next.js + TypeScript):

import { useEffect, useRef, useState } from 'react';

const VirtualList = () => {
  const total = 100000; // 总数据条数
  const once = 20;      // 每次渲染条数
  const page = total / once;
  const [index, setIndex] = useState(0);
  const containerRef = useRef<HTMLUListElement>(null);

  useEffect(() => {
    const loop = (curTotal: number, curIndex: number) => {
      const pageCount = Math.min(once, curTotal);

      setTimeout(() => {
        if (containerRef.current) {
          const fragment = document.createDocumentFragment();
          for (let i = 0; i < pageCount; i++) {
            const li = document.createElement('li');
            li.innerText = `${curIndex + i}: ${Math.random()}`;
            fragment.appendChild(li);
          }
          containerRef.current.appendChild(fragment);

          if (curTotal > pageCount) {
            loop(curTotal - pageCount, curIndex + pageCount);
          }
        }
      }, 0);
    };

    loop(total, index);
  }, [index]);

  return <ul ref={containerRef}></ul>;
};

export default VirtualList;

效果:让浏览器分多次渲染,每次只绘制 20 条数据,从而避免页面卡顿。

优化方式:requestAnimationFrame + document.createDocumentFragment

requestAnimationFrame 更符合浏览器的刷新节奏,配合 document.createDocumentFragment 批量更新 DOM,进一步提升性能。

代码示例(Next.js + TypeScript):

import { useEffect, useRef, useState } from 'react';

const VirtualList = () => {
  const total = 100000;  // 总数据条数
  const once = 20;       // 每次渲染条数
  const page = total / once;
  const [index, setIndex] = useState(0);
  const containerRef = useRef<HTMLUListElement>(null);

  useEffect(() => {
    const loop = (curTotal: number, curIndex: number) => {
      const pageCount = Math.min(once, curTotal);

      requestAnimationFrame(() => {
        if (containerRef.current) {
          const fragment = document.createDocumentFragment();
          for (let i = 0; i < pageCount; i++) {
            const li = document.createElement('li');
            li.innerText = `${curIndex + i}: ${Math.random()}`;
            fragment.appendChild(li);
          }
          containerRef.current.appendChild(fragment);

          if (curTotal > pageCount) {
            loop(curTotal - pageCount, curIndex + pageCount);
          }
        }
      });
    };

    loop(total, index);
  }, [index]);

  return <ul ref={containerRef}></ul>;
};

export default VirtualList;

优势

  • requestAnimationFrame 确保渲染与浏览器刷新率匹配。
  • document.createDocumentFragment 减少回流和重绘,提高性能。

方法二:虚拟列表

核心思想:只渲染可见区域的数据,而非一次性渲染所有数据。

实现步骤:

  1. 初始化:设置一个固定高度的容器和数据源。
  2. 计算可见区域:通过容器高度和单个数据项高度,计算当前可见的数据条数。
  3. 监听滚动事件:实时更新需要渲染的起始和结束数据索引。
  4. 动态渲染:只更新当前视口内的 DOM 元素,减少渲染开销。

代码示例(Next.js + TypeScript):

import { useEffect, useRef, useState } from 'react';

type Item = { id: number, value: string };

const VirtualList = () => {
  const itemHeight = 50;  // 每个列表项的高度
  const containerHeight = 400; // 可见区域的高度
  const listData = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    value: `Item ${i}`,
  }));

  const [start, setStart] = useState(0);
  const [offset, setOffset] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const visibleData = listData.slice(start, start + visibleCount);
  const listHeight = listData.length * itemHeight;

  const onScroll = () => {
    const scrollTop = containerRef.current?.scrollTop || 0;
    setStart(Math.floor(scrollTop / itemHeight));
    setOffset(scrollTop - (scrollTop % itemHeight));
  };

  useEffect(() => {
    if (containerRef.current) {
      containerRef.current.addEventListener('scroll', onScroll);
    }
    return () => {
      if (containerRef.current) {
        containerRef.current.removeEventListener('scroll', onScroll);
      }
    };
  }, []);

  return (
    <div
      ref={containerRef}
      style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
    >
      <div style={{ height: listHeight }} />
      <div
        style={{
          position: 'absolute',
          top: offset,
          left: 0,
        }}
      >
        {visibleData.map(item => (
          <div
            key={item.id}
            style={{
              height: itemHeight,
              lineHeight: `${itemHeight}px`,
              textAlign: 'center',
              borderBottom: '1px solid #ccc',
            }}
          >
            {item.value}
          </div>
        ))}
      </div>
    </div>
  );
};

export default VirtualList;

效果:仅加载可视区域内的数据,滚动时动态更新 DOM,节省资源。


总结

  • 时间分片虚拟列表 是提升大数据量渲染性能的两大核心方法。
  • 在 Next.js 中,我们通过合理使用 setTimeoutrequestAnimationFramedocument.createDocumentFragment,可以有效避免主线程阻塞。
  • 虚拟列表技术能将仅渲染可见区域的数据,极大降低渲染负担,提高性能。

这种多维度的优化策略可以显著提高大数据量下的前端渲染性能,确保应用在复杂交互和大量数据情况下仍能保持流畅。


进一步优化技术方法

懒加载(Lazy Loading)

核心思想:懒加载是指仅在数据接近视口时才加载新的数据,而不是一次性加载所有数据。结合虚拟列表的技术,可以进一步减少不必要的渲染和数据加载。

在无限滚动的场景下,懒加载能有效减轻页面负担,并提高性能。

实现方式

  • 监听滚动事件,当滚动接近底部时加载更多数据。
  • 动态加载组件,避免初始渲染时加载不必要的组件。

代码示例(Next.js + TypeScript)

import { useEffect, useRef, useState } from 'react';

type Item = { id: number, value: string };

const VirtualListWithLazyLoading = () => {
  const itemHeight = 50;  // 每个列表项的高度
  const containerHeight = 400; // 可见区域的高度
  const [listData, setListData] = useState<Item[]>([]);  // 列表数据
  const [loading, setLoading] = useState(false);  // 是否在加载数据
  const containerRef = useRef<HTMLDivElement>(null);

  const totalItems = 1000;  // 假设总数据量为1000条
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const listHeight = totalItems * itemHeight;

  // 加载更多数据的函数
  const loadData = () => {
    if (loading) return;
    setLoading(true);

    // 假设我们每次加载20条数据
    setTimeout(() => {
      const newData = Array.from({ length: 20 }, (_, i) => ({
        id: listData.length + i,
        value: `Item ${listData.length + i}`,
      }));
      setListData(prevData => [...prevData, ...newData]);
      setLoading(false);
    }, 1000); // 模拟延迟
  };

  // 滚动事件监听函数
  const onScroll = () => {
    const scrollTop = containerRef.current?.scrollTop || 0;
    const scrollHeight = containerRef.current?.scrollHeight || 0;
    const clientHeight = containerRef.current?.clientHeight || 0;

    if (scrollTop + clientHeight >= scrollHeight - 50) {
      loadData();
    }
  };

  useEffect(() => {
    if (containerRef.current) {
      containerRef.current.addEventListener('scroll', onScroll);
    }

    // 初始加载数据
    loadData();

    return () => {
      if (containerRef.current) {
        containerRef.current.removeEventListener('scroll', onScroll);
      }
    };
  }, [listData, loading]);

  return (
    <div
      ref={containerRef}
      style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
    >
      <div style={{ height: listHeight }} />
      <div style={{ position: 'absolute', top: 0, left: 0 }}>
        {listData.map(item => (
          <div
            key={item.id}
            style={{
              height: itemHeight,
              lineHeight: `${itemHeight}px`,
              textAlign: 'center',
              borderBottom: '1px solid #ccc',
            }}
          >
            {item.value}
          </div>
        ))}
      </div>
      {loading && <div>Loading...</div>}
    </div>
  );
};

export default VirtualListWithLazyLoading;

优势

  • 数据只有在需要时加载,避免一次性加载过多数据。
  • 配合虚拟列表,可以进一步减少 DOM 渲染量。

Web Workers

核心思想:JavaScript 是单线程的,所有计算任务都会阻塞主线程,导致页面的渲染卡顿。通过使用 Web Workers,可以将数据处理任务移到后台线程执行,确保主线程不被阻塞,从而提高应用响应速度。

实现方式

  1. 将繁重的计算任务移至 Web Worker 中。
  2. 通过 postMessageonmessage 进行主线程和 Worker 之间的通信。

代码示例(Next.js + TypeScript)

  1. 创建 Web Worker 脚本(worker.js)
// worker.js
self.onmessage = function (event) {
  const { start, end, data } = event.data;
  const result = data.slice(start, end); // 可以做更复杂的数据处理
  self.postMessage(result);  // 将结果发送回主线程
};
  1. 在 Next.js 项目中使用 Web Worker
import { useEffect, useRef, useState } from 'react';

const WorkerBasedDataProcessing = () => {
  const [data, setData] = useState<number[]>([]);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    // 创建 Worker
    workerRef.current = new Worker(new URL('./worker.js', import.meta.url));

    workerRef.current.onmessage = (e) => {
      setData(e.data); // 从 Worker 中接收数据并更新视图
    };

    return () => {
      if (workerRef.current) {
        workerRef.current.terminate();
      }
    };
  }, []);

  const loadDataInChunks = (start: number, end: number) => {
    const allData = Array.from({ length: 1000 }, (_, i) => i); // 假设这是大数据集
    if (workerRef.current) {
      workerRef.current.postMessage({ start, end, data: allData });
    }
  };

  const handleLoadData = () => {
    loadDataInChunks(0, 100);  // 加载前100条数据
  };

  return (
    <div>
      <button onClick={handleLoadData}>Load Data</button>
      <ul>
        {data.map(item => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
};

export default WorkerBasedDataProcessing;

解释

  • worker.js 是一个独立的 JavaScript 文件,它接收主线程传来的数据,进行处理并返回结果。
  • 主线程创建 Web Worker,并通过 postMessage 向 Worker 发送数据,Worker 进行处理后通过 onmessage 返回结果。
  • 在 Next.js 中使用 Web Worker 时,new URL('./worker.js', import.meta.url) 用于正确地解析 Worker 的路径。

优势

  • Web Worker 可以让数据处理在后台线程进行,避免主线程阻塞,提升性能。
  • 可以将大数据的计算任务(如排序、过滤)移至 Worker,保持 UI 的流畅性。

结合懒加载与 Web Worker

结合懒加载和 Web Worker,可以进一步优化性能:

  • 主线程负责视图渲染、懒加载和虚拟列表的显示。
  • Web Worker 在后台处理数据,如排序或过滤。
  • 数据处理完成后,主线程更新视图,减少卡顿。

代码示例

import { useEffect, useRef, useState } from 'react';

const VirtualListWithWorkerAndLazyLoading = () => {
  const itemHeight = 50;  // 每个列表项的高度
  const containerHeight = 400; // 可见区域的高度
  const [listData, setListData] = useState<number[]>([]);
  const [loading, setLoading] = useState(false);  // 是否在加载数据
  const containerRef = useRef<HTMLDivElement>(null);
  const workerRef = useRef<Worker | null>(null);

  const totalItems = 1000;  // 假设总数据量为1000条
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const listHeight = totalItems * itemHeight;

  // 初始化 Web Worker
  useEffect(() => {
    workerRef.current = new Worker(new URL('./worker.js', import.meta.url));
    workerRef.current.onmessage = (e) => {
      setListData(prevData => [...prevData, ...e.data]);
    };

    return () => {
      if (workerRef.current) {
        workerRef.current.terminate();
      }
    };
  }, []);

  const loadData = () => {
    if (loading) return;
    setLoading(true);

    // 假设每次加载20条数据
    setTimeout(() => {
      const newData = Array.from({ length: 20 }, (_, i) => listData.length + i);
      if (workerRef.current) {
        workerRef.current.postMessage({ start: listData.length, end: listData.length + 20, data: newData });
      }
      setLoading(false);
    }, 1000);
  };

  const onScroll = () => {
    const scrollTop = containerRef.current?.scrollTop || 0;
    const scrollHeight = containerRef.current?.scrollHeight || 0;
    const clientHeight = containerRef.current?.clientHeight || 0;

    if (scrollTop + clientHeight >= scrollHeight - 50) {
      loadData();
    }
  };

useEffect(() => { if (containerRef.current) { containerRef.current.addEventListener('scroll', onScroll); }

loadData();  // 初始加载数据

return () => {
  if (containerRef.current) {
    containerRef.current.removeEventListener('scroll', onScroll);
  }
};

}, [listData, loading]);

return ( <div ref={containerRef} style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }} > <div style={{ height: listHeight }} /> <div style={{ position: 'absolute', top: 0, left: 0 }}> {listData.map(item => ( <div key={item} style={{ height: itemHeight, lineHeight: `${itemHeight}px`, textAlign: 'center', borderBottom: '1px solid #ccc', }} > Item {item} ))} {loading && Loading...} ); };

export default VirtualListWithWorkerAndLazyLoading;

最终优化方案总结

  • 虚拟列表:通过只渲染可视区域的数据,显著减少需要渲染的 DOM 元素数量。
  • 时间分片:使用 requestAnimationFramesetTimeout 将渲染任务分解,避免单次渲染的阻塞。
  • 懒加载:只在需要时加载数据,减少不必要的渲染。
  • Web Workers:将耗时的计算移至后台线程,确保主线程流畅地渲染 UI。

通过这些多维度的优化方法,可以有效提升大数据量渲染的性能,确保应用在复杂交互和大量数据情况下保持流畅。