在日常的前端需求中,对文件的处理可能不常用到会比较陌生。例如在浏览器端计算文件的 MD5、压缩图片…… 还有这种操作??没错,当我们要写一个功能完备的 OSS 上传 组件时,可能都会涉及到。 一睹为快:@alife/react-oss-uploadreact-oss-upload@alife/qn-upload示例 demo

前端需求

  1. 实现一个 React 上传组件,文件上传到阿里云 OSS 私人空间(上传后的链接有时效性,防止客户的隐私图片泄漏);
  2. 可限制上传文件大小、个数,可配置,实时显示上传进度;
  3. 为节省如果上传图片超过了限制的大小,要在上传之前 压缩图片。如果压缩后还是超过限制大小,报错提示;
  4. 支持在 input 里 Ctrl + V 直接上传剪贴板截图;
  5. 如果文件已经在 OSS 中存在,实现 秒传(参考网盘的秒传功能);

整体流程与技术方案

大多数上传方案一般是应用服务端给前端开一个上传接口,文件流可能是:

但这种方案有三个缺点:

  1. 上传慢。先上传到应用服务器,再上传到 OSS,网络传送多了一倍。如果数据直传到 OSS,不走应用服务器,速度将大大提升,而且 OSS 是采用 BGP 带宽,能保证各地各运营商的速度;
  2. 扩展性不好。如果后续用户多了,应用服务器会成为瓶颈;
  3. 费用高。由于 OSS 上传流量是免费的。如果数据直传到 OSS,不走应用服务器,可以省下几台应用服务器;

所以,既然文件是存到 OSS,其实文件流没必要经过应用服务器, 前端直接传到 OSS 即可。这既解决了以上问题,节省了不必要的网络流量,又能将集中式变成分布式,为应用服务器分担压力。

但这也带来了几个问题:

  1. 实现秒传,基本原理实际就是计算本地文件的 MD5,如果远程 OSS 已存在相同的文件(MD5 比对),就不用再上传一遍了。前端如何计算文件 MD5 呢?
  2. 前端如何实现图片的压缩?
  3. 安全如何保证?

首先解决安全问题。如果采用客户端直接签名有一个很严重的安全隐患,OSS AccessId/AccessKey 暴露在前端页面,其他人可以随意拿到 AccessId/AccessKey 然后上传文件,这是非常不安全的做法。 解决方案:这里采用基于 session 的服务端签名后直传的方案:

剩下的两个问题:前端计算 MD5、实现图片压缩,下面我们逐个搞定。 最终的整体流程时序图如下(省略了服务端与 OSS 交互细节): 上传流程

前端计算 MD5

spark-md5

这个可以帮到我们:spark-md5。要实现在浏览器端计算 MD5,需要浏览器支持 FileAPI 才可以(具体兼容性可以看这里)。 实现原理:

具体可以看spark-md5 源码实现,以及我的使用方式。官方还提供了一个在线 demo

性能

你一定想知道,在浏览器里读文件流计算 MD5,性能好吗?如果读大文件,浏览器会不会卡死崩溃?这也是我之前担心的,如果浏览器为此而 crash,那体验太差了。

md5 算法有很多种实现。spark-md5 是基于 Joseph’s Myers 的 JKM md5 实现,这也是目前最快的实现。性能对比。 经过在不同环境中简单测试,可以放心了。结果如下:

文件大小 虚拟机计算 MD5 耗时(ms) MacOS 计算 MD5 耗时(ms)
291K 14 8
4.1M 168 88
27.5M 932 542
52.6M 1792 976
82.6M 2621 1473
880.7M 26740 15501

所以看业务场景,如果经常有大于 100M 文件的场景,为了良好体验,建议给用户一个 loading 或提示。

前端压缩图片

传统的方案一般是前端原样上传文件,由应用服务器来做图片压缩处理。但现在图片大小动辄就有几 MB,有些场景下为了节省流量和存储空间,更好的做法是在上传之前就进行一次压缩。有了 canvas 之后,前端对图片的处理也能游刃有余。

原理

前端压缩图片的原理:

  1. 利用 Canvas 2D ContextdrawImage( ) 方法传入原始图片,绘制新的图片,设置绘制的图片的 width、height;
  2. 然后通过 canvas 的 toDataURL( ) 方法,设置压缩比率,生成压缩后的 dataURI ( MIME + base64 字符串);
  3. 最后将 dataURI 转换成 Blob (二进制 jpeg 文件)。

部分核心代码:

function html5ImgCompress(file) {
    let cvs = document.createElement('canvas');
    let ctx = cvs.getContext('2d');
    let img = new Image();
    let fileURL = window.URL.createObjectURL(file);
    img.src = fileURL;
    img.onload = () => {
        cvs.width = img.width;
        cvs.height = img.height;
        ctx.drawImage(img, 0, 0, cvs.width, cvs.height); // 绘制新图片
        dataURI = cvs.toDataURL('image/jpeg', 0.6); // 设置压缩比率
        window.URL.revokeObjectURL(img.src); // 为了性能最好主动释放
    };
    let newImg = convertToBinary(dataURI, { type: 'image/jpeg' });
    resolve(newImg);
}

代码说明:

压缩效果

明白基于 canvas 压缩图片原理之后,有没有已经实现了的压缩工具呢?还真有:html5ImgCompressdemo)。 有一点遗憾的是作者没有发 npm 包,但是提供了几个 js bundle,可以作为项目 vendor 资源使用。

如果想看到不同压缩比率下的效果,我也做了一个简单的 demo,可以输入不同的质量参数,在线看到压缩后的效果:canvas-compressor demo

对一个大小 4.5MB,尺寸 2387 × 3264 的原图进行压缩,如果限制宽度最大 1000px,不同压缩比率下,生成文件大小对比如下:

压缩比率 文件大小(KB)
0.1 22
0.5 63
0.8 117
0.92(默认) 203
0.95 273
1 1029

上传功能

UI 和功能分离

初期刚完成这个上传组件的时候,UI 和功能代码耦合度比较高,复用性很差。后来对 UI 和功能进行了分离,使业务和功能尽量解耦。

所以现在提供两个组件:

  1. @alife/react-oss-upload:抽出底层的计算 MD5、图片压缩、上传等功能,可以看做是一个 SDK,没有 UI 样式。
  2. @alife/qn-upload:基于上面组件进行的业务封装。增加了千牛业务的 UI,指定了业务接口地址。

其他业务如果想用这个组件,可以参考这个实现,在 @alife/react-oss-upload 这个 SDK 层之上,加入自己想要的 UI,让服务端遵循里面的接口字段约定,很快就能封装出来。

后续完善

参考资料