Service服务在前端项目中的设计实践与常见问题解决方案

朱莉~ 工具 阅读 2,989
赞 39 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年下半年接了个后台管理系统的重构活儿,需求挺典型:权限粒度细(按钮级)、数据实时性要求高(比如审批流状态变更要秒推)、还要兼容 IE11(别问,问就是政务客户)。一开始我真想用 Vue 的 Composition API + Pinia 拎起来就干,结果翻了翻旧代码——全是 jQuery 时代留下的 $.ajax 封装,还夹杂着十几个全局 window.api_XXX 函数,一查调用链,有 37 处地方直接拼 URL 字符串……我当场把键盘往后推了 20 厘米。

Service服务在前端项目中的设计实践与常见问题解决方案

最后定了个折中方案:保留原有请求底层(避免改崩老逻辑),但把所有网络请求抽象成统一的 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 之类再升级。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
UI怡平
UI怡平 Lv1
这个之前没注意到的小知识点,解决了我项目中一个隐藏的问题,收获很大。
点赞 1
2026-02-14 08:25