We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
故事的开始,2023 年的一个早晨,你刚到公司座位坐下,PM 扔给了你一个需求,需要编写一个 React 应用,从接口获取一个列表的数据并渲染到页面。身经百战的你打开 Visual Studio Code 完成了项目的初始化,考虑到网络请求属于一个渲染副作用,于是你毫不犹豫的选择了 useEffect 进行数据的获取,仅用了一分钟,你就完成了代码编写,
type ListItem = { id?: string | number; name?: string; }; function App() { const [list, setList] = useState<ListItem[]>([]); useEffect(() => { fetch("/api/list") .then((res: Response) => res.json()) .then((data: ListItem[]) => setList(data)); }, []); return ( <ul> {list.map((item: ListItem) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
成就满满的你熟练地打开 terminal,敲下 npm run dev,列表成功渲染,
terminal
npm run dev
你刷新了一次页面,发现首次加载到数据获取完成期间,页面出现了短暂的白屏,用户体验很不好,身为一个 "将极致的用户体验和最佳的工程实践作为探索的目标" 的前端工程师,你决定实现一个加载中的进度提示,于是引入了一个新的状态 isLoading,考虑到列表结构稳定,更好的视觉效果和用户体验,你选择了骨架屏作为加载提示,
isLoading
function App() { const [list, setList] = useState<ListItem[]>([]); const [isLoading, setIsLoading] = useState<boolean>(false); useEffect(() => { setIsLoading(true); fetch("/api/list") .then((res: Response) => res.json()) .then((data: ListItem[]) => { setIsLoading(false); setList(data); }); }, []); // 加载状态,数据获取期间展示骨架屏 if (isLoading) { return <Skeleton />; } return ( <ul> {list.map((item: ListItem) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
加载状态虽然有了,但是你又意识到,接口还有可能报错,你还需要在数据请求出错时显示错误提示,必要时可能还需要上报错误日志,于是你又引入了一个新的状态 error,处理数据请求失败的情况,
error
function App() { const [list, setList] = useState<ListItem[]>([]); const [isLoading, setIsLoading] = useState<boolean>(false); const [error, setError] = useState<boolean>(false); useEffect(() => { setIsLoading(true); fetch("/api/list") .then((res: Response) => res.json()) .then((data: ListItem[]) => { setIsLoading(false); setList(data); }) .catch((error) => { setError(true); setIsLoading(false); }); }, []); // 加载状态,数据获取期间展示骨架屏 if (isLoading) { return <Skeleton />; } // 数据请求出错 if (error) { // 上报错误... // 支持重试... return <div>请求出错啦~</div>; } return ( <ul> {list.map((item: ListItem) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
后来,你发现每个需要从接口获取数据的场景都要写上面类似的代码,不仅重复而且繁琐,机智的你想到了自定义 hook,于是你决定将数据请求的逻辑封装为一个 useFetch 的 hook,
hook
useFetch
type FetchOptions = { method?: string; }; function useFetch(url: string, options: FetchOptions) { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState<boolean>(false); const [error, setError] = useState<boolean>(false); useEffect(() => { setIsLoading(true); fetch(url, options) .then((res) => res.json()) .then((data) => { setIsLoading(false); setData(data); }) .catch((err) => { setError(true); setIsLoading(false); }); }, [url, options]); return { data, isLoading, error }; }
这种方式非常有用,你在项目中大量地使用了 useFetch,数据请求的模板代码减少了很多,逻辑也更加简洁,
function ComponentFoo() { const { data, isLoading, error } = useFetch("/api/foo"); if (isLoading) { // ... } if (error) { // ... } } function ComponentBar() { const { data, isLoading, error } = useFetch("/api/bar"); if (isLoading) { // ... } if (error) { // ... } }
你非常有成就感,useFetch 真是太好用了,直到有一天 PM 又扔给了你一个需求,这次你需要实现点击某个列表项的时候显示对应的详情,结果测试的时候你发现,快速地在多个列表项间切换点击时,有时候你点击的是下一个列表项,页面确渲染了上一个列表项的详情。机智的你很快就找到了原因,因为你没有在 useEffect 中声明如何清除你的副作用,发送网络请求是一个异步的行为,收到服务器数据的顺序并不一定是网络请求发送时的顺序,导致出现了 Race Condition,
useEffect
| =============== Request Detail 1 ===============> | setState() | ===== Request Detail 2 ====> | setState() |
比如上面的第二个列表项详情数据返回比第一个快的情况,你的 data 就会被前一个数据覆盖,
data
于是你在 useFetch 里面写了一个清除副作用的逻辑,
function useFetch(url: string, options: FetchOptions) { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState<boolean>(false); const [error, setError] = useState<boolean>(false); useEffect(() => { let isCancelled = false; setIsLoading(true); fetch(url, options) .then((res) => res.json()) .then((data) => { if (!isCancelled) { setIsLoading(false); setData(data); } }) .catch((err) => { if (!isCancelled) { setError(true); setIsLoading(false); } }); return () => { isCancelled = true; setIsLoading(false); }; }, [url, options]); return { data, isLoading, error }; }
感谢 JavaScript 闭包的力量,现在即使出现了请求的 Race Condition,你的数据也不会被覆盖掉了,不仅如此,机智的你还想到了在清除副作用时检测下浏览器是否支持 AbortController,如果支持的话尝试取消请求,
const isAbortControllerSupported: boolean = typeof AbortController !== "undefined"; function useFetch(url: string, options: FetchOptions) { // ... useEffect(() => { let isCancelled = false; let abortController = null; if (isAbortControllerSupported) { abortController = new AbortController(); } setIsLoading(true); fetch(url, options).then({ // ... }); return () => { isCancelled = true; abortController?.abort(); setIsLoading(false); }; }, [url, options]); return { data, isLoading, error }; }
你迫不及待地将新的 useFetch 用在了列表中,然后来回地切换列表项,这次详情数据终于没有被覆盖了,但每次切换都会由于 url 改变,导致 useEffect 重新执行,触发一次新的网络请求,实际上频繁快速地切换触发的网络请求是不必要的,你考虑为 useFetch 加一个缓存,
url
const isAbortControllerSupported = typeof AbortController !== "undefined"; // 使用 Map 更快的访问缓存 const cache = new Map(); function useFetch(url: string, options: FetchOptions) { // ... useEffect(() => { // ... // 如果有缓存数据,不再发起网络请求 if (cache.has(url)) { setData(cache.get(url)); setIsLoading(false); } else { setIsLoading(true); fetch(url, options) .then((res) => res.json()) .then((data) => { if (!isCancelled) { // 缓存 url 对应的接口数据 cache.set(url, data); setData(data); setIsLoading(false); } }); // ... } return () => { isCancelled = true; abortController?.abort(); setIsLoading(false); }; }, [url, options]); return { data, isLoading, error }; }
知名前 Netscape 工程师 Phil Karlton 曾说过,
There are only two hard things in Computer Science: cache invalidation and naming things.
一旦引入缓存,就需要考虑缓存失效的问题,什么时候刷新缓存,否则我们的 UI 显示的数据就可能会过时,机智的你想到了可以在下面的这些时机去刷新缓存,
以上的缓存刷新方式对应了不同的应用场景,正常来说你应该让 useFetch 全部支持,为了让自己还能有精力多搬几年砖,你决定先实现一个标签页失去焦点的缓存刷新,
const isAbortControllerSupported = typeof AbortController !== "undefined"; const cache = new Map(); const isSupportFocus = typeof document !== "undefined" && typeof document.hasFocus === "function"; function useFetch(url: string, options: FetchOptions) { const [isLoading, setIsLoading] = useState(true); const [data, setData] = useState(null); const [error, setError] = useState(null); const removeCache = useCallback(() => { cache.delete(url); }, [url]); const revalidate = useCallback(() => { // 重新 fetch 数据更新缓存 }, []); useEffect(() => { const onBlur = () => removeCache(); const onFocus = () => revalidate(); window.addEventListener("focus", onFocus); window.addEventListener("blur", onBlur); return () => { window.removeEventListener("focus", onFocus); window.removeEventListener("blur", onBlur); }; }); // fetch 相关逻辑 // useEffect(() => ... return { data, isLoading, error }; }
实现了缓存的 useFetch 如虎添翼,你本以为从此数据请求可以高枕无忧了,但是你发现你使用了新版本的 React 18 Concurrent Rendering,这个模式下,低优先级的任务在 render 阶段可能会被打断、暂停甚至终止,而我们在实现 useFetch 缓存的时候,cache 是一个全局变量,一个 useFetch 调用 cache.set 后无法通知其他 useFetch 更新,可能会导致多个组件缓存数据的不一致,
render
cache
cache.set
试想下面的场景,我们开启了 Concurrent Mode,渲染了两个组件 <Foo /> 和 <Bar /> 都使用了 useFetch 从同一个 url 获取数据,它们共享一份缓存数据,但 React 为了响应用户在 <Bar /> 组件更高优先级的交互,暂停了 <Foo /> 的更新,导致了两个组件更新是不同步的,而恰巧在这两次更新期间,<Bar /> 调用了 useFetch 导致缓存刷新,发上了改变,但 <Foo /> 仍然使用的是上次缓存的数据,导致了最终的缓存不一致。
Concurrent Mode
<Foo />
<Bar />
为了解决这个问题,你需要重写 cache 实现,在缓存更新时通知同一个 url 的 useFetch 自动执行来保持缓存一致性,机智的你还发现 React 18 提供了一个 useSyncExternalStore 的 hook 来订阅外部的更新,
useSyncExternalStore
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
于是你打算再折腾一下,基于 useSyncExternalStore 重新实现了 cache,
const cache = { __internalStore: new Map(), __listeners: new Set(), set(key) { this.__internalStore.set(key); this.__listeners.forEach((listener) => listener()); }, delete(key) { this.__internalStore.delete(key); this.__listeners.forEach((listener) => listener()); }, subscribe(listener) { this.__listeners.add(listener); return () => this.__listeners.delete(listener); }, getSnapshot() { return this.__internalStore; }, }; function useFetch(url: string, options: FetchOptions) { // 获取最新同步的 cache const currentCache = useSyncExternalStore( cache.subscribe, useCallback(() => cache.getSnapshot().get(url), [url]), ); // 缓存刷新逻辑 // useEffect(() => {})... useEffect(() => { let isCancelled = false; let abortController = null; if (isAbortControllerSupported) { abortController = new AbortController(); } if (currentCache) { setData(currentCache); setIsLoading(false); } else { setIsLoading(true); fetch(url, { signal: abortController?.signal, ...requestInit }) .then((res) => res.json()) .then((data) => { if (!isCancelled) { cache.set(url, data); setData(data); setIsLoading(false); } }) .catch((err) => { // if (!isCancelled) ... }); } return () => { isCancelled = true; abortController?.abort(); setIsLoading(false); }; }, [url, options]); }
现在每当有一个 useFetch 写入 cache 时,所有使用了相同缓存的 useFetch 的组件都会同步到最新的缓存。
你信心满满地将 useFetch 用在了项目中,然后你发现同一个页面内,使用相同 url 的 useFetch 同步渲染的多个组件,在首次加载没有缓存时,仍然会向同一个 url 发送不止一次的请求,为了合并相同的请求,你可能还需要实现一个互斥锁(mutex lock)或者单例,然后你还要实现一个发布订阅,将接口响应数据广播到所有使用这个 url 的 useFetch。
等等,还没完,作为一个基础通用的用于发送网络请求的工具 hook,你可能还需要实现,
所以你为什么不用类似 SWR 一样现成的数据请求库,它能够覆盖上述所有的需求。
最终,你放弃了自己封装的 useFetch,尽管他已经支持了许多功能,转而拥抱了 SWR,
仅需一行代码,你就可以简化项目中数据请求的逻辑,
import useSWR from 'swr' function Profile() { const { data, error, isLoading } = useSWR('/api/user', fetcher) if (error) return <div>failed to load</div> if (isLoading) return <div>loading...</div> return <div>hello {data.name}!</div> }
作为一个由 vercel 团队出品的 React Hooks 数据请求库,特性自然不会太少,
在近一年的下载量趋势上,与 React-Query 不相上下,
重要的是,你不再需要为了数据请求的能力花费时间和精力去维护 useFetch 了,你需要的,SWR 都能给到。
本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!
如果有疑问或者发现错误,可以在评论区进行提问和勘误,
如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。
The text was updated successfully, but these errors were encountered:
No branches or pull requests
故事的开始,2023 年的一个早晨,你刚到公司座位坐下,PM 扔给了你一个需求,需要编写一个 React 应用,从接口获取一个列表的数据并渲染到页面。身经百战的你打开 Visual Studio Code 完成了项目的初始化,考虑到网络请求属于一个渲染副作用,于是你毫不犹豫的选择了 useEffect 进行数据的获取,仅用了一分钟,你就完成了代码编写,
成就满满的你熟练地打开
terminal
,敲下npm run dev
,列表成功渲染,Loading State
你刷新了一次页面,发现首次加载到数据获取完成期间,页面出现了短暂的白屏,用户体验很不好,身为一个 "将极致的用户体验和最佳的工程实践作为探索的目标" 的前端工程师,你决定实现一个加载中的进度提示,于是引入了一个新的状态
isLoading
,考虑到列表结构稳定,更好的视觉效果和用户体验,你选择了骨架屏作为加载提示,Error State
加载状态虽然有了,但是你又意识到,接口还有可能报错,你还需要在数据请求出错时显示错误提示,必要时可能还需要上报错误日志,于是你又引入了一个新的状态
error
,处理数据请求失败的情况,Custom Hook
后来,你发现每个需要从接口获取数据的场景都要写上面类似的代码,不仅重复而且繁琐,机智的你想到了自定义
hook
,于是你决定将数据请求的逻辑封装为一个useFetch
的hook
,这种方式非常有用,你在项目中大量地使用了
useFetch
,数据请求的模板代码减少了很多,逻辑也更加简洁,Request Race
你非常有成就感,
useFetch
真是太好用了,直到有一天 PM 又扔给了你一个需求,这次你需要实现点击某个列表项的时候显示对应的详情,结果测试的时候你发现,快速地在多个列表项间切换点击时,有时候你点击的是下一个列表项,页面确渲染了上一个列表项的详情。机智的你很快就找到了原因,因为你没有在useEffect
中声明如何清除你的副作用,发送网络请求是一个异步的行为,收到服务器数据的顺序并不一定是网络请求发送时的顺序,导致出现了 Race Condition,比如上面的第二个列表项详情数据返回比第一个快的情况,你的
data
就会被前一个数据覆盖,于是你在
useFetch
里面写了一个清除副作用的逻辑,AbortController
感谢 JavaScript 闭包的力量,现在即使出现了请求的 Race Condition,你的数据也不会被覆盖掉了,不仅如此,机智的你还想到了在清除副作用时检测下浏览器是否支持 AbortController,如果支持的话尝试取消请求,
Cache
你迫不及待地将新的
useFetch
用在了列表中,然后来回地切换列表项,这次详情数据终于没有被覆盖了,但每次切换都会由于url
改变,导致useEffect
重新执行,触发一次新的网络请求,实际上频繁快速地切换触发的网络请求是不必要的,你考虑为useFetch
加一个缓存,Cache Refresh
知名前 Netscape 工程师 Phil Karlton 曾说过,
一旦引入缓存,就需要考虑缓存失效的问题,什么时候刷新缓存,否则我们的 UI 显示的数据就可能会过时,机智的你想到了可以在下面的这些时机去刷新缓存,
以上的缓存刷新方式对应了不同的应用场景,正常来说你应该让
useFetch
全部支持,为了让自己还能有精力多搬几年砖,你决定先实现一个标签页失去焦点的缓存刷新,Concurrent Rendering
实现了缓存的
useFetch
如虎添翼,你本以为从此数据请求可以高枕无忧了,但是你发现你使用了新版本的 React 18 Concurrent Rendering,这个模式下,低优先级的任务在render
阶段可能会被打断、暂停甚至终止,而我们在实现useFetch
缓存的时候,cache
是一个全局变量,一个useFetch
调用cache.set
后无法通知其他useFetch
更新,可能会导致多个组件缓存数据的不一致,试想下面的场景,我们开启了
Concurrent Mode
,渲染了两个组件<Foo />
和<Bar />
都使用了useFetch
从同一个url
获取数据,它们共享一份缓存数据,但 React 为了响应用户在<Bar />
组件更高优先级的交互,暂停了<Foo />
的更新,导致了两个组件更新是不同步的,而恰巧在这两次更新期间,<Bar />
调用了useFetch
导致缓存刷新,发上了改变,但<Foo />
仍然使用的是上次缓存的数据,导致了最终的缓存不一致。为了解决这个问题,你需要重写
cache
实现,在缓存更新时通知同一个url
的useFetch
自动执行来保持缓存一致性,机智的你还发现 React 18 提供了一个useSyncExternalStore
的hook
来订阅外部的更新,于是你打算再折腾一下,基于
useSyncExternalStore
重新实现了cache
,现在每当有一个
useFetch
写入cache
时,所有使用了相同缓存的useFetch
的组件都会同步到最新的缓存。Request Deduplication & Merge
你信心满满地将
useFetch
用在了项目中,然后你发现同一个页面内,使用相同url
的useFetch
同步渲染的多个组件,在首次加载没有缓存时,仍然会向同一个url
发送不止一次的请求,为了合并相同的请求,你可能还需要实现一个互斥锁(mutex lock)或者单例,然后你还要实现一个发布订阅,将接口响应数据广播到所有使用这个url
的useFetch
。等等,还没完,作为一个基础通用的用于发送网络请求的工具
hook
,你可能还需要实现,所以你为什么不用类似 SWR 一样现成的数据请求库,它能够覆盖上述所有的需求。
SWR
最终,你放弃了自己封装的
useFetch
,尽管他已经支持了许多功能,转而拥抱了 SWR,仅需一行代码,你就可以简化项目中数据请求的逻辑,
作为一个由 vercel 团队出品的 React Hooks 数据请求库,特性自然不会太少,
在近一年的下载量趋势上,与 React-Query 不相上下,
重要的是,你不再需要为了数据请求的能力花费时间和精力去维护
useFetch
了,你需要的,SWR 都能给到。参考链接
写在最后
本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!
如果有疑问或者发现错误,可以在评论区进行提问和勘误,
如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。
The text was updated successfully, but these errors were encountered: