原文链接:React useTransition: performance game changer or...?
作者:NADIA MAKAREVICH
探索 React 的并发渲染是什么,以及像useTransition
和useDeferredValue
的钩子函数是做什么的,以及使用他们的好处和坏处分别是什么
除非你过去两年生活在石头下面,否则你可能已经听说过"并发渲染"这个词语。为了支持并发渲染,React在架构层面进行了重写,这是一个全新的架构,提供了useTransition
和useDeferredValue
去控制 transition。并且它可能即将成为我们 UI 交互的性能改变者。甚至是 Vercel 也正在用 transitions 提升他们的性能。
但是它真的是一个规则的改变者吗(性能up)?真的没有任何限制?我们可以在所有位置使用?首先,他们是什么,以及我们为什么需要他们?让我们一起研究研究,并且找找答案吧!
让我们实现一个缓慢的状态更新。
首先,让我们实现一些有性能问题的例子。在React的官方文档中,他们使用"tabs"组件作为例子,useTransition
在这个组件中是有作用的,所以让我们具体来实现下它。不要复制粘贴,让我们从头开始做。
我们有一个渲染 Issues、Projects和Reports的tab的App组件,并且会根据tab的值,有条件的渲染三者中最新的组件。以供我们的小竞争对手Jira和Linear使用。App 组件会记录 tabs 间切换的状态的状态并渲染正确的组件,到目前位止都很简单。
js
export default function App() {
const [tab, setTab] = useState('issues');
return (
<div className="container">
<div className="tabs">
<TabButton
onClick={() => setTab('issues')}
name="Issues"
/>
<TabButton
onClick={() => setTab('projects')}
name="Projects"
/>
<TabButton
onClick={() => setTab('reports')}
name="Reports"
/>
</div>
<div className="content">
{tab === 'issues' && <Issues />}
{tab === 'projects' && <Projects />}
{tab === 'reports' && <Reports />}
</div>
</div>
);
}
现在,我们就需要 transitions 来帮我们处理一个问题了:"如果说这个页面太'重'了导致渲染的很慢怎么办呢?"让我们假设这个页面需要渲染最近的 100 个项目,并且列表里的组件都很重,每个渲染大概要 10ms 左右。理论上来说,100个组件并不算多。并且尽管每个组件渲染10ms有点牵强,它仍然还是可能发生的,尤其是在一个很慢的笔记本电脑上。结果就是,它会花费 1s 钟去挂载这个项目的页面。
玩一下下面这个写好的样板代码:example
所以当你点击 "project" 按钮的时候,渲染 Projects 页面花了多长时间?现在试试在这些页面直接快速的切换。如果我尝试从Issues变为Projects,然后立刻变为 Reports,你会发现我做不到这个效果,因为交互并没有反应。这个确实不是最好的用户体验:"即使页面很重,我现在并不能优化它,但至少不要让我阻塞页面的其他交互。"
如果我们在这个卡顿时间内点击一个 tab,在点击之后,这个任务会被放到任务执行队列里,等队列里的主任务执行完毕之后,我们就会执行这个任务。你可以在一个卡顿的例子的控制台里看到这个行为:"所有你通过点击触发的从夫悬案都会被打印出来,即使当时屏幕卡住了。"任务和任务队列就是现在 JS 里的执行方式。如果你不是很了解这个过程是怎么work的,我写了一个简单的文章概要,链接在这"Say no to "flickering" UI: useLayoutEffect, painting and browsers story"
并发渲染以及用 useTransition 处理缓慢的状态更新
典型的状态更新会阻塞主任务正是同步渲染想要去处理的事情。通过它,我们可以明确地将一些状态更新及其引起的重新渲染标记为'不重要'。这样处理的结果是 React会计算这些更新到"背景"里而非阻塞主任务。如果一些"重要"的事情发生了(比如一个状态更新),React就会暂停它的"后台"渲染,执行"重要"的更新,执行完后要么继续执行之前的任务,要么抛弃当前任务然后开一个新任务。
"后台"在这里只是一个有用的心智模型,我们知道:JavaScript 是一个单线程的语言。React 只会在忙于"后台"任务的时候定期检查主任务队列。如果一些新的任务出现在任务队列中,新的任务优先级将会高于"后台"任务。
话说的够多了,让我们回归到写代码吧。理论上,我们遇到的切换 tab 很缓慢并且影响用户体验的事情正是并发渲染可以解决的。我们需要做的事情只有渲染的项目页为"不重要"。
我们可以通过 useTransition
钩子来做到这一点。它返回一个loading
的布尔值作为第一个参数,返回一个函数所谓第二个参数。在这个函数组件里,我们将执行 setTab("projects")
,从这个时候起,这个状态更新会在不阻塞页面的情况下在"后台"计算。还有,我们可以在等待状态更新结束的过程中用isPending
这个布尔值去增加一个loading
状态,用来告诉用户正在发生的事情。
只需要三行额外的代码,和下面的例子一样简单:
js
export default function App() {
const [tab, setTab] = useState('issues');
// add the useTransition hook
const [isPending, startTransition] = useTransition();
return (
<div className="container">
<div className="tabs">
...
<TabButton
// indicate that the content is loading
isLoading={isPending}
onClick={() => {
// call setTab inside a function
// that is passed to startTransition
startTransition(() => {
setTab('projects');
});
}}
name="Projects"
/>
...
</div>
...
</div>
);
}
看起来的效果是这样的:[链接](wwmm6z.csb.app/)
当我点击这个"Projects"tab按钮的时候,这个 loading 的指示器显示了,并且在这个时候我点击"Reports"的时候,我很快就跳转了。并没有交互的卡顿,真是像一个魔法!
useTransition和同步渲染的缺点
好,现在到页面和返回页面的跳转已经搞定了,让我们考虑的更远一些。在我们实际的代码中,这些 tabs 的任何一个部分都可能很重。尤其是这个Reports页面。我可以想象到其中会有很多很重的图标。为什么不提前考虑考虑,并且在所有的 tabs 之间都用 transitions 标记为"不重要",然后通过 startTransition 为他们更新呢?
我要做的就是去抽象 transition 为一个函数
js
const onTabClick = (tab) => {
startTransition(() => {
setTab(tab);
})
}
然后在所有的按钮上使用这个函数而非直接设置状态。
js
<div className="tabs">
<TabButton
onClick={() => onTabClick('issues')}
name="Issues"
/>
<TabButton
onClick={() => onTabClick('projects')}
name="Projects"
/>
<TabButton
onClick={() => onTabClick('reports')}
name="Reports"
/>
</div>
这样写了之后,所有的状态更新都源自于这些 button,也会在更新时被标记为"不重要",因此如果 Reports 和 Projects 页面碰巧很重的时候,他们的渲染并不会阻塞 UI。
如果我们我们就这样写的话,我们会看到....
我使得这个页面的表现变差了
如果我导航去了 Projects 页面并且努力去导航到 Issues 或者 Reports,它并不会瞬间发生了。我没有在这些页面里做任何改动,他们仅仅需要在页面渲染一个字符串,但是他们两个表现的好像他们页面里面东西很多,这是发生什么了?
效果链接:效果链接
这里的问题在于,如果我将状态更新包裹在 transition 里面,React 不只是在后台进行状态更新。它实际上是两个执行步骤,首先,随着旧状态被触发,立刻会执行"重要"同步渲染执行,并且我们从useTransition 提取的isPending
布尔值从false
转为了true
.能够在渲染输出中使用它应该是一个很大的提示。只有在关键的"传统"重新渲染完成之后,React才会开始进行非关键的状态更新。
简而言之,useTransition会导致两次重新渲染,而不是一次。因此,我们会看到上面示例中的行为。如果我在项目页面上点击Issues tab,首先会触发初始重新渲染,选项卡状态仍为"projects"。非常耗时的项目组件会阻塞主任务1秒钟,同时进行重新渲染。只有在此完成后,才会安排并执行从"projects"到"issues"的非关键状态更新。
那我们该怎么用 useTransition 呢?
对所有东西都进行记忆化
为了解决前面出现的性能退化问题,我们需要去确保额外的第一次并发渲染要尽可能的轻。通常来说,这意味着我们需要去记忆化所有会拉慢它速度的东西。
- 所有重组件应该被包裹在
React.memo
中,并且使用useMemo
和useCallback
来记忆他的 props - 所有很重的操作都用
useMemo
做缓存 isPending
不能作为prop或依赖传递给上面的任何东西
在我们的例子里,简单的包裹页面组件就行
js
const IssuesMemo = React.memo(Issues);
const ProjectsMemo = React.memo(Projects);
const ReportsMemo = React.memo(Reports);
他们并没有任何的 props,所以我们在这里直接渲染就行
js
<div className="content">
{tab === 'issues' && <IssuesMemo />}
{tab === 'projects' && <ProjectsMemo />}
{tab === 'reports' && <ReportsMemo />}
</div>
瞧,问题解决了,重项目页面重新渲染不再阻碍点击标签页了。
效果链接:效果链接
但是这个事情告诉我们 useTransition 绝对不是一个可以每天用的工具,在记忆化的一个小错误都会使你的App肉眼可见的出现问题。正确地进行记忆化实际上是相当困难的。例如,你能否立即说出,如果App因为初始转换而重新渲染,IssuesMemo会不会重新渲染?
js
const ListMemo = React.memo(List);
const IssuesMemo = React.memo(Issues);
const App = () => {
// if startTransition is triggered, will IssuesMemo re-render?
const [isPending, startTransition] = useTransition();
return (
...
<IssuesMemo>
<ListMemo />
</IssuesMemo>
)
}
这里的答案是 - 是的,它会重新渲染。一路上都会重新渲染!问题没有被正确地进行记忆化。如果你不确定原因,这里有一个视频供你参考:youtu.be/G7RNVYaRS3E...
从空白到有很多内容的过渡
确保这个额外的初始重新渲染尽可能轻量化的另一种方式是,只有在从"nothing"到"very heavy stuff"的过渡时才使用useTransition。一个典型的例子是数据获取和将数据放入状态中。例如:
js
const App = () => {
const [data, setData] = useState();
useEffect(() => {
fetch('/some-url').then((result) => {
// lots of data
setData(result);
})
}, [])
if (!data) return 'loading'
return ... // render that lots of data when available
}
在这种情况下,如果没有数据,我们只返回一个加载状态,这不太可能是繁重的。因此,如果我们将setData包装在startTransition中,由此引起的初始重新渲染不会很糟糕:它将以空状态和加载指示器重新渲染它。
那么useDeferredValue呢?
有另一个钩子函数也允许我们使用并发渲染:useDeferredValue
。它的效果和useTransition
是类似的,允许我们标记一些更新为不重要并且把他们移动到后台。通常建议在无法访问状态更新功能时使用它。比如说值从 props 里来。
js
const TabContent = ({ tab }) => {
// mark the "tab" value as non-critical
const tabDeffered = useDeferredValue(tab);
return (
<>
{tabDeffered === 'issues' && <Issues />}
{tabDeffered === 'projects' && <Projects />}
{tabDeffered === 'reports' && <Reports />}
</>
);
};
但是,两次渲染的问题也在下面展示出来了。
所以这里的解决方法就和 useTransition
一模一样了,只有在以下条件才能去标记不重要:
- 所有被影响的都被记忆化了
- 或者,如果我们从"无"过渡到"重",而不是相反的方向;
我们可以用 useTransition 来处理节流吗
有时候,useTransition 的另一个使用案例是防抖。当我们快速输入某些内容到一个输入框中时,我们不希望在每个按键触发时都向后端发送请求,这可能会导致服务器崩溃。相反,我们希望引入一些延迟,只有完整的文本才会被发送。 通常情况下,我们可以使用 lodash(或类似的库)中的防抖函数来实现这个功能:"
js
function App() {
const [valueDebounced, setValueDebounced] = useState('');
const onChangeDebounced = debounce((e) => {
setValueDebounced(e.target.value);
}, 300);
useEffect(() => {
console.log('Value debounced: ', valueDebounced);
}, [valueDebounced]);
return (
<>
<input type="text" onChange={onChangeDebounced} />
</>
);
}
在这里,onChange 回调函数被防抖处理,所以只有在我停止在输入框中输入 300ms 后,setValueDebounced 才会被触发。
如果不使用外部库,而是使用 useTransition,会怎么样呢?这种做法似乎很合理:在转换中设置状态本质上是可中断的,这正是它的主要作用。类似下面的代码是否可行?
js
function App() {
const [value, setValue] = useState('');
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
startTransition(() => {
setValue(e.target.value);
});
};
useEffect(() => {
console.log('Value: ', value);
}, [value]);
return (
<>
<input type="text" onChange={onChange} />
</>
);
}
答案是:不行,这样做不会产生防抖效果。或者更准确地说,在这个例子中不会发生防抖效果。React 太快了,它能够在按键之间计算和提交"背景"值。在这个例子中,我们会在控制台输出中看到每个值的变化。
你可以在这里对比实际的防抖和使用 useTransition 的尝试:"
今天就到这里吧。希望现在对并发渲染是什么、与之相关的钩子以及如何使用它们有了更清楚的了解。
如果你只能记住一件事,那就是并发渲染钩子会导致双重重新渲染。因此,永远不要将它们用于所有状态更新。它们的使用案例非常具体,需要对React生命周期、重新渲染和记忆化有非常深入的理解。
至于我自己,我可能不会很快使用 useTransition 或 useDeferredValue。在谈论性能时,需要记住的事情太多了。错误的代价太高:我最不需要的就是意外地使性能变差。而对于防抖,它太不可预测了。我认为我更喜欢"老派"的做法。"