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 - Concurrent Mode 之 <Suspense /> #60

Open
jtwang7 opened this issue Feb 17, 2023 · 0 comments
Open

React - Concurrent Mode 之 <Suspense /> #60

jtwang7 opened this issue Feb 17, 2023 · 0 comments

Comments

@jtwang7
Copy link
Owner

jtwang7 commented Feb 17, 2023

React - Concurrent Mode 之 <Suspense />

参考:

本文对于 <Suspense /> 组件的作用,使用场景,实现机制等都不再具体概述,若想要深入了解,请仔细阅读上述参考文章。
本文主要教您如何正确实现一个 <Suspense /> 组件并在项目中成功运行。

<Suspense />

Suspense 是 React 提供的一种异步处理的机制。它是React 提供的原生的组件异步调用原语。在 React 没有正确提出 Suspense 这一概念之前,组件请求数据并异步调用通常需要维护一个 loading 变量,来确保组件在不同请求阶段展示不同的视觉效果:

const [loading, setLoading] = useState(true)

fetchData( ... ).then( data => {
    // do something...
    setLoading(false)
})

// switch UI
loading ? (<div>Loading ... </div>) : (<other Component />)

<Suspense /> 的出现让我们可以用同步的方法去书写异步组件。Suspense 翻译为中文的话是等待、悬垂、悬停的意思。React给出了一个更规范的定义:

Suspense 不是一个‘数据获取库’, 而是一种提供给‘数据获取库’的机制,数据获取库通过这种机制告诉 React 数据还没有准备好,然后 React就会等待它完成后,才继续更新 UI。

也就是说,React 推出了一套专门用于异步组件加载的方式,通过 <Suspense /> 包裹的组件会在数据加载完成前展示预定的组件,等待数据加载完毕后展示完整的组件。

初窥门径

// 异步组件 (包含异步请求)
function Posts() {
  const posts = useQuery(GET_MY_POSTS)

  return (
    <div className="posts">
      {posts.map(i => <Post key={i.id} value={i}/>)}
    </div>
  )
}

function App() {
  return (<div className="app">
    // Suspense 包裹异步组件,并提供一个 fallback 回退组件
    <Suspense fallback={<Loader>Posts Loading...</Loader>}>
      <Posts />
    </Suspense>
  </div>)
}

有两个要点值得去注意:

  1. 我们需要 Suspense 来包裹这些包含异步操作的组件,并给它们提供回退(fallback)。在异步请求期间,会显示这个回退。
  2. 上面的代码获取异步资源就跟同步调用似的。有了 Suspense, 我们可以和async/await或者Generator 一样,用’同步‘的代码风格来处理异步请求。

🔥 原理剖析

<Suspense /> 其实利用了 React 的 ErrorBoundary 类似的机制来实现。<Suspense /> 可以捕获组件中抛出的 promise 异常,并中断该组件的渲染,并用 fallback 注册的回退组件暂时替代,直到 promise 成功执行或返回非 promise 的异常状态时重启组件渲染。

当组件中抛出 Promise 异常时,React 会向上查找最近的 Suspense 来处理它,如果找不到,React 会抛出错误。

基于这个原理,我们就初步掌握了 <Suspense /> 中断组件渲染的核心 —— 包裹组件抛出 promise 异常即可中断渲染。这个过程是可由我们控制的,通常的场景就是数据请求,当我们挂在组件并请求数据时,可在等待阶段抛出 promise 异常从而调用 fallback 实现等待。
<Suspense /> 恢复组件渲染是由异步结果及 <Suspense /> 内部自身掌控和实现的。<Suspense /> 内置了捕获 promise 异常的专属逻辑,它会调用这个 error promise 对象继续执行,等待 promise 状态改变后,主动触发重渲染,代码模拟实现如下:

export interface SuspenseProps {
  fallback: React.ReactNode
}

interface SuspenseState {
  pending: boolean
  error?: any
}

export default class Suspense extends React.Component<SuspenseProps, SuspenseState> {
  // ⚛️ 首先,记录是否处于挂载状态,因为我们不知道异步操作什么时候完成,可能在卸载之后
  // 组件卸载后就不能调用 setState 了
  private mounted = false

  // 组件状态
  public state: SuspenseState = {
    // ⚛️ 表示现在正阻塞在异步操作上
    pending: false,
    // ⚛️ 表示异步操作出现了问题
    error: undefined
  }

  public componentDidMount() {
    this.mounted = true
  }

  public componentWillUnmount() {
    this.mounted = false
  }

  // ⚛️ 使用 Error Boundary 机制捕获下级异常
  public componentDidCatch(err: any) {
    if (!this.mounted) {
      return
    }

    // ⚛️ 判断是否是 Promise, 如果不是则向上抛
    if (isPromise(err)) {
      // 设置为 pending 状态
      this.setState({ pending: true })
      err.then(() => {
        // ⚛️ 异步执行成功, 关闭pending 状态, 触发重新渲染
        this.setState({ pending: false })
      }).catch(err => {
        // ⚛️ 异步执行失败, 我们需要妥善处理该异常,将它抛给 React
        // 因为处于异步回调中,在这里抛出异常无法被 React 捕获,所以我们这里先记录下来
        this.setState({ error: err || new Error('Suspense Error')})
      })
    } else {
      throw err
    }
  }

  // ⚛️ 在这里将 异常 抛给 React
  public componentDidUpdate() {
    if (this.state.pending && this.state.error) {
      throw this.state.error
    }
  }

  public render() {
    // ⚛️ 在pending 状态时渲染 fallback
    return this.state.pending ? this.props.fallback : this.props.children
  }
}

👉 注意

  1. 在异步过程中,抛出的对象并非 Error Object,而是一个 Promise Object,<Suspense /> 捕获包裹组件 throw 的 Promise Object 之后,内部调用它并等待 promise 状态改变,主动触发自身的重渲染,其包裹的组件由于是其子组件,也会重启渲染过程,从而以“同步“的姿态获取到请求的数据完成页面布局和绘制。
  2. <Suspense /> 触发重渲染并不一定是调用 setState,本例只是简单模拟。
  3. 以上代码只在v16.6(不包括)之前有效. 16.6正式推出 Suspense 后,Suspense 就和普通的 ErrorBoundary 隔离开来了,所以无法在 componentDidCatch 中捕获到 Promise。

🌈 createSuspenseResource

我们已知了触发 <Suspense /> 的方法: 异步组件内部 throw Promise。但是仍存在一个问题:异步组件死循环
设想一个场景:

  1. 我们在 <Suspense /> 包裹组件A 中编写了一个数据请求。
  2. 在该请求等待阶段抛出一个 promise ,意图触发 Suspense 挂起。
  3. Suspense 会捕获这个 promise 并在内部维护它。
  4. promise 接收到数据,Suspense 触发重渲染。
  5. 组件A 触发重渲染,并再次调用了数据请求,又抛出了一个 promise。
  6. 跳转到步骤 3 得到死循环

因此,我们需要一个变量来额外缓存当前的 promise 状态,避免死循环的发生,可选方案有:

  • 全局缓存。 例如全局变量、全局状态管理器(如Redux、Mobx)
  • 使用 Context API
  • 由父级组件来缓存状态

本文提供的方法是将变量提升到父组件缓存,原理其实一样,实际使用时要学会融会贯通:

type StateType = "initial" | "pending" | "resolved" | "rejected";
const STATE: { [key: string]: StateType } = {
  INITIAL: "initial",
  PENDING: "pending",
  RESOLVED: "resolved",
  REJECTED: "rejected",
};

export interface Resource {
  read(): any;
  preload(): void;
}

export function createSuspenseResource<T>(load: () => Promise<T>): Resource {
  // 缓存变量
  const result: {
    state: StateType;
    value: any;
  } = {
    state: STATE.INITIAL,
    value: null,
  };

  // 开启异步操作
  function init() {
    if (result.state !== STATE.INITIAL) {
      return;
    }
    result.state = STATE.PENDING;
    const p = (result.value = load());
    p.then(
      (res) => {
        if (result.state === STATE.PENDING) {
          result.state = STATE.RESOLVED;
          result.value = res;
        }
      },
      (err) => {
        if (result.state === STATE.PENDING) {
          result.state = STATE.REJECTED;
          result.value = err;
        }
      }
    );
    return p;
  }

  return {
    // 针对不同异步状态调用不同的逻辑
    read() {
      switch (result.state) {
        case STATE.INITIAL:
          throw init();
        case STATE.PENDING:
          throw result.value;
        case STATE.RESOLVED:
          return result.value;
        case STATE.REJECTED:
          throw result.value;
      }
    },
    preload() {
      init();
    },
  };
}

可以看到,该函数的作用无非是两点:

  1. 维护当前异步操作的状态
  2. 调用查看当前异步操作状态,并基于不同状态调用不同的逻辑,例如:
  • initial 初始化或 pending 等待时,抛出 promise 触发 suspense 挂起
  • resolved 时,传递数据
  • rejected 时,抛出错误信息

createSuspenseResource 的用法也很简单, 在父组件创建 Resource ,将异步状态缓存变量保存在父组件中,然后将触发对象 resourece 通过 Props 传递给子组件, 子组件调用 resource.read() 触发异步操作,并在每次重渲染时调用 resource.read() 检查当前的异步操作进度。

组件层级如下

<Parent Component>
  // create resource
  const resource = createResource(...)

  <Suspense>
     <Child Component resource={resource} >
        { resourece.read() }
     </ Child>
  </ Suspense>

</ Parent Component>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant