征服 Fetch 流:在 Vue 中构建健壮的大数据请求系统

在现代 Web 应用中,fetch API 和 Streams API 为我们处理网络请求提供了前所未有的强大能力,尤其是在处理可能达到数 GB 的大型数据集时,流式处理是唯一可行的方案。然而,强大的能力也伴随着巨大的责任。当用户交互与大数据流相遇时,我们很容易陷入并发请求、内存飙升和 UI 冻结的陷阱。
本文将带你踏上一段从问题到解决方案的完整旅程,我们将一步步构建一个既快速又健壮的系统,它能够优雅地处理流式数据、智能地管理请求生命周期,并主动优化内存使用。

问题一:用户的“暴力”点击与并发请求

想象一个场景:你有一个查询按钮,用于从服务器获取一个巨大的数据流。如果用户是个“点击狂魔”,在第一次请求还在处理时,他又连续点击了多次,会发生什么?

  • 服务器压力:多个重复的请求涌向服务器。
  • 浏览器混乱:多个数据流同时涌入浏览器,可能导致 UI 更新混乱。
  • 资源浪费:最糟糕的是,只有最后一个请求的结果是有用的,前面的请求都浪费了带宽和计算资源。

第一道防线:UI 层的“枷锁”

最直观的解决方法是在用户点击后,立即禁用按钮并显示一个加载遮蔽层,直到请求完成。UI 组件库(如 Element Plus)让这件事变得异常简单。

<template>
  <div>
    <el-button @click="handleQuery" :loading="isLoading">查询数据</el-button>
    
    <!-- 使用 v-loading 指令为内容区域添加遮蔽层 -->
    <div
      v-loading="isLoading"
      element-loading-text="正在获取数据..."
      class="data-container"
    >
      <!-- 数据展示区 -->
    </div>
  </div>
</template>
<script setup>
import { ref } from 'vue';
const isLoading = ref(false);
const handleQuery = async () => {
  isLoading.value = true;
  try {
    // ... 发起 fetch 请求
  } finally {
    isLoading.value = false; // 无论成功失败,都解锁 UI
  }
};
</script>

优点:简单、有效,能极大改善用户体验。
局限性:这只是一个“软”限制。它阻止了新请求,但没有取消那个正在运行的、已经无用的旧请求。如果旧请求的数据量巨大,它依然会持续消耗内存。


问题二:无法回头的请求——AbortSignal 的力量

我们需要一种机制来主动告诉浏览器:“停止那个旧的请求,我不需要它了!” 这正是 AbortControllerAbortSignal 的用武之地。
AbortController 就像一个遥控器,它有一个 signal 属性(信号)和一个 abort() 方法(按钮)。我们将 signal 传递给 fetch,一旦在别处调用了 abort()fetch 请求就会立即中断并抛出一个错误。

封装取消逻辑:创建查询管理器

在每个组件里都手动管理 AbortController 会很繁琐。一个更优雅的方案是创建一个高阶函数,来“包装”我们的查询逻辑,使其具备自动取消能力。

// queryManager.ts
let currentAbortController: AbortController | null = null;
export function createManagedQuery(queryFn) {
  return async (params) => {
    // ✨ 核心:新请求开始前,取消旧请求
    if (currentAbortController) {
      currentAbortController.abort();
    }
    currentAbortController = new AbortController();
    try {
      // 将 signal 传递给原始查询函数
      return await queryFn(params, currentAbortController.signal);
    } catch (error) {
      // 区分“取消”和其他错误
      if (currentAbortController?.signal.aborted) {
        throw new Error('REQUEST_CANCELLED');
      }
      throw error;
    } finally {
      currentAbortController = null;
    }
  };
}

现在,我们的业务代码变得无比干净,只需调用被管理后的函数即可,无需关心内部的取消逻辑。


问题三:内存的无底洞——优化数据流处理

解决了并发问题,我们迎来了一个更隐蔽的“杀手”:内存泄漏。即使请求不是并发的,多次请求大数据后,你会发现浏览器内存占用持续攀升。
罪魁祸首可能就藏在你处理流的代码里:

// ❌ 低效的流处理方式
const rows = [];
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // ... 解析每一行 ...
  rows.push(parsedData); // 将所有数据都存入内存!
}
return rows[0]; // 最后却只用了第一个?

如果数据流有 50 万行,这个 rows 数组就会在内存中变得无比巨大,即使我们只需要第一行的数据。

核心优化:尽早停止,及时释放

正确的做法是,一旦找到我们想要的数据,就立即停止读取流并释放资源。

// ✅ 高效的流处理方式
export const getRecordData = async (params, signal) => {
  const { reader } = await GetQueryList(params, signal);
  const decoder = new TextDecoder();
  let buf = '';
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const lines = buf.split('\n');
      buf = lines.pop() || '';
      for (const line of lines) {
        const parsedData = JSON.parse(line);
        // ✨ 核心:找到第一个有效数据后,立即返回
        if (Array.isArray(parsedData) && parsedData.length > 0) {
          reader.releaseLock(); // 释放 reader,一个好习惯
          return parsedData;   // 立即退出,不再读取后续数据
        }
      }
    }
    return null; // 没找到数据
  } catch (error) {
    // ... 错误处理
  }
};

这个改动将内存占用从 O(N)(N为总数据量)降低到了 O(1),是性能上的巨大飞跃。


问题四:挥之不去的幽灵——主动清理内存引用

即使我们优化了流,const data = await getRecordData(...) 这个变量依然会持有上一次请求的结果。JavaScript 的垃圾回收(GC)并非实时,旧数据可能在内存中逗留很久。

防御性清理:在下次请求前主动“断舍离”

我们可以改进之前的查询管理器,让它在发起新请求时,主动将上一次的结果引用清空。

// queryManager.ts (改进版)
let lastQueryResult: any = null;
export function createManagedQueryWithCleanup(queryFn) {
  return async (params) => {
    // ✨ 核心:新请求开始前,清理上一次的内存引用
    console.log('准备新查询,主动清理上一次的内存引用...');
    lastQueryResult = null;
    const abortController = new AbortController();
    try {
      const result = await queryFn(params, abortController.signal);
      lastQueryResult = result; // 存储新结果
      return result;
    } finally {
      abortController = null;
    }
  };
}

通过 lastQueryResult = null,我们向垃圾回收器发出了一个明确的信号:“这块内存可以收回了!”


最后的拼图:健壮的错误处理

经过以上优化,我们的 getRecordData 在没有数据时会返回 null。如果后续代码直接访问 data.length,就会遇到 Cannot read properties of null (reading 'length') 的经典错误。
因此,在使用数据前进行空值检查是必不可少的最后一道防线。

const data = await managedGetRecordData(windFarmCode);
// ✨ 核心:永远不要相信数据一定存在
if (data && Array.isArray(data)) {
  console.log(`获取到数据,长度为: ${data.length}`);
  // 安全地更新 UI
} else {
  console.log('本次查询未返回任何数据。');
  // 显示“暂无数据”的 UI
}

总结

我们从处理用户快速点击的简单需求出发,逐步深入,构建了一个覆盖多方面的健壮系统:

  1. UI 层防护:使用 v-loading 提升用户体验,防止误操作。
  2. 请求层控制:利用 AbortSignal 主动取消无用请求,避免资源浪费。
  3. 数据层优化:优化流式读取逻辑,从源头减少内存占用。
  4. 内存层管理:通过主动清理引用,帮助垃圾回收器高效工作。
  5. 逻辑层健壮:通过空值检查,确保代码在各种边界条件下都能稳定运行。
    通过这一系列组合拳,我们不仅解决了眼前的问题,更建立了一套可复用、可扩展的模式,让我们的应用在面对未来任何大规模数据挑战时,都能游刃有余,从容不迫。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐