Service服务在前端项目中的设计实践与常见问题解决方案
项目初期的技术选型
去年下半年接了个后台管理系统的重构活儿,需求挺典型:权限粒度细(按钮级)、数据实时性要求高(比如审批流状态变更要秒推)、还要兼容 IE11(别问,问就是政务客户)。一开始我真想用 Vue 的 Composition API + Pinia 拎起来就干,结果翻了翻旧代码——全是 jQuery 时代留下的 $.ajax 封装,还夹杂着十几个全局 window.api_XXX 函数,一查调用链,有 37 处地方直接拼 URL 字符串……我当场把键盘往后推了 20 厘米。
最后定了个折中方案:保留原有请求底层(避免改崩老逻辑),但把所有网络请求抽象成统一的 Service 层。不是为了炫技,纯粹是怕下周交接给新同事时,他 grep 出来 56 个 /api/v2/user/ 然后蹲在厕所里哭。
最大的坑:缓存和并发请求打架
Service 层写得挺顺,封装了 token 自动注入、错误统一拦截、loading 状态托管……直到上线前压测,发现一个诡异现象:用户点两次“刷新列表”,接口只发了一次请求,但 UI 刷新了两次,第二次数据还是旧的。
查了半天,原来是我在 Service 里加了「相同参数的 GET 请求自动复用上次响应」的缓存逻辑,用的是 Map 存 key(URL + JSON.stringify(params)),但没考虑 Promise 的状态。第一次请求 pending 中,第二次进来直接返回同一个 pending Promise,然后两个 .then() 都绑在上面——结果第一次 resolve 后,两个回调全执行了,但第二次的请求其实根本没发出去。
折腾了半天发现,这玩意儿不能简单地“复用 Promise”,得区分 pending、fulfilled、rejected 三种状态。后来改成这样:
class ApiService {
constructor() {
this.cache = new Map();
}
request(config) {
const key = this.generateCacheKey(config);
// 已完成的直接返回克隆响应(避免引用污染)
if (this.cache.has(key) && this.cache.get(key).status === 'fulfilled') {
return Promise.resolve({ ...this.cache.get(key).data });
}
// 正在请求中?返回已存在的 Promise,但不绑定新回调
if (this.cache.has(key) && this.cache.get(key).status === 'pending') {
return this.cache.get(key).promise;
}
// 新请求
const promise = this._fetch(config)
.then(res => {
this.cache.set(key, { status: 'fulfilled', data: res });
return res;
})
.catch(err => {
this.cache.set(key, { status: 'rejected', error: err });
throw err;
});
this.cache.set(key, { status: 'pending', promise });
return promise;
}
generateCacheKey(config) {
return ${config.url}?${new URLSearchParams(config.params || {}).toString()};
}
_fetch(config) {
return fetch(${config.baseURL || 'https://jztheme.com/api'}${config.url}, {
method: config.method || 'GET',
headers: {
'Authorization': Bearer ${localStorage.getItem('token')},
...config.headers
},
body: config.data ? JSON.stringify(config.data) : null
}).then(r => r.json());
}
}
这里注意我踩过好几次坑:一开始用 JSON.stringify 序列化 params,但对象属性顺序不固定导致 key 不一致;后来换成 URLSearchParams,稳妥。还有个细节是,fulfilled 状态下返回的是 {...data} 而不是原引用——不然某个组件改了 response.data.list,另一个组件里的 list 也跟着变,这谁顶得住。
最终的解决方案
Service 类本身不处理业务逻辑,只管发请求、缓存、错误码映射。业务层调用长这样:
// user.service.js
import { ApiService } from './api.service';
export const UserService = {
list(params) {
return ApiService.request({
url: '/users',
params,
cache: true // 显式声明是否缓存
});
},
create(data) {
return ApiService.request({
url: '/users',
method: 'POST',
data,
cache: false // POST 默认不缓存,但显式写出来更安心
});
}
};
实际页面里就一行:const users = await UserService.list({ page: 1, size: 20 });。比以前写 fetch('/api/users?page=1&size=20') 强太多——至少出错了能一眼看出是哪个 Service 报的。
另外加了个小技巧:所有 Service 方法都返回 Promise,但额外挂了个 .abort() 方法(基于 AbortController),虽然项目里只在搜索框防抖取消时用了两次,但写的时候心里踏实。
回顾与反思
现在回头看,这个 Service 层最值钱的不是代码多优雅,而是它成了前后端联调的“缓冲带”。后端改字段名?我只改 Service 里那一行映射逻辑,不用满项目搜 res.data.userName。测试环境切 mock 数据?只要替换 ApiService 的 _fetch 方法就行,业务代码零改动。
当然也有没做好的地方:比如没集成 axios 的 CancelToken(我们用原生 fetch),导致某些长轮询场景取消不太干净;还有错误重试策略是硬编码的 3 次,没做成可配置——不是不想,是上线 deadline 卡得太死,先保主流程稳定。
还有个小问题至今没动:缓存 key 生成没过滤掉 token 或时间戳这类动态参数,导致本该复用的请求没复用上。不过影响不大,因为实际业务里带时间戳的请求本来就不该缓存,所以就……先留着吧。
以上是我踩坑后的总结,希望对你有帮助。这个 Service 模式在中小项目里亲测有效,大项目可能得配合 GraphQL 或 SWR 之类再升级。有更优的实现方式欢迎评论区交流。
