返回

Nuxt/Vue文件上传性能优化:解决API重复调用问题

vue.js

好的,这是你要的博客文章:

Nuxt/Vue 中如何避免组件内 Store 被重复调用:优化文件上传逻辑

咱在用 Nuxt 和 Vue 开发时,碰到了一个挺常见的问题:一个文件上传组件(比如 FileGallery),每次添加图片,它都“太积极”地去调用 API 保存,导致传 5 张图就请求了 5 次后端接口。这显然不是我们想要的,理想状态是,用户选完所有图片,点击“保存”按钮时,再一次性把所有图片信息发给后端。

问题出在 FileGallery 组件被用在了一个叫 Communication 的表单组件里。FileGallery 每次添加文件就触发一个 @add 事件,而 Communication 组件监听了这个事件,并在事件处理函数 addFiles 里直接调用了 comunicacao.postComunicacaoImages 这个方法去请求接口。

更麻烦的是,这个 Communication 表单自身没有提交按钮,它的提交动作依赖于一个更基础的 BaseForm 组件。BaseForm 包含了“保存”和“取消”按钮,点击“保存”时,它会 emit 一个 submit 事件,Communication 组件只是简单地把这个事件继续往上传递 (@submit="emits('submit')"), 自己并没有处理图片上传的逻辑。

咱的目标是:改造这个流程,让图片只在 BaseForm 的“保存”按钮被点击时,才被统一发送到后端。

问题根源分析

问题的核心在于 API 调用时机过早

目前的逻辑是:

  1. FileGallery:用户选择文件 -> handleChange 触发 -> emit @add 事件,带着新文件信息。
  2. Communication:监听到 @add -> 执行 addFiles 方法 -> 在 addFiles 内部立即 调用 postComunicacaoImages 发送新增的文件 -> 调用 getComunicacaoImagens 刷新列表。

这种“即时同步”的模式,在需要“批量提交”的场景下显然不合适。它不仅造成了不必要的 API 请求,还可能因为网络延迟或并发问题导致数据不一致。我们需要把“添加文件到列表”(纯前端操作)和“将文件列表发送到服务器”(后端交互)这两个动作分开。

解决方案

核心思路是:在 Communication 组件内部维护一个临时的文件列表,FileGallery@add@remove 事件只负责更新这个临时列表。真正的 API 调用推迟到 BaseForm 触发 submit 事件时再执行。

方案一:在 Communication 组件内部管理待上传状态

这是最直接的方法,把文件管理的逻辑和提交逻辑都放在 Communication 组件内处理。

原理:

  1. Communication 组件维护一个响应式变量(比如 gallery),用来存储当前所有待提交的图片信息(包括用户新添加的和可能已存在的)。
  2. FileGallery 组件的 @add 事件触发时,Communication 组件的 addFiles 方法只负责把新文件信息添加到 gallery 变量中,不再调用 API
  3. 同样,需要给 FileGallery 添加 @remove 事件监听,当触发时,Communication 组件对应的方法从 gallery 中移除文件信息。
  4. Communication 组件监听 BaseForm 传来的 @submit 事件。在这个事件的处理函数里,我们执行真正的 API 调用 ,也就是 comunicacao.postComunicacaoImages,把整个 gallery 的内容一次性发送给后端。
  5. 成功上传后,可以根据需要执行后续操作,比如清理 gallery、提示用户、或者触发一个表示“真正完成”的事件给父组件。

具体步骤与代码示例:

  1. 修改 Communication.vue<template>

确保 FileGallery@remove 事件也被监听:

<template>
  <BaseForm
    :state="data"
    :schema="schema"
    :loading="loading || loadingGallery" <!-- 可以合并 loading 状态 -->
    class="py-5"
    @submit="handleSubmit" <!-- 修改监听的方法 -->
    @cancel="handleCancel"
  >
    <FileGallery
      :files="gallery"
      :loading="false" <!-- FileGallery 自身的 loading 可能不再需要 Communication 控制 -->
      multiple
      @add="addFilesToLocalList" <!-- 重命名,更清晰 -->
      @remove="removeFileFromLocalList" <!-- 新增监听 -->
      @change="changeFiles" <!-- 这个 @change 事件在原代码没看到实现,按需保留 -->
    />
  </BaseForm>
</template>
  1. 修改 Communication.vue<script setup>
<script lang="ts" setup>
import type { LocationQuery } from "vue-router";
// 假设 IFile 包含 base64String, nomeArquivo, index, 可能还有个标识是否是新文件的字段
interface IFile {
  base64String: string;
  nomeArquivo: string;
  index: number;
  arquivoApiId?: string; // 已存在图片的 ID
  fileObject?: File; // 原始 File 对象,可能用于 FormData 上传
  isNew?: boolean; // 标记是否为新添加的文件
}

interface IPostComunicacaoImages {
    comunicacaoId: string;
    imagemPadrao: IFile[]; // 这个结构可能需要根据后端调整,特别是批量上传时
    ordem: number;
    imagem: string; // 这个字段用途不明,如果 base64 或 FormData 上传,可能不需要
    comunicacaoImagemId: string;
}

const emits = defineEmits(["submit", "cancel", "submitSuccess", "submitError"]); // 添加成功/失败事件

const comunicacao = useComunicacao();
const route = useRoute();

// gallery 现在代表本地维护的完整文件列表
const gallery = ref<IFile[]>([]);
const loadingGallery = ref(false); // 用于提交时的 loading 状态
const loading = ref(false); // 来自 props 或其他地方的 loading 状态

// Props (假设 data 是从父组件传入,包含初始图片信息)
const props = defineProps({
  data: {
    type: Object,
    required: true,
    // 假设 data.comunicacaoImagens 存储的是已有的图片信息,需要转换成 IFile[]
  },
  // ... 其他 props
});

// 初始化时,从 props.data 加载已存在的图片
onMounted(async () => {
  // 这里需要根据你的实际数据结构,将 props.data 中已有的图片信息加载到 gallery.value
  // 例如,如果 props.data.comunicacaoImagens 存有图片 URL 和 ID,你需要获取它们并构造成 IFile 格式
  // 如果需要从 API 获取初始图片,可以在这里加逻辑
  if (route.params.id) {
    loadingGallery.value = true;
    try {
      const existingImages = await comunicacao.getComunicacaoImagens(route.params.id.toString());
      if (existingImages) {
          // 注意:这里的 existingImages 需要适配 IFile 接口
          // 可能需要处理 base64 转换或者只存 URL/ID
          gallery.value = existingImages.map(img => ({
              ...img, // 假设 getComunicacaoImagens 返回的数据结构符合 IFile 或可转换
              isNew: false // 标记为非新文件
          }));
      }
    } catch (err) {
      console.error("获取初始图片失败:", err);
      // 可以在这里添加错误提示
    } finally {
      loadingGallery.value = false;
    }
  }

  // 同时,初始化 props.data.comunicacaoImagens
  updateParentState();
});

// 只将文件添加到本地列表,不调用 API
const addFilesToLocalList = (files: IFile[]) => {
  // 为新文件添加标记,并确保它们有唯一的临时标识(如果需要)
  const newFiles = files.map(file => ({ ...file, isNew: true, index: gallery.value.length + (files.indexOf(file)) }));
  gallery.value.push(...newFiles);
  // 注意:原 `addFiles` 中的 getComunicacaoImagens 调用被移除
  // 如果 `FileGallery` 添加的是原始 File 对象,你可能需要在这里处理 base64 转换
  // 或者保留原始 File 对象,用于后续 FormData 上传
};

// 从本地列表移除文件
const removeFileFromLocalList = (fileToRemove: IFile) => {
  gallery.value = gallery.value.filter(file => {
      // 如果是已存在的图片,需要调用删除 API
      if (!fileToRemove.isNew && fileToRemove.arquivoApiId) {
          // 这里可能需要异步处理,并且管理 loading 状态
          // handleRemoveExistingImage(fileToRemove.arquivoApiId);
          // 注意:实际删除 API 的调用应该放在 handleSubmit 里,或者提供即时删除的选项
          // 为了简化,这里暂时只处理前端列表
          // return file.arquivoApiId !== fileToRemove.arquivoApiId;
          console.warn("删除已存在的图片需要额外处理 API 调用"); // 提醒开发者
      }
      // 如果是新添加的文件,直接移除
      // 需要一个可靠的方式来比较文件,base64 可能过长,用 index 或临时 ID
      return file.base64String !== fileToRemove.base64String; // 假设 base64String 可作为唯一标识
  });
};

// 这个 watch 仍然有用,用于更新传递给 BaseForm 的 state
watch(
  gallery,
  (currentGallery) => {
    updateParentState(currentGallery);
  },
  { deep: true }
);

// 封装更新父组件 state 的逻辑
function updateParentState(currentGallery = gallery.value) {
    let aux: IPostComunicacaoImages[] = [];
    currentGallery.forEach((item, idx) => {
      // 更新索引以反映当前顺序
      item.index = idx;
      // 构建传递给 props.data 的结构
      // 这个结构看起来像是每个图片一个对象,可能需要根据后端批量接口调整
      aux.push({
        comunicacaoId: route.params.id ? route.params.id.toString() : "",
        imagemPadrao: [item], // 这里的结构很奇怪,似乎是单个文件,确认下后端是否接受数组
        ordem: item.index,
        imagem: item.nomeArquivo, // 这个字段可能不需要了
        comunicacaoImagemId: item.arquivoApiId || "", // 已存在图片的 ID
        // 如果需要传递 base64 或 File 对象,在这里添加
        // fileData: item.base64String // 或 item.fileObject
      });
    });
    props.data.comunicacaoImagens = aux;
}


// 处理 BaseForm 的提交事件
const handleSubmit = async () => {
  loadingGallery.value = true;
  try {
    if (!route.params.id) {
      console.error("缺少 comunicacaoId,无法上传图片");
      // 可能需要先创建 comunicacao 记录,拿到 ID 再上传
      // 或者由父组件处理这种情况
      emits('submitError', '缺少必要的 ID');
      return;
    }

    // 筛选出真正需要上传的新文件
    const filesToUpload = gallery.value.filter(file => file.isNew && file.fileObject); // 假设我们保留了 File 对象用于上传

    if (filesToUpload.length > 0) {
      // **执行批量上传** 
      // 后端接口 `postComunicacaoImages` 需要能接受批量文件
      // 推荐使用 FormData 进行上传,而不是 Base64,效率更高
      const formData = new FormData();
      filesToUpload.forEach((fileWrapper) => {
        if (fileWrapper.fileObject) {
          formData.append('files', fileWrapper.fileObject, fileWrapper.nomeArquivo); // 'files' 是后端接收的字段名
        }
      });
      // 可能还需要传递其他参数,比如 comunicacaoId
      formData.append('comunicacaoId', route.params.id.toString());

      // 调整 API 调用以适应 FormData 或新的批量接口
      // await comunicacao.postComunicacaoImagesBulk(route.params.id.toString(), formData);
      await comunicacao.postComunicacaoImages(route.params.id.toString(), filesToUpload); // 如果接口确实接受 IFile[] 且能处理 Base64

      // 上传成功后,可能需要更新 gallery 中文件的状态(如 isNew: false, 设置 arquivoApiId)
      // 可以通过 API 返回的结果来更新
      const updatedImages = await comunicacao.getComunicacaoImagens(route.params.id.toString());
       if (updatedImages) {
          gallery.value = updatedImages.map(img => ({ ...img, isNew: false }));
       }
    }

    // 处理完图片后,可以继续触发表单的默认提交行为(如果需要)
    // 或者认为图片上传就是整个提交过程的一部分
    emits('submitSuccess'); // 发射成功事件
    // emits('submit'); // 如果还需要通知父组件继续处理其他表单数据

    // 清理或重置状态
    // 例如,标记所有文件为非新
    gallery.value.forEach(file => file.isNew = false);

  } catch (err) {
    console.error("图片上传失败:", err);
    emits('submitError', err); // 发射错误事件
  } finally {
    loadingGallery.value = false;
  }
};

const handleCancel = () => {
  // 取消时,可能需要重置 gallery 到初始状态,或者只是简单地 emit cancel
  emits('cancel');
};

// 如果有 changeFiles 逻辑,也在这里实现
const changeFiles = (files: IFile[]) => {
  // 处理文件顺序或属性变化
  // 注意:这里也**不应** 调用 API
  gallery.value = files; // 简单示例,可能需要更复杂的合并逻辑
}

</script>
  1. 调整 FileGallery.vuehandleChange

确保 emit('add', ...) 发送的数据包含了需要的信息(比如原始 File 对象,如果使用 FormData 上传的话)。Base64 转换可以在 Communication 组件中完成,或者在 FileGallery 中做完再 emit。建议在 FileGallery 处理文件读取和基础验证。

// FileGallery.vue script part (handleChange adjusted)
const handleChange = async (e: Event) => {
  const files = (e.target as HTMLInputElement).files;

  if (files) {
    const processedFiles: IFile[] = []; // 要 emit 的文件数组

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      // ... (执行你的验证逻辑: type, size)

      const base64 = await toBase64(file); // 假设有个 toBase64 辅助函数
      processedFiles.push({
        base64String: base64,
        nomeArquivo: file.name,
        index: 0, // index 会在 Communication 组件中重新计算
        fileObject: file, // 保留原始 File 对象
        isNew: true // 可以在这里标记为新文件
      });
    }

    // 更新内部状态用于显示
    addedFiles.value.push(...processedFiles);
    // 发射处理过的文件信息
    emits("add", processedFiles);

    // 清空 input value,使得可以再次选择相同文件
    if (inputRef.value) {
        inputRef.value.value = '';
    }
  }
};

// 辅助函数:将 File 转为 Base64
function toBase64(file: File): Promise<string> {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.readAsDataURL(file);
        reader.onload = () => resolve(reader.result as string);
        reader.onerror = error => reject(error);
    });
}

// FileGallery 的 remove 也需要对应修改
const handleRemove = (file: IFile) => {
  const index = addedFiles.value.findIndex(item => item.base64String === file.base64String); // 或用其他唯一标识
  if (index !== -1) {
    const removedFile = addedFiles.value.splice(index, 1)[0];
    emits("remove", removedFile); // 发射被移除的文件信息
  }
};

安全建议:

  • 文件类型和大小不仅要在前端验证(FileGallery 中),后端接收时必须再次进行严格验证 ,不能信任任何来自客户端的数据。
  • 考虑上传过程中的权限校验,确保用户有权为指定的 comunicacaoId 上传图片。

进阶技巧:

  • 错误处理:handleSubmitcatch 块中,应该给用户明确的反馈,告知上传失败。如果部分文件上传成功、部分失败,需要后端接口支持或前端做更复杂的逻辑来处理。
  • 进度显示: 对于大文件上传,可以使用 axiosfetchonUploadProgress 功能来显示上传进度。loadingGallery 可以变成一个表示进度的百分比。
  • 并发控制: 如果用户在短时间内快速添加和删除文件,gallery 的更新可能会有竞争条件。确保状态更新是原子性的。Vue 的响应式系统通常能处理好,但复杂交互时需注意。
  • FormData vs Base64: 对于文件上传,强烈推荐使用 FormData。Base64 会增加约 33% 的体积,且编解码耗费资源。需要调整后端接口以接收 multipart/form-data

方案二:使用状态管理库(如 Pinia)

如果你的应用结构比较复杂,或者文件上传状态需要在多个组件间共享,使用 Pinia 会是更优雅的选择。

原理:

  1. 创建一个 Pinia store,比如 useCommunicationFilesStore
  2. 这个 store 包含 state(如 files 列表)、actions(如 addFile, removeFile, uploadFiles)和 getters(如 getFilesForDisplay)。
  3. Communication 组件不再维护本地 gallery 状态,而是从 Pinia store 中读取文件列表进行展示。
  4. FileGallery@add@remove 事件触发 Communication 组件调用 Pinia store 的对应 actions (addFile, removeFile) 来修改 store 中的状态。
  5. Communication 组件的 handleSubmit 方法调用 Pinia store 的 uploadFiles action。这个 action 内部封装了调用 comunicacao.postComunicacaoImages 的逻辑。

代码示例片段:

  1. 创建 Pinia Store (stores/communicationFiles.ts)
import { defineStore } from 'pinia';
// 假设 comunicacaoService 封装了 API 调用
import comunicacaoService from '@/services/comunicacaoService';
// 复用之前的 IFile 接口定义
// interface IFile { ... }

export const useCommunicationFilesStore = defineStore('communicationFiles', {
  state: () => ({
    files: [] as IFile[],
    isLoading: false,
    error: null as any,
    currentComunicacaoId: null as string | null,
  }),
  actions: {
    setComunicacaoId(id: string) {
      this.currentComunicacaoId = id;
      // 可以选择在这里加载初始文件
      // this.loadInitialFiles(id);
    },
    async loadInitialFiles(id: string) {
      if (!id) return;
      this.isLoading = true;
      try {
        const existingImages = await comunicacaoService.getComunicacaoImagens(id);
        this.files = existingImages.map((img: any) => ({ ...img, isNew: false })) || [];
      } catch (err) {
        this.error = err;
        console.error("Pinia: 获取初始图片失败", err);
      } finally {
        this.isLoading = false;
      }
    },
    addFile(file: IFile) {
      // 可以在这里进行额外的处理或验证
      this.files.push({ ...file, isNew: true, index: this.files.length });
    },
    removeFile(fileToRemove: IFile) {
      this.files = this.files.filter(file => {
        // 注意:处理已存在文件的删除逻辑
        if (!fileToRemove.isNew && fileToRemove.arquivoApiId) {
          console.warn("Pinia: 删除已存在图片需额外 API 调用,可在 uploadFiles 前处理或提供独立 action");
        }
        return file.base64String !== fileToRemove.base64String; // 简单示例标识
      });
    },
    async uploadFiles() {
      if (!this.currentComunicacaoId) {
        this.error = '缺少 Comunicacao ID';
        console.error(this.error);
        return;
      }
      const filesToUpload = this.files.filter(f => f.isNew);
      if (filesToUpload.length === 0) {
          console.log("Pinia: 没有新文件需要上传");
          // 可能需要处理仅删除或排序的情况
          return;
      }

      this.isLoading = true;
      this.error = null;
      try {
        // 使用 FormData 或调整 API 调用
        // const formData = new FormData(); ...
        // formData.append('comunicacaoId', this.currentComunicacaoId);
        // await comunicacaoService.postComunicacaoImagesBulk(this.currentComunicacaoId, formData);

        // 示例:使用原始接口,假设它能处理 base64 列表
        await comunicacaoService.postComunicacaoImages(this.currentComunicacaoId, filesToUpload);

        // 上传成功后更新状态
        // 理想情况是 API 返回更新后的列表
        await this.loadInitialFiles(this.currentComunicacaoId); // 重新加载以同步状态
        this.files.forEach(f => f.isNew = false); // 或根据 API 返回标记

      } catch (err) {
        this.error = err;
        console.error("Pinia: 图片上传失败", err);
        throw err; // 向上抛出错误,让组件知道失败了
      } finally {
        this.isLoading = false;
      }
    },
  },
  getters: {
    // 可以添加 getter 用于排序或筛选
    orderedFiles: (state) => [...state.files].sort((a, b) => a.index - b.index),
  },
});
  1. Communication.vue 中使用 Store
<script lang="ts" setup>
import { useCommunicationFilesStore } from '@/stores/communicationFiles';

const emits = defineEmits(["submitSuccess", "submitError", "cancel"]); // 更新 emits

const route = useRoute();
const fileStore = useCommunicationFilesStore();

// 从 store 获取状态和 actions
const gallery = computed(() => fileStore.orderedFiles); // 使用 getter 获取排序后的文件
const loadingGallery = computed(() => fileStore.isLoading);

// 初始化时设置 ID 并加载数据
onMounted(() => {
  const id = route.params.id as string;
  if (id) {
    fileStore.setComunicacaoId(id);
    fileStore.loadInitialFiles(id); // 加载初始文件
  }
  // 更新父组件的 state 仍然需要,但现在从 store 读取数据
  watch(() => fileStore.files, (newFiles) => {
    updateParentState(newFiles);
  }, { deep: true });
  updateParentState(fileStore.files); // 初始同步
});

// Props 和 updateParentState 函数基本保持不变,只是数据源改为 fileStore.files

const addFilesToLocalList = (files: IFile[]) => {
  files.forEach(file => fileStore.addFile(file));
};

const removeFileFromLocalList = (file: IFile) => {
  fileStore.removeFile(file);
};

// 提交时调用 store action
const handleSubmit = async () => {
  try {
    await fileStore.uploadFiles();
    emits('submitSuccess');
    // 根据需要,可能调用 emits('submit') 让父组件继续
  } catch (error) {
    console.error("提交失败:", error);
    emits('submitError', error);
    // 可以显示 Pinia store 中的 error 信息给用户
  }
};

// handleCancel 和 changeFiles 按需实现,可能与 store 交互或不交互

// 清理:组件卸载时,可能需要清空 store 状态,防止污染下次使用
onUnmounted(() => {
    // 根据应用逻辑决定是否重置 store
    // fileStore.$reset(); // Pinia 提供的重置方法
});
</script>

Pinia 方案的优点:

  • 状态集中管理: 文件状态和操作逻辑内聚在 store 中,易于维护和测试。
  • 组件解耦: Communication 组件变得更“瘦”,主要负责展示和触发 store actions。
  • 跨组件共享: 如果其他组件也需要访问或修改这些文件,Pinia 提供了方便的途径。

选择哪种方案取决于你的项目规模和复杂度。对于当前问题的场景,方案一(在 Communication 组件内部管理)可能更简单直接。但如果项目倾向于使用状态管理,或者预期未来会有更多共享状态的需求,方案二(Pinia)是更健壮和可扩展的选择。

这两种方法都能有效解决重复调用 store (API) 的问题,实现点击“保存”时才进行文件上传的目标。选择一个,动手试试吧!