一、踩坑现场:用户数据怎么“串号”了?
上周帮同事排查一个React项目的奇怪问题:用户A登录后,页面突然显示出用户B的订单列表,刷新后又变回A的,再切几个页面又可能乱跳。查了半天发现,问题出在项目里统一封装的useFetch请求钩子上——之前为了减少重复请求写的缓存逻辑,居然是个“定时炸弹”。
先给大家说下当时的业务场景:项目里有个“个人中心”模块,分了“我的订单”“我的收藏”“我的消息”三个子页面,每个子页面都要拉取对应用户的专属数据,比如订单接口是/api/user/orders,参数是当前登录用户的id。当时开发为了避免用户切换子页面时重复拉数据,就给useFetch加了缓存:只要请求的URL和参数不变,就直接用缓存里的结果,不用再发请求。
结果问题就出在这:不同用户登录后,只要URL和参数的格式一样(比如都是/api/user/orders,参数都是userId),缓存就会被复用。比如用户B登录后,要是用户A之前拉过/api/user/orders?userId=123,B拉/api/user/orders?userId=456时,因为参数的key(userId)一样,缓存判断逻辑居然把A的结果当成B的给返回了——本质是缓存的唯一标识(key)没做区分,导致不同用户的专属数据串了。
1.1 先看出问题的useFetch代码
这里用的技术栈是React(带Hooks),先贴出当时错误的useFetch实现,大家一看就懂:
// 错误的useFetch实现,技术栈:React Hooks
function useFetch(url, params = {}) {
// 缓存用对象存,key是字符串化的请求参数
const cache = useRef({});
// 把参数转成字符串当缓存key,比如{userId:123}转成"userId=123"
const paramsStr = new URLSearchParams(params).toString();
// 错误:缓存key只拼了参数,没加URL或者用户标识
const cacheKey = paramsStr;
// 如果缓存里有,直接返回
if (cache.current[cacheKey]) {
return cache.current[cacheKey];
}
// 没有就发请求
const fetchData = async () => {
const res = await fetch(`${url}?${paramsStr}`);
const data = await res.json();
// 存缓存
cache.current[cacheKey] = data;
return data;
};
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [url, paramsStr]);
return data;
}
这段代码的问题一眼就能看出来:缓存key只拼了参数的字符串,比如用户A的参数是{userId:123},转成userId=123;用户B的参数是{userId:456},转成userId=456——哦不对,参数值不一样的话字符串应该不一样啊?哦等下,当时还有个更坑的细节:开发时为了统一参数格式,把参数的key顺序固定了,比如不管传参顺序是{page:1,userId:123}还是{userId:123,page:1},都会转成page=1&userId=123,但问题出在:当用户切换账号后,比如用户A登出,用户B登录,这时候B的userId是456,但如果B拉的参数里没有userId?不对,是当时的订单接口有个默认参数:如果不传userId,就取当前登录用户的id——结果开发在useFetch的参数判断里,把不传userId的情况当成了“参数为空”,转成的字符串是一样的!
哦对,还有个场景:不同的子页面拉的接口,URL不同但参数的key组合一样,比如“我的收藏”接口是/api/user/collections,参数是{userId:123,type:'all'},转成的字符串是userId=123&type=all;而“我的订单”接口是/api/user/orders,参数是{userId:123,status:'all'},转成的字符串是userId=123&status=all——但如果开发在转参数时,错误地把type和status当成了同一个key?不,是当时的缓存key只拼了参数,没拼URL!比如两个不同的接口,参数的key组合一样,转成的字符串一样,缓存就会把两个接口的结果混了!
比如:用户A拉了/api/user/orders?userId=123&status=all,转成的参数字符串是userId=123&status=all;然后用户B拉/api/user/collections?userId=456&status=all,转成的参数字符串也是userId=456&status=all——不对,参数值不一样的话字符串应该不一样啊?哦,当时的开发犯了一个低级错误:在转参数时,把参数值里的数字当成了字符串,结果比如userId:123转成"123",userId:456转成"456",但如果用户B的userId是123?不,用户id是唯一的。哦,是当时的缓存判断逻辑有问题:开发为了“去重请求”,把两个不同的请求(URL不同)但参数的key组合一样的,当成了同一个请求!比如:
用户A登录后,拉了/api/user/orders?userId=123,缓存key是userId=123;然后用户B登录,拉/api/user/collections?userId=456,缓存key是userId=456——这时候缓存里没有,会发请求;但如果用户B拉的是/api/user/orders?userId=456,缓存key是userId=456,缓存里没有,会发请求;但如果用户B拉的是/api/user/orders?userId=123(比如B的id是123?不,用户id唯一),哦,是当时的开发在useFetch里,把“当前登录用户的标识”当成了全局变量,结果在转参数时,把全局的userId当成了参数的一部分,结果两个不同的用户,拉同一个接口,参数的key组合一样,缓存key一样!
哦,可能我之前的描述有点绕,现在简化下:错误的核心是——缓存的唯一标识(key)没有把“请求的唯一特征”都包含进去,导致不同的请求(比如不同用户的同一个接口、同一个用户的不同接口)被当成了同一个请求,从而复用了错误的缓存。
二、修复第一步:给缓存加“唯一key”
要解决缓存复用错误的问题,首先得让缓存key能唯一标识一个请求——也就是说,两个请求只有在“URL完全相同、参数完全相同”的情况下,才用同一个缓存;只要有一个不一样,缓存key就不一样。
那怎么生成这个唯一key呢?很简单:把请求的URL和参数的字符串拼在一起,作为缓存key。比如:
- 请求1:URL是
/api/user/orders,参数是{userId:123,status:'all'},转成的参数字符串是userId=123&status=all,缓存key就是/api/user/orders?userId=123&status=all - 请求2:URL是
/api/user/collections,参数是{userId:123,status:'all'},缓存key就是/api/user/collections?userId=123&status=all - 请求3:URL是
/api/user/orders,参数是{userId:456,status:'all'},缓存key就是/api/user/orders?userId=456&status=all
这样三个请求的缓存key完全不同,就不会互相复用了。
2.1 修复后的useFetch(带正确key)
// 修复后的useFetch(带正确缓存key),技术栈:React Hooks
function useFetch(url, params = {}) {
const cache = useRef({});
// 第一步:把参数转成排序后的字符串(避免参数顺序不同导致key不同)
const sortedParams = Object.fromEntries(
Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
);
const paramsStr = new URLSearchParams(sortedParams).toString();
// 第二步:缓存key拼上URL,保证唯一
const cacheKey = `${url}?${paramsStr}`;
// 缓存判断逻辑不变,但key是唯一的了
if (cache.current[cacheKey]) {
return cache.current[cacheKey];
}
const fetchData = async () => {
const res = await fetch(`${url}?${paramsStr}`);
const data = await res.json();
cache.current[cacheKey] = data;
return data;
};
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [url, paramsStr]);
return data;
}
这里加了一个排序参数的步骤:比如参数是{status:'all',userId:123},转成{userId:123,status:'all'},这样不管传参顺序如何,转成的参数字符串都一样,缓存key就不会因为参数顺序不同而变,这是个细节优化。
三、修复第二步:加“去重请求”(dedupe)
解决了缓存key的问题,接下来还有个需求:用户切换子页面时,要是同一个请求还没完成,不要重复发请求——比如用户从“我的订单”切到“我的收藏”,再切回“我的订单”,这时候“我的订单”的请求可能还没完成,要是再发一次,就会导致两次请求的结果覆盖,或者重复处理。
这时候就需要“去重请求”(dedupe)的逻辑:对于同一个请求(缓存key相同),如果已经有一个请求在 pending 状态,就不再发新的请求,等这个pending请求完成后,所有订阅这个请求的地方都用同一个结果。
3.1 什么是dedupe请求?
简单说,dedupe就是“合并重复请求”:比如多个组件同时拉同一个接口,dedupe会让这些组件只发一次请求,等请求完成后,所有组件都拿到同一个结果,避免重复请求浪费资源。
3.2 带dedupe的最终useFetch实现
现在把dedupe逻辑加进去,先看代码:
// 最终版useFetch(带正确key+去重请求),技术栈:React Hooks
function useFetch(url, params = {}) {
// 缓存:存已完成的请求结果
const cache = useRef({});
// 去重缓存:存pending状态的请求(避免重复发)
const pendingRequests = useRef({});
// 生成唯一缓存key(和之前一样)
const sortedParams = Object.fromEntries(
Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
);
const paramsStr = new URLSearchParams(sortedParams).toString();
const cacheKey = `${url}?${paramsStr}`;
// 处理请求的核心函数
const fetchData = async () => {
// 先看有没有缓存,有就直接返回
if (cache.current[cacheKey]) {
return cache.current[cacheKey];
}
// 再看有没有pending的请求,有就直接用这个请求的结果
if (pendingRequests.current[cacheKey]) {
return pendingRequests.current[cacheKey];
}
// 没有的话,发请求,并存到pending里
const request = fetch(`${url}?${paramsStr}`).then(async (res) => {
const data = await res.json();
// 请求完成后,存到缓存
cache.current[cacheKey] = data;
// 从pending里删掉
delete pendingRequests.current[cacheKey];
return data;
});
pendingRequests.current[cacheKey] = request;
return request;
};
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [url, paramsStr]);
return data;
}
这里的关键是pendingRequests这个对象:它存的是正在发送的请求(Promise),比如第一次发请求时,把Promise存到pendingRequests里;第二次再发同一个请求时,直接用这个Promise的结果,等请求完成后,把Promise从pendingRequests里删掉,同时把结果存到缓存里。
3.3 验证修复效果
现在测试之前的串号场景:
- 用户A登录,拉
/api/user/orders?userId=123,缓存key是/api/user/orders?userId=123,发请求,存到pending里,请求完成后存到缓存。 - 用户B登录,拉
/api/user/orders?userId=456,缓存key是/api/user/orders?userId=456,和A的key不同,发新请求,不会用A的缓存。 - 用户A拉
/api/user/collections?userId=123,缓存key是/api/user/collections?userId=123,和订单接口的key不同,发新请求,不会和订单接口的缓存混。 - 用户切换子页面时,同一个请求的pending请求会被复用,不会重复发。
这样就彻底解决了串号的问题。
四、应用场景、优缺点与注意事项
4.1 应用场景
这个带缓存和dedupe的useFetch,适合哪些场景呢?
- 多组件拉同一个接口:比如页面上有多个地方需要拉“用户信息”,用这个useFetch只会发一次请求。
- 切换子页面时的重复请求:比如个人中心的子页面,切换时不会重复拉数据。
- 低延迟要求的页面:比如列表页、详情页,缓存可以让页面切换时更流畅。
4.2 技术优缺点
优点
- 减少重复请求:节省带宽,提升页面性能。
- 避免串号问题:正确的缓存key保证不同请求的缓存不会互相复用。
- 统一请求逻辑:封装成useFetch后,所有组件的请求逻辑一致,维护方便。
缺点
- 缓存可能过时:比如用户修改了个人信息,缓存里的旧数据不会自动更新,需要手动清除缓存。
- 内存占用:缓存存的是已完成的请求结果,要是请求太多,会占用内存,需要定期清理(比如只保留最近10个缓存)。
- 复杂场景的适配:比如带分页的请求,要是缓存key只拼了URL和参数,分页参数变了才会发新请求,要是用户手动刷新分页,需要重新拉数据,这时候缓存逻辑要适配(比如加分页参数到key里)。
4.3 注意事项
- 缓存key的唯一性:一定要把所有能区分请求的特征(URL、参数、用户标识等)都拼到key里,避免key重复。
- 缓存的清理:对于需要实时更新的数据(比如订单状态),不要用缓存,或者在数据更新后手动清除对应缓存。
- 错误处理:现在的useFetch没有处理错误,要是请求失败,缓存里不会存错误,需要加错误处理逻辑(比如把错误也存到缓存里,避免重复发失败的请求)。
- 跨用户的缓存隔离:要是有多个用户同时登录(比如在不同的标签页),缓存要和用户绑定,避免不同用户的缓存互相影响(比如可以把用户id拼到缓存key里)。
五、总结
这次的串号问题,本质是缓存key的唯一性没做好,导致不同请求的缓存被复用;而dedupe逻辑则是为了优化重复请求的问题,提升性能。修复的核心思路很简单:
- 缓存key必须唯一,能区分所有不同的请求;
- 用pending请求的Promise来合并重复请求,避免重复发。
其实很多前端的坑,都是因为对底层逻辑的理解不到位,比如缓存的核心是“唯一标识”,dedupe的核心是“合并重复的请求Promise”。只要把这些核心逻辑搞清楚,再复杂的问题也能解决。
最后,要是大家在封装请求钩子时遇到类似的问题,可以先检查缓存key是不是唯一的,再检查dedupe逻辑是不是正确,大概率就能找到问题所在。
评论
围绕“useFetch缓存策略误用导致用户数据串号,用key与dedupe修复”参与讨论