Vue3 + Vite 项目实现页面离开时取消所有未完成请求

引言

在前端开发中,我们经常会遇到这样的场景:用户在页面上触发了一个数据请求,但还没等到请求完成就切换到了其他页面,或者直接关闭了浏览器标签页。此时,如果不做任何处理,这些"孤儿请求"仍然会在后台继续执行,不仅浪费服务器资源,还可能导致一些意想不到的问题------比如用户在 A 页面请求了数据,然后快速切换到 B 页面,但最终显示的数据却是 A 页面的,这就是典型的请求竞态条件问题。

本文将详细介绍在 Vue3 + Vite 项目中,如何优雅地实现页面离开时自动取消所有未完成请求的功能。我们会从问题分析出发,逐步讲解多种实现方案,并给出完整的 JavaScript 代码示例,帮助你构建更加健壮的前端应用。

一、问题分析

1.1 为什么要取消未完成请求

取消未完成请求的需求主要来源于以下几个方面。首先是资源浪费的问题,每一个发出去的 HTTP 请求都会占用服务器的计算资源和网络带宽,如果用户已经离开了当前页面,这些资源就等于被白白消耗了。其次是状态污染的问题,当用户快速切换页面时,可能会出现"后来者居上"的情况------后发出的请求先返回,而先发出的请求后返回,导致页面显示的数据与实际应该显示的数据不一致。再者是用户体验的问题,未取消的请求可能会在控制台产生大量错误,影响前端监控系统的准确性。最后是内存泄漏的预防,如果大量请求的回调函数持续存活在内存中,可能会导致内存泄漏,影响应用的长期运行稳定性。

1.2 常见的业务场景

在实际开发中,需要取消未完成请求的场景非常普遍。列表页面的搜索和筛选操作就是典型案例,用户在搜索框中输入关键字时会触发搜索请求,但如果用户输入速度较快或者网络较慢,可能会同时发出多个搜索请求,必须取消旧的请求以避免显示错误的结果。详情页面的数据加载也很常见,当用户从列表页快速点击进入详情页时,列表页的请求应该被取消。表单提交场景同样需要考虑,如果用户提交了表单但在服务端还未响应时就离开了页面,应该尝试取消正在进行的提交请求。实时搜索和自动补全功能更是需要每次新的输入都能取消之前的请求,确保只显示最新输入的搜索结果。

二、解决方案概述

2.1 技术选型

在 Vue3 + Vite 项目中取消未完成请求,主要有以下几种技术方案。第一种是基于 Axios 的 CancelToken,这是传统的解决方案,兼容性较好,但 CancelToken 在 Axios 1.x 版本后已被标记为废弃。第二种是基于 Fetch API 的 AbortController,这是现代浏览器原生支持的 API,推荐在新项目中使用,代码更加简洁直观。第三种是基于自定义请求管理类,我们可以封装一个请求管理器来统一管理所有请求的生命周期,实现更加灵活的控制。

2.2 方案对比

特性 CancelToken AbortController 自定义管理器
兼容性 Axios 0.x 现代浏览器 取决于底层实现
API 简洁度 一般 简洁 可自定义
统一管理 需额外封装 需额外封装 原生支持
推荐程度 不推荐新使用 推荐 推荐

从对比可以看出,AbortController 是未来的发展趋势,现代浏览器都已原生支持,代码简洁且符合 Web 标准。配合自定义的请求管理器,可以实现非常优雅的请求取消功能。

三、方案一:使用 Axios 的 AbortController

3.1 封装 axios 实例

首先,我们需要创建一个封装好的 axios 实例,支持 AbortController。这是目前最推荐的方式,因为 CancelToken 已在 Axios 1.x 中被废弃。

javascript 复制代码
// src/utils/http.js
import axios from 'axios'

// 创建 axios 实例
const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器
http.interceptors.request.use(
  (config) => {
    // 可以在这里添加 token 等通用逻辑
    const token = localStorage.getItem('token')
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
http.interceptors.response.use(
  (response) => {
    return response.data
  },
  (error) => {
    if (axios.isCancel(error)) {
      console.log('请求被取消:', error.message)
      return Promise.reject(new Error('请求已取消'))
    }
    // 其他错误处理
    return Promise.reject(error)
  }
)

export default http

3.2 封装支持取消的请求方法

接下来,我们需要封装一个支持取消的请求方法。这个方法会创建一个 AbortController,并将其与请求关联。

javascript 复制代码
// src/utils/http.js (续)
export function createCancelableRequest(config) {
  const controller = new AbortController()

  const request = http.request({
    ...config,
    signal: controller.signal
  })

  return {
    request,
    cancel: (message = '请求已被取消') => {
      controller.abort(message)
    }
  }
}

// 带取消功能的快捷方法
export const cancelableGet = (url, config = {}) => {
  return createCancelableRequest({
    method: 'GET',
    url,
    ...config
  })
}

export const cancelablePost = (url, data, config = {}) => {
  return createCancelableRequest({
    method: 'POST',
    url,
    data,
    ...config
  })
}

四、方案二:基于组合式函数的请求管理

4.1 创建请求管理器

在 Vue3 中,我们可以利用组合式 API(Composition API)的优势,创建一个请求管理器。这个管理器会在组件卸载或页面切换时自动取消所有未完成的请求。

javascript 复制代码
// src/composables/useRequestManager.js
import { onBeforeUnmount } from 'vue'

class RequestManager {
  constructor() {
    this.pendingRequests = new Map()
    this.requestId = 0
  }

  // 生成唯一的请求 ID
  generateRequestId() {
    return `request_${++this.requestId}_${Date.now()}`
  }

  // 创建可取消的请求
  createRequest(promiseFactory, options = {}) {
    const controller = new AbortController()
    const requestId = options.key || this.generateRequestId()

    // 如果传入了 key,替换已有的同 key 请求
    if (this.pendingRequests.has(requestId)) {
      const oldController = this.pendingRequests.get(requestId)
      oldController?.abort('请求被新的同类型请求替换')
    }

    this.pendingRequests.set(requestId, controller)

    const request = promiseFactory(controller.signal).finally(() => {
      this.pendingRequests.delete(requestId)
    })

    return {
      request,
      cancel: (message = '请求已被取消') => {
        if (this.pendingRequests.has(requestId)) {
          controller.abort(message)
          this.pendingRequests.delete(requestId)
          options.onCancel?.()
        }
      }
    }
  }

  // 取消特定 key 的请求
  cancelRequest(key, message) {
    const controller = this.pendingRequests.get(key)
    if (controller) {
      controller.abort(message || '请求已被取消')
      this.pendingRequests.delete(key)
    }
  }

  // 取消所有请求
  cancelAll(message = '页面已离开') {
    this.pendingRequests.forEach((controller) => {
      controller.abort(message)
    })
    this.pendingRequests.clear()
  }

  // 获取当前待处理的请求数量
  getPendingCount() {
    return this.pendingRequests.size
  }
}

// 创建全局请求管理器实例
const globalRequestManager = new RequestManager()

// Vue 组合式函数
export function useRequestManager() {
  // 在组件卸载时自动取消所有请求
  onBeforeUnmount(() => {
    globalRequestManager.cancelAll('组件已卸载')
  })

  return {
    requestManager: globalRequestManager,
    createRequest: globalRequestManager.createRequest.bind(globalRequestManager),
    cancelRequest: globalRequestManager.cancelRequest.bind(globalRequestManager),
    cancelAll: globalRequestManager.cancelAll.bind(globalRequestManager)
  }
}

export { globalRequestManager }

4.2 创建带取消功能的 Fetch Hook

为了更方便地使用,我们可以创建一个封装了 Fetch API 的组合式函数:

javascript 复制代码
// src/composables/useCancelableFetch.js
import { ref } from 'vue'
import { globalRequestManager } from './useRequestManager'

export function useCancelableFetch(url, options = {}) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const { key, immediate = false, onSuccess, onError } = options

  async function execute(fetchOptions = {}) {
    loading.value = true
    error.value = null

    const { cancel } = globalRequestManager.createRequest(
      async (signal) => {
        const response = await fetch(url, {
          ...fetchOptions,
          signal
        })

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        return await response.json()
      },
      { key }
    )

    try {
      const result = await execute.catch((err) => {
        if (err.name === 'AbortError') {
          console.log('请求被取消')
          return null
        }
        throw err
      })

      data.value = result
      onSuccess?.(result)
    } catch (err) {
      error.value = err
      onError?.(error.value)
    } finally {
      loading.value = false
    }

    return { cancel }
  }

  // 立即执行
  if (immediate) {
    execute()
  }

  return {
    data,
    loading,
    error,
    execute,
    cancel: () => globalRequestManager.cancelRequest(key || '')
  }
}

五、方案三:路由级别的请求管理

5.1 使用 Vue Router 的导航守卫

在大型应用中,我们通常需要根据路由来管理请求。当用户离开某个路由时,取消该路由下所有的未完成请求。

javascript 复制代码
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { globalRequestManager } from '@/composables/useRequestManager'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 全局前置守卫 - 路由切换前取消所有请求
router.beforeEach((_to, _from, next) => {
  // 在这里取消上一个路由的所有请求
  globalRequestManager.cancelAll('路由切换')
  next()
})

export default router

5.2 基于路由的请求分组管理

我们可以让每个路由拥有自己的请求管理器,这样可以更精细地控制请求的取消:

javascript 复制代码
// src/composables/useRouteRequest.js
import { ref, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'

export function useRouteRequest() {
  const route = useRoute()
  const pendingRequests = ref(new Map())

  // 生成与当前路由关联的请求 key
  function getRouteRequestKey(action) {
    return `${route.fullPath}_${action}`
  }

  // 创建路由关联的请求
  function createRouteRequest(promiseFactory, action) {
    const controller = new AbortController()
    const key = getRouteRequestKey(action)

    // 取消同 action 的旧请求
    if (pendingRequests.value.has(key)) {
      pendingRequests.value.get(key)?.abort('请求被新的同类型请求替换')
    }

    pendingRequests.value.set(key, controller)

    return promiseFactory(controller.signal)
      .finally(() => {
        pendingRequests.value.delete(key)
      })
  }

  // 取消当前路由的所有请求
  function cancelAll(message = '离开当前页面') {
    pendingRequests.value.forEach((controller) => {
      controller.abort(message)
    })
    pendingRequests.value.clear()
  }

  // 组件卸载时自动取消
  onBeforeUnmount(() => {
    cancelAll('组件已卸载')
  })

  return {
    createRouteRequest,
    cancelAll,
    getRouteRequestKey
  }
}

六、完整项目集成示例

6.1 项目结构

首先,让我们看一下完整的项目结构:

复制代码
src/
├── api/
│   └── user.js           # API 接口定义
├── composables/
│   ├── useRequestManager.js
│   └── useRouteRequest.js
├── utils/
│   └── http.js           # HTTP 请求封装
├── views/
│   ├── UserList.vue      # 用户列表页
│   └── UserDetail.vue    # 用户详情页
├── router/
│   └── index.js          # 路由配置
├── App.vue
└── main.js

6.2 API 接口定义

javascript 复制代码
// src/api/user.js
import http from '@/utils/http'
import { cancelableGet, cancelablePost } from '@/utils/http'

// 获取用户列表(支持取消)
export function getUserList(params) {
  return cancelableGet('/users', { params })
}

// 获取用户详情(支持取消)
export function getUserDetail(id) {
  return cancelableGet(`/users/${id}`)
}

// 创建用户
export function createUser(data) {
  return cancelablePost('/users', data)
}

6.3 用户列表页面实现

vue 复制代码
<!-- src/views/UserList.vue -->
<template>
  <div class="user-list">
    <div class="search-bar">
      <input
        v-model="keyword"
        type="text"
        placeholder="搜索用户名或邮箱"
        @input="handleSearch"
      />
      <button @click="handleSearch">搜索</button>
    </div>

    <div v-if="loading" class="loading">加载中...</div>
    <div v-else-if="error" class="error">{{ error.message }}</div>

    <ul v-else class="user-list">
      <li
        v-for="user in userList"
        :key="user.id"
        @click="goToDetail(user.id)"
      >
        <img v-if="user.avatar" :src="user.avatar" :alt="user.name" />
        <span>{{ user.name }}</span>
        <span>{{ user.email }}</span>
      </li>
    </ul>

    <div class="pagination">
      <button
        :disabled="page <= 1"
        @click="prevPage"
      >
        上一页
      </button>
      <span>{{ page }} / {{ totalPages }}</span>
      <button
        :disabled="page >= totalPages"
        @click="nextPage"
      >
        下一页
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { getUserList } from '@/api/user'
import { globalRequestManager } from '@/composables/useRequestManager'

const router = useRouter()

const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const userList = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref(null)

// 计算总页数
const totalPages = ref(1)
watch(total, (val) => {
  totalPages.value = Math.ceil(val / pageSize.value)
})

// 保存取消函数
let currentRequest = null

// 搜索处理函数
async function fetchUserList() {
  loading.value = true
  error.value = null

  try {
    // 使用请求管理器创建可取消的请求
    // key: 'userList' 确保同类型的请求只会保留一个
    const { cancel } = globalRequestManager.createRequest(
      async (signal) => {
        // 调用 API 获取数据
        const result = await getUserList({
          page: page.value,
          pageSize: pageSize.value,
          keyword: keyword.value
        })
        return result
      },
      {
        key: 'userList',
        onCancel: () => {
          console.log('用户列表请求已取消')
        }
      }
    )

    // 保存取消函数
    currentRequest = { cancel }

    const result = await globalRequestManager.createRequest(
      async (signal) => {
        const response = await fetch('/api/users', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            page: page.value,
            pageSize: pageSize.value,
            keyword: keyword.value
          }),
          signal
        })
        return response.json()
      },
      { key: 'userList' }
    )

    userList.value = result.list || []
    total.value = result.total || 0
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('用户列表请求被取消')
    } else {
      error.value = err
    }
  } finally {
    loading.value = false
  }
}

// 防抖搜索
let searchTimeout = null
function handleSearch() {
  if (searchTimeout) {
    clearTimeout(searchTimeout)
  }
  searchTimeout = setTimeout(() => {
    page.value = 1
    fetchUserList()
  }, 300)
}

function prevPage() {
  if (page.value > 1) {
    page.value--
    fetchUserList()
  }
}

function nextPage() {
  if (page.value < totalPages.value) {
    page.value++
    fetchUserList()
  }
}

function goToDetail(id) {
  router.push(`/user/${id}`)
}

// 组件卸载时取消请求
onBeforeUnmount(() => {
  if (currentRequest) {
    currentRequest.cancel()
  }
  // 同时也使用全局管理器取消
  globalRequestManager.cancelRequest('userList', '组件已卸载')
})

// 初始化加载
fetchUserList()
</script>

<style scoped>
.user-list {
  padding: 20px;
}

.search-bar {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
}

.search-bar input {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.loading,
.error {
  padding: 40px;
  text-align: center;
}

.error {
  color: #e53935;
}

.user-list ul {
  list-style: none;
  padding: 0;
}

.user-list li {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border-bottom: 1px solid #eee;
  cursor: pointer;
  transition: background 0.2s;
}

.user-list li:hover {
  background: #f5f5f5;
}

.user-list img {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  object-fit: cover;
}

.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
  margin-top: 20px;
}

.pagination button {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: #fff;
  border-radius: 4px;
  cursor: pointer;
}

.pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

6.4 优化后的用户列表页面

让我们来看一个更加简洁的优化版本:

vue 复制代码
<!-- src/views/UserList.vue - 优化版 -->
<template>
  <div class="user-list">
    <div class="search-bar">
      <input
        v-model="keyword"
        type="text"
        placeholder="搜索用户名或邮箱"
        @input="debouncedSearch"
      />
    </div>

    <div v-if="loading" class="loading">加载中...</div>
    <div v-else-if="error" class="error">{{ error.message }}</div>

    <ul v-else class="user-items">
      <li
        v-for="user in userList"
        :key="user.id"
        @click="goToDetail(user.id)"
      >
        <img v-if="user.avatar" :src="user.avatar" :alt="user.name" />
        <div class="user-info">
          <span class="name">{{ user.name }}</span>
          <span class="email">{{ user.email }}</span>
        </div>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouteRequest } from '@/composables/useRouteRequest'

const router = useRouter()
const { createRouteRequest } = useRouteRequest()

const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const userList = ref([])
const loading = ref(false)
const error = ref(null)

// 搜索函数
async function fetchUserList() {
  loading.value = true
  error.value = null

  try {
    // 使用路由级别的请求管理
    const data = await createRouteRequest(
      async (signal) => {
        // 模拟 API 请求
        await new Promise(resolve => setTimeout(resolve, 1000))

        // 这里应该调用实际的 API
        // const response = await fetch('/api/users?...', { signal })
        // return response.json()

        // 模拟数据
        return Array.from({ length: 10 }, (_, i) => ({
          id: (page.value - 1) * pageSize.value + i + 1,
          name: `用户 ${(page.value - 1) * pageSize.value + i + 1}`,
          email: `user${(page.value - 1) * pageSize.value + i + 1}@example.com`
        }))
      },
      'fetchUserList'
    )

    userList.value = data || []
  } catch (err) {
    if (err.name !== 'AbortError') {
      error.value = err
    }
  } finally {
    loading.value = false
  }
}

// 防抖处理
let debounceTimer = null
function debouncedSearch() {
  if (debounceTimer) {
    clearTimeout(debounceTimer)
  }
  debounceTimer = setTimeout(() => {
    page.value = 1
    fetchUserList()
  }, 300)
}

function goToDetail(id) {
  router.push(`/user/${id}`)
}

// 监听 keyword 变化
watch(keyword, () => {
  debouncedSearch()
})

// 页面加载时获取数据
onMounted(() => {
  fetchUserList()
})
</script>

<style scoped>
.user-list {
  padding: 20px;
}

.search-bar {
  margin-bottom: 20px;
}

.search-bar input {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}

.loading,
.error {
  padding: 40px;
  text-align: center;
}

.error {
  color: #e53935;
}

.user-items {
  list-style: none;
  padding: 0;
  margin-top: 20px;
}

.user-items li {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border-bottom: 1px solid #eee;
  cursor: pointer;
  transition: background 0.2s;
}

.user-items li:hover {
  background: #f5f5f5;
}

.user-items img {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  object-fit: cover;
}

.user-info {
  display: flex;
  flex-direction: column;
}

.user-info .name {
  font-weight: bold;
}

.user-info .email {
  color: #666;
  font-size: 14px;
}
</style>

七、结合 Pinia 进行全局状态管理

7.1 创建请求状态管理 Store

我们可以创建一个 Pinia Store 来统一管理请求状态,这样可以更方便地在应用各处追踪和管理请求。

javascript 复制代码
// src/stores/request.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useRequestStore = defineStore('request', () => {
  const pendingRequests = ref([])
  let requestIdCounter = 0

  // 添加新的请求
  function addRequest(key, controller, url) {
    const id = `req_${++requestIdCounter}_${Date.now()}`
    pendingRequests.value.push({
      id,
      key,
      controller,
      startTime: Date.now(),
      url
    })
    return id
  }

  // 移除请求
  function removeRequest(id) {
    const index = pendingRequests.value.findIndex(r => r.id === id)
    if (index !== -1) {
      pendingRequests.value.splice(index, 1)
    }
  }

  // 取消特定 key 的所有请求
  function cancelByKey(key, message = '请求已取消') {
    pendingRequests.value
      .filter(r => r.key === key)
      .forEach(r => {
        r.controller.abort(message)
      })
  }

  // 取消所有请求
  function cancelAll(message = '全局取消') {
    pendingRequests.value.forEach(r => {
      r.controller.abort(message)
    })
    pendingRequests.value = []
  }

  // 获取待处理请求数量
  function getPendingCount() {
    return pendingRequests.value.length
  }

  // 检查是否有特定 key 的请求正在处理
  function hasPendingRequest(key) {
    return pendingRequests.value.some(r => r.key === key)
  }

  return {
    pendingRequests,
    addRequest,
    removeRequest,
    cancelByKey,
    cancelAll,
    getPendingCount,
    hasPendingRequest
  }
})

7.2 在应用入口处配置

javascript 复制代码
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useRequestStore } from '@/stores/request'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(router)

// 在全局配置请求 store
const requestStore = useRequestStore()

// 页面可见性变化时取消所有请求(可选)
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    // 当页面不可见时,可以取消非关键的请求
    console.log('页面不可见,待处理请求数:', requestStore.getPendingCount())
  }
})

// 页面卸载时取消所有请求
window.addEventListener('beforeunload', () => {
  requestStore.cancelAll('页面已卸载')
})

app.mount('#app')

八、最佳实践与注意事项

8.1 请求取消的最佳时机

在实际应用中,我们需要根据业务场景选择合适的取消时机。全局取消是一个比较激进的做法,适用于路由切换频繁的应用,可以在路由守卫中统一处理。按路由取消则更加精细,每个路由维护自己的请求列表,切换路由时只取消当前路由的请求。按功能取消则针对特定的业务功能,比如搜索功能会取消之前的搜索请求,表单提交会取消之前的提交请求。

8.2 错误处理策略

当请求被取消时,axios 会抛出一个特殊的错误,我们需要在响应拦截器中正确处理这个错误,避免将取消请求当作真正的网络错误来处理。以下是推荐的错误处理方式:

javascript 复制代码
// src/utils/http.js (错误处理部分)
import axios from 'axios'

// 判断是否是取消请求错误
export function isCancelError(error) {
  return axios.isCancel(error) || error?.name === 'AbortError'
}

// 统一错误处理函数
export function handleRequestError(error) {
  if (isCancelError(error)) {
    // 取消请求不抛出错误,只是静默处理
    return new Error('请求已取消')
  }

  if (error.response) {
    // 服务器返回了错误状态码
    const status = error.response.status
    const message = error.response.data?.message || '服务器错误'

    switch (status) {
      case 401:
        // 未授权,可能需要跳转登录页
        console.error('未授权,请重新登录')
        break
      case 403:
        console.error('没有权限访问该资源')
        break
      case 404:
        console.error('请求的资源不存在')
        break
      case 500:
        console.error('服务器内部错误')
        break
      default:
        console.error(`请求失败: ${message}`)
    }

    return new Error(message)
  } else if (error.request) {
    // 请求已发出但没有收到响应
    console.error('网络连接失败,请检查网络')
    return new Error('网络连接失败')
  } else {
    // 请求配置出错
    console.error('请求配置错误:', error.message)
    return new Error(error.message)
  }
}

8.3 性能优化建议

在使用请求管理功能时,需要注意一些性能优化点。首先是请求去重,对于短时间内重复的相同请求,应该自动取消旧的请求,只保留最新的一个。其次是超时设置,每个请求都应该设置合理的超时时间,避免请求长时间挂起。再者是并发控制,对于需要大量请求的场景(如列表的无限滚动),应该限制同时进行的请求数量。另外,对于关键请求(如表单提交),应该谨慎取消,最好在用户明确操作时才取消。最后是请求日志,记录请求的开始和结束时间,有助于排查问题。

8.4 安全性考虑

在实现请求取消功能时,也需要注意一些安全性问题。敏感操作如支付、删除等操作应该设计为不可取消,确保用户操作能够完成。取消请求不应该影响数据的一致性,如果某个操作已经被服务器执行,即使客户端取消了请求,服务器端的状态变更仍然存在。某些接口可能需要幂等性设计,以便在网络不稳定时能够安全地重试。

九、完整配置教程

9.1 环境要求

开始之前,请确保你的开发环境满足以下要求:Node.js 版本需要在 16.0.0 或以上,推荐使用 LTS 版本。Vue 版本为 3.x,这是我们实现组合式函数的基础。Vite 版本推荐 4.x 或 5.x,以获得更好的开发体验。

9.2 安装依赖

bash 复制代码
# 创建项目(如果你还没有项目)
npm create vite@latest my-vue-app -- --template vue

# 进入项目目录
cd my-vue-app

# 安装 Pinia(状态管理)
npm install pinia

# 安装 axios(如果你使用 axios)
npm install axios

# 安装 vue-router(路由管理)
npm install vue-router

9.3 快速开始配置

按照以下步骤快速集成请求管理功能:

第一步,创建请求管理器文件。

src/composables/useRequestManager.js 中创建以下内容:

javascript 复制代码
// src/composables/useRequestManager.js
import { onBeforeUnmount } from 'vue'

class RequestManager {
  constructor() {
    this.pendingRequests = new Map()
    this.requestId = 0
  }

  generateRequestId() {
    return `request_${++this.requestId}_${Date.now()}`
  }

  createRequest(promiseFactory, options = {}) {
    const controller = new AbortController()
    const requestId = options.key || this.generateRequestId()

    if (this.pendingRequests.has(requestId)) {
      this.pendingRequests.get(requestId)?.abort('请求被新的同类型请求替换')
    }

    this.pendingRequests.set(requestId, controller)

    const request = promiseFactory(controller.signal).finally(() => {
      this.pendingRequests.delete(requestId)
    })

    return {
      request,
      cancel: (message = '请求已被取消') => {
        if (this.pendingRequests.has(requestId)) {
          controller.abort(message)
          this.pendingRequests.delete(requestId)
          options.onCancel?.()
        }
      }
    }
  }

  cancelAll(message = '取消所有请求') {
    this.pendingRequests.forEach((controller) => {
      controller.abort(message)
    })
    this.pendingRequests.clear()
  }
}

export const requestManager = new RequestManager()

export function useRequestManager() {
  onBeforeUnmount(() => {
    requestManager.cancelAll('组件已卸载')
  })

  return {
    requestManager,
    createRequest: requestManager.createRequest.bind(requestManager),
    cancelAll: requestManager.cancelAll.bind(requestManager)
  }
}

第二步,在路由配置中集成。

src/router/index.js 中添加路由切换时的请求取消逻辑:

javascript 复制代码
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { requestManager } from '@/composables/useRequestManager'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // 你的路由配置
    {
      path: '/',
      name: 'Home',
      component: () => import('@/views/Home.vue')
    }
  ]
})

router.beforeEach((_to, _from, next) => {
  // 切换路由时取消所有未完成的请求
  requestManager.cancelAll('路由切换')
  next()
})

export default router

第三步,在组件中使用。

vue 复制代码
<!-- src/views/Example.vue -->
<template>
  <div>
    <button @click="fetchData">获取数据</button>
    <button @click="cancelFetch">取消请求</button>
    <div v-if="loading">加载中...</div>
    <div v-else-if="data">{{ data }}</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useRequestManager } from '@/composables/useRequestManager'

const { createRequest } = useRequestManager()

const loading = ref(false)
const data = ref(null)
let currentRequest = null

async function fetchData() {
  loading.value = true

  // 使用 key 确保同类型请求只会保留一个
  currentRequest = createRequest(
    async (signal) => {
      const response = await fetch('/api/some-data', { signal })
      return response.json()
    },
    { key: 'fetchData' }
  )

  try {
    data.value = await currentRequest.request
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('请求已取消')
    } else {
      console.error('请求失败:', error)
    }
  } finally {
    loading.value = false
  }
}

function cancelFetch() {
  if (currentRequest) {
    currentRequest.cancel()
    currentRequest = null
  }
}
</script>

9.4 使用 Fetch API 的简化示例

如果你不想使用 axios,也可以直接使用浏览器原生的 Fetch API:

javascript 复制代码
// src/utils/fetchHelper.js

// 全局请求管理器
const requestManager = {
  controllers: new Map(),
  requestId: 0,

  generateId() {
    return `req_${++this.requestId}_${Date.now()}`
  },

  async request(url, options = {}) {
    const controller = new AbortController()
    const id = options.key || this.generateId()

    // 取消同 key 的旧请求
    if (this.controllers.has(id)) {
      this.controllers.get(id).abort('请求被替换')
    }
    this.controllers.set(id, controller)

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      })

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      return await response.json()
    } finally {
      this.controllers.delete(id)
    }
  },

  cancel(key) {
    if (this.controllers.has(key)) {
      this.controllers.get(key).abort('手动取消')
      this.controllers.delete(key)
    }
  },

  cancelAll() {
    this.controllers.forEach((controller) => {
      controller.abort('全部取消')
    })
    this.controllers.clear()
  }
}

export default requestManager

在组件中使用:

javascript 复制代码
// src/views/Example.vue
import requestManager from '@/utils/fetchHelper'

async function fetchUserList() {
  try {
    const data = await requestManager.request('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ page: 1, pageSize: 10 }),
      key: 'userList'  // 使用 key 确保只保留最新请求
    })
    console.log(data)
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('请求被取消')
    }
  }
}

十、总结

本文详细介绍了在 Vue3 + Vite 项目中实现页面离开时取消所有未完成请求的多种方案。我们从问题分析出发,讲解了为什么需要取消未完成请求以及常见的业务场景。然后,我们介绍了三种主要的实现方案:基于 Axios 的 AbortController、基于组合式函数的请求管理、以及路由级别的请求管理。

通过封装统一的请求管理器,我们可以实现请求的自动取消,避免资源浪费和状态污染。结合 Pinia 进行全局状态管理,可以更方便地追踪和管理应用中的所有请求。在实际开发中,我们可以根据具体的业务场景选择合适的方案,并遵循最佳实践来构建更加健壮的前端应用。

希望本文能够帮助你在项目中实现优雅的请求管理,提升用户体验和应用性能。如果你在使用过程中遇到任何问题,欢迎在评论区留言讨论。

相关推荐
小彭努力中18 小时前
199.Vue3 + OpenLayers 实现:点击 / 拖动地图播放音频
前端·vue.js·音视频·openlayers·animate
2501_9160074718 小时前
网站爬虫原理,基于浏览器点击行为还原可接口请求
前端·javascript·爬虫·ios·小程序·uni-app·iphone
前端大波18 小时前
Sentry 每日错误巡检自动化:设计思路与上手实战
前端·自动化·sentry
ZC跨境爬虫20 小时前
使用Claude Code开发校园交友平台前端UI全记录(含架构、坑点、登录逻辑及算法)
前端·ui·架构
慧一居士20 小时前
Vue项目中,何时使用布局、子组件嵌套、插槽 对应的使用场景,和完整的使用示例
前端·vue.js
Можно20 小时前
uni.request 和 axios 的区别?前端请求库全面对比
前端·uni-app
M ? A20 小时前
解决 VuReact 中 ESLint 规则冲突的完整指南
前端·react.js·前端框架
Jave210821 小时前
实现全局自定义loading指令
前端·vue.js
奔跑的呱呱牛21 小时前
CSS Grid 布局参数详解(超细化版)+ 中文注释 Demo
前端·css·grid
木斯佳21 小时前
前端八股文面经大全:影刀AI前端一面(2026-04-01)·面经深度解析
前端·人工智能·沙箱·tool·ai面经