產品說:你在系統中新增一個全域性檔案上傳

Aaron發表於2021-10-13

在平時工作過程中,檔案上傳是一個再平常不過的功能了。如果使用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>

感謝大家閱讀這篇文章,文章中如果有什麼問題,大家在下方留言我會及時做出改正。

相關文章