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 吗?

3786 次点击
所在节点    React
54 条回复
cyitao
2022-11-10 13:00:02 +08:00
setCount(count => // 剩余代码)
sillydaddy
2022-11-10 13:05:53 +08:00
@cyitao
我知道可以这样改,但为什么主题里的代码会出问题呢?问题出在哪里我没有想明白。

谢谢你的回答,不过如果只是为了寻找一个暂时的答案,我也不用整理最小 demo 发在这里询问大家了。
gydi
2022-11-10 13:05:54 +08:00
数据量大的话,useEffect 的执行就没有 callback 频繁了吧,你这个写法必须保证执行一次 callback 然后再执行一次 useEffect ,才能保证数据正常。
TWorldIsNButThis
2022-11-10 13:07:13 +08:00
这代码的意思是每变一次 count
都要新增一个 event listener ?
gydi
2022-11-10 13:10:47 +08:00
你在 callback 里再加一个 removeEventListener ,帧数可能不高,但这样写数据不会异常
sillydaddy
2022-11-10 13:16:59 +08:00
@gydi
但是 callback 本身会修改 count ,count 变了的话,就会触 useEffect 。这样 useEffect 用到的不就始终是最新的吗?
westoy
2022-11-10 13:21:51 +08:00
用 useLayoutEffect 和 requestAnimationFrame
gydi
2022-11-10 13:22:21 +08:00
@sillydaddy #6 是这么理解,但是 callback 可以在下一次调用 useEffect 之前就执行 n 次,至于 n 是多少,你如何保证。dom 的事件回调和 react 的 hook ,哪个优先级更高呢
shuding
2022-11-10 13:27:19 +08:00
有好几个问题,首先 event listener 不应该放在这个带 count 依赖的 useEffect 里面,否则每次 count 变化都会造成 event listener 被删除然后重新注册。其次如 @cyitao 所说,你的 callback 里面的 count 是一个固定的外部引用。由于 React 的状态更新是异步的,event callback 和 rerender 的触发并不是完全 1:1 运行的。

建议读读 https://overreacted.io/zh-hans/a-complete-guide-to-useeffect 这篇文章。
sillydaddy
2022-11-10 13:43:31 +08:00
@gydi > “数据量大的话,useEffect 的执行就没有 callback 频繁了吧”

我测试了一下,25000 条渲染和 50 条的情况下,2 者的执行次数确实有差异。
帧率高的时候 callback 和 useEffect 基本是交叉一次,偶尔有例外。帧率低时 callback 明显更频繁。

这确实解释了为什么帧率低的时候能明显发现这个问题。帧率高时可能是不明显。

现在发现这个问题,其实就是怎么在 hook 里获取某个 state 的最新值。useEffect 里面的回调要用某个 state 的话,那 useEffect 能提供的更新频率就不够了。

@TWorldIsNButThis #4 也有 remove 的操作啊
@westoy #7 我去了解下,这个看名字像是与 Dom 相关的吧。
sillydaddy
2022-11-10 13:47:06 +08:00
@shuding #9
callback 的重新创建,主要是希望能引用到最新的 count ,在最新 count 的基础上修改为新值。如果不重新创建 callback ,那 callback 闭包引用的 count 就是固定的旧值了。
sweetcola
2022-11-10 14:02:22 +08:00
有几种可能,一是 setCount 被 batch 了,又或者是因为渲染问题导致 wheel 信号发送不稳定。
Manweill
2022-11-10 14:24:31 +08:00
你这依赖死循环了啊
serco
2022-11-10 14:36:38 +08:00
1. setCount 第一次调用 --> 2. count 第一次更新 --> 3. setCount 第二次调用 --> 4. count 第二次更新

由于 setCount 更新值是异步的,你完全没法保证 2/3 这两步的执行的顺序,那么就可能出现 3 执行时读取的 count 还是第一次更新前的值

改成 setCount((pre)=>pre+Math.floor(Math.abs(ev.deltaY/5)));不会出错的原因是,你的 update 的方式从直接修改值,变成了"从前值加一个数",setCount 的修改是有队列的,那么修改就是有序的。

你完全可以只添加一次 addEventListener ,并且在 callback 里面调用另一个真正需要执行的 function , 这个 function 用 useCallback 来根据 count 生成。或者只添加一次 addEventListener ,callback 里面用 setCount(count => ...)这种基于前值修改的方式。后者应该才是正确的方式。
shawn158
2022-11-10 14:48:30 +08:00
用 useLayoutEffect
sillydaddy
2022-11-10 14:54:16 +08:00
@serco > “。。在 callback 里面调用另一个真正需要执行的 function , 这个 function 用 useCallback 来根据 count 生成。”
这个效果应该跟 useEffect 是一样的,dom 上的 callback 执行与 useCallback 执行不是 1:1 的。

“callback 里面用 setCount(count => ...)这种基于前值修改的方式。”
这种方式是可以,但有时很麻烦,比如说:
```
if(count %3 == 0) {};
if(count %3 == 1) setCount(pre=>pre+1);
if(count %3 == 2) setCount(pre=>pre+2);
```

if 语句里还是要读取 count 的最新值。用函数形式实现就很反直觉(本来是用 count 值来判断是否需要 setCount 的,现在必须调用 setCount 来获取 count 的最新值):

setCount(pre=>{
if(pre%3==0) return ???
if(pre%3==1) return pre+1;
...
});
morelearn1990
2022-11-10 15:01:06 +08:00
react 垃圾,来用 vue3
serco
2022-11-10 15:24:05 +08:00
@sillydaddy 确实,useCallback 也是有问题的。

“用函数形式实现就很反直觉”更新方法可以用到前值为什么反直觉?

你也可以参考这个回答,把传入 useWheel 的 count 更换成一个 ref
serco
2022-11-10 15:24:21 +08:00
yhxx
2022-11-10 15:26:57 +08:00
https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/
看这个,应该能解决一部分疑问

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

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

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

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

© 2021 V2EX