## 1 引言

很多人都用过 React Suspense，但如果你认为它只是配合 React.lazy 实现异步加载的蒙层，就理解的太浅了。实际上，React Suspense 改变了开发规则，要理解这一点，需要作出思想上的改变。

我们结合 [Why React Suspense Will Be a Game Changer](https://medium.com/react-in-depth/why-react-suspense-will-be-a-game-changer-37b40fea71ec) 这篇文章，带你重新认识 React Suspense。

## 2 概述

异步加载是前端开发的重要环节，也是一直以来样板代码最严重的场景之一，原文通过三种取数方案的对比，逐渐找到一种最佳的异步取数方式。

在讲解这三种取数方案之前，首先通过下面这张图说明了 Suspense 的功能：

![](https://img.alicdn.com/tfs/TB12.npyoz1gK0jSZLeXXb9kVXa-1024-808.gif)

从上图可以看出，子元素在异步取数时会阻塞父组件渲染，并一直冒泡到最外层第一个 Suspense，此时 Suspense 不会渲染子组件，而是渲染 `fallback`，当所有子组件异步阻塞取消后才会正常渲染。

下面介绍文中给出的三种取数方式，首先是最原始的本地状态管理方案。

### 本地异步状态管理，直白但不利于维护

在 Suspense 方案出来之前，我们一般都在代码中利用本地状态管理异步数据。

即便代码做了一定抽象，那也只是把逻辑从一个文件移到了另一个问题，可维护性与可拓展性都没有本质的改变，因此基本可以用下面的结构说明：

```javascript
class DynamicData extends Component {
  state = {
    loading: true,
    error: null,
    data: null
  };

  componentDidMount() {
    fetchData(this.props.id)
      .then(data => {
        this.setState({
          loading: false,
          data
        });
      })
      .catch(error => {
        this.setState({
          loading: false,
          error: error.message
        });
      });
  }

  componentDidUpdate(prevProps) {
    if (this.props.id !== prevProps.id) {
      this.setState({ loading: true }, () => {
        fetchData(this.props.id)
          .then(data => {
            this.setState({
              loading: false,
              data
            });
          })
          .catch(error => {
            this.setState({
              loading: false,
              error: error.message
            });
          });
      });
    }
  }

  render() {
    const { loading, error, data } = this.state;
    return loading ? (
      <p>Loading...</p>
    ) : error ? (
      <p>Error: {error}</p>
    ) : (
      <p>Data loaded ?</p>
    );
  }
}
```

如上所述，首先申明本地状态管理至少三种数据：异步状态、异步结果与异步错误，其次在不同的生命周期中处理初始化发请求与重新发请求的问题，最后在渲染函数中根据不同的状态渲染不同的结果，所以实际上我们写了三个渲染组件。

从下面几个角度对上述代码进行评价：

- **冗余的三种状态 - 糟糕的开发体验**
  - 很明显，存储了三套数据，渲染三种结果，不利于开发维护。
- **冗余的样板代码 - 糟糕的开发体验**
  - 为了管理异步状态，上述代码非常冗长，显然这个问题是存在的。
- **数据与状态封闭性 - 糟糕的用户体验 + 开发体验**
  - 所有数据与状态管理都存储在每一个这种组件中，将取数状态与组件绑定的结果就是，我们只能忍受组件独立运行的 Loading 逻辑，而无法对他们进行统一管理。
- **重新取数 - 糟糕的开发体验**
  - 需要在另一个生命周期中申明重新取数，很明显是个麻烦的行为。
- **一闪而过的短暂 Loading - 糟糕的用户体验**
  - 如果用户网速足够快，则 Loading 时间会非常短，此时一闪而过的 Loading 反而比没有 Loading 更烦人，我们应该在用户感知到卡的时候再出现 Loading 状态。

### Context 管理状态，有进步但问题依然很多

如果利用 Context 做状态共享，我们将取数的数据管理与逻辑代码写在父组件，子组件专心用于展示，效果会好一些，代码如下：

```javascript
const DataContext = React.createContext();

class DataContextProvider extends Component {
  // We want to be able to store multiple sources in the provider,
  // so we store an object with unique keys for each data set +
  // loading state
  state = {
    data: {},
    fetch: this.fetch.bind(this)
  };

  fetch(key) {
    if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
      // Data is either already loaded or loading, so no need to fetch!
      return;
    }

    this.setState(
      {
        [key]: {
          loading: true,
          error: null,
          data: null
        }
      },
      () => {
        fetchData(key)
          .then(data => {
            this.setState({
              [key]: {
                loading: false,
                data
              }
            });
          })
          .catch(e => {
            this.setState({
              [key]: {
                loading: false,
                error: e.message
              }
            });
          });
      }
    );
  }

  render() {
    return <DataContext.Provider value={this.state} {...this.props} />;
  }
}

class DynamicData extends Component {
  static contextType = DataContext;

  componentDidMount() {
    this.context.fetch(this.props.id);
  }

  componentDidUpdate(prevProps) {
    if (this.props.id !== prevProps.id) {
      this.context.fetch(this.props.id);
    }
  }

  render() {
    const { id } = this.props;
    const { data } = this.context;

    const idData = data[id];

    return idData.loading ? (
      <p>Loading...</p>
    ) : idData.error ? (
      <p>Error: {idData.error}</p>
    ) : (
      <p>Data loaded ?</p>
    );
  }
}
```

`DataContextProvider` 组件承担了状态管理与异步逻辑工作，而 `DynamicData` 组件只需要从 Context 获取异步状态渲染即可，这样来看至少解决了一部分问题，我们还是从之前的角度进行评价：

- **冗余的三种状态 - 糟糕的开发体验**
  - 问题依然存在，只不过代码的位置转移了一部分到父组件。
- **冗余的样板代码 - 糟糕的开发体验**
  - 将展示与逻辑分离，成功降低了样板代码数量，至少当一个异步数据复用于多个组件时，不需要写多份样板代码了。
- **数据与状态封闭性 - 糟糕的用户体验 + 开发体验**
  - 这个问题得到一定程度解决，但是引入了新问题，即这个子组件仅在特定环境下可以正常运行。但在一个良好的设计下，组件运行不应该依赖于它所处的位置。
- **重新取数 - 糟糕的开发体验**
  - 问题依然存在。
- **一闪而过的短暂 Loading - 糟糕的用户体验**
  - 问题依然存在。

### Suspense 管理状态，最棒的方案

利用 Suspense 进行异步处理，代码处理大概是这样的：

```javascript
import createResource from "./magical-cache-provider";
const dataResource = createResource(id => fetchData(id));

class DynamicData extends Component {
  render() {
    const data = dataResource.read(this.props.id);
    return <p>Data loaded ?</p>;
  }
}

class App extends Component {
  render() {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <DynamicData />
        </DeepNesting>
      </Suspense>
    );
  }
}
```

在原文写作的时候，Suspense 仅能对 React.lazy 生效，但现在已经可以对任何异步状态生效了，只要符合 Pending 中 throw promise 的规则。

我们再审视一下上面的代码，可以发现代码量减少了很多，其中和转换成 Function Component 的写法也有关系。

最后还是从如下几个角度进行评价：

- **冗余的三种状态 - 糟糕的开发体验** - ⭐️
  - 可以看到，组件只要处理成功得到数据的状态即可，三种状态合并成了一种状态。
- **冗余的样板代码 - 糟糕的开发体验** - ⭐️
  - 展示与逻辑完全分离，展示只要拿到数据展示 UI 即可。
- **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - ⭐️
  - 这个问题得到了完美的解决，具体看下面详细介绍。
- **重新取数 - 糟糕的开发体验** - ⭐️
  - 不需要关心何时需要重新取数，当数据变化时会自动执行。
- **一闪而过的短暂 Loading - 糟糕的用户体验**
  - 问题依然存在。

为了进一步说明 Suspense 的魔力，笔者特意把这段代码单独拿出来说明：

```javascript
class App extends Component {
  render() {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <MaybeSomeAsycComponent />
          <Suspense fallback={<p>Loading content...</p>}>
            <ThereMightBeSeveralAsyncComponentsHere />
          </Suspense>
          <Suspense fallback={<p>Loading footer...</p>}>
            <DeeplyNestedFooterTree />
          </Suspense>
        </DeepNesting>
      </Suspense>
    );
  }
}
```

上面代码表明了逻辑与展示的完美分离。

从代码结构上来看，我们可以在任何需要异步取数的组件父级添加 Suspense 达到 Loading 的效果，也就是说，如果只在最外层加一个 Suspense，那么整个应用所有 Loading 都结束后才会渲染，然而我们也能随心所欲的在任何层级继续添加 Suspense，那么对应作用域内的 Loading 就会首先执行完毕，并由当前的 Suspense 控制。

**这意味着我们可以自由决定 Loading 状态的范围组合。** 试想当 Loading 状态交由组件控制的方案一与方案二，是不可能做到合并 Loading 时机的，而 Suspense 方案做到了将 Loading 状态与 UI 分离，我们可以通过添加 Suspense 自由控制 Loading 的粒度。

## 3 精读

Suspense 对所有子组件异步都可以作用，因此无论是 React.lazy 还是异步取数，都可以通过 Suspense 进行 Pending。

异步时机被 Suspense pending 需要遵循一定规则，这个规则在之前的 [精读《Hooks 取数 - swr 源码》](https://github.com/dt-fe/weekly/blob/v2/128.%E7%B2%BE%E8%AF%BB%E3%80%8AHooks%20%E5%8F%96%E6%95%B0%20-%20swr%20%E6%BA%90%E7%A0%81%E3%80%8B.md) 有介绍过，即 Suspense 要求代码 suspended，即抛出一个可以被捕获的 Promise 异常，在这个 Promise 结束后再渲染组件，因此取数函数需要在 Pending 状态时抛出一个 Promise，使其可以被 Suspense 捕获到。

另外，关于文中提到的 fallback 最小出现时间的保护间隔，目前还是一个 [Open Issue](https://github.com/facebook/react/issues/17351)，也许有一天 React 官方会提供支持。

不过即便官方不支持，我们也有方式实现，即让这个逻辑由 fallback 组件实现：

```jsx
<Suspense fallback={MyFallback} />;

const MyFallback = () => {
  // 计时器，200 ms 以内 return null，200 ms 后 return <Spin />
};
```

## 4 总结

之所以说 Suspense 开发方式改变了开发规则，是因为它做到了将异步的状态管理与 UI 组件分离，所有 UI 组件都无需关心 Pending 状态，而是当作同步去执行，这本身就是一个巨大的改变。

另外由于状态的分离，我们可以利用纯 UI 组件拼装任意粒度的 Pending 行为，以整个 App 作为一个大的 Suspense 作为兜底，这样 UI 彻底与异步解耦，哪里 Loading，什么范围内 Loading，完全由 Suspense 组合方式决定，这样的代码显然具备了更强的可拓展性。

> 讨论地址是：[精读《Suspense 改变开发方式》 · Issue #238 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/238)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
