这篇是我在组内做的一次技术分享的讲稿。没怎么修改就直接分享出来了。
Suspense
前言
React 16.6 添加了一个
1 2 3 4 5 6 | const ProfilePage = React.lazy(() => import('./ProfilePage')); // Lazy-loaded // Show a spinner while the profile is loading <Suspense fallback={<Spinner />}> <ProfilePage /> </Suspense> |
后来 React 想,这 Suspense 既然能用来等待 lazy load 的 Promise,其实也可以用来等待其他东西,比如请求数据的 Promise,因此就有了 Suspense for Data Fetching 这个特性。目前它仍是一个实验特性,官网上的文档也主要面向的是请求库的开发者(比如
是什么?不是什么?能干什么?
Suspense 是一种“等待”机制,它作为一个组件,可以让你显式地声明当等待时应该渲染什么。
Suspense 不是一个请求库。它本身并不负责创建和管理请求。
Suspense 可以让请求库与 React 深度集成,请求库可以直接“告诉” React 它正在等待响应,而无需用户手动管理 loading 状态。
怎么用?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function Post({ id }) { const post = getPost(id); return <article>{post}</article>; } function App() { return ( <Suspense fallback={<div>Loading...</div>}> <Post id={1} /> </Suspense> ); } |
这个
getPost(id) 是…同步的?
也许你感到这个写法符合直觉却又有些困惑,这就要说到数据获取的方式。
获取数据的方式
也许你在 React 文档中看过这部分的内容,我会用与官方文档稍有不同的方式讲解。这里有两种获取数据的方式:
- 渲染后获取(传统的方式)
- 渲染即获取(Suspense 的方式)
渲染后获取
渲染后获取就是我们最常写的方式,在
1 2 3 4 5 6 7 8 9 10 11 12 13 | function Post({ id }) { const [post, setPost] = useState(null); useEffect(() => { fetchPost(id).then(data => setPost(data)); }, []); if (!post) { return <div>Loading...</div>; } return <article>{post}</article>; } |
这种方式中,
渲染即获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function Post({ id }) { const post = getPost(id); // just works return <article>{post}</article>; } function App() { return ( <Suspense fallback={<div>Loading...</div>}> <Post id={1} /> </Suspense> ); } |
这种方式中
两种方式的不同
放弃了 state
你会注意到渲染即获取的方案里,是没有组件内部 state 的。这就是为什么它给人的第一印象是干净、清爽、符合直觉。
可难道也就只有更纯净了这种乌托邦式的区别吗?也并不是。放弃 state 同样能够避免一些问题,说不定你也曾遇到过。
1. Waterfall
请求瀑布。看如下使用内部 state 的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | function Profile() { const [user, setUser] = useState(null); useEffect(() => { fetchUser().then(data => setUser(data)); }, []) if (!user) { return <div>Loading profile...</div>; } return ( <div> <h1>{user.name}</h1> <Posts /> </div> ); } function Posts() { const [posts, setPosts] = useState(); useEffect(() => { fetchPosts().then(data => setPosts(data)); }, []); if (!posts) { return <div>Loading posts...</div>; } return ( <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> ); } |
这个例子里,
2. Race condition
考虑如下渲染后获取的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | function Post({ id }) { const [post, setPost] = useState(null); useEffect(() => { fetchPost(id).then(data => setPost(data)); }, [id]); if (!post) { return <div>Loading...</div>; } return <article>{post}</article>; } function App() { const [id, setId] = useState(1); return ( <div> <button onClick={() => setId(currentId => currentId + 1)}>Next post</button> <Post id={id} /> </div> ); } |
这个例子中
更早地获取数据
再讲一种情况,渲染即获取可以更早的获取数据。刚才不是讲过更早获取数据了么?这是一种不同的情况。
考虑如下例子,还是一个按钮修改 id,我们用渲染即获取的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // Post.js function Post({ post }) { return <article>{post}</article>; } // App.js const Post = React.lazy(() => import('./Post')); function App() { const [id, setId] = useState(1); return ( <div> <button onClick={() => setId(currentId => currentId + 1)}>Next post</button> <Post post={getPost(id)} /> </div> ); } |
这个例子中,我们可以同时获取
这个 getPost(id) 是…同步的?
终于回到这个问题了,我们讲了这么多关于 Suspense for Data Fetching 所推崇的的“渲染即获取”,可究竟该怎么实现呢?
1 2 3 4 5 6 | function Post({ id }) { const post = getPost(id); return <article>{post}</article>; } |
虽然在
1 2 3 4 5 6 7 8 9 10 11 | // 不是示例的源码,但是是内意思 let post; let promise; function getPost(id) { if (post) return post; if (promise) throw promise; promise = fetchPost(id).then(data => post = data); throw promise; } |
没想到吧,这个
但这写法也太奇葩了
这就是为什么 React 关于这部分的文档是面向请求库作者,而非 React 用户的。
SWR
前言
日常开发中有时会遇到这些场景和处理方式:
相同的 URL (以及参数),缓存上一次的结果
设置一些常量来标识不同的请求,请求的结果根据标识放在 redux 里缓存,取数据的时候从 redux 取。这样来提高页面的响应效率,减少看到空页面的时间。
实际上,
是什么?不是什么?
SWR 并不是一个十足的“请求库”。它主要针对的是数据获取的管理,而数据的更新、删除,它不管。
SWR is a React Hooks library for remote data fetching.
用法
1 2 3 4 | import useSWR from 'swr'; // fetch current user const { data } = useSWR('/api/user'); |
也许你见过 SWR 这样的用法示例,心想这和普通的请求库做个 hook 没啥区别。那么我们来细细看下到底 SWR 是个什么东西。
API
1 | const { data, error, isValidating, mutate } = useSWR(key, fetcher, options); |
参数
key : 请求的标识fetcher : 返回请求数据的异步方法options : 更多配置项
返回值
data : 标识key 对应的数据error : 加载数据过程中抛出错误isValidating : 是否正在请求或重新验证数据mutate(data?, shouldRevalidate) : 用于修改缓存数据
useSWR
Data Fetching
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import useSWR from 'swr'; async function fetchCurrentUser() { const { data } = await axios('/api/user'); return data; } function Profile() { const { data: user } = useSWR('currentUser', fetchCurrentUser); if (!user) { return <div>Loading profile...</div>; } return <div>{user.name}</div>; } |
注意,SWR 并非请求库,它并非接收 URL 作为第一个参数并向其地址发送请求。从上面例子就可看出
1 | const { data: user } = useSWR('/api/user', fetchCurrentUser); |
SWR 会将
1 2 3 4 5 6 | async function request(url) { const { data } = await axios(url); return data; } const { data: user } = useSWR('/api/user', request); |
再加上 SWR 支持全局配置默认
1 | const { data: user } = useSWR('/api/user'); |
Emmm,有内味了。看起来就像 SWR 是一个请求库一样,但你一定要清楚,其实并不是这样??。这一点很重要,对于你理解 SWR 的更多用法会有帮助。
Conditional Fetching & Dependent Fetching
1 | const { data: posts } = useSWR(user ? `/api/users/${user.id}/posts` : null); |
如果你觉得这样有些反直觉,为什么 URL 为
如果还是绕不清楚,换一种写法看看
1 2 3 4 5 6 7 8 | async function fetchUserPosts(key) { const userId = key.match(/^posts by user (\\d+)$/)[1]; const { data } = await axios(`/api/users/${userId}/posts`); return data; } const { data: posts } = useSWR(user ? `posts by user ${user.id}` : null, fetchUserPosts); |
至此你应该不会再混淆这个概念了。
花了这么大工夫搞清楚这个概念之后,我们继续来看 SWR 的用法。刚才讲了
1 2 3 4 5 | // function as key const { data: user } = useSWR(() => '/api/user'); // conditional const { data: posts } = useSWR(() => user ? `/api/users/${user.id}/posts` : null); |
SWR 对于函数
1 2 | const { data: user } = useSWR(() => '/api/user'); const { data: posts } = useSWR(() => `/api/users/${user.id}/posts`); |
当
Multiple Arguments
除了函数,
1 2 3 4 5 | async function fetchUserPosts(_, userId) { return axios(`/api/users/${userId}/posts`); } const { data: posts } = useSWR(['posts by user', userId], fetchPostsByUser); |
Mutate
Manually Revalidate
前面的示例都是初次获取数据,那怎么手动再次获取某个标识的数据呢?SWR 提供了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import useSWR, { mutate } from 'swr'; function Profile () { const { data: user } = useSWR('currentUser'); return ( <div> <h2>{user.name}</h2> <button onClick={() => { mutate('currentUser') }}> Refresh </button> </div> ) } |
当调用了
或者,你也可以直接使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function Profile () { const { data: user, mutate } = useSWR('currentUser'); return ( <div> <h2>{user.name}</h2> <button onClick={() => { mutate() }}> Refresh </button> </div> ) } |
Mutation
我们前面说 SWR 本身不管更新数据。但是他允许我们修改缓存的数据。用的还是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | function Todo({ id }) { const { data: todo, mutate } = useSWR(() => `/api/todos/${id}`); async function markTodoAsDone() { await axios.patch(`/api/todos/${todo.id}`, { done: true }); mutate({ ...todo, done: true }); // 更新缓存数据,同时重新请求数据 } if (!todo) { return <div>Loading...</div>; } return ( <div> {todo.content} <button onClick={markTodoAsDoen}> Mark as done </button> </div> ); } |
很多时候,更新数据的请求会直接返回更新后的资源,这时我们可能希望更新缓存的同时不用再去重新验证资源。
1 2 | const { data: updated } = await axios.patch(`/api/todos/${todo.id}`, { done: true }); mutate(updated, false); // shouldRevalidate=false 表示无需重新验证资源 |
或者,你也可以用
1 2 3 4 5 6 | function updateTodo(id, data) { const { data: updated } = await axios.patch(`/api/todos/${todo.id}`, data); return updated; } mutate(updateTodo(todo.id, { done: true })); |
Optimistic UI
也许你听说过 Optimistic UI 的概念。它描述的是当我请求更新/删除数据时,可以假设请求是成功的,并据此更新 UI;待请求完成,再根据实际结果更新 UI,这样提高页面响应速度。结合上面
1 2 | mutate({ ...todo, done: true }, false); // 先乐观更新本地缓存,且不重新验证 mutate(updateTodo(todo.id, { done: true })); // 再用请求结果更新缓存 |
不止这些
上面只是介绍了 SWR 的一些 API 的常见用法,而 SWR 的能力可不止于此。
Focus Revalidation
当你重新聚焦到页面的时候,SWR 会自动重新验证数据。比如你在两个标签页打开了一个应用,然后在其中一个标签页修改了你的头像,当你切换到另一个标签页中时,新的头像已经加载好了。而这一机制无需你编写任何多余的代码。
Refetch on Interval
通过一个配置项开启周期性重新验证。这听起来自己实现也并不难,但别忘了,手动更新数据后重启定时器、页面离屏时暂停定时器,这些 SWR 都已帮你处理好。
从 request 到 SWR
key 的复用
我自己开发应用的时候习惯把请求按照 API 或者业务逻辑封装成一个个函数来调用,以便复用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | import { fetchUsers, updateUser } from '@/services/users'; function UserList() { const [users, setUsers] = useState(); useEffect(() => { (async () => { const { data } = await fetchUsers(); setUsers(data); })(); }, []); return ( <UserTable users={users} onEditUser={openEditUserDrawer} // ... /> ); } |
而在使用 SWR 的应用中,则应将 key 管理起来进行复用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import * as resources from '@/constants/swr'; function UserList() { const { data: users } = useSWR(resources.users); return ( <UserTable users={users} onEditUser={openEditUserDrawer} // ... /> ); } |
在前面讲到的 Conditional Fetching 和 Data Fetching 的用法中可以看出,当使用函数作为
1 2 3 4 5 6 7 8 9 10 | export const user = '/api/user'; // conditional export const userPosts = user => user ? `/api/users/${user.id}/posts` : null; // dependent export const userPosts = user => () => `/api/users/${user.id}/posts`; function App() { const { data: user } = useSWR(resources.users); const { data: posts } = useSWR(resources.userPosts(user)); } |
fetcher 的复用
当你处理好了 key 的复用,你会发现他们并没能完全代替原来复用的异步方法。你仍需要管理 fetcher,因为全局 fetcher 并没能应对你所有的 key。而在 Multiple Arguments 的例子中,像查询参数这类请求的参数应当展开成数组,这就需要 fetcher 的单独支持。
1 2 3 4 5 6 7 8 9 10 11 12 13 | export const users = (page, pageSize, orderColumn, orderType, groupId) => ['/api/users', page, pageSize, orderColumn, orderType, groupId]; async function queryUsers(url, page, pageSize, orderColumn, orderType, groupId) { return axios(url, { page, pageSize, orderColumn, orderType, groupId }); } function UserList() { const { data: users } = useSWR(resources.users(page, pageSize, orderColumn, orderType, groupId/*, ...*/), queryUsers); return <UserTable users={users} />; } |
这里 fetcher 的 url 参数耦合度也很怪。
当你又最终想办法处理好了 key 和 fetcher 的复用,你发现,
有没有 workaround?
也不是没有。我们来看 Multiple Arguments 的参数列表耦合问题。为什么会出现这个问题?是因为在函数中无法知道
1 2 3 4 5 6 7 8 9 10 11 | export const users = (params) => ['/api/users', JSON.stringify(params)]; async function queryUsers(url, params) { return axios(url, { params: JSON.parse(params) }); } function UserList() { const { data: users } = useSWR(resources.users(params), queryUsers); return <UserTable users={users} />; } |
用
1 2 3 4 5 6 7 | export const users = (params) => ['/api/users', JSON.stringify(params)]; function UserList() { const { data: users } = useSWR(resources.users(params)); return <UserTable users={users} />; } |
机灵的你也许已经注意到,我的 key 里先进行一次
我们可以用
1 2 3 4 5 6 7 8 9 10 11 | async function fetcher(url, querystring) { return axios(`${url}${querystring}`); } export const users = (params) => ['/api/users', qs.stringify(params)]; function UserList() { const { data: users } = useSWR(resources.users(params)); return <UserTable users={users} />; } |
但是使用范式会带来一点点代价。URL 相同而参数不同的情况下,会被认为是不同的资源,因此我们在表格页修改查询参数的时候,原先的查询结果无法留在界面上。
Suspense
讲了半天 Suspense,又讲了半天 SWR,他俩到底有啥关系呢?让我们回看这个问题:
这个
getPost(id) 是…同步的?
1 2 3 4 5 6 | function Post({ id }) { const post = getPost(id); return <article>{post}</article>; } |
你可能已经注意到,SWR 的 API 就实现了 Suspense 中推行的这一范式,让你摆脱 state 管理异步数据。并且,SWR 通过配置项支持了 Suspense 模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function Post({ id }) { const { data: post } = useSWR(`/api/posts/${id}`, { suspense: true }); return <article>{post}</article>; } function App() { return ( <Suspense fallback={<div>Loading...</div>}> <Post id={1} /> </Suspense> ); } |
当你开启了
总结
这次介绍 Suspense 和 SWR,并非推销这两个技术,推行大家去使用它们。把它们放在一起讲,是因为它们引入了异步数据使用的思想上的更新。换个角度看问题,有时问题便不再是问题。
参考链接
- Suspense for Data Fetching
- zeit/swr: React Hooks library for remote data fetching
- "useSWR" - React Hooks for Remote Data Fetching