React开发日记-Concurrent模式之Suspense Api探索和对比

381 阅读7分钟

写在前面

目前Concurrent模式是react的一个实验性属性,不适用初学者,如果是想学习如何使用传统的react开发方式,暂时不需要了解。

本文尝试使用suspenses相关的api,并尝试对比他对我们的开发过程会有什么改变

本文适合对于suspenses感到好奇的react开发者阅读。

This page describes experimental features that are not yet available in a stable release. Don’t rely on experimental builds of React in production apps. These features may change significantly and without a warning before they become a part of React.

This documentation is aimed at early adopters and people who are curious. If you’re new to React, don’t worry about these features — you don’t need to learn them right now. For example, if you’re looking for a data fetching tutorial that works today, read this article instead.

前置知识

Concurrent模式是什么
是一组react提供的新特性,目的在于提高用户的交互体验,

例如可中断的渲染,想象下面的场景:用户的输入会导致表格更新,当用户不断输入时,会出现页面卡顿的现象。

因为在浏览器中,js线程和UI渲染线程是互斥的(js的操作可能会影响到渲染结果),因此当我们不断的输入,此时js线程执行,uI渲染线程挂起,会造成卡顿的现象。

concurrent模式的可中断式渲染,可让程序将交互产生的变更及时反馈。

下文介绍的suspense是其中的一个特性,更多介绍可以看官网~

[Introducing Concurrent Mode (Experimental)](https://reactjs.org/docs/concurrent-mode-intro.html)
suspense是什么
concurrent模式的api之一,用于处理异步代码的组件

suspense解决了什么问题

消除状态判断的模板
想象下面的场景:当我们需要在获取到数据前显示loading的状态时,我们通常会使用以下模板
if (!state) return <div>Loading Foo……</div>

Suspense中,可以这么写

function Posts() {
  const posts = useQuery(GET_MY_POSTS)

  return (<div className="posts">
    {posts.map(i => <Post key={i.id} value={i}/>)}
  </div>)
}

function App() {
  return (<div className="app">
    <Suspense fallback={<div>Posts Loading...</div>}>
      <Posts />
    </Suspense>
  </div>)
}

将包含异步请求或者其他异步操作的操作的组件包裹在`Suspense`组件中,

并提供fallback属性(我们的loading组件),在异步操作完成之前,会显示fallback中的内容
瀑布问题
需要理解瀑布问题,可以快速看下以下代码(官网)
/**
 * 用户信息页面
 */
function ProfilePage() {
  const [user, setUser] = useState(null);

  // 先拿到用户信息
  useEffect(() => {
    fetchUser().then(u => setUser(u));
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }

  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline />
    </>
  );
}

/**
 * 用户时间线
 */ 
function ProfileTimeline() {
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetchPosts().then(p => setPosts(p));
  }, []);

  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

当引用ProfilePage组件时,流程如下,

  • 加载 ProfilePage的数据
  • 等待..
  • 渲染 ProfilePage
  • 加载 ProfileTimeline 的数据
  • 等待
  • 渲染 ProfileTimeline

当组件层级较多时,下层组件需要等上层组件数据请求返回并且响应才能开始请求数据,有没有办法并发请求数据?

我们试试promise.all?

function fetchProfileData() {
  // 使用 promise all 并发加载
  return Promise.all([
    fetchUser(),
    fetchPosts()
  ]).then(([user, posts]) => {
    return {user, posts};
  })
}

const promise = fetchProfileData();
function ProfilePage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    promise.then(data => {
      setUser(data.user);
      setPosts(data.posts);
    });
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      {/* ProfileTimeline 变成了纯组件,不包含业务请求 */}
      <ProfileTimeline posts={posts} />
    </>
  );
}

此时流程变为

  • 并行加载 ProfileTimeline ,ProfilePage数据
  • 等待数据返回
  • 开始渲染 ProfilePage
  • 开始渲染 ProfileTimeline

这样会出现问题~?

  • 子组件的请求提到上层,层级加深时这么操作不妥
  • 开始渲染的时间取决于数据获取到的时间最长的那个接口,好像并没有解决加快渲染速度的问题...

如果没有suspense的话,我们可以考虑将两个组件从父子关系改成兄弟关系,那么两个组件并行请求,互不干扰, 但是会出现两个loading icon的问题...

function ProfilePage() {
  return (<div className="profile-page">
    <ProfileDetails />
    <ProfileTimeline />
  </div>)
}

这个时候 我们康康suspense如何解决这个问题!

const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );

官方demo

用两层 suspense 组件将两个异步组件包裹在里面可以达到下图效果
视觉上是先加载外层title在加载内部detail,实际上两个数据请求是并行发送的
竞态问题
由于异步请求的返回时间可能不固定,因此会产生竞态问题。


function getNextId(id) {
  return id === 3 ? 0 : id + 1;
}

function App() {
  const [id, setId] = useState(0);
  return (
    <>
      <button onClick={() => setId(getNextId(id))}>Next</button>
      <div>{id}</div>
      <ProfilePage id={id} />
    </>
  );
}

function ProfilePage({ id }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(id).then(u => setUser(u));
  }, [id]);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
    </>
  );
};

参考上面的代码,频繁点击Next button时,获取下一个id,ProfilePage根据id请求当前用户姓名,

如果此时第一次点击的返回值晚于第二次点击的返回值返回,

后面setstate的值覆盖了前者

ui显示的name就是用户第一次点击产生的id对应的名字

通常情况下,我们会通过比较id.current是否等于当前的props.id来判断是否调用setState

// ProfilePage 组件
      if (id !== currentId.current) {
        return
      }
      setUser(user)

suspense 如何解决这个问题?

// fetchProfileData返回一个promise 
const initialResource = fetchProfileData(0);

function App() {
  const [resource, setResource] = useState(initialResource);
  return (
    <>
      <button onClick={() => {
        const nextUserId = getNextId(resource.userId);
        setResource(fetchProfileData(nextUserId));
      }}>
        Next
      </button>
      <ProfilePage resource={resource} />
    </>
  );
}

function ProfilePage({ resource }) {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails resource={resource} />
    </Suspense>
  );
}

function ProfileDetails({ resource }) {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

传给ProfileDetails一个封装过的 promise对象,每次点击新Next按钮,都会传一个新的promise对象给ProfileDetails, 子组件处理最新的promise, 因此不会出现覆盖的情况。 demo

错误处理
异步请求失败时 react会抛出异常,因此可以使用ErrorBoundary 捕获它。
      <ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}>
        <Suspense fallback={<h1>Loading posts...</h1>}>
          <ProfileTimeline />
        </Suspense>
      </ErrorBoundary>
多个suspense的排列
当我们有多个相邻嵌套的suspense组件时,可能出现多个loading的情况

不太美观,官方提供了一些属性,让我们可以设置如何显示它。

这里需要用到 SuspenseList

<SuspenseList revealOrder="forwards">
  <Suspense fallback={'加载中...'}>
    <ProfilePicture id={1} />
  </Suspense>
  <Suspense fallback={'加载中...'}>
    <ProfilePicture id={2} />
  </Suspense>
  ...
</SuspenseList>
给SuspenseList传递以下属性可以设置未加载时和加载完成时多个组件的显示状态。

revealOrder (forwards, backwards, together) 定义了 SuspenseList 子组件应该显示的顺序。
tail (collapsed, hidden) 指定如何显示 SuspenseList 中未加载的项目。

比较和react hook 获取数据的区别

看到这里 我们大概可以了解到,suspenses给我们开发提供了哪些新的feature,
接下来,我们对比一下使用react hook和使用suspense请求数据时,有什么不同
//suspense

const resource = fetchProfileData();

// ...
<Suspense fallback={<h1>Loading profile...</h1>}>
  <ProfileDetails />
</Suspense>
// ...

function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}


//hook
function App() {
  const [data, setData] = useState({ hits: [] });
  useEffect(async () => {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=redux',
    );
    setData(result.data);
  });
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}

这里suspenses中引用了和上节瀑布问题一样的例子,我们可以直观的看出,

在class或者hook的写法中(这里没有举例class的写法,一般class中的数据请求都是放在componentDidMount中)

执行顺序为

  • 加载数据
  • 获得数据
  • 渲染

而在suspense组件中,执行顺序为

  • 加载数据
  • 渲染
  • 获得数据

两者相比,渲染时机提前,交互上,用户体验更好。

总结

  • Concurrent模式目的在于解决用户交互体验问题,而类似hook之类的属性注重于提高开发体验
  • suspense 是实验性属性,不能用于生产环境的应用
  • 更多相关使用可参考 官方文档, suspense只是其中一个api,关于Concurrent,官方还结合hook提出了一些其他的api
  • 本文为新特性的探索,有不正确,描述不充分的地方,务必指正~感谢~

参考文章

www.robinwieruch.de/react-hooks…

react.docschina.org/docs/concur…