Vue3 EMS 项目实战:Axios 请求封装与全局 Loading 联动设计

系列:组件与工具专题
本篇主题request.ts + useLoadingStore + 智能 message + 文件导出
源码src/utils/request.tssrc/stores/loading.tssrc/utils/common.ts


一、为什么需要统一的请求层?

EMS 项目接口上百个,如果每个页面自己处理:

  • Token 注入
  • 401 登出
  • Loading 开关
  • 业务 code 判断
  • 文件流下载

代码会重复且容易漏。本项目在 request.ts 中集中处理,业务层 then直接拿 data,无需再判断 code === 0


二、Axios 实例与拦截器架构

export const rM = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json;charset=utf-8' },
})

2.1 请求拦截:Token + 语言 + Loading 计数

instance.interceptors.request.use((config) => {
  const isToken = config.headers['noToken'] !== true
  if (isToken) {
    config.headers[getTokenName()] = getToken()
    config.headers['Accept-Language'] = i18n.global.locale.value
  }

  // GET 请求自动剔除空字符串和 undefined 参数
  if (config.method === 'get' && config.params) {
    config.params = Object.keys(config.params).reduce((pre, cur) => {
      if (config.params[cur] !== '' && config.params[cur] !== undefined) {
        pre[cur] = config.params[cur]
      }
      return pre
    }, {})
  }

  const loadingStore = useLoadingStore()
  config.id = loadingStore.addRequest()
  if (!config.noLoading) {
    loadingStore.startLoading()
  }
  return config
})

设计要点

能力 实现方式
跳过 Token 请求头 noToken: true
跳过 Loading 配置 noLoading: true
国际化 自动带 Accept-Language
FormData 自动改 Content-Type
x-www-form-urlencoded qs.stringify 序列化

2.2 响应拦截:业务码统一消化

if (code === '0') {
  if (method === 'get') return resData
  if (['put', 'post', 'delete'].includes(method!)) {
    if (noMsg) return resData ?? 'success'
    message(msg)  // 智能识别 success/warn/error
    return resData ?? 'success'
  }
} else if (code === '401') {
  // 弹窗确认 + 10 秒超时自动登出
  $confirm(msg).then(() => useUserStore().logOut())
  return Promise.reject(resData)
} else if (code === '431') {
  // 表单字段级校验失败
  ElMessage.error(`${field}: ${message}`)
  return Promise.reject(resData)
}

业务层写法对比

// ❌ 传统写法
const res = await axios.get('/api/list')
if (res.data.code === '0') {
  list.value = res.data.data
} else {
  ElMessage.error(res.data.msg)
}

// ✅ 本项目写法
getList(params).then(resData => {
  list.value = resData
})

2.3 文件流导出

responseTypeblob / arraybuffer 时走 exportFileFn

export const exportFileFn = (data: any) => {
  // 失败时后端返回 JSON,需 FileReader 解析错误信息
  if (data.data.type === 'application/json') {
    const reader = new FileReader()
    reader.readAsText(data.data, 'utf-8')
    reader.onload = () => {
      const { msg } = JSON.parse(reader.result as string)
      ElMessage.error(msg)
    }
    return
  }
  // 成功:从 content-disposition 解析文件名并触发下载
  const fileName = decodeURIComponent(data.headers['content-disposition']).split("''")[1]
  const blob = new Blob([data.data])
  // ... 创建 a 标签下载
}

三、Loading Store:多请求并发计数

全局 Loading 不能「发请求就 true、回来就 false」,并发请求会闪烁。本项目用 Symbol 队列 计数:

export const useLoadingStore = defineStore('loading', () => {
  const loading = ref(false)
  const activeRequest = ref<any[]>([])

  function addRequest() {
    let symbol = Symbol('request')
    activeRequest.value.push(symbol)
    return symbol
  }

  function removeRequest(symbol?: symbol) {
    const index = activeRequest.value.indexOf(symbol)
    if (index > -1) activeRequest.value.splice(index, 1)
  }

  function startLoading() { loading.value = true }
  function stopLoading() { loading.value = false }

  return { loading, addRequest, removeRequest, startLoading, stopLoading }
})

响应拦截器在 success / error 分支都会 removeRequest,再 stopLoading

与 HForm / HDialog 联动:查询按钮、确认按钮绑定 :disabled="loadingStore.loading",防止重复提交。


四、智能 message:按后缀识别消息类型

后端返回的 msg 可能是「操作成功」「参数异常 error」等混合格式,message() 根据后缀关键词自动选择 ElMessage 类型:

export const message = (message: string) => {
  let warnStrings = ['warn', 'exception', 'timeout', ...]
  let errorStrings = ['error', ...]
  let successStrings = ['success', ...]
  // 匹配后缀 → info / warning / error / success
  ElMessage.closeAll()
  funcArr[funcIndex]!(message)
}

这样业务层无需关心该用 ElMessage.success 还是 .error


五、组件级 Loading:useLoad

全局 HTTP Loading 不适合局部区块(如组态图加载)。useLoad 提供轻量封装:

export const useLoad = () => {
  const loading = ref(false)
  const withLoading = <T>(promise: Promise<T>): Promise<T> => {
    loading.value = true
    promise.finally(() => { loading.value = false })
    return promise
  }
  return { loading, withLoading }
}

// 用法
const { loading, withLoading } = useLoad()
withLoading(getWebtopoProjectSvgByStationId(id)).then(res => { ... })

// 模板
<div v-loadingh="loading">...</div>

六、设备参数下发:useProcessing 轮询

EMS 读写设备参数是异步任务,需要轮询下发状态:

export const useProcessing = (IssueType: IssueType) => {
  return (id: number, cmdType: CmdType, readLoad: Ref<boolean>) => {
    return new Promise((resolve, reject) => {
      const timer = setInterval(() => {
        getIssueDetail(IssueType, id, cmdType).then(res => {
          if (res.issueStatus !== '1') {  // 非进行中
            clearInterval(timer)
            res.issueStatus === '2' ? resolve(res) : reject(res)
          }
        })
      }, 1000)
      setTimeout(() => { clearInterval(timer); reject('') }, 31000)  // 31s 超时
    })
  }
}

典型场景:参数配置页下发读/写指令,按钮 loading 直到设备响应或超时。


七、本篇小结

模块 职责
fetchData / rM Token、语言、业务码、文件导出
useLoadingStore 并发请求 Loading 计数
message() 按后缀智能提示
useLoad 组件局部 Loading
useProcessing 设备指令轮询

踩坑

  1. 并发请求时不要用单个 boolean 控制全局 Loading
  2. blob 导出失败时后端可能返回 JSON,要先判断 type
  3. 401 要防重复弹窗,用 isOperated 标记

源码索引src/utils/request.tssrc/stores/loading.tssrc/utils/common.ts

更多推荐