React Conf 2021 (1) - React API设计理念与suspense

410 阅读5分钟

React API设计理念

本系列是在观看React Conf的随笔记录, 一共六篇。这几天整理了下来,内容全来自每个React Conf的Speaker(感谢🙏). 这其中也包含一些自己的想法。若有错误请评论区指出。 当然还是建议亲自去看一下新鲜出炉的React Conf 2021 - Replay

Untitled.png React设计并不只是为了developer还有designer. 很多React API的设计根源来自于设计原则,而不是编程。

因为React其中一个景愿就是designer与program可以用同一套语言来描述用户体验。而不是designer用设计语言,而前端工程师用的是编程的概念来描述。这样会增加沟通成本,想想看你和UI为了一些细节争吵的瞬间。是不是都想让对方站在自己的立场来看一下同一件事的不同角度。

真正伟大的开发体验是不会牺牲用户体验的。

来看一下suspense吧, suspense就是从设计语言中学习而来的,它并不是一种编程思维,而是一个很简单的设计语言, 占位符。因为在UX设计的时候,设计师总需要考虑,如果这块UI出现了异常(错误/数据没有/网络不可用)时,应该有一个备用方案。这就是suspense。


Untitled.png

1.png

我们知道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的逻辑,与取出数组这个逻辑混在了一起。习惯让我们觉得这样的代码没问题?让我们重新考虑一下这样写会出现什么问题?

  1. 每次使用fetch的情况下,都需要考虑是否要Spinner动画或者直接返回null。这个决策出现在每个需要fetch数据的地方

2.png

  1. 第二个问题相反,当你需要聚合两个组件的loading状态时候,但是两个组件取数逻辑都放在两个组件中,此时你就需要大刀阔斧的进行修改,state需要提升到公共父级, 这些都是成本

3.png

4.png

既然传统的取数逻辑存在问题,那么有没有比较好的解决方案?

5.png

切分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操作)

6.png

Suspense就仅此而已了吗?在React18中带来了更多的可能性。Server端的Suspense也得到了支持。

在SSR的时候也可以通过Suspense来优化首屏优化体验。在Suspense包裹这部分组件未加载的时候,提供一个Skeleton。组件加载完成后,替换掉Skeleton。

(Suspense当然可以应用在fetch的第三方库中,具体可以了解Replay这个库。)