公司如今在移動端使用webuploader實現圖片上傳,但最近需求太奇葩了,插件沒法知足咱們的PM
通過商討決定下掉這個插件,使用H5原生的API實現圖片上傳。css
7.3日發佈:單張圖片上傳html
9.29日更新:多張圖片併發上傳react
11.06日更新:常見問題ios
效果圖:
git
上傳圖片這塊有幾個知識點要先了解的。首先是有幾種常見的移動端圖片上傳方式:es6
經過FormData對象能夠組裝一組用 XMLHttpRequest發送請求的鍵/值對。它能夠更靈活方便的發送表單數據,由於能夠獨立於表單使用。若是你把表單的編碼類型設置爲multipart/form-data ,則經過FormData傳輸的數據格式和表單經過submit() 方法傳輸的數據格式相同。github
這是一種常見的移動端上傳方式,FormData也是H5新增的 兼容性以下:
web
Base64是一種基於64個可打印字符來表示二進制數據的表示方法。 因爲2的6次方等於64,因此每6個位元爲一個單元,對應某個可打印字符。 三個字節有24個位元,對應於4個Base64單元,即3個字節可表示4個可打印字符。canvas
base64能夠說是很出名了,就是用一段字符串來描述一個二進制數據,因此不少時候也可使用base64方式上傳。兼容性以下:segmentfault
還有一些對象須要瞭解:
一個 Blob對象表示一個不可變的, 原始數據的相似文件對象。Blob表示的數據不必定是一個JavaScript原生格式。 File 接口基於Blob,繼承 blob功能並將其擴展爲支持用戶系統上的文件。
簡單說Blob就是一個二進制對象,是原生支持的,兼容性以下:
FileReader 對象容許Web應用程序異步讀取存儲在用戶計算機上的文件(或原始數據緩衝區)的內容,使用 File 或 Blob 對象指定要讀取的文件或數據。
FileReader也就是將本地文件轉換成base64格式的dataUrl。
準備工做都作完了,那怎樣用這些材料完成一件事情呢。
這裏要強調的是,考慮到移動端流量很貴,因此有必要對大圖片進行下壓縮再上傳。
圖片壓縮很簡單,將圖片用canvas
畫出來,再使用canvas.toDataUrl
方法將圖片轉成base64格式。
因此圖片上傳思路大體是:
input(type=‘file’)
的onchange
事件,這樣獲取到文件file
;file
轉成dataUrl
;dataUrl
利用canvas
繪製圖片壓縮,而後再轉成新的dataUrl
;dataUrl
轉成Blob
;Blob
append
進FormData
中;xhr
實現上傳。理想很豐滿,現實很骨感。
實際上因爲手機平臺兼容性問題,上面這套流程並不能全都支持。
因此須要根據兼容性判斷。
通過試驗發現:
onchange
事件(第一步就特麼遇到問題)input
標籤 <input type=「file" name="image" accept="image/gif, image/jpeg, image/png」>
要寫成<input type="file" name="image" accept=「image/*」>
就沒問題了。Blob
對象Blob
對象append
進FormData
中出現問題new File Constructor
,可是支持input
裏的file
對象。通過考慮,咱們決定作兼容性處理:
這裏邊兩條路,最後都是File
對象append
進FormData
中實現上傳。
首先有個html
<input type="file" name="image" accept=「image/*」 onchange='handleInputChange'>
而後js以下:
// 全局對象,不一樣function使用傳遞數據 const imgFile = {}; function handleInputChange (event) { // 獲取當前選中的文件 const file = event.target.files[0]; const imgMasSize = 1024 * 1024 * 10; // 10MB // 檢查文件類型 if(['jpeg', 'png', 'gif', 'jpg'].indexOf(file.type.split("/")[1]) < 0){ // 自定義報錯方式 // Toast.error("文件類型僅支持 jpeg/png/gif!", 2000, undefined, false); return; } // 文件大小限制 if(file.size > imgMasSize ) { // 文件大小自定義限制 // Toast.error("文件大小不能超過10MB!", 2000, undefined, false); return; } // 判斷是不是ios if(!!window.navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)){ // iOS transformFileToFormData(file); return; } // 圖片壓縮之旅 transformFileToDataUrl(file); } // 將File append進 FormData function transformFileToFormData (file) { const formData = new FormData(); // 自定義formData中的內容 // type formData.append('type', file.type); // size formData.append('size', file.size || "image/jpeg"); // name formData.append('name', file.name); // lastModifiedDate formData.append('lastModifiedDate', file.lastModifiedDate); // append 文件 formData.append('file', file); // 上傳圖片 uploadImg(formData); } // 將file轉成dataUrl function transformFileToDataUrl (file) { const imgCompassMaxSize = 200 * 1024; // 超過 200k 就壓縮 // 存儲文件相關信息 imgFile.type = file.type || 'image/jpeg'; // 部分安卓出現獲取不到type的狀況 imgFile.size = file.size; imgFile.name = file.name; imgFile.lastModifiedDate = file.lastModifiedDate; // 封裝好的函數 const reader = new FileReader(); // file轉dataUrl是個異步函數,要將代碼寫在回調裏 reader.onload = function(e) { const result = e.target.result; if(result.length < imgCompassMaxSize) { compress(result, processData, false ); // 圖片不壓縮 } else { compress(result, processData); // 圖片壓縮 } }; reader.readAsDataURL(file); } // 使用canvas繪製圖片並壓縮 function compress (dataURL, callback, shouldCompress = true) { const img = new window.Image(); img.src = dataURL; img.onload = function () { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); let compressedDataUrl; if(shouldCompress){ compressedDataUrl = canvas.toDataURL(imgFile.type, 0.2); } else { compressedDataUrl = canvas.toDataURL(imgFile.type, 1); } callback(compressedDataUrl); } } function processData (dataURL) { // 這裏使用二進制方式處理dataUrl const binaryString = window.atob(dataUrl.split(',')[1]); const arrayBuffer = new ArrayBuffer(binaryString.length); const intArray = new Uint8Array(arrayBuffer); const imgFile = this.imgFile; for (let i = 0, j = binaryString.length; i < j; i++) { intArray[i] = binaryString.charCodeAt(i); } const data = [intArray]; let blob; try { blob = new Blob(data, { type: imgFile.type }); } catch (error) { window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder; if (error.name === 'TypeError' && window.BlobBuilder){ const builder = new BlobBuilder(); builder.append(arrayBuffer); blob = builder.getBlob(imgFile.type); } else { // Toast.error("版本太低,不支持上傳圖片", 2000, undefined, false); throw new Error('版本太低,不支持上傳圖片'); } } // blob 轉file const fileOfBlob = new File([blob], imgFile.name); const formData = new FormData(); // type formData.append('type', imgFile.type); // size formData.append('size', fileOfBlob.size); // name formData.append('name', imgFile.name); // lastModifiedDate formData.append('lastModifiedDate', imgFile.lastModifiedDate); // append 文件 formData.append('file', fileOfBlob); uploadImg(formData); } // 上傳圖片 uploadImg (formData) { const xhr = new XMLHttpRequest(); // 進度監聽 xhr.upload.addEventListener('progress', (e)=>{console.log(e.loaded / e.total)}, false); // 加載監聽 // xhr.addEventListener('load', ()=>{console.log("加載中");}, false); // 錯誤監聽 xhr.addEventListener('error', ()=>{Toast.error("上傳失敗!", 2000, undefined, false);}, false); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { const result = JSON.parse(xhr.responseText); if (xhr.status === 200) { // 上傳成功 } else { // 上傳失敗 } } }; xhr.open('POST', '/uploadUrl' , true); xhr.send(formData); }
這個上限沒多久,需求又改了,一張圖也知足不了咱們的PM了,要求改爲多張圖。
多張圖片上傳方式有三種:
這個一張一張上傳好解決,可是問題是上傳事件太長了,體驗不佳;多張圖片所有上傳事件變短了,可是併發量太大了,極可能出現問題;最後這個併發上傳X個,體驗最佳,只是須要仔細想一想如何實現。
最後咱們肯定X = 3或者4。好比說上傳9張圖片,第一次上傳個3個,其中一個請求回來了,當即去上傳第四個,下一個回來上傳第5個,以此類推。
這裏我使用es6的generator函數來實現的,定義一個函數,返回須要上傳的數組:
*uploadGenerator (uploadQueue) { /** * 多張圖片併發上傳控制規則 * 上傳1-max數量的圖片 * 設置一個最大上傳數量 * 保證最大隻有這個數量的上傳請求 * */ // 最多隻有三個請求在上傳 const maxUploadSize = 3; if(uploadQueue.length > maxUploadSize){ const result = []; for(let i = 0; i < uploadQueue.length; i++){ // 第一次return maxUploadSize數量的圖片 if(i < maxUploadSize){ result.push(uploadQueue[i]); if(i === maxUploadSize - 1){ yield result; } } else { yield [uploadQueue[i]]; } } } else { yield uploadQueue.map((item)=>(item)); } }
調用的時候:
// 經過該函數獲取每次要上傳的數組 this.uploadGen = this.uploadGenerator(uploadQueue); // 第一次要上傳的數量 const firstUpload = this.uploadGen.next(); // 真正開始上傳流程 firstUpload.value.map((item)=>{ /** * 圖片上傳分紅5步 * 圖片轉dataUrl * 壓縮 * 處理數據格式 * 準備數據上傳 * 上傳 * * 前兩步是回調的形式 後面是同步的形式 */ this.transformFileToDataUrl(item, this.compress, this.processData); });
這樣將每次上傳幾張圖片的邏輯分離出來。
而後遇到了下一個問題,圖片上傳分紅5步,
這裏面前兩個是回調的形式,最後一個是異步形式。沒法寫成正常函數一個調用一個;並且各個function之間須要共享一些數據,以前把這個數據掛載到this.imgFile上了,可是此次是併發,一個對象無法知足需求了,改爲數組也有不少問題。
因此此次方案是:第一步建立一個要上傳的對象,每次都經過參數交給下一個方法,直到最後一個方法上傳。而且經過回調的方式,將各個步驟串聯起來。Upload完整的代碼以下:
/** * Created by Aus on 2017/7/4. */ import React from 'react' import classNames from 'classnames' import Touchable from 'rc-touchable' import Figure from './Figure' import Toast from '../../../Feedback/Toast/components/Toast' import '../style/index.scss' // 統計img總數 防止重複 let imgNumber = 0; // 生成惟一的id const getUuid = () => { return "img-" + new Date().getTime() + "-" + imgNumber++; }; class Uploader extends React.Component{ constructor (props) { super(props); this.state = { imgArray: [] // 圖片已上傳 顯示的數組 }; this.handleInputChange = this.handleInputChange.bind(this); this.compress = this.compress.bind(this); this.processData = this.processData.bind(this); } componentDidMount () { // 判斷是否有初始化的數據傳入 const {data} = this.props; if(data && data.length > 0){ this.setState({imgArray: data}); } } handleDelete(id) { this.setState((previousState)=>{ previousState.imgArray = previousState.imgArray.filter((item)=>(item.id !== id)); return previousState; }); } handleProgress (id, e) { // 監聽上傳進度 操做DOM 顯示進度 const number = Number.parseInt((e.loaded / e.total) * 100) + "%"; const text = document.querySelector('#text-'+id); const progress = document.querySelector('#progress-'+id); text.innerHTML = number; progress.style.width = number; } handleUploadEnd (data, status) { // 準備一條標準數據 const _this = this; const obj = {id: data.uuid, imgKey: '', imgUrl: '', name: data.file.name, dataUrl: data.dataUrl, status: status}; // 更改狀態 this.setState((previousState)=>{ previousState.imgArray = previousState.imgArray.map((item)=>{ if(item.id === data.uuid){ item = obj; } return item; }); return previousState; }); // 上傳下一個 const nextUpload = this.uploadGen.next(); if(!nextUpload.done){ nextUpload.value.map((item)=>{ _this.transformFileToDataUrl(item, _this.compress, _this.processData); }); } } handleInputChange (event) { const {typeArray, max, maxSize} = this.props; const {imgArray} = this.state; const uploadedImgArray = []; // 真正在頁面顯示的圖片數組 const uploadQueue = []; // 圖片上傳隊列 這個隊列是在圖片選中到上傳之間使用的 上傳完成則清除 // event.target.files是個類數組對象 須要轉成數組方便處理 const selectedFiles = Array.prototype.slice.call(event.target.files).map((item)=>(item)); // 檢查文件個數 頁面顯示的圖片個數不能超過限制 if(imgArray.length + selectedFiles.length > max){ Toast.error('文件數量超出最大值', 2000, undefined, false); return; } let imgPass = {typeError: false, sizeError: false}; // 循環遍歷檢查圖片 類型、尺寸檢查 selectedFiles.map((item)=>{ // 圖片類型檢查 if(typeArray.indexOf(item.type.split('/')[1]) === -1){ imgPass.typeError = true; } // 圖片尺寸檢查 if(item.size > maxSize * 1024){ imgPass.sizeError = true; } // 爲圖片加上位移id const uuid = getUuid(); // 上傳隊列加入該數據 uploadQueue.push({uuid: uuid, file: item}); // 頁面顯示加入數據 uploadedImgArray.push({ // 顯示在頁面的數據的標準格式 id: uuid, // 圖片惟一id dataUrl: '', // 圖片的base64編碼 imgKey: '', // 圖片的key 後端上傳保存使用 imgUrl: '', // 圖片真實路徑 後端返回的 name: item.name, // 圖片的名字 status: 1 // status表示這張圖片的狀態 1:上傳中,2上傳成功,3:上傳失敗 }); }); // 有錯誤跳出 if(imgPass.typeError){ Toast.error('不支持文件類型', 2000, undefined, false); return; } if(imgPass.sizeError){ Toast.error('文件大小超過限制', 2000, undefined, false); return; } // 沒錯誤準備上傳 // 頁面先顯示一共上傳圖片個數 this.setState({imgArray: imgArray.concat(uploadedImgArray)}); // 經過該函數獲取每次要上傳的數組 this.uploadGen = this.uploadGenerator(uploadQueue); // 第一次要上傳的數量 const firstUpload = this.uploadGen.next(); // 真正開始上傳流程 firstUpload.value.map((item)=>{ /** * 圖片上傳分紅5步 * 圖片轉dataUrl * 壓縮 * 處理數據格式 * 準備數據上傳 * 上傳 * * 前兩步是回調的形式 後面是同步的形式 */ this.transformFileToDataUrl(item, this.compress, this.processData); }); } *uploadGenerator (uploadQueue) { /** * 多張圖片併發上傳控制規則 * 上傳1-max數量的圖片 * 設置一個最大上傳數量 * 保證最大隻有這個數量的上傳請求 * */ // 最多隻有三個請求在上傳 const maxUploadSize = 3; if(uploadQueue.length > maxUploadSize){ const result = []; for(let i = 0; i < uploadQueue.length; i++){ // 第一次return maxUploadSize數量的圖片 if(i < maxUploadSize){ result.push(uploadQueue[i]); if(i === maxUploadSize - 1){ yield result; } } else { yield [uploadQueue[i]]; } } } else { yield uploadQueue.map((item)=>(item)); } } transformFileToDataUrl (data, callback, compressCallback) { /** * 圖片上傳流程的第一步 * @param data file文件 該數據會一直向下傳遞 * @param callback 下一步回調 * @param compressCallback 回調的回調 */ const {compress} = this.props; const imgCompassMaxSize = 200 * 1024; // 超過 200k 就壓縮 // 封裝好的函數 const reader = new FileReader(); // ⚠️ 這是個回調過程 不是同步的 reader.onload = function(e) { const result = e.target.result; data.dataUrl = result; if(compress && result.length > imgCompassMaxSize){ data.compress = true; callback(data, compressCallback); // 圖片壓縮 } else { data.compress = false; callback(data, compressCallback); // 圖片不壓縮 } }; reader.readAsDataURL(data.file); } compress (data, callback) { /** * 壓縮圖片 * @param data file文件 數據會一直向下傳遞 * @param callback 下一步回調 */ const {compressionRatio} = this.props; const imgFile = data.file; const img = new window.Image(); img.src = data.dataUrl; img.onload = function () { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); let compressedDataUrl; if(data.compress){ compressedDataUrl = canvas.toDataURL(imgFile.type, (compressionRatio / 100)); } else { compressedDataUrl = canvas.toDataURL(imgFile.type, 1); } data.compressedDataUrl = compressedDataUrl; callback(data); } } processData (data) { // 爲了兼容性 處理數據 const dataURL = data.compressedDataUrl; const imgFile = data.file; const binaryString = window.atob(dataURL.split(',')[1]); const arrayBuffer = new ArrayBuffer(binaryString.length); const intArray = new Uint8Array(arrayBuffer); for (let i = 0, j = binaryString.length; i < j; i++) { intArray[i] = binaryString.charCodeAt(i); } const fileData = [intArray]; let blob; try { blob = new Blob(fileData, { type: imgFile.type }); } catch (error) { window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder; if (error.name === 'TypeError' && window.BlobBuilder){ const builder = new BlobBuilder(); builder.append(arrayBuffer); blob = builder.getBlob(imgFile.type); } else { throw new Error('版本太低,不支持上傳圖片'); } } data.blob = blob; this.processFormData(data); } processFormData (data) { // 準備上傳數據 const formData = new FormData(); const imgFile = data.file; const blob = data.blob; // type formData.append('type', blob.type); // size formData.append('size', blob.size); // append 文件 formData.append('file', blob, imgFile.name); this.uploadImg(data, formData); } uploadImg (data, formData) { // 開始發送請求上傳 const _this = this; const xhr = new XMLHttpRequest(); const {uploadUrl} = this.props; // 進度監聽 xhr.upload.addEventListener('progress', _this.handleProgress.bind(_this, data.uuid), false); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status === 200 || xhr.status === 201) { // 上傳成功 _this.handleUploadEnd(data, 2); } else { // 上傳失敗 _this.handleUploadEnd(data, 3); } } }; xhr.open('POST', uploadUrl , true); xhr.send(formData); } getImagesListDOM () { // 處理顯示圖片的DOM const {max} = this.props; const _this = this; const result = []; const uploadingArray = []; const imgArray = this.state.imgArray; imgArray.map((item)=>{ result.push( <Figure key={item.id} {...item} onDelete={_this.handleDelete.bind(_this)} /> ); // 正在上傳的圖片 if(item.status === 1){ uploadingArray.push(item); } }); // 圖片數量達到最大值 if(result.length >= max ) return result; let onPress = ()=>{_this.refs.input.click();}; // 或者有正在上傳的圖片的時候 不可再上傳圖片 if(uploadingArray.length > 0) { onPress = undefined; } // 簡單的顯示文案邏輯判斷 let text = '上傳圖片'; if(uploadingArray.length > 0){ text = (imgArray.length - uploadingArray.length) + '/' + imgArray.length; } result.push( <Touchable key="add" activeClassName={'zby-upload-img-active'} onPress={onPress} > <div className="zby-upload-img"> <span key="icon" className="fa fa-camera" /> <p className="text">{text}</p> </div> </Touchable> ); return result; } render () { const imagesList = this.getImagesListDOM(); return ( <div className="zby-uploader-box"> {imagesList} <input ref="input" type="file" className="file-input" name="image" accept="image/*" multiple="multiple" onChange={this.handleInputChange} /> </div> ) } } Uploader.propTypes = { uploadUrl: React.PropTypes.string.isRequired, // 圖上傳路徑 compress: React.PropTypes.bool, // 是否進行圖片壓縮 compressionRatio: React.PropTypes.number, // 圖片壓縮比例 單位:% data: React.PropTypes.array, // 初始化數據 其中的每一個元素必須是標準化數據格式 max: React.PropTypes.number, // 最大上傳圖片數 maxSize: React.PropTypes.number, // 圖片最大致積 單位:KB typeArray: React.PropTypes.array, // 支持圖片類型數組 }; Uploader.defaultProps = { compress: true, compressionRatio: 20, data: [], max: 9, maxSize: 5 * 1024, // 5MB typeArray: ['jpeg', 'jpg', 'png', 'gif'], }; export default Uploader
配合Figure組件使用達到文章開頭的效果。
源碼在github上
使用1-2天時間研究如何實現原生上傳圖片,這樣明白原理以後,上傳不再用藉助插件了,
不再怕PM提出什麼奇葩需求了。
同時,也認識了一些陌生的函數。。