背景
最近在开发中发现了一个问题,发现去请求一个ApI, 然后setState展示某个DOM,竟然可以在setState后面直接获取到我这个刚刚从隐藏状态变回显示状态的DOM节点的ref, 让我很不可思议,具体代码类似如下:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const titleRef = useRef(null);
useEffect(() => {
// api请求 axios.post().then...
Promise.resolve(1).then(() => {
setShowTitle(true);
// 打印了 titleRef.current <h2>觉醒年代</h2> 竞然不是null🧐
console.log("titleRef.current", titleRef?.current);
})
}, [])
return (
<div className="App">
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
</div>
);
}
useRef
在React中,我们要访问DOM节点可以使用React.useRef
返回的值的current属性获取(当然有时候也用来保存一些临时的变量,因为ref在每一次函数执行时指针都不变),即:
如果是简单的useEffect
里面获取已经展示的DOM节点,那是可以获取到的,因为useEffect
的调度发生在React
的commit
流程,在两个Fiber树调换之后
import React, { useEffect, useRef } from "react";
export default function App() {
const titleRef = useRef(null);
useEffect(() => {
console.log(titleRef.current); // 可以获取到对应DOM的真实节点
}, [])
return (
<div className="App">
<h2 ref={titleRef}>觉醒年代</h2>
</div>
);
}
但是当我们在普通的函数里面去setState
展示对应的DOM,接着打印ref.current的时候
,我们返回的是null:
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const titleRef = useRef(null);
const toggleShowTitle = () => {
setShowTitle(!showTitle);
console.log("titleRef.ref.current", titleRef.current);
}
console.log("render");
return (
<div className="App">
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button onClick={toggleShowTitle}>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
</div>
);
}
当我们在useEffect
里面setState, 紧接着去打印最新的ref的时候,我们也获取不到对应的DOM:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const titleRef = useRef(null);
useEffect(() => {
setShowTitle(true);
console.log("titleRef.ref.current", titleRef?.current); // null
}, [])
console.log("render");
return (
<div className="App">
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
</div>
);
}
这个很容易理解,因为setState
是异步执行的,也就是先setState完成后,并没有马上render, 而是React
进行了异步调度, 然后先执行了后面打印的同步代码,所以自然获取不到真实的DOM。
但是当我们把setState
放到setTimeout
或者Promise.then
的回调中的时候,神奇的事情发生了:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const titleRef = useRef(null);
useEffect(() => {
// setTimeout(() => {
// setShowTitle(true);
// console.log("titleRef.ref.current", titleRef?.current);
// });
Promise.resolve(1).then(() => {
setShowTitle(true);
console.log("titleRef.ref.current", titleRef?.current);
})
}, [])
console.log("render");
return (
<div className="App">
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
</div>
);
}
验证同步执行:
如果直接在setShowTitle后面去打印showTitle, 因为闭包问题,我们是不能获取到最新的showTitle=true的,尽管已经放在了 Promise.resolve
中去setState, 当然在类组件中很容易通过this.state.showTitle
获取到最新的state, 那么在函数组件中怎么获取同步更新的最新state呢?答案是从函数组件本身的Fiber
对象中获取:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const titleRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
Promise.resolve(1).then(() => {
setShowTitle(true);
console.log(appRef.current[Object.keys(appRef.current)[0]].return.alternate.memoizedState);
console.log("titleRef.ref.current", titleRef?.current);
})
}, [])
console.log("render");
return (
<div className="App" ref={appRef}>
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
</div>
);
}
至于为什么要从alternate
里面获取state, 原因是同步执行后其实此时的APP函数组件对应的Fiber节点已经commit完成,即完成了两颗Fiber树的切换过程
为什么在setState或者Promise.then里面setState的执行和渲染是同步的呢
要回答这个问题,首先要回到React里面普通的setState为什么异步的呢,这个可以看官方issue的回答:
github.com/facebook/re…,
总结一下大概就是为了性能优化而做的批处理,保证state和props的更新只有在reconciliation and flushing
阶段之后,也就是React
的beginWork阶段之后。也就是多个setState被批量异步处理,然后保证当前页面可以交互的情况下根据新的state计算出新的DOM树,然后再最终切换当前的Fiber树和计算好的新的Fiber树。
也就是下方的demo中,虽然在useEffect后重新setState了两次,但是整个过程却只render了2次。
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const [showParagram, setShowParagram] = useState(false);
const titleRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
setShowTitle(true);
setShowParagram(true);
}, [])
console.log("render"); // 一共render两次
return (
<div className="App" ref={appRef}>
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
{showParagram && <p ref={titleRef}>真好看</p>}
</div>
);
}
但是如果两次放到了setTimeout
或者Promise.then
回调里面,却一共渲染了3次:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const [showParagram, setShowParagram] = useState(false);
const titleRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
setTimeout(() => {
setShowTitle(true);
setShowParagram(true);
})
}, [])
console.log("render"); // 一共render了三次
return (
<div className="App" ref={appRef}>
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
{showParagram && <p ref={titleRef}>真好看</p>}
</div>
);
}
React17及其之前的版本不会对类似setTimeout
, Promise.then
或者原生的事件回调里面的setState
进行批量更新,至于为什么我觉得没有必要知道了,因为在前几天出来的React18的计划中The Plan for React 18, React官方已经说又要在这些本来没有批处理的地方又加上批处理了Automatic batching for fewer renders in React 18。
React18重整批量更新
可以在codesandbox
中更换下React18(需要使用ReactDOM.createRoot
而不是ReactDOM.render
),来看下效果:
import ReactDOM from "react-dom";
import App from "./App";
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);
可以看到即使是放在setTimeout
里面,也是一共只渲染了两次,进行了批量更新:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const [showParagram, setShowParagram] = useState(false);
const titleRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
setTimeout(() => {
setShowTitle(true);
setShowParagram(true);
})
}, [])
console.log("render");
return (
<div className="App" ref={appRef}>
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
{showParagram && <p ref={titleRef}>真好看</p>}
</div>
);
}
那如果我们不想批量更新了怎么办? 使用ReactDOM
的flushSync
模块:我们可以看到又渲染了三次了!
demo地址
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import { flushSync } from 'react-dom';
export default function App() {
const [showTitle, setShowTitle] = useState(false);
const [showParagram, setShowParagram] = useState(false);
const titleRef = useRef(null);
const appRef = useRef(null);
useEffect(() => {
setTimeout(() => {
flushSync(() => setShowTitle(true));
flushSync(() => setShowParagram(true));
})
}, [])
console.log("render");
return (
<div className="App" ref={appRef}>
<h3>测试 useEffect执行 ref更新 demo!</h3>
<button>button</button>
{showTitle && <h2 ref={titleRef}>觉醒年代</h2>}
{showParagram && <p ref={titleRef}>真好看</p>}
</div>
);
}
最后
所以如果我想要在一个隐藏节点展示后去做一些副作用,比如Echarts组件
拿到数据后要setOptions
或者其他case:
那从最开始的demo里面我们可以看到,我们是可以在请求数据后直接在then
回调里面获取到对于的Echarts instance
的,伪代码如下:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showCharts, setShowCharts] = useState(false);
const chartsRef = useRef(null);
useEffect(() => {
// api请求
axios.post(xxx).then((res) => {
showCharts(true);
chartsRef.current.getEchartsInstance().setOptions(res);
})
}, [])
return (
<div className="App">
{showCharts && <EchartsComponent ref={chartsRef}>觉醒年代</EchartsComponent>}
</div>
);
}
那如果React18在这里做了批量更新(异步), 按照上面的思路,我们可以在setShowTitle(true)
包装一层flushSync
, 或者我们简单的使用showCharts
state当作一个新的useEffect
的依赖,当showCharts=true
的时候就是我们Echarts节点展示的时候我们再去获取他真实的DOM去操做的就可以了.
当然还有其他的方式就是callbackRef
啦,相比于useRef
(没有订阅的功能), callbackRef会在ref更新的时候再执行一遍回调
伪代码如下:
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
export default function App() {
const [showCharts, setShowCharts] = useState(false);
// ...
const chartsRef = useCallback(node => {
if (node !== null) {
getEchartsInstance().setOptions(xxx);
}
}, []);
return (
<div className="App">
{showCharts && <EchartsComponent ref={chartsRef}>觉醒年代</EchartsComponent>}
</div>
);
}
总结
编程总是一个探索的过程,好了,继续看《觉醒年代》了,如果有什么写的不对的也希望大家指正!