基于 vueuse 封装现代 vue 请求

1,152 阅读3分钟

在使用 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 进行结果测试:

image.png 至此,一个简单的请求方法也就封装完成了。

双 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,
  }
}

接下来编写单元测试:

由于篇幅原因,这里只简述了一小段的有用的单测(:

import { describe, vi, test, expect } from 'vitest';
import fetchMock from 'fetch-mock'
import { useRequest } from './request';
import { watch } from 'vue';

const { refreshData, count } = vi.hoisted(() => {
  return {
    count: { value: 0 },
    refreshData: { value: null as any },
    getData: () => count.value++
  }
})
fetchMock.get(`http://example.com/user1`, () => {
  count.value++
  if (count.value % 2 === 0) {
    return {
      code: 0,
      data: {
        name: 'John',
        age: 30,
      },
      message: '请求成功'
    }
  }
  return {
    code: 401,
    data: null,
    message: '请求失败'
  }
}, { overwriteRoutes: true })

fetchMock.get(`http://example.com/user2`, () => {
  return {
    code: 401,
    data: null,
    message: '请求失败 refresh token 过期'
  }
}, { overwriteRoutes: true })


fetchMock.post('http://example.com/refresh', () => {
  return refreshData.value
})

class MockError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'MockError';
  }
}

const localStorageMock = {
  store: {},
  setItem(key, value) {
    this.store[key] = value;
  },
  getItem(key) {
    return this.store[key] || null;
  },
  removeItem(key) {
    delete this.store[key];
  },
  clear() {
    this.store = {};
  }
};

const consoleErrorMock = vi.fn();
vi.stubGlobal('console', { ...console, error: consoleErrorMock });

vi.stubGlobal('localStorage', localStorageMock);

vi.stubGlobal('Error', MockError);

describe('refresh module', () => {

  /**
   * @vitest-environment jsdom
   */
  test('refresh is success', () => {
    refreshData.value = {
      code: 0,
      data: {
        token: 'new-token',
        refreshToken: 'new-refresh-token'
      },
      message: '请求成功'
    }
    localStorageMock.setItem('token', 'mockToken')
    localStorageMock.setItem('refreshToken', 'refreshToken')

    return new Promise<void>(resolve => {
      const { data: response, isFinished } = useRequest('http://example.com/user1', { method: 'get' })
      watch(isFinished, () => {
        expect(response.value.data).toEqual({ name: 'John', age: 30, })

        refreshData.value = null
        localStorageMock.removeItem('token')
        localStorageMock.removeItem('refreshToken')
        resolve()
      })
    })
  })

  /**
   * @vitest-environment jsdom
   */
  test('refreshToken is not exist', () => {
    localStorageMock.setItem('token', 'mockToken')

    return new Promise<void>(resolve => {
      const { data: response, isFinished, error } = useRequest('http://example.com/user1', { method: 'get' })
      watch(isFinished, () => {
        expect(response.value).toEqual(null)
        expect(error.value).toEqual('请求失败')

        localStorageMock.removeItem('token')
        resolve()
      })
    })
  })

  /**
   * @vitest-environment jsdom
   */
  test('refresh token is expired', () => {
    refreshData.value = {
      code: 401,
      data: null,
      message: 'token 已过期'
    }

    localStorageMock.setItem('token', 'mockToken')
    localStorageMock.setItem('refreshToken', 'refreshToken')

    return new Promise<void>(resolve => {
      const { data: response, isFinished,error } = useRequest('http://example.com/user2', { method: 'get' })
      watch(isFinished, () => {
        expect(response.value).toEqual(null)
        expect(error.value).toEqual('[refresh token]: token 已过期 请求失败 refresh token 过期')
        expect(consoleErrorMock).toBeCalledWith('token 已过期')

        refreshData.value = null
        localStorageMock.removeItem('token')
        localStorageMock.removeItem('refreshToken')
        resolve()
      })
    })
  })
})

运行结果如下:

image.png

至此,我们实现了一个简易版本的请求框架,并且能够实现双 Token 刷新功能。