4. useTransition会导致重新渲染
通过,对第一段代码施以useTransition
的魔法,让其从半身不遂变的行动自如。此时,你感觉到一切都是向着美好的方向前行着,但是事实哪有那么的顺心遂意。
在现实生活中,这些Button
中的任何一个都可能非常耗时。此时,你也无法预知到底哪个Button
是耗时的。此时你的双脚离地了,病毒就关闭了,聪明的智商又占领高地了
所以,你就将所有这些Button
之间的过渡都标记为非关键,并在其中的startTransition
中更新状态。
并且,为了体现自己的代码功底,他还贴心的把过渡过程封装成了一个函数
const onBtnClick = (btn) => { startTransition(() => { setTab(btn); }); };
然后在所有按钮上使用这个函数,而不是直接设置状态:
<Button isActive={tab === "A"} onClick={() => onBtnClick("A")} name="A" /> <Button isActive={tab === "B"} onClick={() => onBtnClick("B")} name="B" /> <Button isActive={tab === "C"} onClick={() => onBtnClick("C")} name="C" />
所有来自这些按钮的状态更新现在都将被标记为非关键。
在运行代码后,我们发现又出现了新的问题:
如果我们从B
页面切换到A
或C
,不再是瞬间发生了!而我们对天发誓没有改变这两个页面上的任何东西,它们目前都只渲染一个字符串,但它们都表现得好像非常耗时。
这里的问题在于,
如果我们将状态更新包装在一个过渡中,
React
并不只是在"后台"触发状态更新。实际上,这是一个两步过程。
- 首先,会触发一个立即的
关键
重新渲染,使用从useTransition
钩子中提取的isPending
布尔值从false
变为true
。(我们能够在渲染输出中使用它的事实应该是一个重要的线索。)- 只有在这个关键的重新渲染完成后,
React
才会开始进行非关键状态更新。
简而言之,useTransition
会导致两次重新渲染,而不是一次。因此,我们看到了上面示例中的行为。如果我在B
页面上,并点击A Button
,首先触发的是初始重新渲染
,此时B Button
还是选中状态。非常耗时的B
组件在重新渲染时阻塞了主任务1秒钟。只有在这之后,才会安排并执行从B
到A
的非关键状态更新。
点击顺序 B->A
5. 如何正确的使用useTransition
记忆所有的值
为了解决上述性能下降的问题,我们需要确保额外的第一次重新渲染尽可能轻量。通常,这意味着我们需要对可能导致它减速的一切进行记忆化处理:
- 所有耗时的组件应该使用
React.memo
包装,其props
应使用useMemo
和useCallback
进行记忆化处理。 - 所有耗时的操作应使用
useMemo
进行记忆化处理。 isPending
不应该作为属性或依赖项传递给上述任何内容。
在我们的情况下,简单地包装我们的页面组件就可以了,并且它们没有任何props,所以我们可以直接渲染它们:
// .... import { A, B, C } from "@components/Content"; const AMemo = React.memo(A); const BMemo = React.memo(B); const CMemo = React.memo(C); export default function App() { // 代码省略 return ( <div className="container"> // ...代码省略 <div className="content"> {tab === "A" && <AMemo />} {tab === "B" && <BMemo />} {tab === "C" && <CMemo />} </div> </div> ); }
天晴了雨停了你又觉得你行了,此时上面的顽疾被解决了。耗时的B
页面重新渲染不再阻止阻塞页面的渲染了。
我们在之前的就聊过Memo
的情况。React Memo不是你优化的第一选择。其中有一个结论是:Memo很容易被破坏,所以如果在useTransition
处理过程中没很好处理Memo
的话,会使我们的应用比使用useTransition
之前显然更糟糕。得不偿失。
而且,要正确地进行记忆化处理实际上是相当困难的。想象一下,有如下的场景App
因初始过渡而重新渲染,BMemo
是否会重新渲染?
const ListMemo = React.memo(List); const BMemo = React.memo(B); const App = () => { // 如果触发startTransition,BMemo是否会重新渲染? const [isPending, startTransition] = useTransition(); return ( ... <BMemo> <ListMemo /> </BMemo> ) }
答案是 - 是的,它会重新渲染。而且还是那种像吃了炫迈一样,根本停下来的那种。具体的解决方法吗,我们优先考虑下放State和内容提升,在最后万不得已的情况才会考虑React.memo
。
从无到耗时的过渡
确保这种额外的初始重新渲染尽可能轻量的另一种方法是仅在从"无"到"非常耗时的内容"的过渡中使用useTransition
。这种情况的典型示例可能是数据获取,然后将该数据放入状态中。例如:
const App = () => { const [data, setData] = useState(); useEffect(() => { fetch('/some-url').then((result) => { // 大量的数据 setData(result); }) }, []) if (!data) return 'loading' return ... // 在数据可用时渲染大量数据 }
在这种情况下,如果没有数据,我们只返回一个加载状态,这不太可能很耗时。因此,如果我们将setData
包装在startTransition
中,由此引起的初始重新渲染不会太糟糕:它将使用空状态和加载指示器重新渲染。
更多,更详细的语法,请参看React官网 -useTransition
6. useDeferredValue
还有另一个钩子,允许我们利用并发渲染的威力:useDeferredValue
。它的工作方式类似于useTransition
,允许我们将某些更新标记为非关键并将它们移至后台。通常建议在没有访问状态更新函数时使用它,例如,当值来自props
时。
然后,我们对上面的代码做一下改造处理:
import { useState, useDeferredValue } from "react"; // ... import { A, B, C } from "@components/Content"; type Content = "A" | "B" | "C"; const TabContent = ({ tab }: { tab: Content }) => { const tabDeffered = useDeferredValue(tab); return ( <> {tabDeffered === "A" && <A />} {tabDeffered === "B" && <B />} {tabDeffered === "C" && <C />} </> ); }; export default function App() { const [tab, setTab] = useState<Content>("A"); const onBtnClick = (btn: Content) => { setTab(btn); }; return ( <div className="container"> //.... <div className="content"> <TabContent tab={tab} /> </div> </div> ); }
将渲染内容提取到一个组件中,并且组件接收tab
作为props
。
然而,在这里也存在双重渲染的问题。
在页面首次渲染时,A Button
是默认被选中的,我们依次点击B/C
。然后下面是对应的控制台输出。
问题出现了,解决这方面的药方也有,它和解决useTransition
的问题是一样的。
- 所有受影响的内容都已进行了记忆化处理;
- 尽量,在从"无"到"非常耗时的内容"的过渡中使用
useDeferredValue
更多,更详细的语法,请参看React官网 -useDeferredValue
7. debounce VS useTransition
由于useTransition
的延迟特性,有些同学就会想到,我是不是可以将其用在防抖上。当我们在输入框中快速输入内容时,我们不希望在每次输入时向后端发送请求 - 这可能会使我们的服务器崩溃。相反,我们希望引入一点延迟,以便只发送完整的文本。
通常,我们会使用类似lodash
中的防抖函数(或等效函数)来实现:
或者我们可以使用在美丽的公主和它的27个React 自定义 Hook中的自定义hookuseDebounce
。这不就形成了一种闭环了吗。学了,就要用上它。
function App() { const [valueDebounced, setValueDebounced] = useState(''); const onChangeDebounced = debounce((e) => { setValueDebounced(e.target.value); }, 300); useEffect(() => { console.log("防抖处理的值(300ms后显示): ", valueDebounced); }, [valueDebounced]); return ( <> <input type="text" onChange={onChangeDebounced} /> </> ); }
这里的onChange
回调被防抖处理,因此setValueDebounced
只在我们停止在输入框中输入后的300毫秒后触发。
如果不使用外部库,而是使用useTransition
,按照原理来讲,这是可行的。因为在过渡中设置状态是可中断的,所以我们可以利用这个特性来处理值的延迟获取。
function App() { const [value, setValue] = useState(''); const [isPending, startTransition] = useTransition(); const onChange = (e) => { startTransition(() => { setValue(e.target.value); }); }; useEffect(() => { console.log("transition处理的值: ", value); }, [value]); return ( <> <input type="text" onChange={onChange} /> </> ); }
理想很丰满,但是现实很残酷。在我们运行代码后发现,使用useTransition
达不到我们的要求。在输入框中每次输入,控制台都很配合的输出对应的值。
React
太快了,它能够在我们输入的这段时间内计算和提交"后台"值。
也就是说,useTransition
是达不到debounce
的效果。也就是实现不了防抖。
这一点,React
官网也说明了这点。
后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。