目录
首次加载->数据获取(白屏):isLoading状态State+骨架屏
因异步请求,达到顺序非发送顺序->Race Condition:清除副作用return
React 18 Concurrent Rendering->render打断+cache全局变量
普通函数中触发重新请求:import mutate from 'swr';
useEffect 从 API 获取数据
网络请求属于一个渲染副作用
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>
);
}
请求失败->Error State
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>
);
}
优化
首次加载->数据获取(白屏):isLoading状态State+骨架屏
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>
);
}
多处请求:封装->自定义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 };
}
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) {
// ...
}
}
因异步请求,达到顺序非发送顺序->Race Condition:清除副作用return
针对切换列表项,获取数据被覆盖场景
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取消请求
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:Map
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
刷新时机举例
- 标签页失去焦点
- 定时重复更新
- 网络状态改变
以“标签页失去焦点”缓存刷新为例
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 };
}
React 18 Concurrent Rendering->render打断+cache全局变量
低优先级的任务在
render
阶段可能会被打断、暂停甚至终止,而我们在实现useFetch
缓存的时候,cache
是一个全局变量,一个useFetch
调用cache.set
后无法通知其他useFetch
更新,可能会导致多个组件缓存数据的不一致,试想下面的场景,我们开启了
Concurrent Mode
,渲染了两个组件<Foo />
和<Bar />
都使用了useFetch
从同一个url
获取数据,它们共享一份缓存数据,但 React 为了响应用户在<Bar />
组件更高优先级的交互,暂停了<Foo />
的更新,导致了两个组件更新是不同步的,而恰巧在这两次更新期间,<Bar />
调用了useFetch
导致缓存刷新,发上了改变,但<Foo />
仍然使用的是上次缓存的数据,导致了最终的缓存不一致。
useSyncExternalStore
订阅外部更新
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
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]);
}
其他问题
- Error Retry:在数据加载出现问题的时候,要进行有条件的重试(如仅 5xx 时重试,403、404 时放弃重试)
- Preload:预加载数据,避免瀑布流请求
- SSR、SSG:服务端获取的数据用来提前填充缓存、渲染页面、然后再在客户端刷新缓存
- Pagination:针对大量数据、分页请求
- Mutation:响应用户输入、将数据自动发送给服务端
- Optimistic Mutation:用户提交输入时先更新本地 UI、形成「已经修改成功」的假象,同时异步将输入发送给服务端;如果出错,还需要回滚本地 UI,比如点赞
- Middleware:各类的日志、错误上报、Authentication 中间件
数据请求库SWR
“SWR” 这个名字来自于 stale-while-revalidate
:一种由 HTTP RFC 5861 推广的 HTTP 缓存失效策略。这种策略首先从缓存中返回数据(过期的),同时发送 fetch 请求(重新验证),最后得到最新数据。
- 内置缓存和重复请求去除:内置缓存机制,自动缓存请求结果,请求相同的数据直接返回缓存结果,避免重复请求
- 实时更新:支持组件挂载、用户聚焦页面、网络恢复等时机的实时更新
- 智能错误重试:可以根据错误类型和重试次数来自动重试请求
- 间隔轮询:可以通过设置 refreshInterval 选项来实现数据的定时更新
- 支持 SSR/ISR/SSG:可以在服务端获取数据并将数据预取到客户端,提高页面的加载速度和用户体验
- 支持 TypeScript:提供更好的类型检查和代码提示
- 支持 React Native,可以在移动端应用中直接使用
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>
}
请求错误重试: 指数退避算法 重发请求
背景:封装aioxs 响应拦截器,实现当满足某种错误条件时进行错误重试
错误重试的功能默认开启,可配置 重试次数 和 重试时延
useSWR('/api/user', fetcher, {
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// 404 时不重试。
if (error.status === 404) return
// 特定的 key 时不重试。
if (key === '/api/user') return
// 最多重试 10 次。
if (retryCount >= 10) return
// 5秒后重试。
setTimeout(() => revalidate({ retryCount: retryCount }), 5000)
}
})
全局配置App.tsx
value
中传入你的全局配置
<SWRConfig value={options}>
<Component/>
</SWRConfig>
数据突变(mutate)
当数据改变需要重新请求,可用useSWRConfig()
所返回的 mutate
函数,来广播重新验证的消息给其他的 SWR hook,如果数据发生了变更 swr 会更新缓存并重新渲染
即用同一个 key 调用 mutate(key)
import useSWR, { useSWRConfig } from 'swr'
function App () {
const { mutate } = useSWRConfig()
return (
<div>
<Profile />
<button onClick={() => {
// 将 cookie 设置为过期
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
// 告诉所有具有该 key 的 SWR 重新验证
mutate('/api/user')
}}>
Logout
</button>
</div>
)
}
第二个参数options
选项
optimisticData
:立即更新客户端缓存的数据,通常用于 optimistic UI。revalidate
:一旦完成异步更新,缓存是否重新请求。populateCache
:远程更新的结果是否写入缓存,或者是一个以新结果和当前结果作为参数并返回更新结果的函数。rollbackOnError
:如果远程更新出错,是否进行缓存回滚。
普通函数中触发重新请求:import mutate from 'swr';
当我们 目前操作的用户权限突然被调低 了,在获取数据时后端响应了状态码
403
,我们想要在 axios 的响应拦截中配置一个:如果遇到状态码为403
的响应数据就重新获取一下用户的权限以重新渲染页面,将一些当前用户权限不该显示的内容隐藏
import axios from 'axios';
import { mutate } from 'swr';
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
switch (error.response.status) {
case 403: {
mutate('/user/me');
break;
}
case 500: {
// ... do something
break;
}
default: {
// ... do something
}
}
}
return Promise.reject(error);
}
);
推荐使用:二次封装
根据 数据类型 进行分类,并以 hook 的方式进行二次封装
import axios from 'axios';
import useSWR from 'swr';
import { UserResponse } from 'types/User';
const useUser = () => {
const { data, error, isLoading, isValidating, mutate } = useSWR<UserResponse>('/user', (url) =>
axios.get(url).then((res) => res.data.payload)
);
return {
data,
reload: mutate,
isLoading,
isValidating,
isError: error,
};
};
export default useUser;
isLoading
表示目前暂无缓存,正在进行初次加载。
isValidating
则表示已经有缓存了,但是由于重新聚焦屏幕,或者手动触发数据更新数据重新验证的加载
请求间依赖
获取用户 id 后再发送新的请求
import axios from 'axios';
import useSWR from 'swr';
import useUser from './useUser';
const useUserDetail = () => {
const { data } = useUser();
const { data, error, isLoading, isValidating, mutate } = useSWR(
data[0].id ? `users/${data[0].id}/detail` : null,
(url: string) => axios.get(url).then((res) => res.data.payload)
);
return {
data,
reload: mutate,
isLoading,
isValidating,
isError: error,
};
};
export default useUserDetail;
防抖
只有在请求实践超过了
500ms
才响应时展示加载动画
import { Center, Spinner } from '@chakra-ui/react';
import { useDebounce } from 'ahooks';
import { memo } from 'react';
const TableLoading: React.FC<{ isOpen: boolean }> = ({ isOpen }) => {
// To prevent the loading animation from flickering frequently, it will only be displayed if the loading time exceeds 500 ms
const debouncedLoading = useDebounce(isOpen, {
wait: 500,
});
return debouncedLoading ? (
<Center
>
<Spinner />
</Center>
) : null;
};
export default memo(TableLoading);