## 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 ? (

Loading...

) : error ? (

Error: {error}

) : (

Data loaded ?

); } } ``` 如上所述,首先申明本地状态管理至少三种数据:异步状态、异步结果与异步错误,其次在不同的生命周期中处理初始化发请求与重新发请求的问题,最后在渲染函数中根据不同的状态渲染不同的结果,所以实际上我们写了三个渲染组件。 从下面几个角度对上述代码进行评价: - **冗余的三种状态 - 糟糕的开发体验** - 很明显,存储了三套数据,渲染三种结果,不利于开发维护。 - **冗余的样板代码 - 糟糕的开发体验** - 为了管理异步状态,上述代码非常冗长,显然这个问题是存在的。 - **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - 所有数据与状态管理都存储在每一个这种组件中,将取数状态与组件绑定的结果就是,我们只能忍受组件独立运行的 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 ; } } 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 ? (

Loading...

) : idData.error ? (

Error: {idData.error}

) : (

Data loaded ?

); } } ``` `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

Data loaded ?

; } } class App extends Component { render() { return ( Loading...

}>
); } } ``` 在原文写作的时候,Suspense 仅能对 React.lazy 生效,但现在已经可以对任何异步状态生效了,只要符合 Pending 中 throw promise 的规则。 我们再审视一下上面的代码,可以发现代码量减少了很多,其中和转换成 Function Component 的写法也有关系。 最后还是从如下几个角度进行评价: - **冗余的三种状态 - 糟糕的开发体验** - ⭐️ - 可以看到,组件只要处理成功得到数据的状态即可,三种状态合并成了一种状态。 - **冗余的样板代码 - 糟糕的开发体验** - ⭐️ - 展示与逻辑完全分离,展示只要拿到数据展示 UI 即可。 - **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - ⭐️ - 这个问题得到了完美的解决,具体看下面详细介绍。 - **重新取数 - 糟糕的开发体验** - ⭐️ - 不需要关心何时需要重新取数,当数据变化时会自动执行。 - **一闪而过的短暂 Loading - 糟糕的用户体验** - 问题依然存在。 为了进一步说明 Suspense 的魔力,笔者特意把这段代码单独拿出来说明: ```javascript class App extends Component { render() { return ( Loading...

}> Loading content...

}>
Loading footer...

}>
); } } ``` 上面代码表明了逻辑与展示的完美分离。 从代码结构上来看,我们可以在任何需要异步取数的组件父级添加 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 ; const MyFallback = () => { // 计时器,200 ms 以内 return null,200 ms 后 return }; ``` ## 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),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))