Skip to content

Vue3中实现OSS文件上传组件

引言

在现代Web应用开发中,文件上传是一个常见而重要的功能。特别是对于需要处理大量文件的企业应用,例如招投标系统、文档管理系统等,一个高效、稳定的文件上传组件是不可或缺的。本文将详细介绍如何在Vue3项目中实现一个基于对象存储服务(OSS)的文件上传组件,包括前端实现、后端代理设置以及各种优化措施。

系统架构

在开始编码之前,我们先了解一下整个文件上传系统的架构。

graph TD
    Client[客户端浏览器] --> Step1
    Step1[1.请求上传URL] --> Server[后端服务器]
    Server --> Step2
    Step2[2.返回预签名URL] --> Client
    Client --> Step3
    Step3[3.直接上传文件] --> OSS[对象存储服务]

这种架构有几个明显的优势:

  1. 减轻服务器负担:文件直接从客户端上传到OSS,不经过应用服务器,避免了服务器带宽和资源消耗
  2. 提高上传速度:直接对接OSS服务通常比通过应用服务器中转更快
  3. 安全可控:使用预签名URL技术,可以严格控制上传权限和文件类型
  4. 成本优化:大多数云服务商对内网流量免费,减少了带宽成本

前端组件实现

我们的文件上传组件需要具备以下功能:

  1. 文件选择和上传
  2. 上传进度显示
  3. 文件类型和大小验证
  4. 错误处理和重试机制
  5. 文件下载功能
  6. 支持只下载模式(用于查看已上传文件)

下面是我们的OssFileUploader组件实现:

组件模板

html
<template>
  <div class="oss-file-uploader">
    <div class="upload-actions" v-if="!downloadOnly">
      <!-- 上传按钮 -->
      <button class="btn btn-upload" @click="triggerFileInput" :disabled="disabled">
        <span class="icon">📁</span>
        选择文件
      </button>
      <input type="file" ref="fileInput" @change="handleFileSelect" style="display: none" :accept="accept" />

      <!-- 下载按钮 - 仅在文件上传成功后显示 -->
      <button v-if="uploadedFile" class="btn btn-download" @click="downloadFile">
        <span class="icon">⬇️</span>
        下载文件
      </button>
    </div>

    <!-- 只显示下载按钮的模式 -->
    <div v-if="downloadOnly && uploadedFile" class="download-only-mode">
      <button class="btn btn-download" @click="downloadFile">
        <span class="icon">⬇️</span>
        下载文件
      </button>
    </div>

    <!-- 文件名显示 -->
    <span v-if="uploadedFile && !downloadOnly" class="file-name" :title="uploadedFile.name">
      {{ truncateFileName(uploadedFile.name) }}
    </span>

    <!-- 上传进度条 -->
    <div v-if="uploading" class="progress-container">
      <div class="progress-bar" :style="{ width: `${uploadProgress}%` }"></div>
      <span>{{ uploadProgress }}%</span>
    </div>

    <!-- 错误信息 -->
    <div class="error-message" v-if="errorMessage">
      <span>{{ errorMessage }}</span>
      <button @click="resetError" class="btn-retry">重试</button>
    </div>
  </div>
</template>

组件逻辑

js
<script>
import { ref, onMounted } from 'vue';
import ossService from '../utils/ossService';

export default {
  name: 'OssFileUploader',

  props: {
    accept: {
      type: String,
      default: ''
    },
    maxSize: {
      type: Number,
      default: 50 // 默认50MB
    },
    initialFile: {
      type: Object,
      default: null
      // 格式: { objectKey: 'xxx', name: 'xxx.docx' }
    },
    downloadOnly: {
      type: Boolean,
      default: false
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },

  emits: ['upload-success', 'upload-error', 'upload-progress'],

  setup(props, { emit }) {
    const fileInput = ref(null);
    const uploading = ref(false);
    const uploadProgress = ref(0);
    const uploadedFile = ref(null);
    const errorMessage = ref('');
    const isDragOver = ref(false);

    // 初始化文件显示(用于编辑模式)
    onMounted(() => {
      if (props.initialFile) {
        // 如果提供了初始文件,将其设置为已上传文件
        if (props.initialFile.objectKey) {
          // 尝试从objectKey中提取文件名
          let fileName = props.initialFile.name;

          // 如果没有提供文件名,从objectKey中提取
          if (!fileName) {
            fileName = props.initialFile.objectKey.split('/').pop();
          }

          uploadedFile.value = {
            name: fileName,
            objectKey: props.initialFile.objectKey
          };
        }
      }
    });

    const triggerFileInput = () => {
      if (!uploading.value && !props.disabled) {
        fileInput.value.click();
      }
    };

    const handleFileSelect = async (event) => {
      const file = event.target.files[0];
      if (file) {
        const validationError = validateFile(file);
        if (validationError) {
          errorMessage.value = validationError;
          return;
        }
        await uploadFile(file);
      }
    };

    const validateFile = (file) => {
      // 检查文件大小
      const fileSizeMB = file.size / (1024 * 1024);
      if (fileSizeMB > props.maxSize) {
        return `文件大小超过限制 (${fileSizeMB.toFixed(2)}MB > ${props.maxSize}MB)`;
      }

      // 检查文件类型(如果指定了accept属性)
      if (props.accept && props.accept.trim() !== '') {
        const acceptTypes = props.accept.split(',').map(type => type.trim());
        const fileExtension = file.name.split('.').pop().toLowerCase();
        const mimeType = file.type;

        let isValidType = false;

        for (const type of acceptTypes) {
          // 检查MIME类型
          if (type.startsWith('.')) {
            // 文件扩展名检查
            if (`.${fileExtension}` === type) {
              isValidType = true;
              break;
            }
          } else if (type === mimeType || type === '*/*' || (type.endsWith('/*') && mimeType.startsWith(type.replace('*', '')))) {
            isValidType = true;
            break;
          }
        }

        if (!isValidType) {
          return `不支持的文件类型: ${file.type || fileExtension}`;
        }
      }

      return null; // 验证通过
    };

    // 上传文件的核心方法
    const uploadFile = async (file) => {
      try {
        // 开始上传前重置状态
        uploading.value = true;
        uploadProgress.value = 0;
        errorMessage.value = '';

        // 自定义上传进度处理
        const handleProgress = (progressEvent) => {
          const percentCompleted = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          uploadProgress.value = percentCompleted;
          emit('upload-progress', percentCompleted);
        };

        console.log('开始上传文件:', {
          name: file.name,
          type: file.type,
          size: (file.size / (1024 * 1024)).toFixed(2) + 'MB'
        });

        // 1. 获取预签名URL
        const result = await ossService.getUploadPresignedUrl(file.name, file.type);
        console.log('获取上传URL成功:', result);

        if (!result || !result.uploadUrl || !result.objectKey) {
          throw new Error('获取上传URL失败: 返回数据结构不完整');
        }

        // 2. 使用axios直接上传到OSS
        const axios = (await import('axios')).default;

        try {
          await axios.put(result.uploadUrl, file, {
            headers: {
              'Content-Type': file.type
            },
            // 新增配置
            withCredentials: false, // 禁用凭证发送,避免CORS问题
            timeout: 60000, // 增加上传超时时间到60秒
            onUploadProgress: handleProgress
          });

          console.log('文件上传到OSS成功');
        } catch (uploadError) {
          console.error('上传到OSS失败:', uploadError);
          if (uploadError.response) {
            console.error('错误状态码:', uploadError.response.status);
            console.error('错误详情:', uploadError.response.data);
          }
          throw new Error(`上传到OSS失败: ${uploadError.message || '服务器错误'}`);
        }

        // 3. 确认上传完成
        try {
          await ossService.confirmUpload(result.objectKey, file.name);
          console.log('确认上传完成');
        } catch (confirmError) {
          console.error('确认上传失败:', confirmError);
          // 即使确认失败,我们也认为文件已上传成功
          console.warn('文件可能已上传但未能确认,继续处理');
        }

        // 更新组件状态
        uploadedFile.value = {
          name: file.name,
          objectKey: result.objectKey
        };

        uploading.value = false;
        uploadProgress.value = 100;

        // 触发上传成功事件
        emit('upload-success', {
          file: file,
          objectKey: result.objectKey
        });

      } catch (error) {
        console.error('上传处理失败:', error);
        uploading.value = false;
        uploadProgress.value = 0;
        errorMessage.value = `上传失败: ${error.message || '未知错误'}`;
        emit('upload-error', error);
      }
    };

    const downloadFile = async () => {
      if (uploadedFile.value) {
        try {
          await ossService.downloadFile(uploadedFile.value.objectKey, uploadedFile.value.name);
        } catch (error) {
          console.error('下载失败:', error);
          alert('下载失败: ' + (error.message || '未知错误'));
        }
      }
    };

    const resetError = () => {
      errorMessage.value = '';
      // 重置文件输入框
      if (fileInput.value) {
        fileInput.value.value = '';
      }
    };

    const truncateFileName = (fileName) => {
      if (!fileName) return '';

      // 获取文件扩展名
      const lastDotIndex = fileName.lastIndexOf('.');
      if (lastDotIndex === -1) {
        // 没有扩展名,直接截断
        return fileName.length > 20 ? fileName.substring(0, 10) + '...' : fileName;
      }

      // 有扩展名,保留扩展名
      const nameWithoutExt = fileName.substring(0, lastDotIndex);
      const extension = fileName.substring(lastDotIndex);

      if (nameWithoutExt.length <= 10) return fileName;

      return nameWithoutExt.substring(0, 10) + '...' + extension;
    };

    return {
      fileInput,
      uploading,
      uploadProgress,
      uploadedFile,
      errorMessage,
      isDragOver,
      triggerFileInput,
      handleFileSelect,
      downloadFile,
      resetError,
      truncateFileName
    };
  }
};
</script>

OSS服务实现

为了让我们的文件上传组件正常工作,我们需要实现OSS服务接口。下面是我们的ossService.js实现,它封装了与OSS相关的所有操作:

js
// src/utils/ossService.js
import http from '@/utils/http';

const ossService = {
  // 获取上传预签名URL
  async getUploadPresignedUrl(fileName, fileType) {
    try {
      console.log('请求预签名URL,参数:', { fileName, fileType });

      // 使用http实例
      const response = await http.get('/api/oss/getUploadUrl', {
        params: { fileName, fileType }
      });

      console.log('完整响应数据:', JSON.stringify(response.data, null, 2));

      // 数据字段存在性验证
      if (!response.data.data) {
        console.error('响应中缺少data字段:', response.data);
        throw new Error('服务器响应缺少data字段');
      }

      // 必要字段验证
      if (!response.data.data.uploadUrl || !response.data.data.objectKey) {
        console.error('data中缺少必要字段:', response.data.data);
        throw new Error('服务器返回数据缺少uploadUrl或objectKey字段');
      }

      return response.data.data;
    } catch (error) {
      console.error('获取上传URL失败:', error);
      throw error;
    }
  },

  // 上传文件方法
  async uploadFile(file) {
    try {
      console.log('开始上传文件:', {
        name: file.name,
        type: file.type,
        size: (file.size / (1024 * 1024)).toFixed(2) + 'MB'
      });

      // 1. 获取预签名URL
      const result = await this.getUploadPresignedUrl(file.name, file.type);
      console.log('预签名URL结果:', result);

      // 验证结果
      if (!result || typeof result !== 'object') {
        throw new Error('预签名URL返回值不是有效对象');
      }

      // 安全解构
      let uploadUrl = result.uploadUrl;
      const objectKey = result.objectKey;

      if (!uploadUrl || !objectKey) {
        throw new Error('无法从响应中获取uploadUrl或objectKey');
      }

      // 如果URL是代理URL,直接转换为原始OSS URL
      if (uploadUrl.includes('/obs-proxy/')) {
        uploadUrl = uploadUrl.replace('/obs-proxy/', 'https://obs-sdjining.cucloud.cn/');
        console.log('转换为直接OSS URL:', uploadUrl);
      }

      // 2. 使用预签名URL上传文件到OSS
      const axios = (await import('axios')).default;

      await axios.put(uploadUrl, file, {
        headers: {
          'Content-Type': file.type
        },
        // 关键修改:禁用凭证发送,避免CORS问题
        withCredentials: false,
        // 增加超时时间到60秒
        timeout: 60000,
        onUploadProgress: progressEvent => {
          const percentCompleted = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          console.log('上传进度:', percentCompleted + '%');
        }
      });

      console.log('文件上传成功,开始确认上传');

      // 3. 确认上传完成
      await this.confirmUpload(objectKey, file.name);
      console.log('确认上传完成');

      return objectKey;
    } catch (error) {
      console.error('上传文件失败:', error);
      if (error.response) {
        console.error('错误响应状态:', error.response.status);
        console.error('错误响应内容:', error.response.data);
      }
      throw error;
    }
  },

  // 确认上传方法
  async confirmUpload(objectKey, fileName) {
    try {
      console.log('确认上传,参数:', { objectKey, fileName });

      // 创建FormData对象
      const formData = new FormData();
      formData.append('objectKey', objectKey);
      formData.append('fileName', fileName);

      // 使用http实例
      const response = await http.post('/api/oss/confirmUpload', formData);
      console.log('确认上传响应:', response.data);

      return response.data.data;
    } catch (error) {
      console.error('确认上传失败:', error);
      throw error;
    }
  },

  // 获取下载URL
  async getDownloadUrl(objectKey, expirationInSeconds = 3600) {
    try {
      console.log('获取下载URL,参数:', { objectKey, expirationInSeconds });

      // 增加超时时间到30秒
      const response = await http.get('/api/oss/getUrl', {
        params: {
          objectKey,
          expirationInSeconds
        },
        timeout: 30000
      });

      console.log('获取下载URL响应:', response);

      // 更宽松的检查 - 只要有response.data就尝试使用
      if (!response.data) {
        throw new Error('获取下载URL失败: 响应数据为空');
      }

      // 尝试从不同位置获取URL
      let url;
      if (response.data.data) {
        // 标准路径
        url = response.data.data;
      } else if (typeof response.data === 'string') {
        // 可能直接返回了URL字符串
        url = response.data;
      } else if (response.data.url) {
        // 可能使用了不同的字段名
        url = response.data.url;
      } else {
        // 实在找不到,把整个响应转为字符串
        console.warn('无法识别下载URL的标准格式,尝试使用完整响应');
        url = JSON.stringify(response.data);
      }

      console.log('解析得到的下载URL:', url);
      return url;
    } catch (error) {
      console.error('获取下载URL失败:', error);

      // 如果是超时错误,提供更明确的错误信息
      if (error.code === 'ECONNABORTED') {
        throw new Error('获取下载URL超时,请稍后重试或联系管理员检查服务器状态');
      }

      throw error;
    }
  },

  // 下载文件方法
  async downloadFile(objectKey, fileName) {
    try {
      console.log('开始下载文件:', { objectKey, fileName });

      // 1. 获取下载URL
      const url = await this.getDownloadUrl(objectKey);

      // 2. 创建下载状态提示
      const downloadStatus = document.createElement('div');
      downloadStatus.textContent = '准备下载文件...';
      downloadStatus.style.position = 'fixed';
      downloadStatus.style.bottom = '20px';
      downloadStatus.style.right = '20px';
      downloadStatus.style.padding = '10px 15px';
      downloadStatus.style.backgroundColor = '#409eff';
      downloadStatus.style.color = 'white';
      downloadStatus.style.borderRadius = '4px';
      downloadStatus.style.zIndex = '9999';
      document.body.appendChild(downloadStatus);

      try {
        // 3. 使用fetch API下载文件
        console.log('使用fetch开始下载...');
        const response = await fetch(url, {
          method: 'GET',
          mode: 'cors',
          credentials: 'omit', // 不发送凭证,避免CORS问题
          cache: 'no-cache',
          // fetch没有超时选项,需要使用自定义超时逻辑
        });

        // 检查响应状态
        if (!response.ok) {
          throw new Error(`下载文件失败: HTTP ${response.status} ${response.statusText}`);
        }

        // 4. 获取blob数据
        downloadStatus.textContent = '正在接收文件数据...';
        const blob = await response.blob();

        // 5. 创建下载链接并触发下载
        downloadStatus.textContent = '准备文件完成,开始下载...';
        const downloadUrl = window.URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = downloadUrl;
        link.setAttribute('download', fileName);
        document.body.appendChild(link);

        // 延迟一点点再触发下载,确保UI状态更新
        setTimeout(() => {
          link.click();

          // 清理
          window.URL.revokeObjectURL(downloadUrl);
          document.body.removeChild(link);

          downloadStatus.textContent = '下载成功!';
          downloadStatus.style.backgroundColor = '#67c23a';

          // 2秒后移除下载状态提示
          setTimeout(() => {
            document.body.removeChild(downloadStatus);
          }, 2000);
        }, 100);

        return true;
      } catch (fetchError) {
        console.error('使用fetch下载失败,尝试备用方法:', fetchError);

        // 如果fetch失败,尝试使用原始的a标签下载方法作为备用方案
        downloadStatus.textContent = '正在使用备用方法下载...';

        // 使用原始的方法作为备用
        const link = document.createElement('a');
        link.href = url;
        link.setAttribute('download', fileName);
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        downloadStatus.textContent = '下载指令已发送!';
        downloadStatus.style.backgroundColor = '#e6a23c';

        // 3秒后移除下载状态提示
        setTimeout(() => {
          document.body.removeChild(downloadStatus);
        }, 3000);

        return true;
      }
    } catch (error) {
      console.error('下载文件失败:', error);

      // 显示错误消息
      alert('下载失败: ' + (error.message || '未知错误'));

      throw error;
    }
  },

  // 删除文件方法
  async deleteFile(objectKey) {
    try {
      console.log('删除文件,参数:', { objectKey });

      const response = await http.delete('/api/oss/deleteFile', {
        params: { objectKey }
      });

      console.log('删除文件响应:', response.data);

      return response.data.data;
    } catch (error) {
      console.error('删除文件失败:', error);
      throw error;
    }
  }
};

export default ossService;

跨域问题解决方案

在使用OSS服务的过程中,最常见的问题是跨域资源共享(CORS)问题。以下是我们通过Vite代理配置解决这个问题的方法:

js
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    }
  },
  plugins: [
    vue(),
    Components({
      resolvers: [
        AntDesignVueResolver({
          importStyle: false
        })
      ]
    })
  ],
  server: {
    port: 3000,
    open: true,
    proxy: {
      // 代理所有 OSS 服务的请求
      '/obs-proxy': {
        target: 'https://obs-sdjining.cucloud.cn',
        changeOrigin: true, // 设置为true时,请求头中的host会被设置为target的域名,解决跨域问题
        rewrite: (path) => path.replace(/^\/obs-proxy/, ''), // 重写请求路径,将/obs-proxy前缀移除
        configure: (proxy) => {
          // 添加额外配置以处理CORS(跨域资源共享)问题
          proxy.on('proxyReq', (proxyReq) => {
            // 在代理请求发送前修改请求头
            proxyReq.setHeader('Origin', 'https://obs-sdjining.cucloud.cn'); // 设置Origin头为目标服务器域名
          });
          proxy.on('proxyRes', (proxyRes) => {
            // 在收到代理响应后修改响应头
            proxyRes.headers['Access-Control-Allow-Origin'] = '*'; // 允许任何来源的请求访问资源
            proxyRes.headers['Access-Control-Allow-Methods'] = 'PUT, GET, POST, DELETE, OPTIONS'; // 允许的HTTP请求方法
            proxyRes.headers['Access-Control-Allow-Headers'] = 'Content-Type'; // 允许客户端在请求中携带的头信息
          });
        }
      }
    }
  },
  build: {
    outDir: 'dist',
    sourcemap: false,
    rollupOptions: {
      output: {
        chunkFileNames: 'js/[name]-[hash].js',
        entryFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router'],
          'ant-design-vue': ['ant-design-vue']
        }
      }
    }
  }
})

这个代理配置的主要作用是:

  1. 将前端对/obs-proxy路径的请求重定向到实际的OSS服务器
  2. 自动修改请求头和响应头,解决跨域问题
  3. 重写请求路径,移除代理前缀

实际应用示例

下面是在实际业务表单中使用我们的OSS文件上传组件的例子:

html
<template>
  <a-modal :open="modelValue" title="上传文件" @ok="handleOk" @cancel="handleCancel"
    @update:open="$emit('update:modelValue', $event)" :confirmLoading="confirmLoading">
    <a-form :model="formState" :rules="rules" ref="formRef" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
      <a-form-item label="文件名称" name="fileName">
        <a-input v-model:value="formState.fileName" placeholder="请输入文件名称" />
      </a-form-item>

      <a-form-item label="文件" name="fileUrl">
        <!-- OSS文件上传器 -->
        <div class="oss-uploader-container">
          <OssFileUploader @upload-success="handleUploadSuccess" @upload-error="handleUploadError"
            :initialFile="formState.fileUrl ? { objectKey: formState.fileUrl, name: getFileNameFromUrl(formState.fileUrl) } : null" />
        </div>
      </a-form-item>
      
      <!-- 其他表单字段 -->
      <a-form-item label="文件编号" name="fileNumber">
        <a-input v-model:value="formState.fileNumber" placeholder="请输入文件编号" />
      </a-form-item>

      <a-form-item label="有效期" name="validityPeriod">
        <a-date-picker v-model:value="formState.validityPeriod" style="width: 100%" :format="displayFormat" show-time />
      </a-form-item>
    </a-form>
  </a-modal>
</template>

<script setup>
import OssFileUploader from '@/components/OssFileUploader.vue';
import { ref, reactive } from 'vue';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';

// 处理上传成功
const handleUploadSuccess = (result) => {
  // 设置文件URL到表单
  formState.fileUrl = result.objectKey;
  message.success('文件上传成功');
};

// 处理上传失败
const handleUploadError = (error) => {
  message.error(`文件上传失败: ${error.message || '未知错误'}`);
};

// 从文件URL中提取文件名
const getFileNameFromUrl = (url) => {
  if (!url) return '';
  return url.split('/').pop();
};
</script>

文件上传流程详解

让我们详细分析一下完整的文件上传流程,这有助于理解整个系统的工作原理:

sequenceDiagram
    participant Browser as 浏览器
    participant Frontend as 前端应用
    participant Backend as 后端API
    participant OSS as 对象存储服务
    participant DB as 数据库
    
    Browser->>Frontend: 选择文件
    Frontend->>Frontend: 验证文件类型和大小
    Frontend->>Backend: 请求上传预签名URL
    Backend->>OSS: 生成预签名URL
    OSS-->>Backend: 返回预签名URL和对象键
    Backend-->>Frontend: 返回预签名URL和对象键
    Frontend->>OSS: 直接使用预签名URL上传文件
    OSS-->>Frontend: 上传完成响应
    Frontend->>Backend: 确认上传完成
    Backend->>DB: 记录文件信息
    DB-->>Backend: 确认记录成功
    Backend-->>Frontend: 返回上传确认
    Frontend-->>Browser: 显示上传成功

关键优化点

在实现过程中,我们采取了一系列优化措施来提高上传的稳定性和性能:

1. 前端优化

graph TD
    A[前端优化] --> B[文件验证]
    A --> C[上传进度显示]
    A --> D[错误处理和重试]
    A --> E[流式上传]
    
    B --> B1[类型验证]
    B --> B2[大小限制]
    
    C --> C1[实时进度条]
    C --> C2[状态提示]
    
    D --> D1[网络错误处理]
    D --> D2[超时处理]
    D --> D3[重试机制]
    
    E --> E1[取消证书发送]
    E --> E2[增加超时时间]
  1. 文件本地验证:在上传前,先在客户端验证文件类型和大小,避免不必要的网络请求
  2. 上传进度显示:实时展示上传进度,提升用户体验
  3. 流式上传:使用axios.put直接将文件流式上传到OSS服务
  4. 错误处理:完善的错误捕获和展示

2. 网络优化

  1. 禁用凭证发送:设置withCredentials: false,避免CORS问题
  2. 增加超时时间:对于大文件,默认的网络超时往往不够,我们增加到60秒
  3. 代理配置:通过Vite代理解决跨域问题

3. 下载优化

  1. 多重下载策略:首先尝试使用现代的fetch API下载,失败后回退到传统方式
  2. 下载状态提示:添加视觉反馈,让用户知道下载正在进行
  3. 容错处理:增强对各种API返回格式的兼容性

使用注意事项

  1. 代理配置:确保Vite的代理配置正确,特别是目标OSS服务的URL
  2. 权限控制:后端应正确控制预签名URL的权限和有效期
  3. 文件大小限制:前端和后端都应该设置合理的文件大小限制
  4. 文件类型限制:使用accept属性限制可选文件类型

总结

本文介绍了一种基于预签名URL的OSS文件上传解决方案,这种方案有以下优势:

  1. 高效:文件直接从客户端上传到OSS,不经过应用服务器
  2. 安全:通过预签名URL和后端确认机制,保证文件上传的安全性
  3. 用户友好:提供了上传进度、错误处理等功能,提升用户体验
  4. 可扩展:组件设计灵活,可轻松集成到各种表单中

通过这种方式,我们可以构建一个高效、稳定的文件上传系统,满足企业级应用的需求。

参考资料

  1. Vue3 组合式API文档
  2. Vite代理配置指南
  3. 使用Fetch API
  4. 跨域资源共享(CORS)

安全性考虑

在实现OSS文件上传功能时,安全性是一个不可忽视的关键因素。以下是几点重要的安全考虑:

预签名URL的安全控制

预签名URL虽然方便,但也存在安全风险。我们应该:

  1. 设置合理的过期时间:预签名URL应该设置较短的有效期,通常不超过15分钟
  2. 权限最小化:URL应只授予完成特定任务所需的最小权限
  3. IP限制:在某些高安全场景下,可以限制特定IP才能使用预签名URL

文件内容验证

除了前端的文件类型和大小验证外,后端还应该:

  1. 进行二次校验:不要完全信任前端验证结果
  2. 检查文件内容:可能的话检查文件内容是否与声明的类型一致
  3. 病毒扫描:对上传的文件进行病毒扫描

数据传输安全

  1. 强制使用HTTPS:所有API通信和文件传输都应该使用HTTPS
  2. 防止中间人攻击:确保证书验证正确配置

实际应用场景

OSS文件上传组件在各种业务场景中都有广泛应用,以下是几个典型场景:

1. 招投标系统

招投标系统需要处理大量的标书、资质证明等文档,这些文件通常比较大,且需要严格控制访问权限。采用OSS直传方案可以:

  • 提高大文件上传的稳定性
  • 减少应用服务器负担
  • 利用OSS的权限管理,为不同的投标方设置不同的访问权限

2. 内容管理系统(CMS)

内容创作和管理平台需要处理各种媒体资源,如图片、视频、音频等:

  • 利用OSS的图片处理功能,自动生成缩略图
  • 利用视频转码服务,适配不同终端设备
  • 通过CDN加速,提高全球范围内的访问速度

3. 企业文档管理

企业内部的文档管理系统需要处理各种格式的文档,并支持版本控制:

  • 利用OSS的版本控制功能,记录文档的历史版本
  • 基于预签名URL的方式,可以更精细地控制文档访问权限
  • 结合企业身份验证系统,实现单点登录

性能优化建议

除了本文已经介绍的优化措施外,以下是一些进阶的性能优化建议:

1. 分片上传

对于大文件(通常大于5MB),可以考虑实现分片上传:

graph TD
    A[大文件] --> B[分片1]
    A --> C[分片2]
    A --> D[分片3]
    A --> E[...]
    B --> F[并行上传]
    C --> F
    D --> F
    E --> F
    F --> G[合并分片]
    G --> H[完成上传]

分片上传的优势:

  • 支持断点续传
  • 提高上传成功率
  • 充分利用带宽
  • 简化重试逻辑(只需重传失败的分片)

2. 文件压缩

在客户端对文件进行压缩可以显著减少传输时间:

  • 使用CompressionStream API压缩文本文件
  • 使用浏览器原生的图片压缩功能处理图片
  • 对大型应用考虑使用WebWorker进行后台压缩,避免阻塞UI线程

3. 上传队列管理

当需要上传多个文件时,实现一个智能的上传队列可以:

  • 控制并发上传数量
  • 根据网络状况动态调整并发数
  • 支持优先级排序
  • 提供整体进度显示

未来改进方向

随着Web技术的发展,我们的OSS文件上传组件还有很多改进空间:

1. 支持更现代的API

  • 利用AbortController实现更优雅的上传取消
  • 使用ResizeObserver实现响应式UI调整
  • 利用IntersectionObserver实现更智能的延迟加载

2. 功能扩展

  • 增加预览功能,包括文档、图片和视频的在线预览
  • 添加编辑功能,如图片的裁剪、旋转等
  • 实现更高级的权限控制,如文件访问审计

3. 跨平台兼容

  • 提供同样接口的React版本组件
  • 开发原生移动应用版本
  • 支持桌面应用集成

结语

通过本文的实践,我们不仅实现了一个功能完备的OSS文件上传组件,还深入理解了其中的技术原理和最佳实践。在实际项目中,这种基于预签名URL的直传方案已经被证明是一种高效、可靠的解决方案。希望看到这里的你,也能有所收获~