Skip to content

解析 React 新特性 Suspense #38

@lihongxun945

Description

@lihongxun945

Suspense 是什么

Suspense 是React今年推出的一个新特性,在 16.6 已经可以部分使用,不过完整的特性支持要等到2019年中。简单的说,Suspense 可以以更简单的方式来实现异步数据获取。

让我们通过一个示例来理解,假设我们有一个组件,可以加载并显示一个电影的名字和简介。为了演示,我们不会获取一个列表,而是每一个电影单独获取自己的数据。这样就会有两个组件:

  • List 组件会是电影列表,其中每一个电影信息都是一个 Movie 组件
  • Movie 组件会负责加载并显示指定id的电影信息

假设我们不借助任何 Redux 之类的数据层框架,我们一般会这样实现:

movie.js

import React, { Component } from 'react';
import Spinner from '../Spinner.js';
import { fetchMovie } from '../api.js';

class Movie extends Component {
  constructor(props) {
    super(props)
    this.state = {
      loading: true,
      data: undefined
    }
  }
  componentDidMount() {
    fetchMovie(this.props.id).then((data) => {
      this.setState({
        loading: false,
        data: data
      })
    })
  }
  render() {
    if (this.state.loading) return <Spinner />;
    return (
      <div className="movie">
        <h4>{this.state.data.title}</h4>
        <p>{this.state.data.info}</p>
      </div>
    )
  }
}

export default Movie

list.js

import React, { Component } from 'react';
import Movie from './movie.js';

class List extends Component {
  render() {
    return (
      <div>
        <h2>热门电影</h2>
        <Movie id={0} />
        <Movie id={1} />
        <Movie id={2} />
        <Movie id={3} />
      </div>
    );
  }
}

export default List;

这是一段很常见的代码,基本逻辑如下:

  • 组件加载的时候显示一个加载中的状态,同时去异步加载数据
  • 数据加载完成后显示电影信息

那么这么做存在哪些问题呢?

  • 额外的 loading 标记
  • 组件渲染的生命周期和数据加载周期混在一起
  • 每次加载Movie组件都会重新请求数据
  • 使用多个 Movie 的时候会出现多个 Spinner
    那么Suspense 是如何解决这些问题的?

suspense 如何解决

首先我们看用 Suspense 是如何写上述组件的:

import React from 'react';
import { fetchMovie } from '../api.js';
import { unstable_createResource as createResource } from "react-cache";

/**
 * 使用Suspense实现
 */

const MovieFetcher = createResource(fetchMovie);

export default (props) => {
  const data = MovieFetcher.read(props.id)
  return (
    <div className="movie">
      <h4>{data.title}</h4>
      <p>{data.info}</p>
    </div>
  )
}

有人可能立马发现了,不是说好了用 Suspense 吗,在哪呢?

其实是因为 Suspense 会被放在父组件中,别急,我们先看 Movie 组件。

这里有一个非常反常的地方,就是在 render 中竟然加载了数据,而且就直接用了。显然正常情况下这样是错的。

  • 第一,render中不应该加载数据,因为render可能会多次被执行导致多次加载数据
  • 第二,就算非要这么写也没用,因为数据加载是异步的

然而事实是这确实是正确的写法,当执行到 return 的时候我们的数据已经获取到了。这正是 Suspense 的神奇之处,他可以在获取异步数据的时候 暂停 渲染,当数据获取到的时候继续渲染。为了搞清楚他是如何做到的,让我们继续看下父组件是怎么样的。

比如我们有一个 list 组件会显示多个 Movie,那么 List 组件如下

import React, { Suspense, Component } from 'react';
import Movie from './movie.js';
import Spinner from '../Spinner.js';

class List extends Component {
  render() {
    return (
      <div>
        <h2>热门电影</h2>
        <Suspense maxDuration={1500} fallback={<Spinner />}>
          <Movie id={0} />
          <Movie id={1} />
          <Movie id={2} />
          <Movie id={3} />
        </Suspense>
      </div>
    );
  }
}

export default List;

可以看到 Movie 会被一个 Suspense 包裹起来。当Movie在加载数据的时候,Suspense 会降级显示 fallback 中的内容,加载完成后会显示真实的数据。

原理是这样的:

  • createResource 加载数据的时候会进行缓存,如果有缓存就直接用,当没有缓存的时候会抛出一个异常并异步加载数据,这个异常是一个 Promise ,加载完成后会缓存数据并 resolve
  • 由于 Movie 加载数据的时候会抛出异常,因此执行到 const data = MovieFetcher.read(props.id) 会由于抛出异常而停止执行
  • Suspense 会捕获这个异常,然后显示 fallback 中的内容
  • 当异常都被 resolve 之后,Suspense 才会 render children。也就是 movierender 函数会被重新调用,因为已经有数据了,就可以正常渲染了。

所以 Suspense暂停 渲染的魔法就是通过异常来实现的。不过这里需要注意, render 被暂停之后,恢复执行的时候是重新执行了 render ,而不是从被暂停的地方继续执行,JS应该无法实现这种自由的暂停和恢复执行。

模拟实现方式

为了搞清楚Suspense的原理,让我们自己动手实现一个简陋的版本。要实现Suspense的功能,我们只需要实现两个关键点即可:

  • createResource 函数,他会在数据没有加载的时候返回一个 Promise,并且在加载到数据之后 resolve
  • Suspense 高阶组件,如果发现子组件抛出了一个 Promise 错误,则显示 fallback ,当 resolve 之后就显示孩子。

因为完整的实现会稍微麻烦一些,这里为了演示原理,只做一个简单的实现。我们先实现createResource,只支持一个并发请求(因为我们用了一个全局 result 变量做缓存)。实现原理是这样的:

用一个result缓存请求结果,当调用 read 函数的时候先去判断有没有缓存数据,有缓存就返回,没有缓存就抛出一个 promise 异常,并且在resolve的时候缓存数据。

// 简单mock,只支持一个请求
// 重要提示:dev模式下会把错误显示出来,所以请在build模式下运行

let result = undefined;

export const createResource = (p) => {
  return {
    read (a) {
      if (result) return result;
      const _p = p(a).then((r) => {
        result = r;
      });
      throw { p: _p }; // 不要直接抛出 _p,否则react会当做内置的Suspense逻辑处理
    }
  }
}

细心的读者可能会发现,为什么不直接抛出 _p 而是用一个对象包装一下呢?这是因为 React 内部有对 Suspense 特性的支持,如果他发现有一个组件抛出了一个 Promise,就必须要求外面有一个 Suspense组件进行处理,而我们自己写的 Suspense 显然 react是不会认可的,因此会显示错误。所以这里用一个对象进行了一次包装。

那么 Suspense 应该怎么写呢?
Suspense 显然是一个高阶组件,他会包装我们的组件,在未加载的时候显示 fallback ,加载完成的时候显示我们的组件。通过 componentDidCatch 可以捕获 createResource 抛出的异常,并且能在 resolve 的时候读取数据。

import React, { Component } from 'react';

export default class extends Component {
  state = { loading: false };
  componentDidCatch(error, info) {
    this.setState({
      loading: true
    });
    error.p.then(() => {
      this.setState({
        loading: false
      });
    });
  }
  render() {
    return this.state.loading ? this.props.fallback : this.props.children
  }
}

这样我们就自己实现了一个 Suspense,虽然很简陋,不过可以揭示Suspense的原理。

Suspense 的其他特性

Suspense还有一些其他特性,这里不一一展开。

  • 可以设置一个阈值时间,在这个时间内不用显示loading,这样在较快的网速下就不会闪
  • 支持 code splitting

suspense的优缺点

Suspense是一个争议很大的特性,因为他其实颠覆了React的一些哲学:

  • React只负责View层,不会管数据
  • render函数不能有数据获取操作
  • 纯函数组件应该是无状态的

实际使用的时候还发现,Suspense 会等待所有的孩子都加载完成之后才能显示,这样如果有一个加载很慢就会导致页面空白很久。

虽然有这些问题,但是这个特性依然有一些好处:

  • 一些有简单数据逻辑的组件将变得更加简洁易懂
  • 视图和数据状态的解耦
  • 可以替代部分redux的功能

有人可能会问是不是以后就不需要 Redux 之类的数据层框架了?我的看法的是,Suspense 能替代Redux 中获取数据,缓存数据的能力,但是无法替代跨层级的数据通信能力。因此对于需要经常进行跨层级组件通信的复杂应用来说,Suspense 依然无法满足需要。

资源

这篇博客里面提到的全部代码都在这个仓库中可以找到,包括三种不同实现方式的代码:https://github.com/lihongxun945/suspense-demo

参考
https://medium.com/@Charles_Stover/react-suspense-with-the-fetch-api-a1b7369b0469

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions