Posted on:
Last modified:
UI 状态和数据状态是两个东西,然而人们总是把这两个东西混淆在一起。Redux 等工具实际上是 一个 UI 状态工具,但是人们总用它(通过 thunk) 来请求数据,这是错误的。
在 Redux 等工具的 readme 中一般都是提供的 Counter 或者 TODO list 这个 demo, 这是非常误导 人的,因为这两个 demo 只是本地的 UI 状态管理。到了实际使用的过程中,大多数状态是数据状态, 需要使用 thunk 等 middleware 来获取后端数据并管理,非常丑陋。
UI 状态指的是前端的一些状态,比如说是否使用暗黑模式,某个状态栏是否显示等等,和后端的 数据库无关。数据状态指的是从后端数据库中加载的一些数据,比如说用户名,当前的文章,评论 等等。如果前端进行了更新,也需要写回到后端数据库中。
按照前面的划分,实际上 SWR 也算一个(隐式)状态管理工具,它相当于使用了 API 的路径作为了 key, 然后把整个状态存储到了一个类似 kv 字典的结构当中。
一旦把 UI 状态和(后端)数据状态这两个概念分清了,那么解决起来就一下子开朗了。我们可以 使用 redux/useReducer 管理前端程序自身的状态,而获取数据都通过 SWR 来实现。
React 本来的思想就是纵向切分成一个一个的小组件,而不是像 html/css/js 这样横向切分。react 中每个组件都是可以独立使用的,自己获取数据,自己渲染自己的数据。而 redux 的做法又倒了回去, 变成了中心化的状态管理,每个组件实际上就弱化成了只是一个 view 组件,也就是说只有渲染是 独立的,而数据的获取和管理又变成了统一的一个整体。
统一的状态管理当然有好处,那就是不会有(太多)重复数据,也有统一的约束管理。但是坏处也很
明显:给人的心智负担太大了。所有页面的数据都放在一起,然而除了登录信息,或者程序的
皮肤等少数信息之外,一个页面里面用到的数据在另一个页面中是用不到的。每次想要改某个页面,
除了在 views/components
目录中改组件之外,还要再翻到 reducers
中找对应的文件,还要
再翻到 combineReducer 等等一系列操作。这样的操作相当于不是只把当前要改的页面放到脑袋里,
而是总要把整个应用都放在脑袋里,简直要爆炸了。
一个比较好的切分边界是 page, 也就是一个物理页面。每个 react-router 中一个路由顶级元素中的 UI 状态几乎不会需要和另一个组件分享。
一个应用的数据并不能简单地用全局数据或者局部数据简单地概括,而是既有全局数据,又有局部数据。 redux 这种一根筋的全局数据显然是不合适的。我们应该采用一种混合的方式,让局部的归局部, 全局的归全局。
另外一个需要注意的地方就是,URL 实际上是一个隐形的 ui store, 比如搜索页面,它的 URL 是:
/search?q=foo&start=2020-01-01&end=2020-02-01&sort=date
那么构建这个页面的关键状态:query, start, end, sort 其实已经都在 URL 里面了,没必要再 存储一个状态在内存中。
另外,如果使用 redux 做状态管理的话,我们还需要使用 useEffect 钩子在生命周期的适当地方 dispatch 拉取数据的代码。而使用 swr 只需要无脑 useSWR 就好了。
综上所述,我们的最终方案就是:
useSWR
就可以了注意 useSWR 的第二个参数,也就是数据请求函数接收的参数是第一个参数 key.
import useSWR from 'swr'
const fetcher = url => axios.get(url).then(res => res.data)
function Profile() {
const { data, error } = useSWR('/api/user', fetcher)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
特别注意,对于 useSWR 来说,判断 error 和 data 非常重要。 一般都是先请求出错一次然后第二次 render 才有数据,它就是这么设计的,直接使用 data 做渲染很容易引起白屏。
对于复用比较多的请求,可以封装成单独的函数:
function useUser(id) {
const { data, error, isLoading } = useSWR(`/api/user/${id}`, async (url) => {
const res = await fetch(url)
return res.json()
})
return {
user: data,
isLoading: !error && !data,
isError: error,
}
}
function Avatar({ id }) {
const { user, isLoading, isError } = useUser(id)
if (isLoading) return <Spinner />
if (isError) return <Error />
return <img src={user.avatar} />
}
如果需要除了路径外的其他参数,swr 还可以接受数组和对象作为参数
const { data: user } = useSWR(['/api/users', id], async ([url, id]) => {
const u = `${url}/${id}` // 在这里再拼接 url
const res = await fetch(u, id)
return res.data
})
特别注意,在老版本中 <2.0
,数组参数会被展开成不同的参数。
当 POST/PUT/DELETE 请求的时候,会触发数据的修改,这时候应该通过 mutate
同步触发本地的
数据修改。mutate 函数可以使用全局的,也可以使用 useSWR 返回的已绑定的 mutate 函数。
mutate 函数的签名是 mutate(key, data, options)
, 第一个参数是 key,和 useSWR
一样,
第二个是更新,可以是一个对象或者函数,第三个是 options 选项。
mutator 函数接受的值是当前 key 的缓存值。
如果先在本地更新数据,然后再发送请求,也就是所谓的「乐观更新」,好处是前端响应比较快, 坏处是如果失败还需要把数据改回来, 实现起来稍微复杂一点。
如果先发送请求后改数据,也就是「悲观更新」,好处是不需要再验证,坏处是响应比较慢。
在局部更新数据的一个例子:
import useSWR, { mutate } from 'swr'
function Profile () {
const { data, mutate: bindedMutate} = useSWR('/api/user', fetcher) // 局部的 mutate, 已经和 key 绑定
return (
<div>
<h1>My name is {data.name}.</h1>
<button onClick={async () => {
const newName = data.name.toUpperCase()
// 方法 1.1, 更新后触发一次 GET 验证,悲观更新
// 向 API 发送请求更新源,响应的结果我们不关心,只要没错误就行
await putUser({name: newName})
// 触发重新验证(重新 GET 请求)以更新本地数据
mutate('/api/users/me')
// 这种方法是最笨的,甚至需要两次请求才能更新数据
// 方法 1.2,如果 PUT 已经返回了更新后的数据,可以直接使用 PUT 返回值,悲观更新
const newUser = await putUser({name: newName}) // 发送 PUT 请求更新用户名
mutate('/api/users/me', newUser, {revalidate: false})
// 方法 1.3,把 putUser 放到 mutator 函数中
// 如果更新的是局部数据,API 没有返回全部数据,可以在 mutator 函数中自行处理后返回完整数据
// 注意 mutator 函数的参数是 /api/todos 的缓存值
mutate('/api/todos', async todos => {
// 把 ID 为 1 的更新为 completed,该 API 返回更新后的数据
const updatedTodo = await fetch('/api/todos/1', {
method: 'PATCH',
body: JSON.stringify({ completed: true })
})
// 筛选列表,返回更新后的 item
const filteredTodos = todos.filter(todo => todo.id !== '1')
return [...filteredTodos, updatedTodo]
})
// 方法 2,乐观更新
const newName = data.name.toUpperCase()
const user = { ...data, name: newName }
const options = {
optimisticData: user,
rollbackOnError(error) {
// If it's timeout abort error, don't rollback
return error.name !== 'AbortError'
},
}
// updates the local data immediately
// send a request to update the data
// triggers a revalidation (re-fetch) to make sure our local data is correct
mutate('/api/user', async (user) => {
await putUser(user)
}, options);
}}>Uppercase my name!</button>
</div>
)
}
退出登录的例子:
import useSWR, { mutate } from 'swr'
function App () {
return (
<div>
<Profile />
<button onClick={() => {
// 删除 Cookies,或者可以使用 js-cookie 这个库。如果是存在 localStorage 中的 token,也需要删除
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
// 告诉所有具有该 key 的 SWR 重新验证,将会导致得不到登录信息
mutate('/api/users/me')
}}>Logout</button>
</div>
)
}
当程序还没加载好,或者前置条件还没满足的时候,发送一个不合法的空请求是没有意义的,而且会 耗费服务器资源,可以通过 key 函数返回 null 或者抛出异常来避免。
// 传递函数时,SWR 会用返回值作为 `key`。
// 如果函数抛出错误或返回 falsy 值,SWR 会知道某些依赖还没准备好。
// 这种情况下,当 `user` 未加载时,`user.id` 抛出错误
const { data: user } = useSWR('/api/user')
const { data: projects } = useSWR(() => '/api/projects?uid=' + user.id)
import useSWRImmutable from 'swr/immutable'
// ...
useSWRImmutable(key, fetcher, options)
使用 redux-thunk 的时候:
使用 swr 的时候:
我们可以看到 swr 的优点在于:
不需要使用两个 dispatch,一次 thunk,一次更新本地 UI 状态。不需要在 useEffect 中使用,想 在哪儿用在哪儿用,想在哪个组件用在哪个组件用。mutate 的定制性更强,可以按需更新本地状态。
最重要的一点,我们无需手工设计本地的状态树,而是只在前端做一份后端状态的缓存,后端依然是 Single source of truth。
像 redux 这种设计一个前端的状态树,纯属把后端的轮子重新造了一遍,更加麻烦。有了 swr,两个 头疼的难题——设计状态树和同步状态都迎刃而解了。
© 2016-2022 Yifei Kong. Powered by ynotes
All contents are under the CC-BY-NC-SA license, if not otherwise specified.
Opinions expressed here are solely my own and do not express the views or opinions of my employer.
友情链接: MySQL 教程站