基于 vueuse 封装现代 vue 请求

米阳 2021-3-10 445 3/10

在使用 useFetch 之前, axios 是一个被广泛使用 Promise 请求库。 然而,axios 的缺点在于,它需要我们自行管理 loadingerror 状态以及处理请求结果,这样的处理方式较为繁琐。 相比之下,useFetch 内置 loadingerror 的处理,并且请求的返回结果也是响应式的,更符合现代 vue的理念。

本文,我们将使用 vueusecreateFetchuseFetch封装一个基础的请求方法,并且实现双 Token 无感刷新的功能。

首先 安装 @vueuse/core :  npm i @vueuse/core

基础请求库的封装

在与后端进行http交互时,一般会在请求头带上token 。因此,我们使用createFetch创建一个实例,统一处理 token 以及返回结果

import { getToken } from './utils'
import { createFetch } from  '@vueuse/core'

// utils.ts
// export const getToken = () => localStorage.getItem('token')

const baseUrl = 'http://example.com'

export const _useFetch = createFetch({
  baseUrl,
  options: {
    async beforeFetch({ options, cancel }) {
      const token = getToken()
      // 添加 Authorization
      if (token) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${token}`,
        }
      } else {
        cancel()
      }

      return { options }
    },
    async afterFetch(params) {
      if (
        params.response.headers
          .get('Content-Type')
          ?.includes('application/json')
      ) {
        params.data = await params.response.json()
        // 对请求返回结果 code 进行处理
        // Response { code: number, data: unknown, message: string }
        if (params.data.code !== 0) {
          throw new Error(params.data.message)
        }
      }
      return params
    }
  }
})

至此,一个简单的 useFetch 请求方法已经封装完成。 接下来,我们使用 mock 的方式进行测试。

由于 useFetch 是对原生 fetch 的一层封装,因此我们需要 mock 底层的 fetch

安装 fetch-mock:  npm i fetch-mock

对请求 /user 进行 mock

// user.mock.ts
import fetchMock from 'fetch-mock'

fetchMock.get(`http://example.com/user`, () => {
   return {
     code: 0,
     data: 'user',
     message: '请求成功',
   }
}, { overwriteRoutes: true })

接下来编写单元测试

这里使用 vitest 进行单元测试,具体安装这里不多赘述

import { describe, test, expect, vi } from 'vitest'
import { watch } from 'vue'
import './user.mock' // 导入上述的 mock 请求
import { _useFetch } from './request'

const { token } = vi.hoisted(() => ({
  token: { value: 'token' }
}))

vi.mock('./utils', () => {
  return {
    getToken: vi.fn(() => token.value),
  }
})

describe('_useFetch should work', () => {
  /**
   * @vitest-environment jsdom
   */
  test('should return user data with successful response', async () => {
    return new Promise<void>(resolve => {
      const { isFinished, data: res } = _useFetch('/user')
      watch(isFinished, () => {
        expect(res.value.code).toBe(0)
        expect(res.value.data).toBe('user')
        expect(res.value.message).toBe('请求成功')
        resolve()
      })
    })
  })
  
  /**
   * @vitest-environment jsdom
   */
  test('should return null when token is not provided', () => {
    token.value = null
    return new Promise<void>(resolve => {
      const { isFinished, data: res } = _useFetch('/user')
      watch(isFinished, () => {
        expect(res.value).toBe(null)
        resolve()
        // reset token value
        token.value = 'token'
      })
    })
  })
})

运行 npx vitest 至此,一个简单的请求方法也就封装完成了。

双 Token 无感刷新

目前,使用双 Token 实现无感刷新是一种广泛采用的解决方案。接下来,我们使用 useFetch 实现。

首先,是对 afterFetch 进行改进,上述我们只判断了 codesuccess(0) 的情况,现在我们需要添加一个对 token expired 的判断情况, 并且调用 refreshTokenApi

 import { refreshApi } from './refresh'
    // ...
    async afterFetch(params) {
      if (
        params.response.headers
          .get('Content-Type')
          ?.includes('application/json')
      ) {
        params.data = await params.response.json()
        // 对请求返回结果 code 进行处理
        // Response { code: number, data: unknown, message: string }
        if (params.data.code !== 0) {
          // token expaired
         if (params.data.code === 401 && localStorage.getItem('refreshToken')) {
           // 需要阻塞这个请求结果,后续获取到新的 Token 时,还需重新调用接口获取结果
           try {
              const data = await refreshApi()
              const { token, refreshToken } = data
              localStorage.setItem('token', token)
              localStorage.setItem('refreshToken', refreshToken)
            } catch (error) {
              localStorage.removeItem('token')
              localStorage.removeItem('refreshToken')
              throw new Error(`[refresh token]: ${error?.toString()} ${params.data.message}`)
            }
          } else {
            throw new Error(params.data.message)
          }
        }
      }
      return params
    }

refresh Api

接下来就是对 refreshApi 的实现,根据上述的需求,我们需要将 refreshApi 封装一个返回Promise 的方法。

这里我们还是使用 useFetch 实现。

import { useFetch } from "@vueuse/core"
import { effectScope, watch } from "vue"

export const refreshApi = () => {
  const scope = effectScope()
  const ret = new Promise((resolve, reject) => {

    scope.run(() => {
      const { isFinished, data: response } = useFetch('http://example.com/refresh', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Bearer ' + (localStorage.getItem('token') || ''),
        },
      }, {
        afterFetch: async (params) => {
          if (
            params.response.headers
              .get('Content-Type')
              ?.includes('application/json')
          ) {
            try {
              params.data = await params.response.json()
              if (params.data.code !== 0) {
                throw new Error(params.data.message)
              }
            } catch (error) {
              // error
            }
          }
          return params
        },
      })
      watch(isFinished, () => {
        if (response.value.code === 0) {
          resolve(response.value.data)
        } else {
          reject(response.value.message)
        }
      })
    })
  })

  ret.finally(() => {
    scope.stop()
  }).catch((error) => {
    console.error(error)
  })

  return ret
}

现在,我们实现了 token 过期时自动请求refreshApi 获取新的token。

重新发起请求

由于在 afterFetch 我们拿不到header 以及请求的 url ,因此我们需要对 _useFetch 在封装一层。

注意: 上述实现的是 _useFetch,为的就是现在还需封装一层。

import { ref, effectScope } from 'vue'
// ...
export function useRequest<T>(...rest: Parameters<typeof _useFetch>) {
  const {
    onFetchResponse,
    isFetching: _isFetching,
    isFinished: _isFinished,
    onFetchError,
    data,
    execute,
    ...otherParams
  } = _useFetch<T>(...rest)
  const isFetching = ref(true)
  const isFinished = ref(false)
  
  const scope = effectScope()
  scope.run(() => {
    watchEffect(() => {
      if (_isFetching.value !== isFetching.value) {
        isFetching.value = _isFetching.value
      }

      if (_isFinished.value !== isFinished.value) {
        isFinished.value = _isFinished.value
      }
    })
  })
  const stop = () => {
    scope.stop()
    isFetching.value = false
    isFinished.value = true
  }
  onFetchResponse(async (ctx) => {
    try {
      if (ctx.headers.get('Content-Type') === 'application/json') {
        const response = data.value as { code: number } | null
        if (response?.code === 401 && localStorage.getItem('token')) {
          // 重新执行请求
          await execute()
        }
      }
    } finally {
      stop()
    }
  })
  onFetchError(() => {
    stop()
  })
  return {
    onFetchResponse,
    onFetchError,
    isFetching,
    isFinished,
    data,
    ...otherParams,
  }
}

 

 

 

- THE END -

米阳

10月10日17:47

最后修改:2024年10月10日
0

非特殊说明,本博所有文章均为博主原创。