React useTransition:”性能的改变者还是....?“

原文链接:React useTransition: performance game changer or...?

作者:NADIA MAKAREVICH

探索 React 的并发渲染是什么,以及像useTransitionuseDeferredValue的钩子函数是做什么的,以及使用他们的好处和坏处分别是什么

除非你过去两年生活在石头下面,否则你可能已经听说过"并发渲染"这个词语。为了支持并发渲染,React在架构层面进行了重写,这是一个全新的架构,提供了useTransitionuseDeferredValue去控制 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 中,并且使用 useMemouseCallback 来记忆他的 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。在谈论性能时,需要记住的事情太多了。错误的代价太高:我最不需要的就是意外地使性能变差。而对于防抖,它太不可预测了。我认为我更喜欢"老派"的做法。"

相关推荐
Myli_ing36 分钟前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
dr李四维1 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
I_Am_Me_1 小时前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
雯0609~1 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z1 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
前端百草阁2 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜2 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4042 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish2 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue