React Concurrent Mode 之 Suspense 实践

by Teobler on 16 / 05 / 2020

views

自从三大框架成型之后,各个框架都为提升开发者的开发效率作出了不少努力,但是看起来技术革新都到了一个瓶颈。除了 React 引入了一次函数式的思想,感觉已经没有当初从DOM时代到数据驱动时代的惊艳感了。于是 React 将精力放在了用户体验上,想让开发者在不过多耗费精力的情况下,用框架自身去提升用户体验。

于是在最近的几个版本中,React 引入了一个叫做 Concurrent Mode 的东西,同时还引入了 Suspense,旨在提升用户的访问体验,React 应用在慢慢变大以后会慢慢变得越来越卡,这次的新功能就是想在应用初期就解决这些问题。

虽然现在这些功能还处在实验阶段,React 团队并不建议在生产环境中使用,不过大部分功能已经完成了,而且他们已经用在了新的网站功能中,所以面对这样一个相对成熟的技术,其实我们还是可以来自己玩一下的,接下来我来带领大家看看这是一个什么样的东西吧。

Concurrent Mode

WHAT

那么这个 Concurrent Mode 是个啥呢?

Concurrent Mode is a set of new features that help React apps stay responsive and gracefully adjust to the user’s device capabilities and network speed.

在官网的解释中, Concurrent Mode 包含了一系列新功能,这些新功能可以根据用户不同的设备性能和不同的网速进行不同的响应,以使得用户在不同的设备和网速的情况下拥有最好的访问体验。那么问题来了,它是怎么做到这一点的呢?

HOW

可停止的rendering (Interruptible Rendering)

在通常状况下,React 在 render 的时候是没有办法被打断的(这其中有创建新的 DOM 节点等等),rendering 的过程会一直占用 JS 线程,导致此时浏览器无法对用户的操作进行实时反馈,造成了一种整个页面很卡的感觉。

而在 Concurrent Mode 下,rendering 是可以被打断的,这意味着 React 可以让出主线程给浏览器用于更紧急的用户操作。

想象这样一个通用的场景:用户在一个输入框中检索一些信息,输入框中的文字改变后页面都将重新渲染以展示最新的结果,但是你会发现每一次输入都会卡顿,因为每一次重新渲染都将阻塞主线程,浏览器就将没有办法相应用户在输入框中的输入。当然现在通用的解决办法是用 debouncing 或者 throtting。但是这个方式存在一些问题,首先是页面没有办法实时反应用户的输入,用户会发现可能输入了好多个字符页面才刷新一次,不会实时更新;第二个问题是在性能比较差的设备上还是会出现卡顿的情况。

如果在页面正在 render 时用户输入了新的字符,React 可以暂停 render 让浏览器优先对用户的输入进行更新,然后 React 会在内存中渲染最新的页面,等到第一次 render 完成后再直接将最新的页面更新出来,保证用户能看到最新的页面。

image-20200516110545540

这个过程有点像 git 的多分支,主分支是用户能够看到的并且是可以被暂停的,React 会新起一个分支来做最新的渲染,当主分支的渲染完成后就将新的分支合并过来,得到最新的视图。

可选的加载顺序(Intentional Loading Sequences)

在页面跳转的时候,为了提升用户体验,我们往往会在新的页面中加上 skeleton,这是为了防止要渲染的数据还没有拿到,用户看到一个空白的页面。

Concurrent Mode 中,我们可以让 React 在第一个页面多停留一会,此时 React 会在内存中用拿到的数据渲染新的页面,等页面渲染完成后再直接跳转到一个已经完成了的页面上,这个行为要更加符合用户直觉。而且需要说明的是,在第一个页面等待的时间里,用户的任何操作都是可以被捕捉到的,也就是说在等待时间内并不会 block 用户的任何操作。

总结

总结一下就是,新的 Concurrent Mode 可以让 React 同时在不同的状态下进行并行处理,在并行状态结束后又将所有改变合并起来。这个功能主要聚焦在两点上:

  • 对于 CPU 来说(比如创建 DOM 节点),这样的并行意味着优先级更高的更新可以打断 rendering
  • 对于 IO 来说(比如从服务端拿数据),这样的并行意味着 React 可以将先拿到的一部分数据用于在内存中构建 DOM,全部构建完成后在进行一次性的渲染,同时不影响当前页面

而对于开发者来说,React 的使用方式并没有太大的变化,你以前怎么写的 React,将来还是怎么写,不会让开发者有断层的感受。下面我们可以通过几个例子来看看具体怎么使用。

Suspense

开始前的准备

与之前的功能不同的是,Concurrent Mode 需要开发者手动开启(只是使用 Suspense 貌似不用开启,但是我为了下一篇文章的代码,现在就先开启了)。为了方(tou)便(lan),我们用 cra 创建一个新的项目,为了使用 Concurrent Mode 我们需要做如下修改:

  • 删除项目中的react版本,该用实验版 npm install react@experimental react-dom@experimental

  • 为了正常使用 TypeScriptreact-app-env.d.ts 文件中加入实验版 React 的 type 引用

    /// <reference types="react-dom/experimental" />
    /// <reference types="react/experimental" />
  • index.tsx 中开启 Concurrent Mode

    ReactDOM.unstable_createRoot(
      document.getElementById("root") as HTMLElement
    ).render(<App />);
  • Suspense 中如果你需要在拿后端数据时”挂起“你的组件,你需要一个按照 React 要求实现的 "Promise Wrapper",在这里我选择的是 swr

    • 从后面的结果来看,目前 swr 对于 Suspense 的实现还没有完成,但是已经有一个 pr 了

本文中所有的代码都可以在我的 github repo 里找到,建议时间充裕的同学 clone 一份和文章一同食用效果更佳。

Data Fetching

React 团队在 16.6 中加入了一个新的组件 SuspenseSuspense 与其说是一个组件,更多的可以说是一种机制。这个组件可以在子节点渲染的时候进行”挂起“,渲染一个你设定好的等待图标之类的组件,等子节点渲染完成后再显示子节点。在 Concurrent Mode 之前,该组件通常用来作懒加载:

const ProfilePage = React.lazy(() => import('./ProfilePage')); // Lazy-loaded
 
// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

在最新的 React 版本中,现在 Suspense 组件可以用来”挂起“任何东西,比如可以在从服务端拿数据的时候”挂起“某个组件,比如一个图片,比如一段脚本等等。我们在这里仅仅以从后端拿数据为例。

在传统的数据获取中,往往是组件先 render 然后再去后端获取数据,拿到数据后重新 render 一遍组件,将数据渲染到组件中(Fetch-on-render)。但是这样就会有一些问题,最明显就就是触发瀑布流 -- 第一个组件 render 触发一次网络请求,完了以后 render 第二个组件又触发一次网络请求,但是其实这两个请求可以并发处理。

然后可能有人为了避免这种情况的出现就会来一些技巧,比如我在 render 这两个组件之前先发两次请求,等两次请求都完了我再用数据去 render 组件(Fetch-then-render)。这样的确会解决瀑布流的问题,但是引入了一个新的问题 -- 如果第一个请求需要 2s 而第二个请求只需要 500ms,那第二个请求就算已经拿到了数据,也必须等第一个请求完成后才能 render 组件。

Suspense 解决了这个问题,它采用了 render-as-you-fetch 的方式。Suspense 会先发起请求,在请求发出的几乎同一时刻就开始组件的渲染,并不会等待请求结果的返回。此时组件会拿到一个特殊的数据结构而不是一个 Promise,而这个数据结构由你选择的 "Promise wrapper" 库(在上文提到过,在我的例子里我用的是 swr)来提供。由于所需数据还没有准备好,React 会将此组件”挂起“并暂时跳过,继续渲染其他组件,其他组件完成渲染后 React 会渲染离”挂起“组件最近的 Suspense 组件的 fallback。之后等某一个请求成功后,就会继续重新渲染相对应的组件。

在我的例子中我尝试用 Suspense + swr + axios 来实现。

在 parent 中 fetch data

在第一个版本中我尝试在 parent 组件(PageProfile)中先 fetch data,然后在子组件中渲染:

const App: React.FC = () => (
  <Suspense fallback={<h1>Loading...</h1>}>
    <PageProfile />
  </Suspense>
);
 
export default App;
export const PageProfile: React.FC = () => {
  const [id, setId] = useState(0);
  const { user, postList } = useData(id);
 
  return (
    <>
      <button onClick={() => setId(id + 1)}>next</button>
      <Profile user={user} />
      <ProfileTimeline postList={postList} />
    </>
  );
};
export const useData = (id: number) => {
  const { data: user } = useRequest(
    { baseURL: BASE_API, url: `/api/fake-user/${id}`, method: "get" },
    {
      suspense: true,
    },
  );
  const { data: postList } = useRequest(
    { baseURL: BASE_API, url: `/api/fake-list/${id}`, method: "get" },
    {
      suspense: true,
    },
  );
 
  return {
    user,
    postList,
  };
};
import useSWR, { ConfigInterface, responseInterface } from "swr";
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
 
export type GetRequest = AxiosRequestConfig | null;
 
interface Return<Data, Error>
  extends Pick<responseInterface<AxiosResponse<Data>, AxiosError<Error>>, "isValidating" | "revalidate" | "error"> {
  data: Data | undefined;
  response: AxiosResponse<Data> | undefined;
}
 
export interface Config<Data = unknown, Error = unknown>
  extends Omit<ConfigInterface<AxiosResponse<Data>, AxiosError<Error>>, "initialData"> {}
 
export const useRequest = <Data = unknown, Error = unknown>(
  requestConfig: GetRequest,
  { ...config }: Config<Data, Error> = {},
): Return<Data, Error> => {
  const { data: response, error, isValidating, revalidate } = useSWR<AxiosResponse<Data>, AxiosError<Error>>(
    requestConfig && JSON.stringify(requestConfig),
    () => axios(requestConfig!),
    {
      ...config,
    },
  );
 
  return {
    data: response && response.data,
    response,
    error,
    isValidating,
    revalidate,
  };
};

但是不知道是不是我的写法有问题,有几个问题我死活没弄明白:

  • 为了实现 render-as-you-fetch,文档中有提到过可以尽可能早的 fetch data,从而可以让 render 和 fetch 并行并且缩短拿到 data 的时间(如果我理解没错的话)
    • 我的想法是先在 parent 组件中 fetch data,然后用两个 SuspenseProfile 组件和 ProfileTimeline 组件包起来,然后就能够在拿到相对应的数据(user 和 postList)之后渲染相对应的组件
    • 但是在使用的过程中我发现 ”在哪个组件中 fetch data,就必须用 Suspense 将这个组件包起来,否则就会报错“,所以这里我将整个 PageProfile 包了起来。而这个时候就算我用两个 SuspenseProfile 组件和 ProfileTimeline 组件包起来也没办法实现两条加载信息,只会显示最外层的 loading,也就没有办法实现 render-as-you-fetch
    • swr 在这样的写法下会多发一次莫名其妙的请求,目前还没有找到原因
      • image-20200515111513161
      • 图中第一个第二个请求分别是请求的 user 和 postList 数据,但是在完了之后又请求了一次 user
  • swr 目前还没有实现在 Suspense 模式下避免 waterfall,所以两个请求会依次发出去,等待时间是总和,不过翻看github已经有 pr 在解决这个问题了,目前来看处于codereview的阶段

在当前组件中 fetch data

为了解决上面的问题,我换了一种写法:

const App: React.FC = () => {
  const [id, setId] = useState(0);
 
  return (
    <>
      <button onClick={() => setId(id + 1)}>next</button>
      <Suspense fallback={<h1>Loading profile...</h1>}>
        <Profile id={id} />
        <Suspense fallback={<h1>Loading posts...</h1>}>
          <ProfileTimeline id={id} />
        </Suspense>
      </Suspense>
    </>
  );
};
 
export default App
export const Profile: React.FC<{ id: number }> = ({ id }) => {
  const { data: user } = useRequest({ baseURL: BASE_API, url: `/api/fake-user/${id}`, method: "get" }, { suspense: true });
 
  return <h1>{user.name}</h1>;
};
export const ProfileTimeline: React.FC<{ id: number }> = ({ id }) => {
  const { data: postList } = useRequest(
    { baseURL: BASE_API, url: `/api/fake-list/${id}`, method: "get" },
    { suspense: true },
  );
 
  return (
    <ul>
      {postList.data.map((listData: { id: number; text: string }) => (
        <li key={listData.id}>{listData.text}</li>
      ))}
    </ul>
  );
};

此时我将对应的请求放在子组件内,这样的写法不管是两个组件 loading 的状态,还是网络请求都是正常的了,但是按照我的理解这样的写法是不符合 React 的初衷的,在文档中 React 提倡在顶层(比如上层组件)先 kick off 网络请求,然后先不管结果,开始组件的渲染:

const resource = fetchProfileData();
 
function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}
 
function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}
 
function ProfileTimeline() {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

但是目前这种写法很明显是开始渲染子组件了才发的网络请求。关于这个问题我会等 swr merge完最新的 pr 更新下一个版本后再进行实验。

one more thing

除了上面介绍过的以外, Suspense 还给开发者带来了另外一个好处 -- 你不用再写 race condition 了。

在之前的请求方式中,首次渲染组件的时候你是拿不到任何数据的,此时你需要写一个类似于这样的判断:

if (requestStage !== RequestStage.SUCCESS) return null;

而在同一个项目中经不同人的手还会有这样的判断:

if (requestStage === RequestStage.START) return null;

而如果请求挂了,你还得这样:

return requestStage === RequestStage.FAILED ? (
  <SomeComponentYouWantToShow />
) : (
  <YourComponent />
);

这堆东西就是一堆模板代码,有些时候还容易脑抽就忘加了,在 Suspense 下,你再也不用写这些东西了,数据没拿到会直接渲染 Suspense 的 fallback,至于请求错误,在外层加一个 error boundary 就行了,这里就不过多展开了,详见文档

总的来说 Suspense 的初衷是好的,可以提升用户体验,可能现在各个工具包括 React 本身还处于实验阶段,还多多少少会有一些问题,接下来我会尝试去找找怎么解决这些问题再回来更新。下一篇我会接着躺坑 UI 部分。