乐趣区

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

在平时工作过程中,文件上传是一个再平时不过的性能了。如果应用 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>

感激大家浏览这篇文章,文章中如果有什么问题,大家在下方留言我会及时做出改过。

退出移动版