移动端图片压缩上传实现

移动端图片压缩上传实现

移动端上传的图片一般都是手机照片,现在的手机都是高清像素,一张图片都在三四兆,直接上传不仅传输速度慢,而且如果用户使用的是流量,势必会耗费大量流量。

H5的各种API在移动端的主流浏览器都得到了很好的支持,比如案例中用到的FileReader、Blob、FormData、canvas等API,所以压缩上传图片在前端已经是必备的操作。

压缩上传基本操作流程:
  1. 图片上传后使用FileReader将文件读取成base64
  2. 创建Image,设置src属性为图片base64
  3. 创建canvas,绘制Image
  4. 调用canvas的toDataURL方法压缩,返回压缩后的base64
  5. 将base64转成Blob对象
  6. 创建FormData对象,append Blob对象,提交给服务端

下面是每一步的具体实现以及一些坑(比如:API的兼容性、IOS图片旋转、底色等),并贴上全部代码。

<input type="file" id="upload" accept='image/*'>

<h3>调用系统录制功能</h3>
<input name='video' type='file' id='video-input' accept='video/*' capture='camcorder' />

<h3>调用系统相机</h3>
<input name='video' type='file' id='video-input' accept='image/*' capture='camcorder' />
import EXIF from 'exif';
一、监听input的change事件,读取成base64。如果照片是竖着拍的,在IOS手机上传后图片会被旋转。这里需要用到一个库EXIF),可以获取相片的属性,比如曝光度、拍照方向、GPS等。图片加载完成后,在压缩前需要解决IOS图片是否被旋转的问题和图片压缩格式的问题。
  • 图片旋转

    1. 问题: IOS竖着拍的照片会旋转。
    2. 解决: 首先创建临时canvas,绘制图片,旋转成正确方向
  • canvas的toDataURL() 参数type的默认值是 “image/png”,如果传入的类型非“image/png”,但是返回的值以“data:image/png”开头,那么该传入的类型是不支持的。把类型统一设成jpeg,也就是统一用canvas.toDataURL('image/jpeg', 0.3) ,压缩默认值 0.92,这里我设的0.3。

let inp = document.getElementById('upload');

inp.onchange = function (event) {
    let file = event.target.files[0];
    let reader = new FileReader();
    let Orientation;

    // 读取文件转base64 
    reader.readAsDataURL(file);

    // 读取完成
    reader.onload = function () {
        let result = this.result;

        /**
          *  result.length 的单位是字节
          *  如果图片小于100K直接上传,反之压缩图片
         */
        if (result.length <= (100 * 1024)) {
            // 直接上传 调用API

        }
        else {
            // 创建image
            let image = new Image();
            image.src = result;

            // 图片加载完成
            image.onload = function () {
                //获取拍照的信息,解决IOS拍出来的照片旋转问题
                EXIF.getData(image, function () {
                    Orientation = EXIF.getTag(this, 'Orientation');
                });

                // 首先旋转成正确位置 再根据大小压缩 然后根据像素判断是否需要通过瓦片绘制
                let canvas;

                // 修复ios拍照上传图片的时被旋转的问题
                if (Orientation !== '' && Orientation !== 1) {
                    // 创建临时canvas  用来调整正确方位
                    canvas = document.createElement('canvas');

                    switch (Orientation) {
                        case 6://需要顺时针(向左)90度旋转
                            console.log(image.width, image.height);
                            rotateImg(image, 'left', canvas);
                            break;
                        case 8://需要逆时针(向右)90度旋转
                            rotateImg(image, 'right', canvas);
                            break;
                        case 3://需要180度旋转
                            rotateImg(image, 'right', canvas);//转两次
                            rotateImg(image, 'right', canvas);
                            break;
                    }
                }
                else {
                    canvas = compress(image);
                }

                // 对缩小比例后的canvas再进行压缩
                let compressData = canvas.toDataURL("image/jpeg", 0.3);  // 默认MIME image/png
                let blob = convertBase64UrlToBlob(compressData);

                // 提交数据
                submitFormData(blob);
            }
        }
    }
}
二、 解决了图片旋转和图片格式问题在压缩前需要解决canvas绘制图片的两个限制和图片格式转换的问题。
  1. 两个限制

    • 问题
    1. 第一是图片的大小:如果图片的大小超过两百万像素,图片也是无法绘制到canvas上的,调用drawImage的时候不会报错,但是你用toDataURL获取图片数据的时候获取到的是空的图片数据。

    2. 第二是canvas的大小有限制,如果canvas的大小大于大概五百万像素(宽 * 高)时,不仅图片画不出来,其他什么东西也都是画不出来的。

    • 解决方法
    1. 第一种限制,处理办法就是瓦片绘制。瓦片绘制,也就是将图片分割成多块绘制到canvas上,代码里的实现是把图片分割成100万像素一块的大小,再绘制到canvas上。

    2. 第二种限制,对图片的宽高进行适当压缩。具体实现以上限四百万像素为基准,如果图片大于四百万像素就压缩到小于四百万像素。

  2. 如果是png转jpg,绘制到canvas上的时候,canvas存在透明区域的话,当转成jpg的时候透明区域会变成黑色,因为canvas的透明像素默认为rgba(0,0,0,0),所以转成jpg就变成rgba(0, 0, 0 ,1)了,也就是透明背景会变成了黑色。解决办法就是绘制之前在canvas上铺一层白色的底色。

function compress(image) {
    let {width, height} = image;

    // 创建canvas 获取上下文
    let canvas = document.createElement('canvas');
    let ctx = canvas.getContext('2d');

    /**
    * 判断像素大小
    * 像素 = 宽 * 高 
    */

    // 如果像素大于400万 则需计算压缩比 压缩至400万以下
    let ratio = (width * height) / 4000000;

    if (ratio > 1) {
        // 倍数开方 (相当于面积为多少倍,则宽高对应的倍数需对面积倍数开方)
        ratio = Math.sqrt(ratio);

        // 宽高对应的值
        width /= ratio;
        height /= ratio;
    }
    else {
        ratio = 1;
    }

    // 画布宽高
    canvas.width = width;
    canvas.height = height;

    // 铺底色
    ctx.fillStyle = '#fff';

    // 绘制矩形
    ctx.fillRect(0, 0, width, height);

    // 如果缩放比例后画布像素仍大于100万像素 则使用瓦片绘制, 反之直接绘制
    let count = width * height / 1000000;

    if (count > 1) {
        // 创建瓦片 获取2d上下文
        let tcanvas = document.createElement('canvas');
        let tctx = tcanvas.getContext('2d');

        /**
         * 瓦片数量 = count的平方 + 1
         * +1不是必须得,是为了瓦片更小,数量更多一些
         */
        count = ~~(Math.sqrt(count) + 1);  // 比如count为2.3 则转成3

        let tWidth = ~~(width / count);
        let tHeight = ~~(height / count);

        // 瓦片的宽高
        tcanvas.width = tWidth;
        tcanvas.height = tHeight;

        for (let i = 0; i < count; i++) {
            for (let j = 0; j < count; j++) {
                tctx.drawImage(image, i * tWidth * ratio, j * tHeight * ratio, tWidth * ratio, tHeight * ratio, 0, 0, tWidth, tHeight);
                console.log(tcanvas.width, tcanvas.height, tcanvas.width * tcanvas.height);
                ctx.drawImage(tcanvas, i * tWidth, j * tHeight, tWidth, tHeight);
            }
        }
    }
    else {
        // 直接绘制
        ctx.drawImage(image, 0, 0, width, height);
    }

    return canvas;
}
  1. 完成图片压缩后,先将base64提取出来,再实例化一个ArrayBuffer,然后将字符串以8位整型的格式传入ArrayBuffer,再通过Blob对象(可能需要兼容Blob),将8位整型的ArrayBuffer转成二进制对象blob,然后把blob对象append到formdata里,再提交给后台。
function convertBase64UrlToBlob(urlData) {
    let bytes = window.atob(urlData.split(',')[1]);

    // 处理异常,将ascii码小于0的转换为大于0
    let ab = new ArrayBuffer(bytes.length);
    let ia = new Uint8Array(ab);
    for (let i = 0; i < bytes.length; i++) {
        ia[i] = bytes.charCodeAt(i);
    }

    // 二进制对象
    return getBlob([ab], "image/jpeg");
}
  1. 兼容Blob对象
/**
* Blob对象的兼容性写法
* @param buffer 数据流
* @param format 表示将会被放入到blob中的数组内容的MIME类型。类型默认 '' 
*/
function getBlob(buffer, format = 'image/jpeg') {
    try {
        return new Blob(buffer, {
            type: format
        });
    }
    catch (e) {
        let blob = new (window.BlobBuilder || window.WebKitBlobBuilder || window.MSBlobBuilder)();

        buffer.forEach(function (buf) {
            blob.append(buf);
        });
        return blob.getBlob(format);
    }
}
  1. 低版本的Android机不支持FormData,需要做兼容处理。首先判断是否需要兼容
function needsFormDataShim() {
    return  ~navigator.userAgent.indexOf('Android')
            && ~navigator.vendor.indexOf('Google')
            && !~navigator.userAgent.indexOf('Chrome')
            && navigator.userAgent.match(/AppleWebKit\/(\d+)/).pop() <= 534;
}
  1. 给不支持FormData上传Blob的android机打补丁,定义boundary分隔符,设置请求体。重写XMLHttpRequest原型的send方法。
function FormDataShim() {
    let o = this,
        // 请求体 
        parts = [],
        // 分隔符
        boundary = Array(5).join('-') + (+new Date() * (1e16 * Math.random())).toString(36),
        oldSend = XMLHttpRequest.prototype.send;

    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');
    };

    // override XHR send()
    XMLHttpRequest.prototype.send = function (val) {
        let fr,
            data,
            oXHR = this;

        if (val === o) {
            // 不能漏最后的\r\n ,否则服务器有可能解析不到参数.
            parts.push(`--${boundary}--\r\n`);

            // 创建Blob对象
            data = getBlob(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);

            // 设置请求头Content-Type的类型和分隔符 服务端是根据Content-Type来解析请求体中
            this.setRequestHeader(
                'Content-Type',
                `multipart/form-data; boundary=${boundary}`
            );

            XMLHttpRequest.prototype.send = oldSend;
        }
        else {
            oldSend.call(this, val);
        }
    };
}
  1. 提交数据。判断是否支持FormData
function submitFormData(blob) {
    let isNeedShim = needsFormDataShim();
    let formdata = isNeedShim ? new FormDataShim() : new FormData();

    formdata.append('imagefile', blob);
    
    if (isNeedShim) {
        let ajax = new XMLHttpRequest();

        ajax.open('POST', '/');
        ajax.onreadystatechange = function() {
            if (ajax.status === 200 && ajax.readyState === 4) {

            }
        }
        ajax.send(formdata);
    }
    else {
        // 调用API
        axios.post('/upload', formdata)
            .then(response => {
                console.log(response);
            })
            .catch(error => {
                console.log(error);
            });

        // axios 会根据提交的文件类型,设置相应的Content-Type类型
    }
}
  1. 旋转图片
/**
* @param 旋转的图片
* @param 方向
* @param 绘制的canvas
*/
function rotateImg(img, direction, canvas) {
    //最小与最大旋转方向,图片旋转4次后回到原方向
    const min_step = 0;
    const max_step = 3;

    if (img == null) return;

    // 缩小比例后的canvas
    let lessCnavas = compress(img);
    let {width, height} = lessCnavas;
    let step = 2;

    if (step == null) {
        step = min_step;
    }

    if (direction == 'right') {
        step++;

        //旋转到原位置,即超过最大值
        step > max_step && (step = min_step);
    } else {
        step--;
        step < min_step && (step = max_step);
    }

    //旋转角度以弧度值为参数
    let degree = (step * 90 * Math.PI) / 180;
    let ctx = canvas.getContext('2d');

    switch (step) {
        case 0:
            canvas.width = width;
            canvas.height = height;
            ctx.drawImage(lessCnavas, 0, 0);
            break;
        case 1:
            canvas.width = height;
            canvas.height = width;
            ctx.rotate(degree);
            ctx.drawImage(lessCnavas, 0, -height);
            break;
        case 2:
            canvas.width = width;
            canvas.height = height;
            ctx.rotate(degree);
            ctx.drawImage(lessCnavas, -width, -height);
            break;
        case 3:
            canvas.width = height;
            canvas.height = width;
            ctx.rotate(degree);
            ctx.drawImage(lessCnavas, -width, 0);
            break;
    }
}

以上就是压缩上传的全部实现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,113评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,644评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,340评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,449评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,445评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,166评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,442评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,105评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,601评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,066评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,161评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,792评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,351评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,352评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,584评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,618评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,916评论 2 344

推荐阅读更多精彩内容