背景

每个COS的用户都会用到上传服务。Web端常见的上传方法是用户在浏览器或App端上传文件到应用服务器,应用服务器再把文件上传到COS。具体流程如下图所示。

image-20210925131158303

和数据直传到COS相比,以上方法有三个缺点:

  • 上传慢:用户数据需先上传到应用服务器,之后再上传到COS。网络传输时间比直传到COS一倍。如果用户数据不通过应用服务器中转,而是直传到COS,速度将大大提升。而且COS采用BGP带宽,能保证各地各运营商之间的传输速度。
  • 扩展性差:如果后续用户多了,应用服务器会成为瓶颈。
  • 费用高:需要准备多台应用服务器。由于COS上传流量是免费的,如果数据直传到COS,不通过应用服务器,那么将能省下几台应用服务器。

后端说明

关于服务端签名,参考链接:

https://cloud.tencent.com/document/product/436/14048

前端实现

关于前端直传,参考链接:

https://cloud.tencent.com/document/product/436/9067

相关依赖
"cos-js-sdk-v5": "^1.2.8",
定义工具类
/**
 * 生成随机文件名称
 * 规则八位随机字符,加下划线连接时间戳
 */
export const getFileNameUUID = () => {
  function rx() {
    return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
  }
  return `${+new Date()}_${rx()}${rx()}`
}

创建实例
<template>
  <div>
    <!--照片墙样式-->
    <el-upload
        v-if="uploadStyle===0"
        action=""
        list-type="picture-card"
        :file-list="fileList"
        :auto-upload="true"
        :on-exceed="handleExceed"
        :before-upload="handleBeforeUpload"
        :http-request="handleUploadFile"
        :on-preview="handleFileCardPreview"
        :on-remove="handleRemove">
      <i class="el-icon-plus"></i>
    </el-upload>
    <!--缩略图样式-->
    <el-upload
        v-if="uploadStyle===1"
        action=""
        list-type="picture"
        :file-list="fileList"
        :auto-upload="true"
        :on-exceed="handleExceed"
        :before-upload="handleBeforeUpload"
        :http-request="handleUploadFile"
        :on-preview="handleFileCardPreview"
        :on-remove="handleRemove">
      <el-button style="width: 100%" size="small" type="primary">点击上传</el-button>
    </el-upload>
    <!--拖拽文件样式 云-->
    <el-upload
        v-if="uploadStyle===2"
        drag
        multiple
        action=""
        :file-list="fileList"
        :auto-upload="true"
        :on-exceed="handleExceed"
        :before-upload="handleBeforeUpload"
        :http-request="handleUploadFile"
        :on-preview="handleFileCardPreview"
        :on-remove="handleRemove">
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">
        将文件拖到此处,或<em>点击上传</em>
        <span v-if="uploadInstructions" style="color: red">
           <el-divider direction="vertical"></el-divider>
          {{ uploadInstructions }}
        </span>
      </div>
    </el-upload>
    <!--进度条-->
    <el-progress
        v-show="showProgress"
        :text-inside="true"
        :stroke-width="15"
        :percentage="progress"
        status="success"
    ></el-progress>
    <!--展示上传的文件-->
    <el-dialog :title="dialogFileUrlTitle"
               :visible.sync="dialogFileUrlVisible"
               :close-on-click-modal="false"
               :close-on-press-escape="false"
               :destroy-on-close="true"
               append-to-body>
      <div v-if="dialogFileFormat==='image'">
        <img width="100%" :src="dialogFileUrl" alt="">
      </div>
      <div v-else-if="dialogFileFormat==='video'">
        <video
            controls
            controlslist="nodownload"
            preload="auto"
            style="width: 98%;text-align: center"
            :src="dialogFileUrl">
        </video>
      </div>
      <div v-else>
        <el-alert
            title="当前格式暂不支持预览!"
            type="error"
            center
            show-icon>
        </el-alert>
      </div>
    </el-dialog>
  </div>
</template>
数据方法
import COS from 'cos-js-sdk-v5'
// 获取后端鉴权的接口,具体看自己业务实现。
import {cosCredential} from '@/api/common/common'
// 重新生成文件名
import {getSimpleUUID} from "@/utils/cms";
export default {
  name: 'CosUpload',
  components: {},
  created() {
    this.initialize()
  },
  data() {
    return {
      // 上传样式 0:卡片照片墙 1:缩略图 2:拖拽上传文件
      uploadStyle: 0,
      // COS
      cosData: {},
      // 文件列表
      fileList: [],
      // 进度条的显示
      showProgress: false,
      // 进度条数据
      progress: 0,
      // 文件表单
      fileParams: {
        // 上传的文件目录
        folder: '/default/'
      },
      // 上传说明
      uploadInstructions: "",
      // 展示上传
      dialogFileUrlVisible: false,
      dialogFileUrlTitle: '',
      dialogFileUrl: '',
      dialogFileFormat: ''
    }
  },
  model: {
    event: 'complete',
    prop: ''
  },
  // 组件插槽
  props: {
    // 上传位置
    position: {
      type: String,
      required: true
    },
    // 使用样式
    styleType: {
      type: Number
    },
    // 展示图片
    showFile: {
      type: String
    },
    // 限制格式
    formatList: {
      type: Array
    }
  },
  // 监听事件
  watch: {
    showFile: {
      handler() {
        this.onShowFile()
      }
    }
  },
  methods: {
    /** 组件初始化 */
    initialize() {
      let position = this.position
      if (position === '' || position == null) {
        this.$message({
          message: '组件初始化失败!缺少[position]',
          type: 'warning'
        })
        return
      }
      this.onShowFile()
      switch (position) {
        case 'default':
          this.fileParams.folder = '/default/'
          break
        case 'news':
          this.fileParams.folder = '/news/'
          break
        case 'videos':
          this.fileParams.folder = '/videos/'
          break
        case 'channel':
          this.fileParams.folder = '/channel/'
          break
        default:
          this.$message({
            message: '组件初始化失败!未知[position]',
            type: 'warning'
          })
          return
      }
      this.uploadStyle = this.styleType
      if (this.formatList && this.formatList.length > 0) {
        this.uploadInstructions = `仅支持后缀名为: ${JSON.stringify(this.formatList)} 的文件!`
      }
    },

    /** showFile */
    onShowFile() {
      console.log(`log- showFileUrl:${this.showFile}`)
      if (this.showFile) {
        let url = this.showFile
        let name = '点击预览文件'
        this.fileList = [{name, url}]
      } else {
        this.fileList = []
      }
    },

    /** 上传文件之前 */
    handleBeforeUpload(file) {
      console.log(`handleBeforeUpload formatList: ${JSON.stringify(this.formatList)};`)
      return new Promise((resolve, reject) => {
        // 格式校验格式
        if (this.formatList && this.formatList.length > 0) {
          let exist = false
          let stringFormat = ""
          let fileFormat = file.name.replace(/.+\./, "");
          for (let format of this.formatList) {
            if (format.toLowerCase() === fileFormat.toLowerCase()) {
              exist = true
            }
            if (stringFormat === "") {
              stringFormat = format
            } else {
              stringFormat = stringFormat + " 或 " + format
            }
          }
          if (!exist) {
            this.$message({
              duration: 0,
              showClose: true,
              message: `请上传后缀名为:${stringFormat} 的文件!`,
              type: 'error'
            });
            reject(false)
          }
        }
        // 加载OSS配置参数
        cosCredential().then(response => {
          this.cosData = response.data
          resolve(true)
        }).catch(error => {
          this.$message.error('加载上传配置失败!msg:' + error)
          reject(false)
        })
      })
    },

    /** 文件超出个数限制 */
    handleExceed(files, fileList) {
      this.$message({
        message: '停!不能再多了~',
        type: 'warning'
      })
    },

    /** 文件列表移除文件 */
    handleRemove(file, fileList) {
      this.$emit('complete', null)
      this.$message({
        message: '成功移除一个文件',
        type: 'success'
      })
    },

    /** 点击文件列表中已上传的文件 */
    handleFileCardPreview(file) {
      let fileFormat = this.judgeFileFormat(file.url);
      switch (fileFormat) {
        case 'image':
          this.dialogFileUrlTitle = '图片预览'
          break;
        case "video":
          this.dialogFileUrlTitle = '视频预览'
          break;
        default:
          this.$message.error(`当前格式为${fileFormat},暂不支持预览!`)
          return;
      }
      this.dialogFileFormat = fileFormat
      this.dialogFileUrl = file.url
      this.dialogFileUrlVisible = true
    },

    /** 根据URL判断文件格式 */
    judgeFileFormat(fileUrl) {
      // 获取最后一个.的位置
      const index = fileUrl.lastIndexOf(".");
      // 获取后缀
      const suffix = fileUrl.substr(index + 1);
      console.log(`当前文件后缀格式为 suffix: ${suffix}`);
      // 获取类型结果
      let result = '';
      // 图片格式
      const imgList = ['png', 'jpg', 'jpeg', 'bmp', 'gif'];
      // 进行图片匹配
      result = imgList.find(item => item === suffix);
      if (result) {
        return 'image';
      }
      // 匹配txt
      const txtList = ['txt'];
      result = txtList.find(item => item === suffix);
      if (result) {
        return 'txt';
      }
      // 匹配 excel
      const excelList = ['xls', 'xlsx'];
      result = excelList.find(item => item === suffix);
      if (result) {
        return 'excel';
      }
      // 匹配 word
      const wordList = ['doc', 'docx'];
      result = wordList.find(item => item === suffix);
      if (result) {
        return 'word';
      }
      // 匹配 pdf
      const pdfList = ['pdf'];
      result = pdfList.find(item => item === suffix);
      if (result) {
        return 'pdf';
      }
      // 匹配 ppt
      const pptList = ['ppt', 'pptx'];
      result = pptList.find(item => item === suffix);
      if (result) {
        return 'ppt';
      }
      // 匹配 视频
      const videoList = ['mp4', 'm2v', 'mkv', 'rmvb', 'wmv', 'avi', 'flv', 'mov', 'm4v'];
      result = videoList.find(item => item === suffix);
      if (result) {
        return 'video';
      }
      // 匹配 音频
      const radioList = ['mp3', 'wav', 'wmv'];
      result = radioList.find(item => item === suffix);
      if (result) {
        return 'radio';
      }
      // 其他 文件类型
      return 'other';
    },

    /** 执行文件上传 */
    handleUploadFile(file) {
      let that = this
      console.log(`log- ossData: ${JSON.stringify(that.cosData)}`)
      // 获取COS实例
      const cos = new COS({
        // 必选参数
        getAuthorization: (options, callback) => {
          const obj = {
            TmpSecretId: that.cosData.credentials.tmpSecretId,
            TmpSecretKey: that.cosData.credentials.tmpSecretKey,
            XCosSecurityToken: that.cosData.credentials.sessionToken,
            // 时间戳,单位秒,如:1580000000
            StartTime: that.cosData.startTime,
            // 时间戳,单位秒,如:1580000900
            ExpiredTime: that.cosData.expiredTime
          }
          callback(obj)
        }
      })
      // 处理文件名称路径
      let temporary = file.file.name.lastIndexOf('.')
      let fileNameLength = file.file.name.length
      let fileFormat = file.file.name.substring(temporary + 1, fileNameLength)
      // 文件路径和文件名
      let cloudFilePath = this.fileParams.folder + getSimpleUUID() + '.' + fileFormat
      console.log(`log- cloudFilePath: ${JSON.stringify(cloudFilePath)}`)

      // 执行上传服务
      cos.putObject({
        // 你的存储桶名称
        Bucket: 'xxxxxxxxxxx',
        // 你的存储桶地址
        Region: 'ap-nanjing',
        // key加上路径写法可以生成文件夹
        Key: cloudFilePath,
        StorageClass: 'STANDARD',
        // 上传文件对象
        Body: file.file,
        onProgress: progressData => {
          console.log(`log- progressData: ${JSON.stringify(progressData)}`)
          if (progressData.percent) {
            that.showProgress = true
            that.progress = Math.floor(progressData.percent * 100)
          }
        }
      }, (err, data) => {
        console.log(`log- upload data: ${JSON.stringify(data)} | err: ${JSON.stringify(err)}`)
        if (data && data.statusCode === 200) {
          // 文件上传成功后填充
          
          // 自定义CDN域名
          // let uploadResult = 'https://static.files.xxxxxxx.cn' + cloudFilePath
          // 未使用自定义CDN域名
          let uploadResult = `https://${data.Location}`
          that.showProgress = false
          console.log(`log- 上传完成 URL:${uploadResult}`)
          that.$message({message: '上传成功', type: 'success'})
          that.$emit('complete', uploadResult)
        } else {
          that.$message.error("上传失败,请稍后重试!")
        }
      })
    }
  }

}

组件使用

<el-form-item label="新闻封面" prop="coverUrl">
  <cos-upload position="news"
              :styleType="2"
              :showFile="form.coverUrl"
              v-model="form.coverUrl">
  </cos-upload>
</el-form-item>

以上就是在Vue.js下,腾讯云COS WEB直传的具体应用。

Q.E.D.