logo

发个请求还有这么多门道?什么是竞态问题、什么是乐观更新?直接在mounted里调接口不行吗?

Authors
  • avatar
    Name
    White Play
    Twitter

调接口→获取数据并处理→页面上渲染,这个工作流程所有前端开发是最熟悉不过了。

function getBooks() {
  return http.get('/api/book')
}

这里的http可能是axios或者浏览器原生的fetch,这都没问题,但我个人认为,不应该把getBooks函数直接赋值给按钮的点击事件或者直接写在mounted/useEffect里。

接下来我们讨论为什么。

1. 缺少派生状态

每个接口调用都意味着将要有大量的派生状态需要处理,比如接口是不是在等待响应,或者是响应成功还是失败,报错信息是什么等等。

这些派生状态都是有用的,但如果每个接口调用都要手动处理你可能觉得太麻烦,因为变量太多了,isLoading/isPending/isError/error/data..,所以你可能更倾向于多数情况下不理会这些派生状态,需要理会的时候再去写处理逻辑,但我觉得这么干活的话就太糙了,给以后代码维护留下伏笔。

比如因为没写 isLoadingisPending,那对用户来说,等待接口响应的时间 UI 就是卡死的状态,用户会觉得体验糟糕透了。如果没有 isErrorerror,那假如后端的接口崩了,你也要跟着背锅,因为你放任了页面的崩溃,没做错误处理。

当然一些比较糙的做法是我们可以封装拦截器,从而让每一个 Loading 和每一个 error 都得到统一的处理。但如果你们对用户体验有比较高的要求的话,你会发现这种"统一处理"是无法满足各个具体场景的,统一的大 Loading 是最粗糙的处理方法,一个无限列表的"加载更多"按钮往往更加细腻直观,还有部分场景需要使用骨架屏、进度条等等。所以,对派生状态视而不见或统一处理都不是合理的解决方案。

2. 竞态问题

先解释一下什么是竞态。

举一个稍微抽象的例子,你去理发店剪头发,你和理发师A说烫个大波浪,理发师A答应了,并转头去准备工具了。此时你又对理发师B说你要剃光头,然后理发师B也去准备工具了。紧接着你一股困意袭来睡着了。那请问你睡醒之后,到底是啥发型?没人知道,答案具体取决于这两个理发师谁先准备好工具并上手干活。虽说你是先跟理发师A表达需求的,但这不意味着理发师A一定先响应你,所以这就是竞态问题:请求发起顺序和响应顺序没关系,但一旦错位就会让用户迷惑。

那我们可以联想到开发场景,比如搜索、筛选、分页等,用户操作的越快,渲染的数据出错概率越大。因为网络存在延迟性,一旦用户交互频率大于网络响应速度,那渲染的结果会和期望大相径庭。

3. 数据缓存

另一个被忽略的问题是数据缓存。想象用户从列表页进入详情页,然后返回列表页,如果你没仔细处理缓存逻辑的话,你的代码就会再一次发起了列表接口请求。这不仅浪费了网络资源,还让用户再次等待数据加载。

如果你 Vue 的经验比较丰富,那你可能知道可以通过 keep-alive 来缓存页面状态避免每次软跳转导致重新获取数据,也可以通过 onActivated 等钩子来让缓存页面也能及时更新缓存的状态。但什么时候更新缓存是“计算机科学中最复杂的问题”,有的数据可能永远不需要主动更新,比如你自己的网名,只要每次改网名的时候缓存一下,就再也不用请求服务器了。再比如“每日热点”或“热搜”,可能每小时刷新一次也完全可以接受。

再比如,有些时效性不强的数据,尽管产品经理要求每次跳转都要获取最新数据,但我不必让 loading 阻塞用户到接口响应,我可以先展示缓存数据再静默请求,把请求到的新数据和缓存数据作比对,如果没有差别就当无事发生,如果有差别就渲染最新数据。可以说在用户体验和数据时效性上找到了一个平衡点。

解决方案

聊到这你可能觉得up主你是不是卷疯了,就 onMounted 里写个 fetch 就能交差的事,卷个什么劲呢。

但其实不是我在卷,而是这一切都有解决方案,社区已经有很多成熟的库来解决这些问题。

如果你是 React 的用户,你八成就知道我要说什么了。因为 React 官方文档已经很敏锐的提出了直接在 useEffect 里发网络请求的弊端。并提供了解决方案

比如下面这段代码,已经暴露了派生状态:data/isLoading/error,也简洁的处理了 loading 和 error 的逻辑。更重要的是它默认就是数据缓存的,尽可能少的阻塞 UI 以提高用户体验,只有服务器返回数据变了才会去更新组件。

import { useQuery } from '@tanstack/react-query'

function useFetchBooks({ userId, page }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['book', page],
    queryFn: () => fetchBook(page),
  })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return <div>{data}</div>
}

其实网络请求的门道远不止以上这些,我能想到的还有乐观更新、重试机制、防抖节流、请求去重、离线处理等等。

比如乐观更新,就是先假设操作会成功,立刻更新 UI。如果请求失败了再回滚并提示用户操作失败,这样用户感觉操作反馈特别流畅特别快。像微信点赞、B站发表评论都是这么干的。

希望这篇内容可以帮助到你,如果觉得有帮助可以给Up一键三连。我目前节奏是每周做一次技术分享,关注我,早日成为高级开发。

如果有不清楚的或者发请求有其他注意项可以发在评论区大家一起讨论。每条评论up都会看的。好的,谢谢大家。