React.useEffect 的效果,为什么还跟帧率有关? bug 到底出在哪里?

2022-11-10 12:56:44 +08:00
 sillydaddy

下面是最小 demo ,逻辑很简单:count 随着鼠标滚轮滚动,不断增加++,通过{divs}渲染出 count 。

const useWheel = (count:number, setCount)=>{
    let refDiv = useRef<HTMLDivElement>(null);
    useEffect(()=>{
        let el = refDiv.current;
        let callback = (ev:WheelEvent)=>{
            ev.preventDefault();
            setCount(count+Math.floor(Math.abs(ev.deltaY/5)));
        };
        el.addEventListener("wheel", callback, {passive:false});
        return (()=>{el.removeEventListener('wheel', callback);});
    }, [count]);
    return refDiv;
}

export const Main = (props)=>{
    let [count, setCount] = useState<number>(0);
    let wheel = useWheel(count, setCount);
    let divs = (new Array<number>(25000)).fill(0).map((v,i)=>(<div key={i+count}>{i+count}</div>));
    return (<div ref={wheel}>
                {divs}
            </div> );
}

但实际上,渲染的 count 会有「忽增忽减」的现象,感觉不正常。而如果把 25000 条改为 50 条,渲染帧率提高,就不会有同样的问题了。

上面的例子里,useEffect 是依赖 count 的,按我的理解,count 更新会触发 useEffect 调用。而 useEffect 里面用到的 count 就是最新的值,那为什么结果会「忽增忽减」呢?

修改 setCount 为函数形式,好像就解决这个问题了。

setCount((pre)=>pre+Math.floor(Math.abs(ev.deltaY/5)));

但我不理解这里的逻辑:为什么改之前的会有问题呢?难道 useEffect 用的不是最新的 count 吗?

4110 次点击
所在节点    React
54 条回复
daysv
2022-11-10 16:05:57 +08:00
addEventListener 依赖于 count, 每次还在事件里动态改变 count, 哪有这么写代码的
luvxy
2022-11-10 16:58:15 +08:00
el.addEventListener("wheel", callback, {passive:false});
sillydaddy
2022-11-10 17:18:01 +08:00
@morelearn1990 #17 > “来用 vue3”
vue 是基于 MVVM 吧,虽然我把 react 和 vue 都当作黑盒来用,但 react 的黑盒似乎更透明些,vue(还有 mobx)有点 magic 的意味。假如遇到性能问题,还是 react 更好调试和优化。

@serco #18
是的,用 useRef 也可以解决,我再去学习学习。。
Envov
2022-11-10 17:20:30 +08:00
这就是 Race Condition 吧,callback 加锁就好了
rabbbit
2022-11-10 17:32:01 +08:00
为啥 setCount 顺序会乱,react 的 setState 是无序的吗?
Envov
2022-11-10 18:01:54 +08:00
@Envov 加锁后确实好了
```
const useWheel = (count: number, setCount:any) => {
const refDiv = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = refDiv.current;
let called = false;
const callback = (ev: WheelEvent) => {
ev.preventDefault();
// console.log(called, count, ev.deltaY);
if (!called) {

const beadd = Math.floor(Math.abs(ev.deltaY / 5));
if (beadd){
called = true;
}
setCount(count + Math.floor(Math.abs(ev.deltaY / 5)));
}
};
el!.addEventListener("wheel", callback, { passive: false });
return () => {
called = false;
el!.removeEventListener("wheel", callback);

};
}, [count]);
return refDiv;
};
```
myl0204
2022-11-10 18:14:12 +08:00
尽量让 react 帮你处理一切。


```tsx
export const Main = (props)=>{
let [count, setCount] = React.useState<number>(0);
const onWheel = (ev: WheelEvent) => {
ev.preventDefault();
setCount(count + Math.floor(Math.abs(ev.deltaY / 5)));
};
let divs = new Array<number>(25000)
.fill(0)
.map((v, i) => <div key={i + count}>{i + count}</div>);
return <div onWheel={onWheel}>{divs}</div>;
}
```
skies457
2022-11-10 21:30:11 +08:00
react 垃圾,来用 solid (逃
ruxuan1306
2022-11-10 23:11:52 +08:00
楼主代码大概是 Effect 异步顺序导致旧渲染帧的 Effect 后执行了。
楼上那篇超长博文很有价值阅读。
监听器一般只设置一次。


https://codesandbox.io/s/fervent-taussig-x3mdkq?file=/src/App.jsx

```
import { useEffect, useState, useRef, useCallback } from "react";

import "./styles.css";

const useWheel = (divRef, count, setCount) => {
const countRef = useRef(0);
countRef.current = count; // 每次 count 变化存入所有渲染帧共享的空间

const callback = useCallback(
// cb 的闭包被冻结,但可以通过所有渲染帧共享的 ref 获取变化
(ev) => {
ev.preventDefault();
setCount(countRef.current + Math.floor(Math.abs(ev.deltaY / 5)));
},
[setCount]
);

useEffect(() => {
const el = divRef.current; // useEffect 是协调之后异步触发,此时 divRef 必然已被填充
if (!el) return; // 除非该节点渲染错误
el.addEventListener("wheel", callback, { passive: false }); // 仅设置一次监听器
return () => el.removeEventListener("wheel", callback); // 仅取消一次监听器
}, [divRef, callback]);
};

export const Main = (props) => {
let [count, setCount] = useState(0);
let divs = new Array(25000)
.fill(0)
.map((v, i) => <div key={i + count}>{i + count}</div>);

const refDiv = useRef(null);
useWheel(refDiv, count, setCount); // ref 作为参数传入语义上更好
return <div ref={refDiv}>{divs}</div>;
};

export default function App() {
return (
<div className="App">
<Main />
</div>
);
}
```
ruxuan1306
2022-11-10 23:34:23 +08:00
对于 React 每一个渲染帧:
1State 变化触发 React 虚拟 DOM 渲染,整个虚拟 DOM 树渲染完毕后,启动协调(对齐虚拟 DOM 到浏览器 DOM ),协调完毕后,开始异步执行上一帧 Effect 的析构函数,最后再执行本帧所有的 Effect 函数。

注意到了吗,因为本帧的 Effect 回收函数实际在下一帧前执行,也就是在上一帧协调完成的时刻,虽然上一帧的 State 已经渲染上屏用户可见了,但此时上一帧 Effect 的析构函数还没执行,注册在浏览器的还是上一帧的 cb 函数。
如果此时浏览器触发的 wheel 事件,上一帧的那个 cb 还困在自己过去的闭包,setCount 就还在用上一帧的 count 。
ruxuan1306
2022-11-10 23:36:30 +08:00
#29 楼第一句描述不准确,#30 楼补充修正了。

如果难以看懂,就好好读#20 楼的那篇博文。
sillydaddy
2022-11-11 08:25:00 +08:00
@Envov 加锁这个办法也不错。

@rabbbit 可以看 29 和 30 楼 @ruxuan1306 的解释

@ruxuan1306 非常感谢写下这么详细的分析!
sillydaddy
2022-11-11 08:27:11 +08:00
@myl0204 您这个才是最小 demo
sillydaddy
2022-11-11 09:44:55 +08:00
@ruxuan1306 #30
我加了一些打印,试图验证你说的 React 的渲染帧过程:

打印 dom 上的 callback 执行时,对应的 count ,以及设置的新 count:
```
let callback = (ev:WheelEvent)=>{
console.log("callback called setcount called, count=" + count + ", set_count_to="+(count+Math.floor(Math.abs(ev.deltaY/5))));
ev.preventDefault();
```

打印 useEffect contruct 和 reconstruct ,以及分别对应的 count 值
```
el.addEventListener("wheel", callback, {passive:false});
console.log("useEffect called. callback added, now count="+count);
return (()=>{el.removeEventListener('wheel', callback); console.log("useEffect destructed. callback removed. closure's count="+count);} );
```


```
console.log("re-rendered count=" + count);
return (<div ref={wheel}>
{divs}
</div> );
```
sillydaddy
2022-11-11 09:51:40 +08:00
@sillydaddy #34
不小心发出去了。

截取了 20 多行打印,应该是验证了 @ruxuan1306 的说法:
每次 useEffect destructed 后面都紧跟着 useEffect called 。
count 值的交替变化,反映了这个复杂的异步流程,看着很烧脑。

```
re-rendered count=0
useEffect called. callback added, now count=0
callback called setcount called, count=0, set_count_to=0
callback called setcount called, count=0, set_count_to=2
re-rendered count=2
callback called setcount called, count=0, set_count_to=18
useEffect destructed. callback removed. closure's count=0
useEffect called. callback added, now count=2
re-rendered count=18
callback called setcount called, count=2, set_count_to=5
useEffect destructed. callback removed. closure's count=2
useEffect called. callback added, now count=18
re-rendered count=5
callback called setcount called, count=18, set_count_to=62
useEffect destructed. callback removed. closure's count=18
useEffect called. callback added, now count=5
re-rendered count=62
useEffect destructed. callback removed. closure's count=5
useEffect called. callback added, now count=62
callback called setcount called, count=62, set_count_to=62
re-rendered count=62
callback called setcount called, count=62, set_count_to=78
re-rendered count=78
callback called setcount called, count=62, set_count_to=66
useEffect destructed. callback removed. closure's count=62
useEffect called. callback added, now count=78
re-rendered count=66
```
ragnaroks
2022-11-11 10:39:06 +08:00
const useWheel = function(element,setCount) : void {
 const callback = useCallback(function(event:WheelEvent){
  event.preventDefault();
  setCount(function(state){return state + 1;});
 },[setCount]);

 useEffect(function(){
  if(!element || !element.current){return;}
  element.current.addEventListener("wheel", callback, {passive:false});
  return function(){
   element.current.removeEventListener('wheel', callback);
  };
 }, [element]);
}
ragnaroks
2022-11-11 10:45:56 +08:00
翻了你的记录发现你最近有很多疑问,我的建议是先不要纠结“为什么会这样”,直接看完文档花一天写个博客 demo ,大多数可能的疑问会在编码过程中自动解答。
sillydaddy
2022-11-11 10:57:11 +08:00
@ragnaroks
这些问题都是我从在做的项目中提炼出来的。。虽然花点时间,但可以保证学到背后的一些东西,防止后续还有重复的问题。
ragnaroks
2022-11-11 11:58:20 +08:00
@sillydaddy 前端的话,我建议你面向 SOF 编程,不开玩笑,这可能是你最快提升的方式。
sillydaddy
2022-11-11 14:06:56 +08:00
@ragnaroks 好好的技术帖子,你来教我做人?不开玩笑,我不是你爸,在外边没人惯着你。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/894109

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX