移動端H5實現圖片上傳

需求

公司如今在移動端使用webuploader實現圖片上傳,但最近需求太奇葩了,插件沒法知足咱們的PM
通過商討決定下掉這個插件,使用H5原生的API實現圖片上傳。css

7.3日發佈:單張圖片上傳html

9.29日更新:多張圖片併發上傳react

11.06日更新:常見問題ios

效果圖:
uploadergit

基礎知識

上傳圖片這塊有幾個知識點要先了解的。首先是有幾種常見的移動端圖片上傳方式:es6

FormData

經過FormData對象能夠組裝一組用 XMLHttpRequest發送請求的鍵/值對。它能夠更靈活方便的發送表單數據,由於能夠獨立於表單使用。若是你把表單的編碼類型設置爲multipart/form-data ,則經過FormData傳輸的數據格式和表單經過submit() 方法傳輸的數據格式相同。github

這是一種常見的移動端上傳方式,FormData也是H5新增的 兼容性以下:
clipboard.pngweb

base64

Base64是一種基於64個可打印字符來表示二進制數據的表示方法。 因爲2的6次方等於64,因此每6個位元爲一個單元,對應某個可打印字符。 三個字節有24個位元,對應於4個Base64單元,即3個字節可表示4個可打印字符。canvas

base64能夠說是很出名了,就是用一段字符串來描述一個二進制數據,因此不少時候也可使用base64方式上傳。兼容性以下:segmentfault

clipboard.png

還有一些對象須要瞭解:

Blob對象

一個 Blob對象表示一個不可變的, 原始數據的相似文件對象。Blob表示的數據不必定是一個JavaScript原生格式。 File 接口基於Blob,繼承 blob功能並將其擴展爲支持用戶系統上的文件。

簡單說Blob就是一個二進制對象,是原生支持的,兼容性以下:

clipboard.png

FileReader對象

FileReader 對象容許Web應用程序異步讀取存儲在用戶計算機上的文件(或原始數據緩衝區)的內容,使用 File 或 Blob 對象指定要讀取的文件或數據。

FileReader也就是將本地文件轉換成base64格式的dataUrl。

clipboard.png

圖片上傳思路

準備工做都作完了,那怎樣用這些材料完成一件事情呢。
這裏要強調的是,考慮到移動端流量很貴,因此有必要對大圖片進行下壓縮再上傳。
圖片壓縮很簡單,將圖片用canvas畫出來,再使用canvas.toDataUrl方法將圖片轉成base64格式。
因此圖片上傳思路大體是:

  1. 監聽一個input(type=‘file’)onchange事件,這樣獲取到文件file
  2. file轉成dataUrl;
  3. 而後根據dataUrl利用canvas繪製圖片壓縮,而後再轉成新的dataUrl
  4. 再把dataUrl轉成Blob
  5. Blob appendFormData中;
  6. xhr實現上傳。

手機兼容性問題

理想很豐滿,現實很骨感。
實際上因爲手機平臺兼容性問題,上面這套流程並不能全都支持。
因此須要根據兼容性判斷。

通過試驗發現:

  1. 部分安卓微信瀏覽器沒法觸發onchange事件(第一步就特麼遇到問題)
    這其實安卓微信的一個遺留問題。 查看討論 解決辦法也很簡單:input標籤 <input type=「file" name="image" accept="image/gif, image/jpeg, image/png」>要寫成<input type="file" name="image" accept=「image/*」>就沒問題了。
  2. 部分安卓微信不支持Blob對象
  3. 部分Blob對象appendFormData中出現問題
  4. iOS 8不支持new File Constructor,可是支持input裏的file對象。
  5. iOS 上通過壓縮後的圖片能夠上傳成功 可是size是0 沒法打開。
  6. 部分手機出現圖片上傳轉換問題,請移步
  7. 安卓手機不支持多選,緣由在於multiple屬性根本就不支持。
  8. 多張圖片轉base64時候卡頓,由於調用了cpu進行了計算。
  9. 上傳圖片可使用base64上傳或者formData上傳

上傳思路修改方案

通過考慮,咱們決定作兼容性處理:

clipboard.png

這裏邊兩條路,最後都是File對象appendFormData中實現上傳。

代碼實現

首先有個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了,要求改爲多張圖。

多張圖片上傳方式有三種:

  1. 圖片隊列一張一張上傳
  2. 圖片隊列併發所有上傳
  3. 圖片隊列併發上傳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

配合Figure組件使用達到文章開頭的效果。
源碼在github

總結

使用1-2天時間研究如何實現原生上傳圖片,這樣明白原理以後,上傳不再用藉助插件了,
不再怕PM提出什麼奇葩需求了。
同時,也認識了一些陌生的函數。。

參考資料

  1. 移動端圖片上傳的實踐
  2. 移動端H5圖片上傳的那些坑
  3. 文件上傳那些事兒
  4. 如何給一個Blob上傳的FormData一個文件名?
  5. 移動端H5圖片壓縮
相關文章
相關標籤/搜索