Vue封裝一個簡單輕量的上傳檔案元件

厚德博學發表於2018-03-20

一、之前遇到的一些問題

專案中多出有上傳檔案的需求,使用現有的UI框架實現的過程中,不知道什麼原因,總會有一些莫名其妙的bug。比如用某上傳元件,明明註明(:multiple="false"),可實際上還是能多選,上傳的時候依然傳送了多個檔案;又比如只要加上了(:file-list="fileList")屬性,希望能手動控制上傳列表的時候,上傳事件this.refs.[upload(元件ref)].submit()就不起作用了,傳不了。總之,懶得再看它怎麼實現了,我用的是功能,介面本身還是要重寫的,如果堅持用也會使專案多很多不必要的邏輯、樣式程式碼……

之前用Vue做專案用的檢視框架有element-ui,團隊內部作為補充的zp-ui,以及iview。框架是好用,但是針對自己的專案往往不能全部拿來用,尤其是我們的設計妹子出的介面與現有框架差異很大,改原始碼效率低又容易導致未知的bug,於是自己就抽時間封裝了這個上傳元件。

二、程式碼與介紹

父元件


<template>
  <div class="content">
		<label for="my-upload">
			<span>上傳</span>
		</label>
    <my-upload
	    ref="myUpload"
	    :file-list="fileList"
	    action="/uploadPicture"
	    :data="param"
	    :on-change="onChange"
	    :on-progress="uploadProgress"
	    :on-success="uploadSuccess"
	    :on-failed="uploadFailed"
	    multiple
	    :limit="5"
	    :on-finished="onFinished">
    </my-upload>
    <button @click="upload" class="btn btn-xs btn-primary">Upload</button>
  </div>
</template>

<script>
import myUpload from './components/my-upload'
export default {
  name: 'test',
  data(){
  	return {
  		fileList: [],//上傳檔案列表,無論單選還是支援多選,檔案都以列表格式儲存
  		param: {param1: '', param2: '' },//攜帶引數列表
  	}
  },
  methods: {
  	onChange(fileList){//監聽檔案變化,增減檔案時都會被子元件呼叫
  		this.fileList = [...fileList];
  	},
  	uploadSuccess(index, response){//某個檔案上傳成功都會執行該方法,index代表列表中第index個檔案
  		console.log(index, response);
  	},
  	upload(){//觸發子元件的上傳方法
  		this.$refs.myUpload.submit();
  	},
  	removeFile(index){//移除某檔案
  		this.$refs.myUpload.remove(index);
  	},
  	uploadProgress(index, progress){//上傳進度,上傳時會不斷被觸發,需要進度指示時會很有用
  		const{ percent } = progress;
  		console.log(index, percent);
  	},
  	uploadFailed(index, err){//某檔案上傳失敗會執行,index代表列表中第index個檔案
  		console.log(index, err);
  	},
  	onFinished(result){//所有檔案上傳完畢後(無論成敗)執行,result: { success: 成功數目, failed: 失敗數目 }
  		console.log(result);
  	}
  },
  components: {
  	myUpload
  }
}
</script>

複製程式碼

父元件處理與業務有關的邏輯,我特意加入索引引數,便於介面展示上傳結果的時候能夠直接操作第幾個值,並不是所有方法都必須的,視需求使用。

子元件

<template>
<div>
	<input style="display:none" @change="addFile" :multiple="multiple" type="file" :name="name" id="my-upload"/>
</div>
</template>

複製程式碼

上傳檔案,html部分就這麼一對兒標籤,不喜歡複雜囉嗦

<script>
export default {
	name: 'my-upload',
	props: {
		name: String,
		action: {
			type: String,
			required: true
		},
		fileList: {
			type: Array,
			default: []
		},
		data: Object,
		multiple: Boolean,
		limit: Number,
		onChange: Function,
		onBefore: Function,
		onProgress: Function,
		onSuccess: Function,
		onFailed: Function,
		onFinished: Function
	},
	methods: {}//下文主要是methods的介紹,此處先省略
}
</script>

複製程式碼

這裡定義了父元件向子元件需要傳遞的屬性值,注意,這裡把方法也當做了屬性傳遞,都是可以的。

自己寫的元件,沒有像流行框架釋出的那樣完備和全面,另外針對開頭提到的繫結file-list就不能上傳了的問題(更可能是我的姿勢不對),本人也想極力解決掉自身遇到的這個問題,所以希望能對檔案列表有絕對的控制權,除了action,把file-list也作為父元件必須要傳遞的屬性。(屬性名父元件使用“-”連線,對應子元件prop中的駝峰命名)

三、主要的上傳功能

methods: {
    addFile, remove, submit, checkIfCanUpload
}
複製程式碼

methods內一共4個方法,新增檔案、移除檔案、提交、檢測(上傳之前的檢驗),下面一一講述:

1.新增檔案

addFile({target: {files}}){//input標籤觸發onchange事件時,將檔案加入待上傳列表
	for(let i = 0, l = files.length; i < l; i++){
		files[i].url = URL.createObjectURL(files[i]);//建立blob地址,不然圖片怎麼展示?
		files[i].status = 'ready';//開始想給檔案一個欄位表示上傳進行的步驟的,後面好像也沒去用......
	}
	let fileList = [...this.fileList];
	if(this.multiple){//多選時,檔案全部壓如列表末尾
		fileList = [...fileList, ...files];
		let l = fileList.length;
		let limit = this.limit;
		if(limit && typeof limit === "number" && Math.ceil(limit) > 0 && l > limit){//有數目限制時,取後面limit個檔案
			limit = Math.ceil(limit);
//			limit = limit > 10 ? 10 : limit;
			fileList = fileList.slice(l - limit);
		}
	}else{//單選時,只取最後一個檔案。注意這裡沒寫成fileList = files;是因為files本身就有多個元素(比如選擇檔案時一下子框了一堆)時,也只要一個
		fileList = [files[0]];
	}
	this.onChange(fileList);//呼叫父元件方法,將列表快取到上一級data中的fileList屬性
	},
	
複製程式碼

2.移除檔案

這個簡單,有時候在父元件叉掉某檔案的時候,傳一個index即可。

remove(index){
	let fileList = [...this.fileList];
	if(fileList.length){
		fileList.splice(index, 1);
		this.onChange(fileList);
	}
},

複製程式碼

3.提交上傳

這裡使用了兩種方式,fetch和原生方式,由於fetch不支援獲取上傳的進度,如果不需要進度條或者自己模擬進度或者XMLHttpRequest物件不存在的時候,使用fetch請求上傳邏輯會更簡單一些

submit(){
	if(this.checkIfCanUpload()){
		if(this.onProgress && typeof XMLHttpRequest !== 'undefined')
			this.xhrSubmit();
		else
			this.fetchSubmit();
	}
},

複製程式碼

4.基於上傳的兩套邏輯,這裡封裝了兩個方法xhrSubmit和fetchSubmit

fetchSubmit
fetchSubmit(){
	let keys = Object.keys(this.data), values = Object.values(this.data), action = this.action;
	const promises = this.fileList.map(each => {
		each.status = "uploading";
		let data = new FormData();
		data.append(this.name || 'file', each);
		keys.forEach((one, index) => data.append(one, values[index]));
		return fetch(action, {
		  method: 'POST',
		  headers: {
		  	"Content-Type" : "application/x-www-form-urlencoded"
		  },
		  body: data
		}).then(res => res.text()).then(res => JSON.parse(res));//這裡res.text()是根據返回值型別使用的,應該視情況而定
	});
	Promise.all(promises).then(resArray => {//多執行緒同時開始,如果併發數有限制,可以使用同步的方式一個一個傳,這裡不再贅述。
		let success = 0, failed = 0;
		resArray.forEach((res, index) => {
			if(res.code == 1){
				success++;                  //統計上傳成功的個數,由索引可以知道哪些成功了
				this.onSuccess(index, res);
			}else if(res.code == 520){      //約定失敗的返回值是520
				failed++;                  //統計上傳失敗的個數,由索引可以知道哪些失敗了
				this.onFailed(index, res);
			}
		});
		return { success, failed };     //上傳結束,將結果傳遞到下文
	}).then(this.onFinished);           //把上傳總結果返回
},


複製程式碼
xhrSubmit
xhrSubmit(){
    const _this = this;
	let options = this.fileList.map((rawFile, index) => ({
		file: rawFile,
		data: _this.data,
        filename: _this.name || "file",
        action: _this.action,
        onProgress(e){
          _this.onProgress(index, e);//閉包,將index存住
        },
        onSuccess(res){
          _this.onSuccess(index, res);
        },
        onError(err){
          _this.onFailed(index, err);
        }
    }));
	let l = this.fileList.length;
	let send = async options => {
		for(let i = 0; i < l; i++){
			await _this.sendRequest(options[i]);//這裡用了個非同步方法,按次序執行this.sendRequest方法,引數為檔案列表包裝的每個物件,this.sendRequest下面緊接著介紹
		}
	};
	send(options);
},

複製程式碼

這裡借鑑了element-ui的上傳原始碼

sendRequest(option){

	const _this = this;
    upload(option);

	function getError(action, option, xhr) {
	  var msg = void 0;
	  if (xhr.response) {
	    msg = xhr.status + ' ' + (xhr.response.error || xhr.response);
	  } else if (xhr.responseText) {
	    msg = xhr.status + ' ' + xhr.responseText;
	  } else {
	    msg = 'fail to post ' + action + ' ' + xhr.status;
	  }

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

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

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

	function upload(option) {
	  if (typeof XMLHttpRequest === 'undefined') {
	    return;
	  }

	  var xhr = new XMLHttpRequest();
	  var 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(e);
	    };
	  }

	  var formData = new FormData();

	  if (option.data) {
	    Object.keys(option.data).map(function (key) {
	      formData.append(key, option.data[key]);
	    });
	  }

	  formData.append(option.filename, option.file);

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

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

	    option.onSuccess(getBody(xhr));
	  };

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

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

	  var headers = option.headers || {};

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


複製程式碼

最後把請求前的校驗加上

checkIfCanUpload(){
	return this.fileList.length ? (this.onBefore && this.onBefore() || !this.onBefore) : false;
},

複製程式碼

如果父元件定義了onBefore方法且返回了false,或者檔案列表為空,請求就不會傳送。

程式碼部分完了,使用時只要有了on-progress屬性並且XMLHttpRequest物件可訪問,就會使用原生方式傳送請求,否則就用fetch傳送請求(不展示進度)。

相關文章