我正在参加「掘金·启航计划」
大家好,我是 Monch,今天想跟大家分享一个关于 React 数据请求的故事。
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
,列表成功渲染,
Loading State
你刷新了一次页面,发现首次加载到数据获取完成期间,页面出现了短暂的白屏,用户体验很不好,身为一个 “将极致的用户体验和最佳的工程实践作为探索的目标” 的前端工程师,你决定实现一个加载中的进度提示,于是引入了一个新的状态 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 State
加载状态虽然有了,但是你又意识到,接口还有可能报错,你还需要在数据请求出错时显示错误提示,必要时可能还需要上报错误日志,于是你又引入了一个新的状态 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>
);
}
Custom Hook
后来,你发现每个需要从接口获取数据的场景都要写上面类似的代码,不仅重复而且繁琐,机智的你想到了自定义 hook
,于是你决定将数据请求的逻辑封装为一个 useFetch
的 hook
,
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) {
// ...
}
}
Request Race
你非常有成就感,useFetch
真是太好用了,直到有一天 PM 又扔给了你一个需求,这次你需要实现点击某个列表项的时候显示对应的详情,结果测试的时候你发现,快速地在多个列表项间切换点击时,有时候你点击的是下一个列表项,页面确渲染了上一个列表项的详情。机智的你很快就找到了原因,因为你没有在 useEffect
中声明如何清除你的副作用,发送网络请求是一个异步的行为,收到服务器数据的顺序并不一定是网络请求发送时的顺序,导致出现了 Race Condition,
| =============== Request Detail 1 ===============> | setState()
| ===== Request Detail 2 ====> | setState() |
比如上面的第二个列表项详情数据返回比第一个快的情况,你的 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 };
}
AbortController
感谢 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 };
}
Cache
你迫不及待地将新的 useFetch
用在了列表中,然后来回地切换列表项,这次详情数据终于没有被覆盖了,但每次切换都会由于 url
改变,导致 useEffect
重新执行,触发一次新的网络请求,实际上频繁快速地切换触发的网络请求是不必要的,你考虑为
useFetch
加一个缓存,
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 };
}
Cache Refresh
知名前 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 };
}
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
来订阅外部的更新,
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
的组件都会同步到最新的缓存。
Request Deduplication & Merge
你信心满满地将 useFetch
用在了项目中,然后你发现同一个页面内,使用相同 url
的 useFetch
同步渲染的多个组件,在首次加载没有缓存时,仍然会向同一个 url
发送不止一次的请求,为了合并相同的请求,你可能还需要实现一个互斥锁(mutex lock)或者单例,然后你还要实现一个发布订阅,将接口响应数据广播到所有使用这个 url
的 useFetch
。
等等,还没完,作为一个基础通用的用于发送网络请求的工具 hook
,你可能还需要实现,
- Error Retry:在数据加载出现问题的时候,要进行有条件的重试(如仅 5xx 时重试,403、404 时放弃重试)
- Preload:预加载数据,避免瀑布流请求
- SSR、SSG:服务端获取的数据用来提前填充缓存、渲染页面、然后再在客户端刷新缓存
- Pagination:针对大量数据、分页请求
- Mutation:响应用户输入、将数据自动发送给服务端
- Optimistic Mutation:用户提交输入时先更新本地 UI、形成「已经修改成功」的假象,同时异步将输入发送给服务端;如果出错,还需要回滚本地 UI,比如点赞
- Middleware:各类的日志、错误上报、Authentication 中间件
所以你为什么不用类似 SWR 一样现成的数据请求库,它能够覆盖上述所有的需求。
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 数据请求库,特性自然不会太少,
- 内置缓存和重复请求去除:内置缓存机制,自动缓存请求结果,请求相同的数据直接返回缓存结果,避免重复请求
- 实时更新:支持组件挂载、用户聚焦页面、网络恢复等时机的实时更新
- 智能错误重试:可以根据错误类型和重试次数来自动重试请求
- 间隔轮询:可以通过设置 refreshInterval 选项来实现数据的定时更新
- 支持 SSR/ISR/SSG:可以在服务端获取数据并将数据预取到客户端,提高页面的加载速度和用户体验
- 支持 TypeScript:提供更好的类型检查和代码提示
- 支持 React Native,可以在移动端应用中直接使用
- …
在近一年的下载量趋势上,与 React-Query 不相上下,
重要的是,你不再需要为了数据请求的能力花费时间和精力去维护 useFetch
了,你需要的,SWR 都能给到。
写在最后
本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!
如果有疑问或者发现错误,可以在评论区进行提问和勘误,
如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。