Nuxt/Vue文件上传性能优化:解决API重复调用问题
2025-04-29 07:17:32
好的,这是你要的博客文章:
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 调用时机过早 。
目前的逻辑是:
FileGallery
:用户选择文件 ->handleChange
触发 -> emit@add
事件,带着新文件信息。Communication
:监听到@add
-> 执行addFiles
方法 -> 在addFiles
内部立即 调用postComunicacaoImages
发送新增的文件 -> 调用getComunicacaoImagens
刷新列表。
这种“即时同步”的模式,在需要“批量提交”的场景下显然不合适。它不仅造成了不必要的 API 请求,还可能因为网络延迟或并发问题导致数据不一致。我们需要把“添加文件到列表”(纯前端操作)和“将文件列表发送到服务器”(后端交互)这两个动作分开。
解决方案
核心思路是:在 Communication
组件内部维护一个临时的文件列表,FileGallery
的 @add
和 @remove
事件只负责更新这个临时列表。真正的 API 调用推迟到 BaseForm
触发 submit
事件时再执行。
方案一:在 Communication
组件内部管理待上传状态
这是最直接的方法,把文件管理的逻辑和提交逻辑都放在 Communication
组件内处理。
原理:
Communication
组件维护一个响应式变量(比如gallery
),用来存储当前所有待提交的图片信息(包括用户新添加的和可能已存在的)。FileGallery
组件的@add
事件触发时,Communication
组件的addFiles
方法只负责把新文件信息添加到gallery
变量中,不再调用 API 。- 同样,需要给
FileGallery
添加@remove
事件监听,当触发时,Communication
组件对应的方法从gallery
中移除文件信息。 Communication
组件监听BaseForm
传来的@submit
事件。在这个事件的处理函数里,我们执行真正的 API 调用 ,也就是comunicacao.postComunicacaoImages
,把整个gallery
的内容一次性发送给后端。- 成功上传后,可以根据需要执行后续操作,比如清理
gallery
、提示用户、或者触发一个表示“真正完成”的事件给父组件。
具体步骤与代码示例:
- 修改
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>
- 修改
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>
- 调整
FileGallery.vue
的handleChange
确保 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
上传图片。
进阶技巧:
- 错误处理: 在
handleSubmit
的catch
块中,应该给用户明确的反馈,告知上传失败。如果部分文件上传成功、部分失败,需要后端接口支持或前端做更复杂的逻辑来处理。 - 进度显示: 对于大文件上传,可以使用
axios
或fetch
的onUploadProgress
功能来显示上传进度。loadingGallery
可以变成一个表示进度的百分比。 - 并发控制: 如果用户在短时间内快速添加和删除文件,
gallery
的更新可能会有竞争条件。确保状态更新是原子性的。Vue 的响应式系统通常能处理好,但复杂交互时需注意。 - FormData vs Base64: 对于文件上传,强烈推荐使用
FormData
。Base64 会增加约 33% 的体积,且编解码耗费资源。需要调整后端接口以接收multipart/form-data
。
方案二:使用状态管理库(如 Pinia)
如果你的应用结构比较复杂,或者文件上传状态需要在多个组件间共享,使用 Pinia 会是更优雅的选择。
原理:
- 创建一个 Pinia store,比如
useCommunicationFilesStore
。 - 这个 store 包含 state(如
files
列表)、actions(如addFile
,removeFile
,uploadFiles
)和 getters(如getFilesForDisplay
)。 Communication
组件不再维护本地gallery
状态,而是从 Pinia store 中读取文件列表进行展示。FileGallery
的@add
和@remove
事件触发Communication
组件调用 Pinia store 的对应 actions (addFile
,removeFile
) 来修改 store 中的状态。Communication
组件的handleSubmit
方法调用 Pinia store 的uploadFiles
action。这个 action 内部封装了调用comunicacao.postComunicacaoImages
的逻辑。
代码示例片段:
- 创建 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),
},
});
- 在
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) 的问题,实现点击“保存”时才进行文件上传的目标。选择一个,动手试试吧!