Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

解析 React 新特性 Suspense #38

Open
lihongxun945 opened this issue Jan 29, 2019 · 0 comments
Open

解析 React 新特性 Suspense #38

lihongxun945 opened this issue Jan 29, 2019 · 0 comments

Comments

@lihongxun945
Copy link
Owner

lihongxun945 commented Jan 29, 2019

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

@lihongxun945 lihongxun945 changed the title 解析 React 最具争议的新特性 Suspense 解析 React 新特性 Suspense Jul 26, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant