处理海量数据
大约 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>
)
}