React useTransition

写在前面

Suspense 之后,又将迎来useTransition

一.有 Suspense 还不够吗?

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

Suspense 子树中只要存在还没回来的 Lazy 组件,就走 fallback 指定的内容,相当于可以提升到任意祖先级的 loading。

Suspense 组件可以放在(组件树中)Lazy 组件上方的任意位置,并且下方可以有多个 Lazy 组件。

单从 loading 场景来看,Suspense 提供了两种能力:

  • 支持 loading 提升

  • 支持 loading 聚合

对于用户体验而言,有两方面的好处:

  • 避免布局抖动(数据回来之后冒出来一块内容)

  • 区别对待不同网络环境(数据返回快的话压根不会出现 loading)

前者是 loading(或 skeleton)带来的好处,而后者得益于 Concurrent Mode 下的间歇调度

P.S.关于 Suspense 的详细信息,见React Suspense——从代码拆分加个 loading 说起……

Suspense 提供的优雅灵活、人性化的 loading 似乎已经达到极致的开发体验与用户体验了,然而,进一步探索发现,围绕 loading 还有几个问题:

  • 加个 loading,体验就一定更好吗?

  • 立即显示 loading,有什么不好?

  • 如何解决交互实时响应与 loading 的冲突?

  • 对于砍不掉的长 loading,用户感知上还有办法更快吗?

  • 布局抖动真的不存在了吗?如果列表中同时存在多个 loading 呢?

接下来,我们逐一探讨这些问题

二.视觉上弱化 loading

加个 loading,体验就一定更好吗?

以典型的分页列表为例,常见的交互过程可能是这样的:

1.第一页内容出现
2.点击下一页
3.第一页内容消失,或者被半透明蒙层罩住
4.显示loading
5.一段时间后loading消失
6.第二页内容出现

其中最大的问题在于,loading 期间第一页内容是不可用的(不可见,或被遮起来)。也就是说,loading 影响了页面内容的完整性,以及应用的响应能力(responsiveness)

既然如此,干脆把 loading 去掉:

1.第一页内容出现
2.点击下一页
3.第一页内容保持原状
...没有任何交互反馈,几秒后
4.第二页内容出现

由于缺少即时交互反馈,用户体验更糟糕了。那么,有没有两全其美的办法,既能保证 loading 期间的响应性,又有类似于 loading 的交互体验呢?

有。弱化 loading 的视觉效果:

  • 把全局 loading(或内容块 loading)弱化成局部 loading:避免 loading 破坏内容完整性

  • 用置灰等方式暗示正在显示的是旧内容:避免旧内容对用户造成的困扰

例如,对于按钮点击的场景,可以简单地将 loading 反馈加在按钮上:

//...
render() {
  const { isLoading } = this.state;

  return (
    <Page>
      <Content style={{ color: isLoading ? "black" : "gray" }} />
      <Button>{isLoading ? "Next" : "Loading..."}</Button>
    </Page>
  );
}

不仅保证了 loading 过程中用户看到的仍然是完整的内容(虽然部分内容有些旧,但已经通过置灰暗示出来了),还能立即给出交互反馈

大多数时候,像上例这样立即展示 loading 没什么问题,然而,在另一些场景下,迅速出现的 loading 却不尽如人意

三.逻辑上延迟 loading

立即显示 loading,有什么不好?

假如 loading 非常快(只需要 100ms),用户可能只感觉到了什么东西忽闪而过……又一个糟糕的用户体验

当然,这样的场景我们通常不加 loading,因为 loading 通常带给用户一种“慢”的心理预期,而给一个本就非常快的操作加上 loading,无疑会拉低用户感知上的速度体验,所以我们选择不加

然而,如果有一个可能极快,也可能极慢的操作,loading 是加还是不加?

此时就要按需 loading,比如延后 loading 时机,200ms 后新内容还没准备好才显示 loading

React 考虑到了这种场景,于是有了useTransition

useTransition

Transition 特性以 Hooks API 形式提供:

const [startTransition, isPending] = React.useTransition({
  timeoutMs: 3000
});

P.S.注意,Transition 特性依赖 Concurrent Mode,并且目前(2019/11/23)尚未正式推出(属于实验特性),具体 API 可能还会发生变化,仅供参考,试玩见Transitions

Transition Hook 的作用是告诉 React,延迟更新 State 也没关系:

Wrap state update into startTransition to tell React it’s okay to delay it.

例如:

function App() {
  const [resource, setResource] = useState(initialResource);
  const [startTransition, isPending] = React.useTransition({
    timeoutMs: 3000
  });

  return (<>
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(() => {
          const nextUserId = getNextId(resource.userId);
          setResource(fetchProfileData(nextUserId));
        });
      }}
    >
      Next
    </button>
    {isPending ? " Loading..." : null}
    <ProfilePage resource={resource} />
  </>);
}

function ProfilePage({ resource }) {
  return (<Suspense fallback={<h1>Loading profile...</h1>} >
    <ProfileDetails resource={resource} />
    <Suspense fallback={<h1>Loading posts...</h1>} >
      <ProfileTimeline resource={resource} />
    </Suspense>
  </Suspense>);
}
  1. 点击Next按钮立即获取ProfileData,接着isPending变成true,显示Loading...

  2. 如果ProfileData在 3 秒内回来了,则(从正在显示的旧ProfilePage切换到)显示新ProfilePage内容

  3. 否则进入ProfilePage的 Suspense fallback,(旧ProfilePage消失)显示Loading profile...

也就是说,startTransition把本该立即传递给ProfilePage的(尚未获取到的)resource状态值往后延了,并且最多延 3 秒,而这正是我们想要的按需 loading 能力timeoutMs毫秒内不 loading,超时才显示 loading

所以,简单来讲,Transition 能够 delay Suspense,即,Transition 能够延迟 loading

按需 loading

从页面内容状态上看,Transition 引入了一种旧内容仍然可用的 Pending 状态

各个状态含义如下:

  • Receded(消失):当前页内容消失,降级到 Suspense fallback

  • Skeleton(骨架):新页已经出现,部分新内容可能仍在加载中

  • Pending(等待中):新内容正在路上,当前页内容完整,仍然可交互

提出 Pending 的出发点是避免开倒车(隐藏已经存在的内容)

However, the Receded state is not very pleasant because it “hides” existing information. This is why React lets us opt into a different sequence (Pending → Skeleton → Complete) with useTransition.

简单地加个 loading 对应的状态变化是Receded → Skeleton → Complete(无论快慢,都显示 loading),而有了 Transition 后,体验最优的情况是Pending → Skeleton → Complete(很快,不需要 loading),差一点的是Pending → Receded → Skeleton → Complete(很慢,不 loading 不行)

所以,为了最优的体验,应该缩短 Pending 时间,以尽快进入 Skeleton 状态,一个小技巧是把慢的、以及不重要的组件用 Suspense 包起来:

Instead of making the transition shorter, we can “disconnect” the slow component from the transition by wrapping it into

最佳实践

同时,得益于Hooks 细粒度逻辑复用方面的优势很容易把 Transition 的按需 loading 效果封装成基础组件,例如Button

function Button({ children, onClick }) {
  const [startTransition, isPending] = useTransition({
    timeoutMs: 10000
  });

  function handleClick() {
    startTransition(() => {
      onClick();
    });
  }

  const spinner = (
    // ...
  );

  return (<>
    <button onClick={handleClick} disabled={isPending}>
      {children}
    </button>
    {isPending ? spinner : null}
  </>);
}

这也是官方推荐的做法,由 UI 组件库来考虑需要 useTransition 的场景,以减少冗余代码:

Pretty much any button click or interaction that can lead to a component suspending needs to be wrapped in useTransition to avoid accidentally hiding something the user is interacting with.

This can lead to a lot of repetitive code across components. This is why we generally recommend to bake useTransition into the design system components of your app.

四.解决交互实时响应与 loading 的冲突

如何解决交互实时响应与 loading 的冲突?

Transition 之所以能延迟 loading 显示,是因为延迟了 State 更新。那么对于无法延迟的 State 更新呢,比如输入值:

function App() {
  const [query, setQuery] = useState(initialQuery);

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);
  }

  return (<>
      <input value={query} onChange={handleChange} />
      <Suspense fallback={<p>Loading...</p>}>
        <Translation input={query} />
      </Suspense>
  </>);
}

这里把input作为受控组件来用(通过onChange处理用户输入),因此必须立即将新value更新到 State 中,否则会出现输入延迟,甚至错乱

于是,冲突出现了,这种实时响应输入的要求与 Transition 延迟 State 更新似乎没办法并存

官方提供的解决方案是把该状态值冗余一份,既然有冲突,干脆分开各用各的:

function App() {
  const [query, setQuery] = useState(initialQuery);
  const [resource, setResource] = useState(initialResource);
  const [startTransition, isPending] = useTransition({
    timeoutMs: 5000
  });

  function handleChange(e) {
    const value = e.target.value;

    // Outside the transition (urgent)
    setQuery(value);

    startTransition(() => {
      // Inside the transition (may be delayed)
      setResource(fetchTranslation(value));
    });
  }

  return (<>
      <input value={query} onChange={handleChange} />
      <Suspense fallback={<p>Loading...</p>}>
        <Translation resource={resource} />
      </Suspense>
  </>);
}

虽然 React 的实践经验告诉我们能算则算,能共享就共享,不要冗余状态值,好处是能避免状态更新时可能的遗漏:

This lets us avoid mistakes where we update one state but forget the other state.

而我们刚刚也确实冗余了一个状态值(queryresource),并不是要推翻实践原则,而是说能够对 State 区分优先级:

  • 高优 State:不想其更新被 delay 的 State,比如输入值

  • 低优 State:需要 delay 的状态,比如 Transition 相关的

也就是说,有了 Transition 之后,State 有了优先级

五.考虑牺牲 UI 一致性

对于砍不掉的长 loading,用户感知上还有办法更快吗?

有。如果愿意牺牲 UI 一致性的话

没有听错,UI 一致性也并非不可撼动,必要时可以考虑牺牲 UI 一致性来换取感知上更好的体验效果。虽然会出现“文不对题”的情况,但也可能要比显示长达 10 秒甚至更久的 loading 要友好一些。同样,我们能够辅以置灰暗示等手段让用户意识到 UI 不一致的事实

为此,React 提供了 DeferredValue Hook

useDeferredValue

const deferredResource = React.useDeferredValue(resource, {
  timeoutMs: 1000
});

// 用法
<ProfileTimeline
  resource={deferredResource}
  isStale={deferredResource !== resource} />

P.S.注意,目前(2019/11/23)尚未正式推出useDeferredValue具体 API 可能还会发生变化,仅供参考,试玩见Deferring a Value

与 Transition 机制类似,相当于延迟状态更新,在新数据准备好之前,可以继续沿用旧数据,如果 1 秒内新数据来了,(从旧内容切换到)显示新内容,否则立即更新状态,该 loading 就 loading

与 Transition 的区别在于,useDeferredValue是面向状态值的,而 Transition 面向状态更新操作,算是 API 及语义上的差异,机制上二者非常相像

六.彻底消除布局抖动

布局抖动真的不存在了吗?如果列表中同时存在多个 loading 呢?

在多 loading 并存的场景下,难免出现 loading 先后顺序不同造成的布局抖动。而视觉效果上,我们通常不希望原有的一块东西被挤到一边去(视觉上应该都是 append,而不要 insert)。要想彻底消除布局抖动,有两种思路:

  • 所有列表项同时显示:等待所有项都准备好了再显示,但等待时间上去了

  • 控制列表项按其相对顺序出现:能消除 insert,等待时间也不总是最坏

那么,异步内容出现(loading 消失)顺序要如何控制?

React 又考虑到了,所以提供了SuspenseList来控制 Suspense 内容的渲染顺序,保证列表中元素的显示顺序按相对位置来,避免内容被挤下去:

<SuspenseList> coordinates the “reveal order” of the closest <Suspense> nodes below it

SuspenseList

import { SuspenseList } from 'react';

function ProfilePage({ resource }) {
  return (
    <SuspenseList revealOrder="forwards">
      <ProfileDetails resource={resource} />
      <Suspense fallback={<h2>Loading posts...</h2>}>
        <ProfileTimeline resource={resource} />
      </Suspense>
      <Suspense fallback={<h2>Loading fun facts...</h2>}>
        <ProfileTrivia resource={resource} />
      </Suspense>
    </SuspenseList>
  );
}

revealOrder="forwards"表示SuspenseList下的子级Suspense必须按照自上而下的顺序出现,无论谁的数据先准备好,类似的值还有backwards(逆序出现)和together(同时出现)

另外,为了避免多个 loading 同时出现可能对用户造成的体验困扰,还提供了tail选项,具体见SuspenseList

P.S.注意,目前(2019/11/23)尚未正式推出SuspenseList具体 API 可能还会发生变化,仅供参考,试玩见SuspenseList

七.总结

如我们所见,在追寻极致体验的康庄大道上,React 正越走越远:

  • Suspense:支持优雅灵活、人性化的内容降级

  • useTransition:支持按需降级,只在确实很慢的情况才降级

  • useDeferredValue:支持牺牲 UI 一致性换取感知上更好的体验效果

  • SuspenseList:支持控制一组降级效果的出现顺序,以及并存数量

P.S.最简单的降级策略就是 loading,其它的比如用缓存值,甚至来一段广告,开个小游戏等都算降级

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code