写在前面
目前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>
);
用两层 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
- 本文为新特性的探索,有不正确,描述不充分的地方,务必指正~感谢~
参考文章