React原理 海量数据处理

requestIdleCallback

图中是浏览器每一帧的生命周期,requestIdleCallback则会在某一帧结束后的空闲时间或者用户处于不活跃状态时,处理我们的工作。

靠自己人工的安排不必要的工作是很困难的。比如,要弄清楚一帧剩余的时间,这显然是不可能的,因为当requestAnimationFrame的回调完成后,还要进行样式的计算,布局,渲染以及浏览器内部的工作等等。为了确保用户不以某种方式进行交互,你需要为各种交互行为添加监听事件(scroll、touch、click),即使你并不需要这些功能,只有这样才能绝对确保用户没有进行交互。另一方面,浏览器能够确切地知道在一帧的结束时有多少的可用时间,如果用户正在交互,通过使用requestIdleCallback这个API,允许我们尽可能高效地利用任何的空闲时间。

当 myNonEssentialWork 被调用,会返回一个 deadline 对象,这个对象包含一个方法,该方法会返回一个数字表示你的工作还能执行多长时间:

1
2
3
4
requestIdleCallback(function myNonEssentialWork (deadline) {
while (deadline.timeRemaining() > 0)
doWorkIfNeeded();
})

调用 timeRemaining 这个方法能获得最后的剩余时间,当 timeRemaining() 返回0,如果你仍有其他任务需要执行,可以在下一次空闲时间继续执行

1
2
3
4
5
6
7
function myNonEssentialWork (deadline) {
while (deadline.timeRemaining() > 0 && tasks.length > 0)
doWorkIfNeeded();

if (tasks.length > 0)
requestIdleCallback(myNonEssentialWork);
}

如果任务耗时太长,可能回调函数永远不能执行,requestIdleCallback有一个可选的第二个参数:含有timeout属性的对象。如果设置了timeout这个值,回调函数还没被调用的话,则浏览器必须在设置的这个毫秒数时,去强制调用对应的回调函数。

如果你的回调函数是因为设置的这个timeout而触发的,你会注意到:

timeRemaining()会返回0
deadline对象的didTimeout属性值是true

注意事项
  • 对非高优先级的任务使用空闲回调。 已经创建了多少回调,用户系统的繁忙程度,你的回调多久会执行一次(除非你指定了 timeout),这些都是未知的。不能保证每次事件循环(甚至每次屏幕更新)后都能执行空闲回调;如果事件循环用尽了所有可用时间,那可能你的任务永远不能执行。在你需要的时候要用 timeout,但记得只在需要的时候才用。 使用 timeout可以保证你的代码按时执行,但是在剩余时间不足以强制执行你的代码的同时保证浏览器的性能表现的情况下,timeout就会造成延迟或者动画不流畅。设置了timeout,如果回调中的任务也是个长时间任务,可能会导致影响用户交互。最好的办是确认任务足够小。

  • 空闲回调应尽可能不超支分配到的时间。尽管即使你超出了规定的时间上限,通常来说浏览器、代码、网页也能继续正常运行,这里的时间限制是用来保证系统能留有足够的时间去完成当前的事件循环然后进入下一个循环,而不会导致其他代码卡顿或动画效果延迟。目前,timeRemaining() 有一个50 ms 的上限时间,但实际上你能用的时间比这个少,因为在复杂的页面中事件循环可能已经花费了其中的一部分,浏览器的扩展插件也需要处理时间,等等。

  • 避免在空闲回调中改变 DOM。 空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局, 你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。 如果你的回调需要改变DOM,它应该使用Window.requestAnimationFrame()来调度它。

  • 避免运行时间无法预测的任务。 你的空闲回调必须避免做任何占用时间不可预测的事情。比如说,应该避免做任何会影响页面布局的事情。你也必须避免 执行Promise (en-US) 的resolve和reject,因为这会在你的回调函数返回后立即引用Promise对象对resolve和reject的处理程序。可能导致任务阻塞。

回退兼容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
window.requestIdleCallback = window.requestIdleCallback || function(handler) {
let startTime = Date.now();

return setTimeout(function() {
handler({
didTimeout: false,
timeRemaining: function() {
return Math.max(0, 50.0 - (Date.now() - startTime));
}
});
}, 1);
}

window.cancelIdleCallback = window.cancelIdleCallback || function(id) {
clearTimeout(id);
}
上报数据

有时我们希望,避免在用户交互行为发生的时候立即上报数据,思路就是将上报信息添加到队列中,在空闲的时间处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
var eventsToSend = [];

function onNavOpenClick () {
eventsToSend.push(
{
category: 'button',
action: 'click',
label: 'nav',
value: 'open'
});

schedulePendingEvents();
}

function schedulePendingEvents() {

// 如果已经在调度中则返回
if (isRequestIdleCallbackScheduled)
return;

isRequestIdleCallbackScheduled = true;

if ('requestIdleCallback' in window) {
// 最晚两秒之后上报数据
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
} else {
processPendingAnalyticsEvents();
}
}

function processPendingAnalyticsEvents (deadline) {

// 重置状态
isRequestIdleCallbackScheduled = false;

// 针对没有实现 requestIdleCallback 的环境
if (typeof deadline === 'undefined')
deadline = { timeRemaining: function () { return Number.MAX_VALUE } };

while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
var evt = eventsToSend.pop();

ga('send', 'event',
evt.category,
evt.action,
evt.label,
evt.value);
}

// 如果还有任务在下次空闲时发送
if (eventsToSend.length > 0)
schedulePendingEvents();
}

渲染分片

对于大量数据渲染,可以考虑利用浏览器空闲时间分片处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
const generateColor = () => {
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)';
}
const getRandomPos = (width,height,distance = 0) => ({
x: distance/2 + (width - distance/2) * Math.random(),
y: distance/2 + (height - distance/2) * Math.random(),
})
const Circle = ({position,color}) =>{
return <div style={{
width:10,
height:10,
borderRadius:"50%",
backgroundColor:color,
position:'absolute',
left:position.x,
top:position.y
}}></div>
}

const getKey =()=> Math.random().toString(36).substring(2,8)
const renderCircle = (count,width,height)=> new Array(count).fill('circle').map(()=>(
<Circle position={getRandomPos(width,height)} key={getKey()} color={generateColor()}/>
))

const calcRenderCount = (count,perRenderCount,times) => {
if(perRenderCount * times > count) return count % perRenderCount;
return perRenderCount;
}

export default function Magnanimity(){
const [list,setList] = useState([]);
const total = useRef(20000);
const throttleCount= useRef(500);

const timeSpliceRender = useCallback((renderTimes,currentTime,width,height)=>{
if(currentTime > renderTimes) return;
const task = () => {
const renderCount = calcRenderCount(total.current,throttleCount.current,currentTime);
setList(list=> list.concat(renderCircle(renderCount,width,height)))
currentTime++;
}

requestIdleCallback((deadline)=>{
while (deadline.timeRemaining()>0) {
task();
}
timeSpliceRender(renderTimes,currentTime,width,height);
})

},[setList,
total,
throttleCount
])

useEffect(()=>{
const currentTime = 1;
const renderTimes = Math.ceil(total.current/throttleCount.current);
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
timeSpliceRender(renderTimes,currentTime,width,height)

},[
timeSpliceRender,
total,
throttleCount
])

return <div
onClick={()=>console.log("click")}
style={{
width:document.documentElement.clientWidth,
height:document.documentElement.clientHeight,
}}>{list}</div>
}

长列表渲染

比较容易想到的思路是:

  • 计算出列表能容纳的个数。

  • 计算需要渲染列表的高度

  • 筛选能显示在列表中的元素,为了保证用户体验,靠近列表头部和尾部的元素也可以渲染出来。

  • 为每个列表元素设置偏移量

  • 当滚动列表时,重新筛选显示元素并设置偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function List({data}){
const [height,setHeight] = useState(0);
const [filterData,setFilterData] = useState([]);
const [scrollTop,setScrollTop] = useState(0);
const wrapperRef = useRef(null)

useEffect(()=>{
setHeight(()=> data.length * 40)
},[
data,
setHeight
])

useEffect(()=>{
const arr = [];
const boxHeight = wrapperRef.current.offsetHeight;

// 如果离列表头尾超过三个元素则不在显示
data.forEach((item,index) => {
if(index * 40 - 1 - scrollTop <= -80) return
if(index * 40 - 1 - boxHeight - scrollTop >= 80) return
console.log(item)
arr.push({
...item,
top: index * 40
})
});
setFilterData(arr);

},[
scrollTop,
data,
height,
setFilterData,
wrapperRef
])

const scroll = useCallback((e)=>{
// 滚动时重新筛选元素
setScrollTop(e.target.scrollTop)
},[setScrollTop])

if(!data || !Array.isArray(data)) return null;
return (
<div style={{
height:'100%',
width:'100%',
overflow:'auto'
}}
ref={wrapperRef}
onScroll={scroll}
>
<ul
style={{height,position:'relative'}}
>
{filterData.map(item => (
<li
key={item.key}
style={{
padding:'5px 4px',
position:'absolute',
left:0,
right:0,
top:item.top
}}
>
<div
style={{
background:'pink',
lineHeight:'30px',
height:'30px'
}}
>
{item.value}
</div>
</li>)
)}
</ul>
</div>
)
}

export default function Comp(){
const [data, setData] = useState(new Array(10).fill(1).map((item,index)=>({key:index,value:index})))
return <div style={{width:300,height:600,border:'1px solid #000'}}>
<List data={data}></List>
</div>
}

上面的实现存在几个问题:

  • 常量没有抽离,不能灵活配置

  • 每次滚动触发,需要遍历数据,性能损耗大

所以,可以优化为根据滚动高度计算出需要展示数据的区间,用transform变换待定绝对定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
function List({data=[]}){
const wrapperRef = useRef(null)
// 元素容器
const containerRef = useRef(null);
const [range,setRange] = useState([0,0])
const info = useRef({
// 屏幕外预渲染的个数
preRenderCount:2,
// 元素高度
itemHeight:40

})

useEffect(()=>{
const boxHeight = wrapperRef.current.offsetHeight;
const {itemHeight,preRenderCount} = info.current;

// 不需要关心data长度,如果截取的长度溢出,仍然会包含data中所有元素

const end = Math.ceil(boxHeight/itemHeight) + preRenderCount;
setRange([0,end]);

},[
info,
wrapperRef,
setRange
])

const scroll = useCallback((e)=>{
const scrollTop = e.target.scrollTop;
const boxHeight = wrapperRef.current.offsetHeight;

const {itemHeight,preRenderCount} = info.current;

// 顶部有两个缓冲元素,滚动高度还在这两个元素高度范围内,则前两个元素会被保留
const start = Math.floor(Math.max(0, scrollTop - preRenderCount * itemHeight) / itemHeight);

// 不需要关心data长度,如果截取的长度溢出,仍然会包含data中所有元素
const end = Math.ceil((scrollTop + boxHeight)/itemHeight) + preRenderCount;

// 计算内容区域偏移量 移动距离大于元素高度,重置视图框的位置
const offset = scrollTop - preRenderCount * itemHeight > 0 ? scrollTop - (scrollTop % itemHeight) - preRenderCount * itemHeight : 0;

containerRef.current.style.transform= `translate3D(0,${offset}px,0)`

setRange([start,end])

},[1
setRange,
wrapperRef,
containerRef
])

const {itemHeight} = info.current;
const height = useMemo(()=>data.length * itemHeight,[data,itemHeight]);
if(!data || !Array.isArray(data)) return null;
return (
<div style={{
height:'100%',
width:'100%',
overflow:'auto',
position:'relative'
}}
ref={wrapperRef}
onScroll={scroll}
>
<div style={{height}}></div>
{/* 注意:对ul列表的操作可能会触发上层滚动条的事件,所以仍然让他脱离文档流 */}
<ul
ref={containerRef}
style={{
position:'absolute',
top:0,
left:0,
right:0
}}
>
{data.slice(...range).map(item => (
<li
key={item.key}
style={{
height:itemHeight,
overflow:'hidden'
}}
>
<div
style={{
background:'pink',
lineHeight:'30px',
height:'30px',
margin:'5px 4px'
}}
>
{item.value}
</div>
</li>)
)}
</ul>
</div>
)
}

export default function Comp(){
const [data, setData] = useState(new Array(100).fill(1).map((item,index)=>({key:index,value:index})))
return <div style={{width:300,height:600,border:'1px solid #000'}}>
<List data={data}></List>
</div>
}
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2015-2025 SunZhiqi

此时无声胜有声!

支付宝
微信