移動端H5實現圖片上傳
需求
公司現在在移動端使用webuploader實現圖片上傳,但最近需求太奇葩了,外掛無法滿足我們的PM
經過商討決定下掉這個外掛,使用H5原生的API實現圖片上傳。
7.3日釋出:單張圖片上傳
9.29日更新:多張圖片併發上傳
效果圖:
基礎知識
上傳圖片這塊有幾個知識點要先了解的。首先是有幾種常見的移動端圖片上傳方式:
FormData
通過FormData物件可以組裝一組用 XMLHttpRequest傳送請求的鍵/值對。它可以更靈活方便的傳送表單資料,因為可以獨立於表單使用。如果你把表單的編碼型別設定為multipart/form-data ,則通過FormData傳輸的資料格式和表單通過submit() 方法傳輸的資料格式相同。
這是一種常見的移動端上傳方式,FormData也是H5新增的 相容性如下:
base64
Base64是一種基於64個可列印字元來表示二進位制資料的表示方法。 由於2的6次方等於64,所以每6個位元為一個單元,對應某個可列印字元。 三個位元組有24個位元,對應於4個Base64單元,即3個位元組可表示4個可列印字元。
base64可以說是很出名了,就是用一段字串來描述一個二進位制資料,所以很多時候也可以使用base64方式上傳。相容性如下:
還有一些物件需要了解:
Blob物件
一個 Blob物件表示一個不可變的, 原始資料的類似檔案物件。Blob表示的資料不一定是一個JavaScript原生格式。 File 介面基於Blob,繼承 blob功能並將其擴充套件為支援使用者系統上的檔案。
簡單說Blob就是一個二進位制物件,是原生支援的,相容性如下:
FileReader物件
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
中出現問題 - iOS 8不支援
new FileConstructor
,但是支援input
裡的file
物件。 - iOS 上經過壓縮後的圖片可以上傳成功 但是size是0 無法開啟。
- 部分手機出現圖片上傳轉換問題,請移步。
上傳思路修改方案
經過考慮,我們決定做相容性處理:
這裡邊兩條路,最後都是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個請求。
這個一張一張上傳好解決,但是問題是上傳事件太長了,體驗不佳;多張圖片全部上傳事件變短了,但是併發量太大了,很可能出現問題;最後這個併發上傳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步,
- 圖片轉dataUrl
- 壓縮
- 處理資料格式
- 準備資料上傳
- 上傳
這裡面前兩個是回撥的形式,最後一個是非同步形式。無法寫成正常函式一個呼叫一個;而且各個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提出什麼奇葩需求了。
同時,也認識了一些陌生的函式。。