HOW - Request Cancellation 请求主动取消

请求取消(Request Cancellation)是现代 Web 应用程序中的一个重要功能,特别是在处理长时间运行的请求、用户交互频繁的操作以及网络不稳定的情况下。

以下是请求取消的常见应用场景和技术方案。

一、常见应用场景

1.1 搜索建议和自动补全

用户在搜索框中输入关键词时,系统会向服务器发送请求获取搜索建议。如果用户快速输入多个字符,每次输入都会触发新的请求,取消之前未完成的请求可以避免不必要的开销和竞争条件。

1. 防抖

在之前,开发者可能更多考虑的是为输入做防抖,即直到用户停止输入后发起请求。这可以避免在用户快速输入时发送过多请求,从而减少不必要的网络开销和服务器负载。

const debounceFn = (fn, delay) => {
	let timerId;
	return function(...args) {
		clearTimeout(timerId);
		timerId = setTimeout(() => {
			fn.apply(this, args);
		}, delay);
	}
}
// 示例使用
const searchInput = document.getElementById('search');
const searchHandler = debounceFn(function(event) {
  console.log('Sending request for:', event.target.value);
  // 这里可以放置 AJAX 请求
}, 300);
searchInput.addEventListener('input', searchHandler);

2. 防抖结合取消请求

尽管防抖是减少请求数量的有效方法,但会存在如下问题:

  1. 必要的 delay 等待时间,即最后一次请求需要等待 delay 时间才能发起请求
  2. 可能发生越过防抖后发起了请求,但上一个请求还未完成。即 点击间隔时间 > 防抖时间 && 加载 Pending 时间 > 点击间隔时间

那现在我们可以在防抖的基础上结合 Fetch API 的 AbortController 来实现即时取消上一次请求,以下是一个示例:

const debounceFn = (fn, delay) => {
	let timerId;
	return function(...args) {
		clearTimeout(timerId);
		timerId = setTimeout(() => {
			fn.apply(this, args);
		}, delay);
	}
}

// AbortController(Fetch API)
let controller;

const searchInput = document.getElementById('search');
const searchHandler = debounceFn(function(event) {
  // 如果存在之前的控制器,则中止之前的请求
  if (controller) {
    controller.abort();
  }
  controller = new AbortController();
  const signal = controller.signal;

  fetch(`/api/search?q=${event.target.value}`, { signal })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(err => {
      if (err.name === 'AbortError') {
        console.log('Request was aborted');
      } else {
        console.error('Request failed', err);
      }
    });
}, 300);
searchInput.addEventListener('input', searchHandler);

上述代码中,在使用 fetch 请求时,如果调用了 AbortController 的 abort 方法,将中止之前进行的请求,fetch 将抛出一个名为 AbortError 的异常。

请添加图片描述

1.2 数据滚动加载和分页加载

1. 滚动加载:isLoading

用户在滚动加载内容时,可能会频繁请求新数据。一种常见且有效的方法是在请求进行时显示加载状态,并提供一个 isLoading 变量,在当前请求完成之前即 isLoadingtrue 时不触发新的请求。

这种方法能有效避免频繁的请求和取消操作,从而减少服务器压力和网络开销。下面是一个实现滚动加载内容并显示加载状态的示例:

<!DOCTYPE html>
<html>
<head>
  <title>Infinite Scroll Example</title>
</head>
<body>
  <div id="content"></div>
  <div id="loading" style="display: none;">Loading...</div>

  <script>
    let isLoading = false;
    let page = 1;

    const contentDiv = document.getElementById('content');
    const loadingDiv = document.getElementById('loading');

    function loadMoreContent() {
      if (isLoading) return;

      isLoading = true;
      loadingDiv.style.display = 'block';

      fetch(`/api/loadMore?page=${page}`)
        .then(response => response.json())
        .then(data => {
          // 假设 data 包含要加载的新内容
          data.items.forEach(item => {
            const div = document.createElement('div');
            div.textContent = item.content;
            contentDiv.appendChild(div);
          });

          page += 1;
          isLoading = false;
          loadingDiv.style.display = 'none';
        })
        .catch(error => {
          console.error('Error loading content:', error);
          isLoading = false;
          loadingDiv.style.display = 'none';
        });
    }

    window.addEventListener('scroll', () => {
      if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
        loadMoreContent();
      }
    });

    // 初始加载内容
    loadMoreContent();
  </script>
</body>
</html>

loadMoreContent 函数中,首先检查是否已经在加载,如果是则直接返回。否则设置 isLoadingtrue,并显示加载状态。发起 Fetch 请求加载更多内容。请求成功后,将新内容添加到 content 容器中,并增加页码,隐藏加载状态,设置 isLoadingfalse。请求失败时,隐藏加载状态,并将 isLoading 设置为 false

提供加载状态并在当前请求完成后再发起新请求,是一种简洁且有效的方法,适用于大多数滚动加载场景。这种方法不仅能减少服务器压力,还能提供更好的用户体验。

2. 分页加载:取消请求,避免数据覆盖

在数据分页加载场景中,快速切换页面值并发起请求,同时取消之前未完成的请求是一个常见的需求。这可以确保用户看到的是最新请求的数据,避免过多的请求占用网络和服务器资源。

场景分析:

用户快速切换分页(如快速点击下一页或上翻页),会触发多个请求。如果不取消前一个请求,可能会导致页面显示不一致的数据。因为网络延迟可能会导致请求的返回顺序与发起顺序不一致。如果不取消未完成的请求,旧请求返回的结果可能会覆盖新请求的结果,导致显示错误的数据。

<!DOCTYPE html>
<html>
<head>
  <title>Pagination with AbortController</title>
</head>
<body>
  <div id="content"></div>
  <div id="pagination">
    <button id="prev" disabled>Previous</button>
    <button id="next">Next</button>
  </div>
  <div id="loading" style="display: none;">Loading...</div>

  <script>
    let currentPage = 1;
    let controller;

    const contentDiv = document.getElementById('content');
    const loadingDiv = document.getElementById('loading');
    const prevButton = document.getElementById('prev');
    const nextButton = document.getElementById('next');

    function loadPage(page) {
      // 显示加载状态
      loadingDiv.style.display = 'block';

      // 如果存在之前的控制器,则中止之前的请求
      if (controller) {
        controller.abort();
      }

      // 创建新的控制器和信号
      controller = new AbortController();
      const signal = controller.signal;

      // 发起新的分页请求
      fetch(`/api/data?page=${page}`, { signal })
        .then(response => response.json())
        .then(data => {
          // 隐藏加载状态
          loadingDiv.style.display = 'none';

          // 更新内容
          contentDiv.innerHTML = '';
          data.items.forEach(item => {
            const div = document.createElement('div');
            div.textContent = item.content;
            contentDiv.appendChild(div);
          });

          // 更新当前页码
          currentPage = page;

          // 更新按钮状态
          prevButton.disabled = currentPage === 1;
          nextButton.disabled = !data.hasMore;
        })
        .catch(err => {
          if (err.name === 'AbortError') {
            console.log('Request was aborted');
          } else {
            console.error('Request failed', err);
          }
          // 隐藏加载状态
          loadingDiv.style.display = 'none';
        });
    }

    // 初始加载第一页内容
    loadPage(currentPage);

    // 绑定按钮点击事件
    prevButton.addEventListener('click', () => {
      if (currentPage > 1) {
        loadPage(currentPage - 1);
      }
    });

    nextButton.addEventListener('click', () => {
      loadPage(currentPage + 1);
    });
  </script>
</body>
</html>

loadPage 函数中,如果有未完成的请求,则调用 abort 方法中止请求。

通过使用 AbortController 请求取消机制,可以有效地管理分页请求,确保用户快速切换页面时只会看到最新请求的数据,提升用户体验和应用性能。这种方法在处理用户频繁操作或网络延迟情况下尤为有效。

1.3 表单提交

用户在提交表单时,可能会误触或重复提交。取消未完成的提交请求可以防止数据重复提交或冲突。

1. 按钮禁用或 loading

为防止重复提交和数据冲突,还有一种方案是在用户点击提交按钮后禁用按钮或者显示加载状态来避免这种情况。通过这种方式,可以确保在当前提交请求完成之前,用户无法再次触发提交操作,从而防止重复提交和数据冲突。

<!DOCTYPE html>
<html>
<head>
  <title>Form Submission Example</title>
</head>
<body>
  <form id="myForm">
    <input type="text" name="name" placeholder="Enter your name" required>
    <button type="submit" id="submitButton">Submit</button>
  </form>
  <div id="loading" style="display: none;">Submitting...</div>

  <script>
    const form = document.getElementById('myForm');
    const submitButton = document.getElementById('submitButton');
    const loadingDiv = document.getElementById('loading');

    form.addEventListener('submit', function(event) {
      event.preventDefault(); // 防止表单的默认提交行为

      // 禁用提交按钮并显示加载状态
      submitButton.disabled = true;
      loadingDiv.style.display = 'block';

      // 获取表单数据
      const formData = new FormData(form);

      // 创建并发起请求
      fetch('/api/submit', {
        method: 'POST',
        body: formData
      })
      .then(response => response.json())
      .then(data => {
        console.log('Form submitted successfully:', data);

        // 处理成功提交后的操作
        // ...

        // 启用提交按钮并隐藏加载状态
        submitButton.disabled = false;
        loadingDiv.style.display = 'none';
      })
      .catch(error => {
        console.error('Form submission failed:', error);

        // 处理提交失败后的操作
        // ...

        // 启用提交按钮并隐藏加载状态
        submitButton.disabled = false;
        loadingDiv.style.display = 'none';
      });
    });
  </script>
</body>
</html>

2. 取消请求

具体实现同样可基于 Fetch API 的 AbortController,这里不再赘述。

1.4 文件上传

用户在上传大文件时,可能会中途取消上传。支持取消请求可以释放资源,避免不必要的带宽占用。

下面是一个具体的代码示例,展示了在上传大文件时如何使用 AbortController 来支持取消上传请求,从而释放资源,避免不必要的带宽占用。

<!DOCTYPE html>
<html>
<head>
  <title>File Upload with AbortController</title>
</head>
<body>
  <input type="file" id="fileInput">
  <button id="uploadButton">Upload</button>
  <button id="cancelButton" disabled>Cancel</button>
  <div id="status"></div>

  <script>
    let controller;
    const fileInput = document.getElementById('fileInput');
    const uploadButton = document.getElementById('uploadButton');
    const cancelButton = document.getElementById('cancelButton');
    const statusDiv = document.getElementById('status');

    uploadButton.addEventListener('click', () => {
      const file = fileInput.files[0];
      if (!file) {
        alert('Please select a file to upload');
        return;
      }

      // 创建新的控制器和信号
      controller = new AbortController();
      const signal = controller.signal;

      // 构造表单数据
      const formData = new FormData();
      formData.append('file', file);

      // 禁用上传按钮和启用取消按钮
      uploadButton.disabled = true;
      cancelButton.disabled = false;
      statusDiv.textContent = 'Uploading...';

      // 发起上传请求
      fetch('/api/upload', {
        method: 'POST',
        body: formData,
        signal: signal
      })
      .then(response => response.json())
      .then(data => {
        // 处理上传成功
        statusDiv.textContent = 'Upload successful: ' + JSON.stringify(data);
        // 重置按钮状态
        uploadButton.disabled = false;
        cancelButton.disabled = true;
      })
      .catch(err => {
        if (err.name === 'AbortError') {
          statusDiv.textContent = 'Upload cancelled';
        } else {
          statusDiv.textContent = 'Upload failed: ' + err.message;
        }
        // 重置按钮状态
        uploadButton.disabled = false;
        cancelButton.disabled = true;
      });
    });

    cancelButton.addEventListener('click', () => {
      if (controller) {
        controller.abort();
      }
    });
  </script>
</body>
</html>

1.5 实时数据更新

实时应用程序(如聊天应用或股票行情)可能需要频繁请求服务器以获取最新数据。取消过时的请求可以减少服务器负载和网络开销。

二、技术方案

前面主要介绍了相关场景,并基于 Fetch API 的 AbortController 给出具体代码示例。但除了该方案,还有其他技术实现。

1. 使用 AbortController (Fetch API)

AbortController 是现代浏览器提供的一种用于取消 Fetch 请求的 API。

示例代码

const controller = new AbortController();
const signal = controller.signal;

// 发起请求
fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Request was aborted');
    } else {
      console.error('Request failed', err);
    }
  });

// 取消请求
controller.abort();

2. 使用 axios 的取消功能

axios 是一个流行的 HTTP 客户端库,内置了请求取消功能。

示例代码

const axios = require('axios');
const CancelToken = axios.CancelToken; // CancelToken
let cancel;

// 发起请求
axios.get('/api/data', {
  cancelToken: new CancelToken(function executor(c) {
    cancel = c;
  })
})
.then(response => {
  console.log(response.data);
})
.catch(thrown => {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    console.error('Request failed', thrown);
  }
});

// 取消请求
cancel('Operation canceled by the user.');

3. axios-use-vue

对于不同技术框架,也有提供类似功能:

https://github.com/axios-use/axios-use-vue

  • Written in TypeScript
  • Cancelable. Auto / Manual cancellation of duplicate requests
  • Works with both Vue 2.7 and Vue 3
const [createRequest, { hasPending, cancel }] = useRequest((id) => ({
  url: `/user/${id}`,
  method: "DELETE",
}));

// useRequest
import { ref } from 'vue';
import axios from 'axios';
export function useRequest(requestConfig) {
  const hasPending = ref(false);
  let cancelTokenSource = null;
  const createRequest = async (params) => {
    if (cancelTokenSource) {
      cancelTokenSource.cancel('Operation canceled due to new request.');
    }
    cancelTokenSource = axios.CancelToken.source();
    hasPending.value = true;
    try {
      const response = await axios({
        ...requestConfig(params),
        cancelToken: cancelTokenSource.token,
      });
      return response.data;
    } catch (error) {
      if (axios.isCancel(error)) {
        console.log('Request canceled', error.message);
      } else {
        console.error('Request failed', error);
      }
    } finally {
      hasPending.value = false;
    }
  };
  const cancel = () => {
    if (cancelTokenSource) {
      cancelTokenSource.cancel('Request canceled by the user.');
      hasPending.value = false;
    }
  };
  return [createRequest, { hasPending, cancel }];
}

4. 使用 RxJS 取消请求

RxJS 是一个用于编写异步和基于事件程序的库,可以通过 switchMaptakeUntil 等操作符实现请求取消。

示例代码

import { fromEvent, of } from 'rxjs';
import { ajax } from 'rxjs/ajax';
import { switchMap, takeUntil, catchError } from 'rxjs/operators';

// 用户输入事件流
const searchInput$ = fromEvent(document.getElementById('search'), 'input');

// 请求取消流
const cancel$ = fromEvent(document.getElementById('cancel'), 'click');

// 发起请求并处理取消
searchInput$
  .pipe(
    switchMap(event => 
      ajax.getJSON(`/api/search?q=${event.target.value}`).pipe(
        takeUntil(cancel$),
        catchError(error => {
          console.error('Request failed', error);
          return of([]);
        })
      )
    )
  )
  .subscribe(data => console.log(data));

5. 后端取消机制

在某些情况下,前端取消请求并不能立即释放后端资源,特别是当后端正在处理复杂的计算或大数据量时。因此,后端也需要实现请求取消的机制。

  • WebSocket: 可以通过 WebSocket 连接通知后端取消正在进行的操作。
  • 定期检查: 后端处理长时间任务时,定期检查请求是否已被取消。

示例代码(假设使用 Node.js 和 Express)

const express = require('express');
const app = express();
let ongoingRequests = {};

app.get('/long-task', (req, res) => {
  const requestId = req.query.requestId;
  ongoingRequests[requestId] = res;

  // 模拟长时间任务
  setTimeout(() => {
    if (ongoingRequests[requestId]) {
      res.send('Task completed');
      delete ongoingRequests[requestId];
    }
  }, 10000);
});

app.get('/cancel-task', (req, res) => {
  const requestId = req.query.requestId;
  if (ongoingRequests[requestId]) {
    ongoingRequests[requestId].send('Task was cancelled');
    delete ongoingRequests[requestId];
  }
  res.send('Cancellation request received');
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

三、总结

请求取消是一种优化用户体验和系统资源使用的重要技术。在设计和实现请求取消机制时,需要根据具体的应用场景选择合适的技术方案。使用现代浏览器提供的 API(如 AbortController)、流行的库(如 axiosRxJS)以及在必要时实现后端取消机制,可以有效地管理和取消不必要的请求。

  • 17
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值