移动端图片上传的实践

TIP

最近在一个项目中需要实现一个移动端上传图片文件的需求,主要需求的是压缩并且按照比例自动裁切图片然后上传。
一听是蛮简单的,因为是在移动端使用,所以完全可以使用 HTML5 的新特性以及一些 API。
主要的思路是这样:

  1. 监听一个 input (type='file')change 事件,然后拿到文件的 file;
  2. file 转成 dataURL
  3. 然后用 canvas 绘制图片,绘制的时候经过算法按比例裁剪;
  4. 然后再把 canvas 转成 dataURL;
  5. 再把 dataURL 转成 blob;
  6. 接着把 blob appendFormData 的实例对象。
  7. 最后上传。

主要用到的 FileReadercanvasFormDataBlob 这几个 API。

开发过程遇到了蛮多坑,特别是在android下的微信浏览器内。

监听 input(type=file) 获取文件内容。

     
     
// html 片段
<input type= "file" id= "file-input" name= "image" accept= "image/gif, image/jpeg, image/png">

对于 typefileinput 我们可以设置 accept 属性来现在我们要上传的文件类型,这里的目的是上传图片文件,所以我们可以设置:accept="image/gif, image/jpeg, image/png"

     
     
// JavaScript
document.getElementById( 'file-input').οnchange= function (event) {
// 通过 event.target 回去 input 元素对象,然后拿到 files list,取第一个 file
let file = event.target.files[ 0];
// compressImage 在下面解释,它接受三个参数,文件、裁剪的长宽比例,回调函数(回调函数获得一个 FormData 对象,文件已经存在里面了);
compressImage(file, [ 1, 1], (targetFormData) => {
//...... 这里获取到了 targetFormData,就可以直接使用它上传了
});
};

fileToDataURL: file 转成 dataURL

这里用到的是 FileReader 这个 API。
https://developer.mozilla.org/en-US/docs/Web/API/FileReader

     
     
/**
* file 转成 dataURL
* @param file 文件
* @param callback 回调函数
*/
function fileToDataURL (file, callback) {
const reader = new window.FileReader();
reader.onload = function (e) {
callback(e.target.result);
};
reader.readAsDataURL(file);
}

compressDataURL:dataURL 图片绘制 canvas,然后经过处理(裁剪 & 压缩)再转成 dataURL

一开始是这样的

  1. 我们需要创建一个 Image 对象,然后把 src 设置成 dataURL ,获取到这张图片;
  2. 我们需要创建一个 canvas 元素,用来处理绘制图片;
  3. 获取裁剪的长宽比例,然后判断图片的实际长宽比例,按照最大化偏小的长或宽然后另一边采取中间部分,和 css 把 background 设置 center / cover 一个道理;
  4. 调用ctx.drawImage绘制图片;
  5. 使用 canvas.toDataURLcanvans 转成 dataURL
     
     
/**
* 使用 canvas 压缩处理 dataURL
* @param dataURL
* @param ratio 比例
* @param callback
*/
function compressDataURL (dataURL, ratio, callback) {
// 1
const img = new window.Image();
img.src = dataURL;
// 2
const canvas = document.createElement( 'canvas');
const ctx = canvas.getContext( '2d');
// 3
canvas.width = 100 * ratio[ 0];
canvas.height = 100 * ratio[ 2];
const RATIO = canvas.width / canvas.height;
let cutx = 0;
let cuty = 0;
let cutw = img.width;
let cuth = img.height;
if (cutw / cuth > RATIO) {
// 宽超过比例了]]
let realw = cuth * RATIO;
cutx = (cutw - realw) / 2;
cutw = realw;
} else if (cutw / cuth < RATIO) {
// 长超过比例了]]
let realh = cutw / RATIO;
cuty = (cuth - realh) / 2;
cuth = realh;
}
// 4
ctx.drawImage(img, cutx, cuty, cutw, cuth, 0, 0, canvas.width, canvas.height);
const ndata = canvas.toDataURL( 'image/jpeg', 1);
callback(ndata);
}

一切的运行在pc端的chrome浏览器下模拟都很好,但是在移动端测试的时候发现 canvas 无法绘制出图片,发现是 img 设置 src 有延迟,导致还没获取到图片图像就开始绘制。
改进:监听 img.onload 事件来处理之后的操作:

     
     
/**
* 使用 canvas 压缩 dataURL
* @param dataURL
* @param ratio
* @param callback
*/
function compressDataURL (dataURL, ratio, callback) {
const img = new window.Image();
img.src = dataURL;
// onload
img.onload = function () {
const canvas = document.createElement( 'canvas');
const ctx = canvas.getContext( '2d');
canvas.width = 100 * ratio.width;
canvas.height = 100 * ratio.height;
const RATIO = canvas.width / canvas.height;
let cutx = 0;
let cuty = 0;
let cutw = img.width;
let cuth = img.height;
if (cutw / cuth > RATIO) {
// 宽超过比例了]]
let realw = cuth * RATIO;
cutx = (cutw - realw) / 2;
cutw = realw;
} else if (cutw / cuth < RATIO) {
// 长超过比例了]]
let realh = cutw / RATIO;
cuty = (cuth - realh) / 2;
cuth = realh;
}
ctx.drawImage(img, cutx, cuty, cutw, cuth, 0, 0, canvas.width, canvas.height);
const ndata = canvas.toDataURL( 'image/jpeg', 1);
callback(ndata);
};
}

dataURLtoBlob:dataURL 转成 Blob

这一步我们把 dataURL 转成 Blob

     
     
/**
* dataURL 转成 blob
* @param dataURL
* @return blob
*/
function dataURLtoBlob (dataURL) {
let binaryString = atob(dataURL.split( ',')[ 1]);
let arrayBuffer = new ArrayBuffer(binaryString.length);
let intArray = new Uint8Array(arrayBuffer);
let mime = dataURL.split( ',')[ 0].match( /:(.*?);/)[ 1]
for ( let i = 0, j = binaryString.length; i < j; i++) {
intArray[i] = binaryString.charCodeAt(i);
}
let data = [intArray];
let result = new Blob(data, { type: mime });
return result;
}

很完美了吗,在pc端模拟成功,在移动端chrome浏览器测试成功,但是在微信浏览器中失败,经过 try...catch 发现是在 new Blob 的时候失败。
查看之后发现是这个 API 对 Android 的支持还不明。
解决方法是利用 BlobBuilder 这个老 API 来解决:https://developer.mozilla.org/en-US/docs/Web/API/BlobBuilder
因为这个 API 已经被遗弃,不同机型和安卓版本兼容性不一致,所以需要一个判断。
解决方法:

     
     
/**
* dataURL 转成 blob
* @param dataURL
* @return blob
*/
function dataURLtoBlob (dataURL) {
let binaryString = atob(dataURL.split( ',')[ 1]);
let arrayBuffer = new ArrayBuffer(binaryString.length);
let intArray = new Uint8Array(arrayBuffer);
let mime = dataURL.split( ',')[ 0].match( /:(.*?);/)[ 1]
for ( let i = 0, j = binaryString.length; i < j; i++) {
intArray[i] = binaryString.charCodeAt(i);
}
let data = [intArray];
let result;
try {
result = new Blob(data, { type: mime });
} catch (error) {
window.BlobBuilder = window.BlobBuilder ||
window.WebKitBlobBuilder ||
window.MozBlobBuilder ||
window.MSBlobBuilder;
if (error.name === 'TypeError' && window.BlobBuilder){
var builder = new BlobBuilder();
builder.append(arrayBuffer);
result = builder.getBlob( type);
} else {
throw new Error( '没救了');
}
}
return result;
}

把获取到的 blob append 到 FormData 实例,执行回调

这一步使用到我们之前的东西。

     
     
/**
* 压缩图片
* @param file 图片文件
* @param ratio 比例
* @param callback 回调,得到一个 包含文件的 FormData 实例
*/
function compressImage (file, ratio, callback) {
fileToDataURL(file, (dataURL) => {
compressDataURL(dataURL, ratio, (newDataURL) => {
const newBlob = dataURLtoBlob(newDataURL);
const oData = new FormData();
oData.append( 'file', blob);
callback(oData);
});
});
}

回到第一步,上传文件

     
     
// JavaScript
document.getElementById( 'file-input').οnchange= function (event) {
// 通过 event.target 回去 input 元素对象,然后拿到 files list,取第一个 file
let file = event.target.files[ 0];
// 接受三个参数,文件、裁剪的长宽比例,回调函数(回调函数获得一个 FormData 对象,文件已经存在里面了);
compressImage(file, [ 1, 1], (targetFormData) => {
let xhr = new XMLHttpRequest();
// 进度监听
// xhr.upload.addEventListener('progress', progFoo, false);
// 加载监听
// xhr.addEventListener('load', loadFoo, false);
// 错误监听
// xhr.addEventListener('error', errorFoo, false);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// 上传成功,获取到结果 results
let results = JSON.parse(xhr.responseText);
// ......
}
} else {
// 上传失败
}
}
};
xhr.open( 'POST', '/api/upload', true);
xhr.send(targetFormData);
});
};

一切似乎都很完美,pc 端模拟测试通过,但是到移动端却发现上传了一个空文件,这不科学!!!
查文档后发现这么一句话:

Note: XHR in Android 4.0 sends empty content for FormData with blob.

简直蒙蔽。
在 上找到了解决方案:http://stackoverflow.com/questions/15639070/empty-files-uploaded-in-android-native-browser/28809955#28809955

通过自己包装 FormDataShim 和重写 XMLHttpRequest.prototype.send 函数:

     
     
// Android上的AppleWebKit 534以前的内核存在一个Bug,
// 导致FormData加入一个Blob对象后,上传的文件是0字节
// QQ X5浏览器也有这个BUG
var needsFormDataShim = ( function () {
var bCheck = ~navigator.userAgent.indexOf( 'Android') &&
~navigator.vendor.indexOf( 'Google') &&
!~navigator.userAgent.indexOf( 'Chrome');
return bCheck && navigator.userAgent.match( /AppleWebKit\/(\d+)/).pop() <= 534 || /MQQBrowser/g.test(navigator.userAgent);
})();
// 重写 Blob 构造函数,在 XMLHttpRequest.prototype.send 中会使用到
var BlobConstructor = (( function () {
try {
new Blob();
return true;
} catch (e) {
return false;
}
})()) ? window.Blob : function (parts, opts) {
let bb = new (
window.BlobBuilder ||
window.WebKitBlobBuilder ||
window.MSBlobBuilder ||
window.MozBlobBuilder
);
parts.forEach( function (p) {
bb.append(p);
});
return bb.getBlob(opts ? opts.type : undefined);
};
// 手动包装 FormData 同时重写 XMLHttpRequest.prototype.send
var FormDataShim = ( function () {
var formDataShimNums = 0;
return function FormDataShim () {
var o = this;
// Data to be sent
let parts = [];
// Boundary parameter for separating the multipart values
let boundary = Array( 21).join( '-') + (+ new Date() * ( 1e16 * Math.random())).toString( 36);
// Store the current XHR send method so we can safely override it
let oldSend = XMLHttpRequest.prototype.send;
this.getParts = function () {
return parts.toString();
};
this.append = function (name, value, filename) {
parts.push( '--' + boundary + '\r\nContent-Disposition: form-data; name="' + name + '"');
if (value instanceof Blob) {
parts.push( '; filename="' + (filename || 'blob') + '"\r\nContent-Type: ' + value.type + '\r\n\r\n');
parts.push(value);
} else {
parts.push( '\r\n\r\n' + value);
}
parts.push( '\r\n');
};
formDataShimNums++;
XMLHttpRequest.prototype.send = function (val) {
let fr;
let data;
let oXHR = this;
if (val === o) {
// Append the final boundary string
parts.push( '--' + boundary + '--\r\n');
// Create the blob
data = new BlobConstructor(parts);
// Set up and read the blob into an array to be sent
fr = new FileReader();
fr.onload = function () {
oldSend.call(oXHR, fr.result);
};
fr.onerror = function (err) {
throw err;
};
fr.readAsArrayBuffer(data);
// Set the multipart content type and boudary
this.setRequestHeader( 'Content-Type', 'multipart/form-data; boundary=' + boundary);
formDataShimNums--;
if (formDataShimNums === 0) {
XMLHttpRequest.prototype.send = oldSend;
}
} else {
oldSend.call( this, val);
}
};
};
})();

SUCCESS

重写 compressImage

     
     
/**
* 压缩图片
* @param file 图片文件
* @param ratio 比例
* @param callback 回调,得到一个 包含文件的 FormData 实例
*/
function compressImage (file, ratio, callback) {
fileToDataURL(file, (dataURL) => {
compressDataURL(dataURL, ratio, (newDataURL) => {
const newBlob = dataURLtoBlob(newDataURL);
// 判断是否需要我们之前的重写
let NFormData = needsFormDataShim() ? FormDataShim : window.FormData;
const oData = new NFormData();
oData.append( 'file', blob);
callback(oData);
});
});
}

到这一步总算成功。


参考:
http://stackoverflow.com/questions/15639070/empty-files-uploaded-in-android-native-browser/28809955#28809955
http://www.alloyteam.com/2015/04/ru-he-zai-yi-dong-web-shang-shang-chuan-wen-jian/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值