跳至主要內容

处理海量数据

Mr.LRH大约 5 分钟

处理海量数据

根据 W3C 性能小组的介绍,超过 50ms 的任务就是长任务。

  • 0 - 16 ms : 人们特别擅长跟踪运动,如果动画不流畅,他们就会对运动心生反感。用户可以感知每秒渲染 60 帧的平滑动画转场。也就是每帧 16 毫秒(包括浏览器将新帧绘制到屏蒂上所需的时间),留给应用大约 10 毫秒的时间来生成一帧。
  • 0 - 100 ms : 在此时间窗口内响应用户操作,他们会觉得可以立即获得结果。时间再长,操作与反应之间的连接就会中断。
  • 100 - 300 ms : 用户会遇到轻微可觉察的延迟。
  • 300 - 1000 ms : 在此窗口内,延迟感觉像是任务自然和持续发展的一部分。对于网络上的大多数用户,加载页面或更改视图代表着一个任务。
  • 1000+ ms : 超过 1 秒,用户的注意力将离开他们正在执行的任务。
  • 10000+ ms : 用户感到失望,可能会放弃任务;之后他们或许不会再回来。

时间分片(Time Slicing)

时间分片本质是将长任务分割为一个个执行时间很短的任务,然后再一个个地执行。主要解决初次加载,一次性渲染大量数据造成的卡顿现象。浏览器执 JavaScript 速度要比渲染 DOM 速度快的多。

/* 获取随机颜色 */
function getColor() {
  const r = Math.floor(Math.random() * 255)
  const g = Math.floor(Math.random() * 255)
  const b = Math.floor(Math.random() * 255)
  return 'rgba(' + r + ',' + g + ',' + b + ',0.8)'
}

/* 获取随机位置 */
function getPostion(position) {
  const { width, height } = position
  return {
    position: 'absolute',
    left: Math.ceil(Math.random() * width) + 'px',
    top: Math.ceil(Math.random() * height) + 'px',
    width: '10px',
    height: '10px',
    borderRadius: '50%',
  }
}

/* 色块组件 */
function Circle({ position }) {
  const style = React.useMemo(() => {
    //用 useMemo 缓存,计算出来的随机位置和色值。
    return {
      background: getColor(),
      ...getPostion(position),
    }
  }, [])

  return <div style={style} />
}

class TimeSlicing extends React.Component {
  state = {
    dataList: [], // 数据源列表
    renderList: [], // 渲染列表
    position: { width: 0, height: 0 }, // 位置信息
    eachRenderNum: 500, // 每次渲染数量
  }

  box = React.createRef()

  componentDidMount() {
    const { offsetWidth, offsetHeight } = this.box.current
    const originList = new Array(20000).fill(1)

    /* 计算需要渲染次数*/
    const times = Math.ceil(originList.length / this.state.eachRenderNum)

    let index = 1

    this.setState(
      {
        dataList: originList,
        position: { height: offsetHeight, width: offsetWidth },
      },
      () => {
        this.toRenderList(index, times)
      }
    )
  }

  toRenderList = (index, times) => {
    /* 如果渲染完成,则退出 */
    if (index > times) return

    const { renderList } = this.state
    /* 通过缓存 Element 把所有渲染完成的 list 缓存下来,下一次更新,直接跳过渲染 */
    renderList.push(this.renderNewList(index))
    this.setState({ renderList })

    /* 用 requestIdleCallback 代替 setTimeout 浏览器空闲执行下一批渲染 */
    /* window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用 */
    window.requestIdleCallback(() => {
      this.toRenderList(++index, times)
    })
  }

  renderNewList(index) {
    /* 获取最新的渲染列表 */
    const { dataList, position, eachRenderNum } = this.state
    const list = dataList.slice(
      (index - 1) * eachRenderNum,
      index * eachRenderNum
    )

    return (
      <React.Fragment key={index}>
        {list.map((item, index) => (
          <Circle key={index} position={position} />
        ))}
      </React.Fragment>
    )
  }

  render() {
    return (
      <div
        ref={this.box}
        style={{ position: 'relative', width: '200px', height: '200px' }}
      >
        {this.state.renderList}
      </div>
    )
  }
}

虚拟列表

在移动端和 PC 端,通过滑动加载数据,如果未经处理,页面 DOM 元素随着数据量的增加会越来越多,会带来性能问题。

虚拟列表是一种长列表的解决方案,是按需显示的一种实现。即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

虚拟列表可划分为三个区域:视图区、缓冲区、虚拟区。

  • 视图区:展示数据列表,此区域元素为真实 DOM 元素。
  • 缓冲区:为防止用户上下滑动过程中,出现白屏等效果。此区域元素为真实 DOM 元素。
  • 虚拟区:不可视区域,不需要进行真实 DOM 元素渲染。

实现思路:

  • 通过 useRef 获取相关元素、缓存数据。
  • 使用 useEffect 初始化计算容器高度,并截取初始化列表长度。以预估高度进行子元素先行渲染,然后获取真实高度并缓存。并进行 div 占位,撑起滚动条高度。
  • 监听滚动容器滚动事件 scroll,根据 scrollTop 来计算渲染区域向上偏移量。注意,当用户向下滑动的时候,为了渲染区域,能在可视区域内,可视区域要向上滚动;当用户向上滑动的时候,可视区域要向下滚动。
  • 通过重新计算 start 和 end 来重新渲染列表。
import React, { useState, useRef, useEffect } from 'react'

function VirtualItem(props) {
  const { itemRender, cacheItemPosition, index } = props
  const virtualItemRef = useRef(null)

  useEffect(() => {
    cacheItemPosition(virtualItemRef.current, index)
  })

  return <div ref={virtualItemRef}>{itemRender}</div>
}

function VirtualList(props) {
  const { listData = [], itemRender, estimatedItemSize = 80 } = props

  const scrollRef = useRef(null) /* 获取 scroll 元素 */
  const phantomRef = useRef(null) /* 获取占位元素 */
  const contentRef = useRef(null) /* 获取内容元素 */

  const [range, setRange] = useState([0, 0])
  const [phantomHeight, setPhantomHeight] = useState(0)

  useEffect(() => {
    initCacheItemPositions()

    const { clientHeight } = scrollRef.current
    const start = 0
    const end = start + Math.ceil(clientHeight / estimatedItemSize)
    setRange([start, end])

    return () => {
      initCacheItemPositions()
    }
  }, [])

  // 缓存子元素位置信息:以预估高度先行渲染,然后获取真实高度并缓存。
  const cacheItemPositions = useRef([])
  // 以预估高度,初始化子元素位置信息
  const initCacheItemPositions = () => {
    cacheItemPositions.current = listData.map((item, index) => ({
      index,
      height: estimatedItemSize,
      top: index * estimatedItemSize,
      bottom: (index + 1) * estimatedItemSize,
    }))
  }
  // 获取元素真实高度,更新缓存位置信息
  const updateCacheItemPosition = (node, index) => {
    const rect = node.getBoundingClientRect()
    const itemPositions = cacheItemPositions.current
    let newHeight = rect.height
    let oldHeight = itemPositions[index].height
    let diffValue = oldHeight - newHeight

    if (diffValue) {
      itemPositions[index].bottom = itemPositions[index].bottom - diffValue
      itemPositions[index].height = newHeight

      for (let k = index + 1; k < itemPositions.length; k++) {
        itemPositions[k].top = itemPositions[k - 1].bottom
        itemPositions[k].bottom = itemPositions[k].bottom - diffValue
      }
    }
    setPhantomHeight(itemPositions[itemPositions.length - 1].bottom)
    setStartOffset()
  }

  // 二分法查找
  const binarySearch = (list, value) => {
    let start = 0
    let end = list.length - 1
    let tempIndex = null
    while (start <= end) {
      let midIndex = parseInt((start + end) / 2)
      let midValue = list[midIndex].bottom
      if (midValue === value) {
        return midIndex + 1
      } else if (midValue < value) {
        start = midIndex + 1
      } else if (midValue > value) {
        if (tempIndex === null || tempIndex > midIndex) {
          tempIndex = midIndex
        }
        end = end - 1
      }
    }
    return tempIndex
  }
  // 获取列表开始索引
  const getStartIndex = (scrollTop = 0) => {
    return binarySearch(cacheItemPositions.current, scrollTop)
  }
  // 设置当前的偏移量
  const setStartOffset = () => {
    const start = range[0]
    const itemPositions = cacheItemPositions.current
    let startOffset = start >= 1 ? itemPositions[start - 1].bottom : 0
    contentRef.current.style.transform = `translate3d(0, ${startOffset}px, 0)`
  }
  // 监听滚动事件
  const handleScroll = () => {
    const { scrollTop, clientHeight } = scrollRef.current

    const start = getStartIndex(scrollTop)
    const end = start + Math.ceil(clientHeight / estimatedItemSize)
    if (end !== range[1] || start !== range[0]) {
      /* 如果 render 内容发生改变,则进行截取  */
      setRange([start, end])
      setStartOffset()
    }
  }

  const [start, end] = range
  const renderList = listData.slice(start, end) /* 渲染区间 */
  return (
    <div
      ref={scrollRef}
      onScroll={handleScroll}
      style={{ height: '100%', position: 'relative', overflow: 'auto' }}
    >
      <div
        ref={phantomRef}
        style={{
          height: phantomHeight,
          position: 'absolute',
          left: 0,
          top: 0,
          right: 0,
          zIndex: -1,
        }}
      ></div>
      <div
        ref={contentRef}
        style={{ position: 'absolute', left: 0, top: 0, right: 0 }}
      >
        {renderList.map((item, index) => (
          <VirtualItem
            key={index}
            index={start + index}
            cacheItemPosition={updateCacheItemPosition}
            itemRender={itemRender(item)}
          />
        ))}
      </div>
    </div>
  )
}
import { faker } from '@faker-js/faker'

let data = []
for (let id = 0; id < 200; id++) {
  data.push({
    id,
    value: faker.lorem.sentences(), // 长文本
  })
}

function ExampleComponent() {
  return (
    <div style={{ width: '100%', height: '400px' }}>
      <VirtualList
        listData={data}
        itemRender={item => (
          <div style={{ padding: '10px', border: '1px solid' }}>
            <span
              style={{ color: 'red', fontWeight: 'bold', paddingRight: '5px' }}
            >
              {item.id}
            </span>
            {item.value}
          </div>
        )}
      />
    </div>
  )
}
上次编辑于:
贡献者: lingronghai