React Suspense for Data(一)

前言

目前Suspense尚处于实验阶段,大部分文档还没有被翻译。我基于目前的官方文档,对Suspense作一些介绍。

什么是 Suspense ?

⚠️⚠️⚠️本文是概念性的,主要介绍Suspense解决了那些问题,而不是正确的使用方法。目前Facebook只在生产中,使用了SuspenseRelay的集成方案。如果你不使用Relay,可能需要等待一段时间才能在应用程序中真正的使用Suspense。(本文示例中的代码是”伪”代码,真实的实现可能要复杂的多,示例代码不要复制粘贴到你的项目中。)

Suspense是React16.6版本中新增的组件,允许我们等待一些代码的加载,并在等待时声明加载状态。

// 使用React.lazy以及Suspense进行代码分割的例子
const Foo = React.lazy(() => import('./Foo'))
function Component () {
  return (
    <Suspense fallback={<Spinner />}>
      <Component />
    </Suspense>
  )
}

Suspense for Data是一个新的特性。允许您使用Suspense等待任何其他内容。包括Ajax请求异步返回的数据。(本文着重于介绍异步获取数据的例子)。Suspense可以让组件在渲染之前进行等待。Suspense是一种通信机制,告知组件数据尚未准备就绪,React会等待它准备好后更新UI。

Suspense for Data 的简单示例

Suspense for Data Demo

const apiParent = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve({ name: 'parent' }), 1000)
  })
}
const apiChild = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve({ name: 'child' }), 2000)
  })
}
// pending 时,wrapPromise会抛出一个Promise
// resolve 时,wrapPromise会返回结果
const wrapPromise = (promise) => {
  let status = 'pending'
  let result = null
  let suspender = promise.then(res => {
    status = 'success'
    result = res
  }).catch(err => {
    status = 'error'
    result = err
  })
  return {
    read() {
      if (status === 'pending') {
        throw suspender
      } else if (status === 'error') {
        throw result
      } else if (status === 'success') {
        return result
      }
    }
  }
}
const http = () => {
  const parentPromise = apiParent()
  const childPromise = apiChild()
  return {
    parent: wrapPromise(parentPromise),
    child: wrapPromise(childPromise)
  }
}
const resource = http()
function Parent () {
  const result = resource.parent.read()
  return <div>Parent: { result.name }</div>
}
function Child () {
  const result = resource.child.read()
  return <div>Child: { result.name }</div>
}
function App() {
  return (
    <div className="App">
      {/* 在Parent没有返回结果前,显示<h1>Loading Parent...</h1> */}
      <React.Suspense fallback={<h1>Loading Parent...</h1>}>
        <Parent/>
        {/* 在Child没有返回结果前,显示<h1>Loading Child...</h1> */}
        <React.Suspense fallback={<h1>Loading Child...</h1>}>
          <Child/>
        </React.Suspense>
      </React.Suspense>
    </div>
  )
}

Suspense 与传统请求数据的方法

在实际开发一个应用时,应该根据需求混合使用不同的方法。这里区别看待,只是为了更好的权衡它们的取舍。

我们完全可以在不提及其他数据获取方法的情况下,介绍 Suspense。但是这样我们就难以知道,Suspense解决了那些问题,以及Suspense与现在的方案有那些不同。

Approach 1: 渲染时请求数据(例如: useEffect,componentDidMount)

渲染时请求数据,是React应用中常用的获取数据的方法。因为它直到组件在屏幕上进行渲染后,才开始请求数据。会导致所谓的“瀑布”问题。

function Foo () {
  const [state, setState] = useState('')
  useEffect(() => {
    api().then(res => setState(res))
  }, [])
  if (!state) return <div>Loading Foo……</div>
  return (
    <div>Foo</div>
  )
}
function Bar () {
  const [state, setState] = useState('')
  useEffect(() => {
    api().then(res => setState(res))
  }, [])
  if (!state) return <div>Loading Bar……</div>
  return (
    <React.Fragment>
      <div>Bar</div>
      <Foo/>
    </React.Fragment>
  )
}
function App() {
  return (
    <div className="App">
      <Bar/>
    </div>
  )
}

考虑上面的代码。代码的执行顺序将会是

  1. 开始获取Bar组件的数据
  2. 等待……
  3. 完成渲染Bar组件
  4. 开始获取Foo组件的数据
  5. 等待……
  6. 完成渲染Foo组件

如果获取Bar组件的数据,需要花费3秒。那么,我们只能在3秒后,开始获取Foo组件的数据。这就是“瀑布问题”, 应该被并行处理的请求序列。

Approach 2: 请求数据完成后渲染

我们可以使用Promise.all避免瀑布问题。

const api = (ms = 1000, type) => {
  return new Promise(resolve => {
    setTimeout(() => resolve('result'), ms)
  })
}
const fakeHttp = () => {
  return Promise.all([api(1000, 'bar'), api(3000, 'foo')])
}
const promise = fakeHttp()
function Foo (props) { 
  if (!props.state) return <div>Loading Foo……</div>
  return (
    <div>Foo</div>
  )
}
function Bar () {
  const [state, setState] = useState('')
  useEffect(() => {
    promise.then(res => setState(true))
  }, [])
  if (!state) return <div>Loading Bar……</div>
  return (
    <React.Fragment>
      <div>Bar</div>
      <Foo state={state}/>
    </React.Fragment>
  )
}

考虑上面的代码。代码的执行顺序将会是

  1. 开始获取Bar组件的数据
  2. 开始获取Foo组件的数据
  3. 等待……
  4. 完成渲染Bar组件
  5. 完成渲染Foo组件

我们解决了瀑布问题。但是却映入了另一个问题,我们必须等待所有数据返回后才开始渲染。

虽然我们可以把请求从Promise.all拆开,分别发起两个Promise,但是随着组件树越发的复杂,这显然不是一个好主意,维护起来将会相当的困难。

Approach 3: 按需渲染(例如:集成了Suspense的Relay)

在之前的方法中。我们的步骤都是

  1. 开始异步获取数据
  2. 异步请求完成
  3. 开始渲染

使用Suspense后,我们可以无需等待响应返回就开始渲染。

  1. 开始异步获取数据
  2. 开始渲染
  3. 异步请求完成
const resource = http()
function Foo () {
  const result = resource.foo.read()
  return <div>Foo: { result.name }</div>
}
function Bar () {
  const result = resource.bar.read()
  return <div>Bar: { result.name }</div>
}
function Page () {
  return (
    <React.Suspense fallback={<h1>Loading Foo...</h1>}>
      <Foo/>
      <React.Suspense fallback={<h1>Loading Bar...</h1>}>
        <Bar/>
      </React.Suspense>
    </React.Suspense>
  )
}
function App() {
  return (
    <div className="App">
      <Page/>
    </div>
  )
}
  1. 渲染之前,http开始请求数据,它会返回一个特殊的资源,而不是Promise(这通常由实现了Suspense的请求库进行封装)。
  2. React尝试渲染Page组件,返回Foo,和Bar作为子组件。
  3. React尝试渲染Foo,resource.foo.read()没有返回数据,组件被挂起。React跳过它,尝试渲染树中的其他组件。
  4. React尝试渲染Bar,resource.bar.read()没有返回数据,组件被挂起,React跳过它。
  5. 暂时没有东西可以渲染了,React会渲染组件树最上方的Suspense fallback
  6. 随着数据的流入,React会尝试重新渲染,最终获取所有的数据后,页面上的Suspense fallback将会消失。

当我们调用read()方法时,要么获取数据,要么将组件挂起

使用Suspense可以帮助我们消除if (statr) return loading这样的的模版代码。我们还可以根据需要,增删Suspense组件控制加载状态的粒度(比如,两个列表的情况下。我只想要一个加载态,可以在两个列表的外面,统一添加一层Suspense边界。如果需想要两个加载态,可以给各个列表各添加一个Suspense边界)而无需对组件代码进行侵入式的修改。

Suspense 与竞态问题


const api = (id) => {
  const ms = getRandomTime()
  return new Promise(resolve => {
    setTimeout(() => resolve(id), ms)
  })
}
function Bar (props) {
  const { id, clickNumber } = props
  if (!id) return <h1>Loading……</h1>
  return (
    <div>state: { id } clickNumber: { clickNumber }</div>
  )
}
let id = 0
let clickNumber = 0
function Page () {
  const [selfId, setSelfId] = useState(id)
  return (
    <>
      <button onClick={() => {
        id += 1
        clickNumber += 1
        api(id).then((id) => setSelfId(id))
      }}>+</button>
      <Bar id={selfId} clickNumber={clickNumber}/>
    </>
  )
}

Race Conditions Bug

在上面的代码,接口返回的结果可能存在“竞态”的问题。

因为每一次接口响应返回时间是不确定的,所以可能存在前一次的返回的结果,覆盖后一次的情况。而使用Suspense可以很好的解决竞态的问题。下面我们使用Suspense重写示例。


const api = (id) => {
  const ms = getRandomTime()
  return new Promise(resolve => {
    setTimeout(() => resolve(id), ms)
  })
}
const http = (id) => {
  return wrapPromise(api(id))
}
function Bar (props) {
  const { resource, clickNumber } = props
  const id = resource.read()
  return (
    <div>state: { id } clickNumber: { clickNumber }</div>
  )
}
let id = 0
let clickNumber = 0
const initResource = http(id)
function Page () {
  const [resource, setResource] = useState(initResource)
  return (
    <>
      <button onClick={() => {
        id += 1
        clickNumber += 1
        setResource(http(id))
      }}>+</button>
      <React.Suspense fallback={<h1>Loading……</h1>}>
        <Bar resource={resource} clickNumber={clickNumber}/>
      </React.Suspense>
    </>
  )
}

Race Conditions Suspense OK

Suspense版本的例子中,我们不需要等待响应结束后设置组件的状态,这样很容易出错,因为我们需要考虑设置对应状态的时机。我们直接传给子组件资源对象resource,只要resource.read没有返回数据,组件将一直处于挂起的状态,当props.resource更新,重新请求,组件依然处于挂起的状态,只到resource.read返回数据,组件才会被重新渲染,我们就不需要考虑竞态的问题。

Suspense 处理错误

当异步请求发生了错误,Suspense可以借助“错误边界”捕获异步请求抛出的错误。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false }
  }
  static getDerivedStateFromError() {
    return { hasError: true }
  }
  render () {
    if (this.state.hasError) {
      return <h1>:( error</h1>
    }
    return this.props.children; 
  }
}
function Page () {
  const [resource, setResource] = useState(initResource)
  return (
    <>
      <button onClick={() => {
        id += 1
        clickNumber += 1
        setResource(http(id))
      }}>+</button>
      {/* 使用错误边界捕获异步错误 */}
      <ErrorBoundary>
        <React.Suspense fallback={<h1>Loading……</h1>}>
          <Bar resource={resource} clickNumber={clickNumber}/>
        </React.Suspense>
      </ErrorBoundary>
    </>
  )
}

结语

上面仅是作者自己的理解,如有错误请及时指出。

参考

https://juejin.im/post/5e247cfd5188254c257c4438

「点点赞赏,手留余香」

    还没有人赞赏,快来当第一个赞赏的人吧!
0 条回复 A 作者 M 管理员
    所有的伟大,都源于一个勇敢的开始!
欢迎您,新朋友,感谢参与互动!欢迎您 {{author}},您在本站有{{commentsCount}}条评论