Skip to content

05 - API与请求层

核心请求封装

API 类(src/api/req.js)

req.js 是整个请求层的核心,封装了一个 API 类,处理完整的请求生命周期:

js
const api = new API({
  baseURL: process.env.APP_BASE_URL
})

export default api
export { API }

API 实例方法

方法签名说明
getget(url, data, config)GET 请求
postpost(url, data, config)POST 请求
putput(url, data, config)PUT 请求
deletedelete(url, data, config)DELETE 请求
patchpatch(url, data, config)PATCH 请求
makeReqmakeReq(config, intereptorRes, intereptorReq)底层请求方法
refreshTokenrefreshToken()Token 刷新
setOptionssetOptions(opts)动态更新配置

请求生命周期

完整流程图

组件调用 api.get('/user/info', { page: 1 })

  ├─→ getReqUrl(url)
  │    └─ 拼接 baseURL + url(http 开头则直接使用)

  ├─→ intereptorReq (请求拦截)
  │    ├─ company_id 注入(多租户)
  │    ├─ country_code / language 注入(多语言)
  │    ├─ Token 选择(mall vs merchant)
  │    ├─ 请求序列化(GET→query / POST→body)
  │    └─ authorizer-appid header(微信)

  ├─→ request(options)  ← 底层请求适配(H5/微信)

  └─→ intereptorRes (响应拦截)
       ├─ statusCode === 200 && status_code === 0 → 成功
       ├─ statusCode === 401 && code === 401001 → Token 刷新
       ├─ statusCode === 401 && code === 401002 → 用户被禁用
       └─ statusCode === 401 → 登出并跳转登录页

URL 拼接

js
getReqUrl(url) {
  if (url.startsWith('http')) return url  // 完整 URL 直接使用
  return this.options.baseURL + url       // 否则拼接 baseURL
}

company_id 解析(多租户)

js
setOptions(opts) {
  let company_id = process.env.APP_COMPANY_ID

  // H5 环境:从域名提取 company_id
  // m38.shopex123.com → company_id = 38
  if (Taro.getEnv() == Taro.ENV_TYPE.WEB && global.location) {
    const match = global.location.hostname.match(/^m(\d+)\./)
    if (match && match[1]) company_id = match[1]
  }

  // 微信小程序:从 ext.json 覆盖
  if (isWeixin) {
    const extConfig = getExtConfigData()
    if (extConfig.company_id) options.company_id = extConfig.company_id
  }
}

语言/国家码注入

js
const langMap = {
  zhcn: 'zh-CN',
  en: 'en-CN',
  zhtw: 'zh-TW',
  ar: 'ar-SA'
}

query['country_code'] = langMap[lang || process.env.APP_COUNTRY_CODE]

Token 选择逻辑

js
// intereptorReq 中:
const token = useMallToken
  ? Taro.getStorageSync(SG_TOKEN)        // 强制使用商城会员 Token
  : getS().getAuthToken()                 // 自动选择(根据当前模块)

// Spx.getAuthToken() 内部:
getAuthToken() {
  if (isMerchantModule()) {
    return this.get(MERCHANT_TOKEN)  // 商户模块用商户 Token
  }
  return this.get(SG_TOKEN)           // 其他用会员 Token
}

useMallToken 参数:当请求需要强制使用商城会员 Token 时(如商户入驻页证照上传走会员接口),传入 useMallToken: true

js
api.post('/upload', data, { useMallToken: true })

请求序列化

方法参数位置Content-Type
GETURL query string-
POST/PUT/DELETE/PATCHBodyapplication/x-www-form-urlencoded

使用 qs.stringify 序列化:

js
// GET
url = `${url}?${qs.stringify(data)}`

// POST
body = qs.stringify(data)
header['content-type'] = 'application/x-www-form-urlencoded'

Token 刷新机制

触发条件

当响应 statusCode === 401data.code === HTTP_STATUS.TOKEN_NEEDS_REFRESH (401001) 时触发 Token 刷新。

刷新流程

请求 A 返回 401 (TOKEN_NEEDS_REFRESH)

  ├─→ refreshToken()
  │    ├─ 检查 refreshTokenPromise(防并发刷新)
  │    ├─ 设置 isRefreshingToken = true
  │    ├─ 用当前 Token 调用 /token/refresh
  │    └─ 从响应 header Authorization 解析新 Token
  │         └─ getS().setAuthToken(newToken)

  ├─→ 刷新成功
  │    └─ 重试原请求(makeReq 递归,noPending: true 防止再次进入等待队列)

  └─→ 刷新期间其他请求(请求 B、C...)
       └─→ isRefreshingToken === true → 进入 pendingReq 等待队列
            └─ 刷新完成后,队列中的请求用新 Token 继续执行

核心代码

js
async makeReq(config) {
  const res = await this.request(options)

  // Token 需要刷新
  if (res.statusCode === 401 && res.data?.data?.code === HTTP_STATUS.TOKEN_NEEDS_REFRESH) {
    const refreshed = await this.refreshToken()
    if (!refreshed) return Promise.reject(...)
    // 用新 Token 重试
    return this.makeReq({ ...config, noPending: true })
  }

  // 正在刷新中,其他请求进入等待队列
  if (this.isRefreshingToken && !config.noPending) {
    return this.pendingReq(config)
  }

  return intereptorRes(res)
}

Token 刷新方法

js
async refreshToken() {
  // 防并发:如果已有刷新 Promise,复用
  if (this.refreshTokenPromise) return this.refreshTokenPromise

  this.isRefreshingToken = true
  this.refreshTokenPromise = (async () => {
    const token = getS().getAuthToken()
    await this.makeReq({
      header: { Authorization: `Bearer ${token}` },
      method: 'get',
      url: this.getReqUrl('/token/refresh'),
      noPending: true  // 防止递归刷新
    }, (res) => {
      // 新 Token 从响应 header 的 Authorization 字段解析
      const newToken = parseRefreshTokenFromResponse(res)
      if (newToken) getS().setAuthToken(newToken)
    })
  })()

  await this.refreshTokenPromise
  this.isRefreshingToken = false
  this.refreshTokenPromise = null
}

登出逻辑

handleLogout

当响应 statusCode === 401(非 Token 刷新场景)时触发登出:

js
handleLogout() {
  this.requestQueue.destroy()  // 清空等待队列
  getS().logout()              // 清除 Token 和 userInfo

  // 根据模块跳转到对应登录页
  if (isMerchantModule()) → '/subpages/merchant/login'
  else if (goodsShelvesModule) → '/subpages/guide/index'
  else if (purchaseModule) → '/subpages/purchase/member'
  else'/subpages/member/index'
}

Spx.logout()

js
logout() {
  this.delete(SG_TOKEN)
  this.delete(SG_USER_INFO)
  store.dispatch(clearUserInfo())
  // 兼容:同时清除 Storage
  Taro.removeStorageSync(SG_TOKEN)
  Taro.removeStorageSync(SG_USER_INFO)
}

HTTP 状态码常量

src/api/consts.js

js
export const HTTP_STATUS = {
  SUCCESS: 200,
  CREATED: 201,
  ACCEPTED: 202,
  CLIENT_ERROR: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  SERVER_ERROR: 500,
  BAD_GATEWAY: 502,
  SERVICE_UNAVAILABLE: 503,
  GATEWAY_TIMEOUT: 504,
  BUSINESS_ERROR: 422,
  TOKEN_NEEDS_REFRESH: 401001,  // Token 需要刷新
  USER_FORBIDDEN: 401002         // 用户被禁用
}

底层请求适配

weapp 和 h5 两个平台的 Taro.request 行为有差异,需要适配:

H5 适配

js
if (isWeb) {
  return async (...args) => {
    let res = await Taro.request(...args).catch(e => e)
    // fetch 失败时转换为统一格式
    if (e instanceof global.Response) {
      res = {
        data: await e.json(),
        statusCode: e.status,
        header: e.headers
      }
    }
    return res
  }
}

默认(微信)

js
return Taro.request

API 模块清单

主包导出模块(30个)

src/api/index.js 导出以下模块:

模块说明模块说明
article文章aftersales售后
cart购物车cashier收银台
category分类item商品
member会员promotion促销
region地区trade交易/订单
user用户seckill秒杀
wx微信shop店铺
distribution分销track追踪
vipVIPgroup拼团
groupBy团购wheel大转盘
pointitem积分商品guide导购
liveroom直播purchase内购
wgts组件salesman业务员
im即时通讯design设计

分包专用模块(7个)

以下模块不从主包导出,分包内直接引入:

模块说明使用分包
community社区团购subpages/community/
dianwu店务POSsubpages/dianwu/
game游戏活动subpages/game-activity/
boost砍价boost/
mdugcUGCsubpages/mdugc/
delivery配送subpages/delivery/
merchant商户入驻subpages/merchant/

使用示例

js
// 主包模块
import { cart, item, member } from '@/api'

const cartList = await cart.getList({ page: 1 })

// 分包专用模块
import communityApi from '@/api/community'
const groupList = await communityApi.getGroupList()

Storage 常量

src/consts/localstorage.js

js
export const SG_TOKEN = 'token'                        // 会员 Token
export const SG_DIANWU_TOKEN = 'dianwu_token'          // 店务 Token
export const MERCHANT_TOKEN = 'merchant_token'         // 商户 Token
export const SG_POLICY = 'policy_info'                  // 隐私协议
export const SG_SHARER_UID = 'distribution_shop_id'    // 推广用户ID
export const SG_TRACK_PARAMS = 'trackParams'           // 追踪参数
export const SG_QW_USERID = 'work_userid'              // 企微用户ID
export const SG_USER_INFO = 'userinfo'                 // 用户信息
export const SG_MEIQIA = 'meiqia'                      // 美洽客服配置
export const SG_YIQIA = 'echat'                        // 一洽客服配置
export const SG_APP_CONFIG = 'settingInfo'             // APP配置
export const SG_SHOW_ADD_TIP = 'addTipIsShow'          // 添加指引
export const SG_ROUTER_PARAMS = 'routerParams'         // 路由参数缓存
export const SG_GUIDE_PARAMS = 'guideParams'           // 导购参数
export const SG_GUIDE_PARAMS_UPDATETIME = 'guideParams_updatetime'
export const SG_GUIDE_PARAMS_EXPRESSTIME = 'guideParams_expresstime'
export const SG_CHECK_STORE_RULE = 'check_store_rule'  // 进店规则
export const SG_BROWSE_HISTORY_ITEMS = 'browse_history_items' // 浏览记录

新增 API 模块步骤

1. 创建模块文件

js
// src/api/myfeature.js
import api from './req'

export default {
  getList(params) {
    return api.get('/myfeature/list', params)
  },
  getDetail(id) {
    return api.get(`/myfeature/detail`, { id })
  },
  create(data) {
    return api.post('/myfeature/create', data)
  }
}

2. 决定导出位置

  • 主包使用 → 在 src/api/index.js 中添加导出
  • 仅分包使用 → 不在 index.js 导出,分包内直接 import from '@/api/myfeature'

3. 主包导出

js
// src/api/index.js
export { default as myfeature } from './myfeature'

使用:

js
import { myfeature } from '@/api'
const list = await myfeature.getList({ page: 1 })