React API设计理念
本系列是在观看React Conf的随笔记录, 一共六篇。这几天整理了下来,内容全来自每个React Conf的Speaker(感谢🙏). 这其中也包含一些自己的想法。若有错误请评论区指出。 当然还是建议亲自去看一下新鲜出炉的React Conf 2021 - Replay
React设计并不只是为了developer还有designer. 很多React API的设计根源来自于设计原则,而不是编程。
因为React其中一个景愿就是designer与program可以用同一套语言来描述用户体验。而不是designer用设计语言,而前端工程师用的是编程的概念来描述。这样会增加沟通成本,想想看你和UI为了一些细节争吵的瞬间。是不是都想让对方站在自己的立场来看一下同一件事的不同角度。
真正伟大的开发体验是不会牺牲用户体验的。
来看一下suspense吧, suspense就是从设计语言中学习而来的,它并不是一种编程思维,而是一个很简单的设计语言, 占位符。因为在UX设计的时候,设计师总需要考虑,如果这块UI出现了异常(错误/数据没有/网络不可用)时,应该有一个备用方案。这就是suspense。
我们知道Suspense, 在平常开发中我们已经用到过, Suspense. 那么React18中对Suspense有什么新的提升吗?
18之前的版本, suspense停留在lazy这个api, 并且只能在client端使用。 18将打破这样的限制, suspense将应用在client端和server端, 带来更好的用户体验。
Suspense出现的意义
还是从日常的例子入手, 以下是一个很简单的列表组件。单纯的只有样式。这和我们正常的开发流程一样,会先把ui的架子搭好,然后往里加东西。
function List({ pageID }) {
const [items, setItems] = useState([]);
return items[pageID]?.map((item) => (
<li>{item}</li>
));
}
在这一步完成的时候,我们会往里加入取数逻辑。你或许会直接使用fetch:
function List({ pageID }) {
const [items, setItems] = useState([]);
useEffect(() => {
fetchData().then(res => {
setItems(res.items);
});
}, []);
return items[pageID]?.map((item) => (
<li>{item}</li>
));
}
又或者你会使用类似react-query这样的取数hook. 但是这样的hook一般会返回两个值, values以及loading. 那么为了用户体验, 我们不得不处理loading时, 应该如何展示ui, 一般情况下会使用spinner.
function List({ pageID }) {
const [items, loading] = useData(pageID);
if(loading) {
return <Spinner />
}
return items.map((item) => (
<li>{item}</li>
));
}
说实话我平常用得比较多的是第二种。
但是你看,这样存在一个问题,展示loading的逻辑,与取出数组这个逻辑混在了一起。习惯让我们觉得这样的代码没问题?让我们重新考虑一下这样写会出现什么问题?
-
每次使用fetch的情况下,都需要考虑是否要Spinner动画或者直接返回null。这个决策出现在每个需要fetch数据的地方
-
第二个问题相反,当你需要聚合两个组件的loading状态时候,但是两个组件取数逻辑都放在两个组件中,此时你就需要大刀阔斧的进行修改,state需要提升到公共父级, 这些都是成本
既然传统的取数逻辑存在问题,那么有没有比较好的解决方案?
切分loading中间状态逻辑与取数逻辑。我们不再关注loading这个状态值,从接口中取出的唯一东西就是数据本身。也就是:
// before
function List({ pageID }) {
const [items, loading] = useData(pageID);
if(loading) {
return <Spinner />
}
return items.map((item) => (
<li>{item}</li>
));
}
// after
function List({ pageID }) {
const [items] = useData(pageID);
return items.map((item) => (
<li>{item}</li>
));
}
可以看到,我们将不去管理loading状态, 甚至把取数当作一个同步的行为。
那么loading怎么办?
显示loading最佳的位置在这个组件的外部, 在外层可以使用Suspense来自动完成loading这个状态控制。而取数的过程我们把它当作一个同步的过程。
<Suspense fallback={<Spinner />}>
<List pageID={pageID} />
</Suspense>
当你想从编程的角度去描述这个suspense的时候,会发现比较难, 这个概念来自于设计体系,类似于骨架屏与Spinner的概念: 你有一个UI, 同时你有个后备方案, 当前的UI不可用的时候,应该把后备方案显示出来。我们认为这里才是加载Loading状态的最佳位置。
所以Suspense组件的定义是: 这个组件会在你的子组件没有准备好的时候,帮你去显示后备方案。
至此,完成了取数与loading状态的分离。
Suspense实际上会改变我们编写loading这种中间状态的范式。我们不需要去关注这个loading状态了,而是由Suspense组件来观测这个loading状态,由它来决定显示Skeleton或者Spinner.
并且,Suspense非常灵活,在开发过程中,如果需要在某个子组件显示不同的Loading效果怎么办?
只需要使用Suspense来包裹对应自组件, 在fallback属性中给定需要的Loading状态即可。
<Suspense fallback={<Spinner />}>
<Header />
<Suspense fallback={<ListSpinner />}>
<List pageID={pageID} />
</Suspense>
</Suspense>
Suspense完成了数据与取数中间状态的解耦, 这会让我们的代码更具有陈述性。
而另一方面,从编程的角度来看,如果我需要一个loading描述两个fetch请求的时候,除了Promise.all你还可以选择使用Suspense。这样的好处是, 你不需要对代码大刀阔斧的改动,提升取数逻辑到更高层的context或者父级中。
(Suspense不仅仅可用于网络请求, 也可用于任何描述异步中间状态的逻辑, 例如IO操作)
Suspense就仅此而已了吗?在React18中带来了更多的可能性。Server端的Suspense也得到了支持。
在SSR的时候也可以通过Suspense来优化首屏优化体验。在Suspense包裹这部分组件未加载的时候,提供一个Skeleton。组件加载完成后,替换掉Skeleton。
(Suspense当然可以应用在fetch的第三方库中,具体可以了解Replay这个库。)