产品说:你在系统中添加一个全局文件上传

Aaron
English

在平时工作过程中,文件上传是一个再平常不过的功能了。如果使用UI框架的情况下通常使用已经封装好的功能组件,但是难免有的时候组件无法完全满足我们的需求。

背景

事情是这个样子的,在某天早起,兴冲冲的来到工作,刚刚准备摸鱼,鼠标刚刚点开心爱的网站,产品经理搬着小板凳坐在了我旁边.就开始说:现在咱们这个系统,上传文件的时候文件太大需要用户等待,弹出层的遮罩遮住了整个页面,用户无法进行任何操作,只能等,文件上传完成之后才能继续操作。我当时大脑飞速运转,弹出层去掉就可以了。产品说:No,我不想要这样的,能不能在用户上传的时候,用户能看到进度,还能做其他的操作,当文件上传完成之后还能有后续的操作,上传文件的时候可以批量上传。内心已经1W只羊驼在奔腾。这还没有完,产品继续:用户在A模块上传的文件和B模块上传的文件,进度都可以看到,无奈最终还是接下需求,开始构思。

程序规划

现有功能

项目整体是使用的是Vue2.0 + Element-ui为基础搭建的系统框架,查看现有上传文件功能,现有内容是依赖于el-upload组件完成的上传功能,现有功能是将上传到后台服务器而非阿里OSS,考虑到后期可能会使用OSS使用分片上传,所以上传这部分打算自己做不再依赖于el-upload自身方便以后程序容易修改。文件选择部分仍然使用el-upload其余部分全部重做。

需求整理

对于产品经理所提出的需求,其中最主要的分为了一下几个重点内容:

  1. 用户上传文件时可以进行其他操作,无需等待结果
  2. 上传文件时用户可以实时查看进度
  3. 文件上传成功可以进行后续操作
  4. 支持批量上传
  5. 上传文件以任务为单位

针对以上几点绘制流程图,规划程序准备开始干:

通过流程图程序已经有了大体的轮廓,接下来就是通过程序实现找个功能。

功能实现

关于进度条部分使用el-progress,事件总线使用则是VueEventBus本打算自己封装,无奈时间紧任务重。

首先要定义MyUpload组件,因为需要在打开任何一个模块的时候都需要看到,把组件放到了系统首页的根页面中,这样除了首页之外的页面就无法在看到该组件了。

<!--进度条显示与隐藏动画-->
<transition name="slide">
    <!--外层-->
    <div class="index-upfile-progress"
          v-progressDrag
          v-if="isShowProgress"
          @click.native.stop="onUpFileProgressClick"
          :title="currentUploadFileName">
      <!--展示上传列表-->
      <el-popover v-model="visible">
          <div class="up-file-list">
            <div v-for="(item,index) of upList"
                :key="index"
                :title="item.name"
                class="module-warp">
              <h5 class="module-title">{{ item.moduleName }}</h5>
              <div>
                <div v-for="(fileInfo,j) of item.children"
                    :key="j"
                    class="up-file-item">
                  <p class="file-name">{{ fileInfo.name }}</p>
                  <p class="status">
                    {{ ["等待中","上传中","上传成功","上传失败","文件错误"][fileInfo.status || 0] }}
                </p>
                </div>
              </div>
            </div>
          </div>
          <template slot="reference">
            <!--展示上传进度-->
            <el-progress type="circle"
                        :percentage="percentage" 
                        width="45"
                        :status="isSuccess?'success':''"
                        @mouseleave.native="onProgressMouseLeave"
                        @mouseenter.native="onProgressMouseEnter"></el-progress>
          </template>
      </el-popover>
    </div>
</transition>

整体结构就是这样的了,样式这里就不做展示了,对于一个合格前端来说,样式无处不在,我与样式融为一体,哈哈哈。既然结构已经出来了,接下来就是对现有内容添加逻辑。

方便程序能够正常的进行和编写,这里需要先完成,发送上传任务,也就是上传组件那部分内容,就不写相关的HTML结构了,相关内容大家可以参考Element-ui相关组件。

export default {
    methods: {
        onUploadFile(){
            const { actions } = this;
            const { uploadFiles } = this.$refs.upload;
            //  不再保留组件内中的文件数据
            this.$refs.upload.clearFiles();
            this.$bus.$emit("upFile",{ 
                files: [...uploadFiles],    //  需要上传文件的列表
                actions,        //  上传地址
                moduleId: "模块id",
                moduleName: "模块名称",
                content: {} //  携带参数
            });
        }
    }
}

el-upload中可以通过组件实例中的uploadFiles获取到所需要上传的文件列表,为了避免二次选择文件的时候,第一次选择的文件仍然保存在组件中,需要调用组件实例的clearFiles方法,清空现有组件中缓存的文件列表。

export default {
  created(){
    this.$bus.$on("upFile", this.handleUploadFiles);
  },
  destroyed(){
    this.$bus.$off("upFile", this.handleUploadFiles);
  }
}

MyUpload组件初始化时订阅一下对应的事件方便接收参数,当组件销毁的时候销毁一下对应的事件。通过Bus现在可以很容易的得到所需要上传的文件以及上传文件中对应所需要的参数。

export default {
    data(){
        return {
            //  是否展示上传列表
            visible: false,
            //  上传文件任务列表
            filesList: [],
            //  显示进度条
            isShowProgress: false,
            //  进度条进度
            percentage: 0,
            //  定时器
            timer: null,
            //  是否全部上传完成
            isSuccess: false,
            //  是否有文件正在上传
            isUpLoading: false,
            //  正在上传的文件名称
            currentUploadFileName: ""
        }
    },
    methods: {
        async handleUploadFiles(data){
            //  唯一消息
            const messageId = this.getUUID();
            data.messageId = messageId;
            this.filesList.push(data);
            //  整理文件上传列表展示
            this.uResetUploadList(data);
            this.isSuccess = false;
            //  如果有文件正在上传则不进行下面操作
            if(this.isUpLoading) return;
            //  显示进度条
            this.isShowProgress = true;
            //  记录当亲
            this.isUpLoading = true;
            await this.upLoadFile();
            this.isSuccess = true;
            this.isUpLoading = false;
            this.delyHideProgress();
        },
        getUUID () {
            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
                return (c === 'x' ? (Math.random() * 16 | 0) : ('r&0x3' | '0x8')).toString(16)
            })
        }
    }
}

由于文件上传任务是分批次的,所以应该为每一个消息设置一个独立的id,这样的话,即使是同一个模块上传的文件也不会发生消息的混乱和破坏了。

接下来就是渲染一下上传任务的列表,这里考虑的是,当文件上传的时候,消息列表中不应该再存储File对象的相关内容了,而且消息列表需要再对应模块中获取到上传列表中的内容,所以需要把上传展示列表存放到Vuex中。

import { mapState } from "vuex";
export default {
    computed: {
        ...mapState("upload",{
            upList: (state) => {
                return state.upList;
            }
        })
    },
    methods: {
        uResetUploadList(data){
            //  上传展示任务列表
            const { upList } = this;
            //  模块名称,模块id,文件列表,上传地址,携带参数,消息id
            const { moduleName, moduleId, files = [], actions, content, messageId } = data;
            const uplistItem = {
                moduleName,
                moduleId,
                actions,
                content,
                messageId,
                isDealWith: false,  //  消息是否已处理
                isUpload: false,    //  是否上传完成
                children: files.map(el => ({    //  文件上传结果
                    name: el.name,
                    status: 0,
                    result: {}
                }))
            };
            this.$store.commit("upload/addUpload",[...upList, uplistItem]);
        },
    }
}

当上传文件列表展示完成之后,接下来需要处理的就是整个组件的核心内容上传文件,由于上传文件时时已任务为节点,当一个任务完成才能继续执行下一个任务。

import ajax from "@/utils/ajax";

export default {
    methods: {
        async upLoadFile(){
            //  执行循环
            while(true){
                //  取出上传任务
                const fileRaw = this.filesList.shift();
                const { actions, files,  messageId, content, moduleId } = fileRaw;
                const { upList, onProgress } = this;
                //  取出对应展示列表中的对应信息
                const upListItem = upList.find(el => el.messageId === messageId);
                //  循环需要上传的文件列表
                for(let i = 0,file; file = files[i++];){
                    //  如果对应示列表中的对应信息不存在,跳过当前循环
                    if(!upListItem) continue;
                    //  设置状态为 上传中
                    upListItem.children[i - 1].status = 1;
                    try{
                        //  执行上传
                        const result = await this.post(file, { actions, content, onProgress });
                        if(result.code === 200){
                            //  设置状态为上传成功
                            upListItem.children[i - 1].status = 2;
                        }else{
                            //  上传失败
                            upListItem.children[i - 1].status = 4;
                        }
                        //  存储上传结果
                        upListItem.children[i - 1].result = result;
                    }catch(err){
                        //  上传错误
                        upListItem.children[i - 1].status = 3;
                        upListItem.children[i - 1].result = err;
                    }
                }
                //  设置上传成功
                upListItem.isUpload = true;
                //  更新展示列表
                this.$store.commit("upload/addUpload",[...upList]);
                //  任务完成,发送消息,已模块名称为事件名称
                this.$bus.$emit(moduleId,{ messageId });
                //  没有上传任务,跳出循环
                if(!this.filesList.length){
                  break;
                }
            }
        },
        async post(file, config){
            const { actions, content = {}, onProgress } = config;
            //  上传文件
            const result = await ajax({
                action: actions,
                file: file.raw,
                data: content,
                onProgress
            });
            return result;
        },
        onProgress(event,){
            //  上传进度
            const { percent = 100 } = event;
            this.percentage = parseInt(percent);
        },
        delyHideProgress(){
            //  延时隐藏进度
            this.timer = setTimeout(() => {
                this.isShowProgress = false;
                this.visible = false;
                this.percentage = 0;
            },3000);
        }
    }
}

到这里除了上传文件ajax部分,任务执行已经文件上传的具体内容已经完成了,关于ajax部分可以直接使用axios进行文件上传也是可以的,这里为了方便以后更好的功能拓展,所以采用了手动封装的形式。

function getError(action, option, xhr) {
  let msg;
  if (xhr.response) {
    msg = `${xhr.response.error || xhr.response}`;
  } else if (xhr.responseText) {
    msg = `${xhr.responseText}`;
  } else {
    msg = `fail to post ${action} ${xhr.status}`;
  }

  const err = new Error(msg);
  err.status = xhr.status;
  err.method = 'post';
  err.url = action;
  return err;
}

function getBody(xhr) {
  const text = xhr.responseText || xhr.response;
  if (!text) {
    return text;
  }

  try {
    return JSON.parse(text);
  } catch (e) {
    return text;
  }
}

function upload(option) {
  return new Promise((resovle, reject) => {
    if (typeof XMLHttpRequest === 'undefined') {
      return;
    }
    const xhr = new XMLHttpRequest();
    const action = option.action;
    if (xhr.upload) {
      xhr.upload.onprogress = function progress(e) {
        if (e.total > 0) {
          e.percent = e.loaded / e.total * 100;
        }
        option.onProgress && option.onProgress(e);
      };
    }

    const formData = new FormData();

    if (option.data) {
      Object.keys(option.data).forEach(key => {
        formData.append(key, option.data[key]);
      });
    }

    formData.append("file", option.file, option.file.name);
    for(let attr in option.data){
      formData.append(attr, option.data[attr]);
    }

    xhr.onerror = function error(e) {
      option.onError(e);
    };

    xhr.onload = function onload() {
      if (xhr.status < 200 || xhr.status >= 300) {
        option.onError && option.onError(getBody(xhr));
        reject(getError(action, option, xhr));
      }
      option.onSuccess && option.onSuccess(getBody(xhr));
    };

    xhr.open('post', action, true);

    if (option.withCredentials && 'withCredentials' in xhr) {
      xhr.withCredentials = true;
    }

    const headers = option.headers || {};

    for (let item in headers) {
      if (headers.hasOwnProperty(item) && headers[item] !== null) {
        xhr.setRequestHeader(item, headers[item]);
      }
    }
    xhr.send(formData);
  })
}

export default (option) => {

  return new Promise((resolve,reject) => {
    upload({
      ...option,
      onSuccess(res){
        resolve(res.data);
      },
      onError(err){
        reject(err);
      }
    })
  })

}

接下来就是完善细节部分了,当所有任务完成用户想要查看上传列表的时候,忽然隐藏了这样就不太好了,这里使用事件进行限制。还有就是点击的进度条的时候需要把上传列表展示出来。

export default {
    methods: {
        async onUpFileProgressClick(){
          await this.$nextTick();
          this.visible = !this.visible;
        },
        onProgressMouseLeave(){
          if(this.isUpLoading) return;
          this.delyHideProgress();
        },
        onProgressMouseEnter(){
          if(this.isUpLoading) return;
          clearTimeout(this.timer);
        }
    }
}

作为一名合格的前端来说,当然要给自己加需求,这样才完美,为了当上传进度出现时不遮挡页面上的数据,所以需要给其添加拖拽,解决这个问题这里使用的时,自定义指令完成的元素的拖拽,这样用以后拓展起来相对来说会方便很多。

expor default {
  directives:{
    progressDrag:{
      inserted(el, binding, vnode,oldVnode){
        let { offsetLeft: RootL, offsetTop: RootT } = el;
        el.addEventListener("mousedown", (event) => {
          const { pageX, pageY } = event;
          const { offsetTop, offsetLeft } = el; 
          const topPoor = pageY - offsetTop;
          const leftPoor = pageX - offsetLeft;
          const mousemoveFn = (event)=> {
            const left = event.pageX - leftPoor;
            const top = event.pageY - topPoor;
            RootT = top;
            if(RootT <= 0) RootT = 0;
            if(RootT )
            el.style.cssText = `left:${left}px; top: ${top}px;`;
          }
          const mouseupFn = () => {
            if(el.offsetLeft !== RootL){
              el.style.cssText = `left:${RootL}px; top: ${RootT}px; transition: all .35s;`;
            }
            document.removeEventListener("mousemove",mousemoveFn);
            document.removeEventListener("mouseup", mouseupFn);
          }

          document.addEventListener("mousemove",mousemoveFn);
          document.addEventListener("mouseup", mouseupFn);
        });
        let { clientHeight: oldHeight, clientWidth:oldWidth } = document.documentElement;
        const winResize = () => {
          let { clientHeight, clientWidth } = document.documentElement;
          let maxT = (clientHeight - el.offsetTop);
          RootL += (clientWidth - oldWidth);
          RootT += (clientHeight - oldHeight);
          if(RootT <= 0) RootT = 0;
          if(RootT >= clientHeight) RootT = maxT;
          oldHeight = clientHeight;
          oldWidth = clientWidth;
          el.style.cssText = `left:${RootL}px; top: ${RootT}px; transition: all .35s;`;
        };
        window.addEventListener("resize",winResize);
      }
    }
  }
}

关于上传文件的组件,基本已经接近尾声了,接下来就是对接到业务方,功能实现之后,对接业务方就简单很多了,毕竟组件是自己写的对功能一清二楚,不需要再看什么文档了。

export default {
    methods:{
        // messageId 消息id用于过滤消息
        handleUploadFiles({ messageId }){
            //  业务逻辑
        },
        uReadUploadTask(){
            //  用户关闭模块是无法获取到事件通知的
            // 重新打开,重新检测任务
        }
    },
    async mounted(){
        //  事件名称写死,和模块id相同
        this.$bus.$on("事件名称", this.handleUploadFiles);
        await this.$nextTick();
        this.uReadUploadTask();
    },
    destroyed(){
      this.$bus.$off("事件名称", this.handleUploadFiles);
    }
}

整个组件就已经完成了,从最开始的事件触发,以及整个上传的过程,到最后组件的对接。虽然整个组件来说是一个全局组件,对于一个全局组件来说不应该使用vuex对于复用性来说不是特别的优雅,目前来说还没有找到一个更好的解决方案。如果有小伙伴有想法的话,可以在评论区里讨论。

组件整体代码:

<template>
  <transition name="slide">
    <div class="index-upfile-progress"
          v-progressDrag
          v-if="isShowProgress"
          @click.native.stop="onUpFileProgressClick"
          :title="currentUploadFileName">
      <el-popover v-model="visible">
          <div class="up-file-list">
            <div v-for="(item,index) of upList"
                :key="index"
                :title="item.name"
                class="module-warp">
              <h5 class="module-title">{{ item.moduleName }}</h5>
              <div>
                <div v-for="(fileInfo,j) of item.children"
                    :key="j"
                    class="up-file-item">
                  <p class="file-name">{{ fileInfo.name }}</p>
                  <p class="status">{{ ["等待中","上传中","上传成功","上传失败","文件错误"][fileInfo.status || 0] }}</p>
                </div>
              </div>
            </div>
          </div>
          <template slot="reference">
            <el-progress type="circle"
                        :percentage="percentage" 
                        width="45"
                        :status="isSuccess?'success':''"
                        @mouseleave.native="onProgressMouseLeave"
                        @mouseenter.native="onProgressMouseEnter"></el-progress>
          </template>
      </el-popover>
    </div>
  </transition>
</template>

<script>
import ajax from '@/utils/upFileAjax';
import { mapState } from "vuex";

export default {
  directives:{
    progressDrag:{
      inserted(el, binding, vnode,oldVnode){
        let { offsetLeft: RootL, offsetTop: RootT } = el;
        el.addEventListener("mousedown", (event) => {
          const { pageX, pageY } = event;
          const { offsetTop, offsetLeft } = el; 
          const topPoor = pageY - offsetTop;
          const leftPoor = pageX - offsetLeft;
          const mousemoveFn = (event)=> {
            const left = event.pageX - leftPoor;
            const top = event.pageY - topPoor;
            RootT = top;
            if(RootT <= 0) RootT = 0;
            if(RootT )
            el.style.cssText = `left:${left}px; top: ${top}px;`;
          }
          const mouseupFn = () => {
            if(el.offsetLeft !== RootL){
              el.style.cssText = `left:${RootL}px; top: ${RootT}px; transition: all .35s;`;
            }
            document.removeEventListener("mousemove",mousemoveFn);
            document.removeEventListener("mouseup", mouseupFn);
          }

          document.addEventListener("mousemove",mousemoveFn);
          document.addEventListener("mouseup", mouseupFn);
        });
        let { clientHeight: oldHeight, clientWidth:oldWidth } = document.documentElement;
        const winResize = () => {
          let { clientHeight, clientWidth } = document.documentElement;
          let maxT = (clientHeight - el.offsetTop);
          RootL += (clientWidth - oldWidth);
          RootT += (clientHeight - oldHeight);
          if(RootT <= 0) RootT = 0;
          if(RootT >= clientHeight) RootT = maxT;
          oldHeight = clientHeight;
          oldWidth = clientWidth;
          el.style.cssText = `left:${RootL}px; top: ${RootT}px; transition: all .35s;`;
        };
        window.addEventListener("resize",winResize);
      }
    }
  },
  computed: {
    ...mapState("upload",{
      upList: (state) => {
        return state.upList;
      }
    })
  },
  data(){
    return {
      visible: false,
      filesList: [],
      isShowProgress: false,
      percentage: 0,
      timer: null,
      isSuccess: false,
      isUpLoading: false,
      currentUploadFileName: ""
    }
  },
  methods: {
    async onUpFileProgressClick(){
      setTimeout(() => {
        this.visible = !this.visible;
      }, 400)
    },
    onProgressMouseLeave(){
      if(this.isUpLoading) return;
      this.delyHideProgress();
    },
    onProgressMouseEnter(){
      if(this.isUpLoading) return;
      clearTimeout(this.timer);
    },
    async handleUploadFiles(data){
      const messageId = this.getUUID();
      data.messageId = messageId;
      this.filesList.push(data);
      this.uResetUploadList(data);
      this.isSuccess = false;
      if(this.isUpLoading) return;
      this.isShowProgress = true;
      this.isUpLoading = true;
      await this.upLoadFile();
      await this.$nextTick();
      this.isSuccess = true;
      this.isUpLoading = false;
      this.delyHideProgress();
    },
    uResetUploadList(data){
      const { upList } = this;
      const { moduleName, moduleId, files = [], actions, content, messageId } = data;
      const uplistItem = {
        moduleName,
        moduleId,
        actions,
        content,
        messageId,
        isDealWith: false,
        isUpload: false,
        business: false,
        children: files.map(el => ({
          name: el.name,
          status: 0,
          result: {}
        }))
      };
      this.$store.commit("upload/addUpload",[...upList, uplistItem]);
    },
    async upLoadFile(){
      while(true){
        const fileRaw = this.filesList.shift();
        const { actions, files,  messageId, content, moduleId } = fileRaw;
        const { upList, onProgress } = this;
        const upListItem = upList.find(el => el.messageId === messageId);
        for(let i = 0,file; file = files[i++];){
          if(!upListItem) continue;
          upListItem.children[i - 1].status = 1;
          try{
            const result = await this.post(file, { actions, content, onProgress });
            if(result.code === 200){
              upListItem.children[i - 1].status = 2;
            }else{
              upListItem.children[i - 1].status = 4;
            }
            upListItem.children[i - 1].result = result;
          }catch(err){
            upListItem.children[i - 1].status = 3;
            upListItem.children[i - 1].result = err;
          }
        }
        upListItem.isUpload = true;
        this.$store.commit("upload/addUpload",[...upList]);
        this.$bus.$emit(moduleId,{ messageId });
        if(!this.filesList.length){
          break;
        }
      }
    },
    async post(file, config){
      const { actions, content = {}, onProgress } = config;
      const result = await ajax({
        action: actions,
        file: file.raw,
        data: content,
        onProgress
      });
      return result;
    },
    onProgress(event,){
      const { percent = 100 } = event;
      this.percentage = parseInt(percent);
    },
    delyHideProgress(){
      this.timer = setTimeout(() => {
        this.isShowProgress = false;
        this.visible = false;
        this.percentage = 0;
      },3000);
    },
    getUUID () {
      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        return (c === 'x' ? (Math.random() * 16 | 0) : ('r&0x3' | '0x8')).toString(16)
      })
    }
  },
  mounted(){
    this.$bus.$on("upFile", this.handleUploadFiles);
  },
  destroyed(){
    this.$bus.$off("upFile", this.handleUploadFiles);
  }
}
</script>

感谢大家阅读这篇文章,文章中如果有什么问题,大家在下方留言我会及时做出改正。

阅读 410

Easy life, happy elimination of bugs.

3.8k 声望
6.1k 粉丝
0 条评论
你知道吗?

Easy life, happy elimination of bugs.

3.8k 声望
6.1k 粉丝
宣传栏