精读《Suspense 改变开发方式》
1 引言
很多人都用过 React Suspense,但如果你认为它只是配合 React.lazy 实现异步加载的蒙层,就理解的太浅了。实际上,React Suspense 改变了开发规则,要理解这一点,需要作出思想上的改变。
我们结合 Why React Suspense Will Be a Game Changer 这篇文章,带你重新认识 React Suspense。
2 概述
异步加载是前端开发的重要环节,也是一直以来样板代码最严重的场景之一,原文通过三种取数方案的对比,逐渐找到一种最佳的异步取数方式。
在讲解这三种取数方案之前,首先通过下面这张图说明了 Suspense 的功能:
从上图可以看出,子元素在异步取数时会阻塞父组件渲染,并一直冒泡到最外层第一个 Suspense,此时 Suspense 不会渲染子组件,而是渲染 fallback
,当所有子组件异步阻塞取消后才会正常渲染。
下面介绍文中给出的三种取数方式,首先是最原始的本地状态管理方案。
本地异步状态管理,直白但不利于维护
在 Suspense 方案出来之前,我们一般都在代码中利用本地状态管理异步数据。
即便代码做了一定抽象,那也只是把逻辑从一个文件移到了另一个问题,可维护性与可拓展性都没有本质的改变,因此基本可以用下面的结构说明:
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 做状态共享,我们将取数的数据管理与逻辑代码写在父组件,子组件专心用于展示,效果会好一些,代码如下:
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 进行异步处理,代码处理大概是这样的:
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 的魔力,笔者特意把这段代码单独拿出来说明:
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 源码》 有介绍过,即 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件,因此取数函数需要在 Pending 状态时抛出一个 Promise,使其可以被 Suspense 捕获到。
另外,关于文中提到的 fallback 最小出现时间的保护间隔,目前还是一个 Open Issue,也许有一天 React 官方会提供支持。
不过即便官方不支持,我们也有方式实现,即让这个逻辑由 fallback 组件实现:
<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 组合方式决定,这样的代码显然具备了更强的可拓展性。
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)
发表评论 (审核通过后显示评论):