赞
踩
效果图
通过FormData对象可以组装一组用 XMLHttpRequest发送请求的键/值对。它可以更灵活方便的发送表单数据,因为可以独立于表单使用。如果你把表单的编码类型设置为multipart/form-data ,则通过FormData传输的数据格式和表单通过submit() 方法传输的数据格式相同。
这是一种常见的移动端上传方式,FormData也是H5新增的 兼容性如下:
Base64是一种基于64个可打印字符来表示二进制数据的表示方法。 由于2的6次方等于64,所以每6个位元为一个单元,对应某个可打印字符。 三个字节有24个位元,对应于4个Base64单元,即3个字节可表示4个可打印字符。
base64可以说是很出名了,就是用一段字符串来描述一个二进制数据,所以很多时候也可以使用base64方式上传。兼容性如下:
一个 Blob对象表示一个不可变的, 原始数据的类似文件对象。Blob表示的数据不一定是一个JavaScript原生格式。 File 接口基于Blob,继承 blob功能并将其扩展为支持用户系统上的文件。
简单说Blob就是一个二进制对象,是原生支持的,兼容性如下:
FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。
FileReader也就是将本地文件转换成base64格式的dataUrl。
准备工作都做完了,那怎样用这些材料完成一件事情呢。
这里要强调的是,考虑到移动端流量很贵,所以有必要对大图片进行下压缩再上传。
图片压缩很简单,将图片用canvas画出来,再使用canvas.toDataUrl方法将图片转成base64格式。
所以图片上传思路大致是:
1.监听一个input(type=‘file’)的onchange事件,这样获取到文件file;
2.将file转成dataUrl;
3.然后根据dataUrl利用canvas绘制图片压缩,然后再转成新的dataUrl;
4.再把dataUrl转成Blob;
5.把Blob append进FormData中;
6.xhr实现上传。
理想很丰满,现实很骨感。
实际上由于手机平台兼容性问题,上面这套流程并不能全都支持。
所以需要根据兼容性判断。
经过试验发现:
1.部分安卓微信浏览器无法触发onchange事件(第一步就特么遇到问题)
这其实安卓微信的一个遗留问题。 查看讨论 解决办法也很简单:input标签 <input type=“file" name=“image” accept="image/gif, image/jpeg, image/png”>要写成就没问题了。
2.部分安卓微信不支持Blob对象
3.部分Blob对象append进FormData中出现问题
4.iOS 8不支持new File Constructor,但是支持input里的file对象。
5.iOS 上经过压缩后的图片可以上传成功 但是size是0 无法打开。
6.部分手机出现图片上传转换问题。
7.安卓手机不支持多选,原因在于multiple属性根本就不支持。
8.多张图片转base64时候卡顿,因为调用了cpu进行了计算。
9.上传图片可以使用base64上传或者formData上传
经过考虑,我们决定做兼容性处理:
这里边两条路,最后都是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); }
多张图片上传方式有三种:
图片队列一张一张上传
图片队列并发全部上传
图片队列并发上传X个,其中一个返回了结果直接触发下一个上传,保证最多有X个请求。
这个一张一张上传好解决,但是问题是上传事件太长了,体验不佳;多张图片全部上传事件变短了,但是并发量太大了,很可能出现问题;最后这个并发上传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步,
1.图片转dataUrl
2.压缩
3.处理数据格式
4.准备数据上传
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
原文地址: https://segmentfault.com/a/1190000010034177?utm_source=tag-newest
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。