在浏览器端对图片进行压缩 & 上传
前言
在移动端,我们经常会有这样的情况发生: 用户在 3G/2G 网络情况下,上传手机拍下的照片在经过上传再下载耗时非常长,流量消耗也不少。 因此我们提出了一个要求:前端先压缩图片,在浏览器中预览,再上传到服务器,并且要兼容 Android 4.0。 这篇博文主要介绍:
- 对图像文件压缩的处理方法;
- 对 File/Blob/data URIs 的互相转化;
- 如何构造 multipart/form-data 格式的请求;
- 以及用 xhr2 发送压缩文件到服务器的解决办法。
图像文件处理步骤
- 获取 input[type="file"] 控件上的图像文件对象;
- 使用 window.URL/FileReader 获取图像路径(BlobURL/DataURL)并通过 Image 对象载入;
- 通过载入图像的 Image 对象绘制到 canvas 画布上;
- 通过 canvas.toDataURL 方法将画布图像压缩并输出 base-64 编码的 dataURL 字符串;
- 通过 window.atob 将 base-64 字符串解码为 binaryString(二进制文本);
- 将 binaryString 构造为 multipart/form-data 格式;
- 用 Uint8Array 将 multipart 格式的二进制文本转换为 ArrayBuffer。
详细操作
测试页面: https://blade254353074.github.io/image-compress/ Repo 地址:https://github.com/blade254353074/image-compress
以下步骤均可以在测试页面中测试。
一、获取 file 类型
HTML
<input
id="J_File"
type="file"
accept="image/*"
capture="camera"
>
<input
id="J_File"
type="file"
accept="image/*"
capture="camera"
>
在 Android 浏览器中,input 加上 capture="camera"
文件的 mime type 要缓存下来,后面在压缩时会用到:
JavaScript
var fileName
file = J_File.files[0]
fileName = file.name
// 某些浏览器得到的 file.type 为空
fileType = file.type ||
'image/' + fileName.substr(fileName.lastIndexOf('.') + 1)
var fileName
file = J_File.files[0]
fileName = file.name
// 某些浏览器得到的 file.type 为空
fileType = file.type ||
'image/' + fileName.substr(fileName.lastIndexOf('.') + 1)
二、通过 URL/FileReader 获取文件的路径
这两个方法都是为了获取文件路径,让 image 可以加载图片文件,只不过 URL 获得是 Blob URL, FileReader 获得的是 Data URL (Base64)。这里有个坑,Android 4.0 不能加载 Data URL,后面详述。
注意:浏览器中 Image 支持的图像类型有限:JPEG、GIF、PNG、APNG、SVG、BMP、ICO(Chrome 还支持 WEBP)。
1. Blob URLs
Blob URLs 支持度如下:
Blob URLs 还是个实验中功能,为了支持老版本手机浏览器,需要加 webkit 前缀:window.URL = window.URL || window.webkitURL。
使用 URL.createObjectURL(File/Blob) 可以将 file/blob 对象挂载到 Document 上并返回一个 BlobURL。
JavaScript
// 如果 document 已挂载过 Blob 对象,则先释放,避免浪费内存
window.URL = window.URL || window.webkitURL
if (url) {
window.URL.revokeObjectURL(url)
}
url = window.URL.createObjectURL(file)
// blob:http://foo.bar/f6913fff-12c9-4c3c-8a40-3712a68e9de4
// 如果 document 已挂载过 Blob 对象,则先释放,避免浪费内存
window.URL = window.URL || window.webkitURL
if (url) {
window.URL.revokeObjectURL(url)
}
url = window.URL.createObjectURL(file)
// blob:http://foo.bar/f6913fff-12c9-4c3c-8a40-3712a68e9de4
2. FileReader
FileReader 兼容性和 Blob URLs 相同,只不过不需要加 webkit 前缀。
用 FileReader 可以以异步的方式读取文件,要获取 DataURL 需要使用 FileReader.readAsDataURL(),它可以将文件读取为包含一个 data: URL 格式的字符串。
JavaScript
fileReader = new FileReader()
image = new Image()
fileReader.onload = function (e) {
var dataURL = e.target.result
// fileReader.result (data:image/png;base64,iVBORw0KG...)
image.src = dataURL
}
image.addEventListener('load', function () {
// Image loaded!
})
image.addEventListener('error', function () {
alert('Image load error')
})
fileReader.readAsDataURL(file)
fileReader = new FileReader()
image = new Image()
fileReader.onload = function (e) {
var dataURL = e.target.result
// fileReader.result (data:image/png;base64,iVBORw0KG...)
image.src = dataURL
}
image.addEventListener('load', function () {
// Image loaded!
})
image.addEventListener('error', function () {
alert('Image load error')
})
fileReader.readAsDataURL(file)
注意:Android 4.0 Image 对象加载 dataURL 会有兼容性问题
注意:Android 4.0 用 FileReader 读取的 dataURL 不完整。 缺少 data:image/png;base64 中的 image/png(如下图)。
使用 Android avd 模拟器测试后,发现:
- 在 Android 4.0.3 下,将 dataURL 赋值到 image.src 后,图片会加载错误:
- 而在 Android 4.3.1 下,则不会加载失败。
Android 4.0.3 下,FileReader 读取的 dataURL 不完整,导致图片会加载失败,因此无法将图片绘制到 Canvas 上,更不用说压缩了。 所以,我们需要使用 URL 对象 createObjectURL 方法来把图片文件挂载到 Document 上,对于重复挂载的操作别忘了用 revokeObjectURL 方法来释放挂载的文件。
三、绘制 Image 到 Canvas 画布上
JavaScript
var context
canvas = new Canvas()
context = canvas.getContext('2d')
// 将 canvas 尺寸设置为图片原始尺寸
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
// 将图片绘制到 canvas 画布上
context.drawImage(image, 0, 0)
var context
canvas = new Canvas()
context = canvas.getContext('2d')
// 将 canvas 尺寸设置为图片原始尺寸
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
// 将图片绘制到 canvas 画布上
context.drawImage(image, 0, 0)
四、通过 toDataURL 压缩图像
先来看一下 MDN 对这个方法的介绍:HTMLCanvasElement.toDataURL()
注意一点: 如果图像本身是 image/png,则 type 参数不能为非 image/png 的其他类型。
JavaScript
var quality = 30
compressedImageDataURL = canvas.toDataURL(filetype, quality/100)
var quality = 30
compressedImageDataURL = canvas.toDataURL(filetype, quality/100)
就这么简单的一行代码,就可以把画布上的内容进行压缩输出了。
如果要获取压缩过的图片大小,需要将 DataURL 转为 Blob 对象:
Android 3.0 - 4.2 之前的浏览器,包括微信浏览器等,都不支持 Blob 的构造方法,需要使用 BlobBuilder。
JavaScript
function newBlob (data, datatype) {
var out
try {
out = new Blob([data], { type: datatype })
} catch (e) {
window.BlobBuilder = window.BlobBuilder ||
window.WebKitBlobBuilder ||
window.MozBlobBuilder ||
window.MSBlobBuilder
if (e.name == 'TypeError' && window.BlobBuilder) {
var bb = new BlobBuilder()
bb.append(data)
out = bb.getBlob(datatype)
} else if (e.name == 'InvalidStateError') {
out = new Blob([data], { type: datatype })
} else {
throw new Error('Your browser does not support Blob & BlobBuilder!')
}
}
return out
}
// data URIs to Blob
function dataURL2Blob (dataURI) {
var byteStr
var intArray
var ab
var i
var mimetype
var parts
parts = dataURI.split(',')
parts[1] = parts[1].replace(/\s/g, '')
if (~parts[0].indexOf('base64')) {
byteStr = atob(parts[1])
} else {
byteStr = decodeURIComponent(parts[1])
}
ab = new ArrayBuffer(byteStr.length)
intArray = new Uint8Array(ab)
for (i = 0; i < byteStr.length; i++) {
intArray[i] = byteStr.charCodeAt(i)
}
mimetype = parts[0].split(':')[1].split(';')[0]
return new newBlob(ab, mimetype)
}
var compressedImageBlob = dataURL2Blob(compressedImageDataURL)
console.log(compressedImageBlob.size) // 压缩图像文件的大小
console.log(file.size) // 源文件的大小
function newBlob (data, datatype) {
var out
try {
out = new Blob([data], { type: datatype })
} catch (e) {
window.BlobBuilder = window.BlobBuilder ||
window.WebKitBlobBuilder ||
window.MozBlobBuilder ||
window.MSBlobBuilder
if (e.name == 'TypeError' && window.BlobBuilder) {
var bb = new BlobBuilder()
bb.append(data)
out = bb.getBlob(datatype)
} else if (e.name == 'InvalidStateError') {
out = new Blob([data], { type: datatype })
} else {
throw new Error('Your browser does not support Blob & BlobBuilder!')
}
}
return out
}
// data URIs to Blob
function dataURL2Blob (dataURI) {
var byteStr
var intArray
var ab
var i
var mimetype
var parts
parts = dataURI.split(',')
parts[1] = parts[1].replace(/\s/g, '')
if (~parts[0].indexOf('base64')) {
byteStr = atob(parts[1])
} else {
byteStr = decodeURIComponent(parts[1])
}
ab = new ArrayBuffer(byteStr.length)
intArray = new Uint8Array(ab)
for (i = 0; i < byteStr.length; i++) {
intArray[i] = byteStr.charCodeAt(i)
}
mimetype = parts[0].split(':')[1].split(';')[0]
return new newBlob(ab, mimetype)
}
var compressedImageBlob = dataURL2Blob(compressedImageDataURL)
console.log(compressedImageBlob.size) // 压缩图像文件的大小
console.log(file.size) // 源文件的大小
不过存在几个问题:
(1). 是否可以压缩所有的浏览器支持的图片格式? (2). 如果图片是已经压缩过的,会不会造成重复压缩问题? (3). 会不会压缩后,反而文件变大? (4). 压缩效率是不是很低?
(1). 是否可以压缩所有的浏览器支持的图片格式?
结论:只支持 JPEG,其他格式均不能实现真正意义上的「压缩」。
验证过程:
在分别用 lena_std 的 jpe、gif、png、bmp、ico 进行不同 Quality 的压缩测试后,发现:
除了压缩 JPEG 会随着 quality 降低,输出的文件大小 & 质量降低,其他格式会输出一个固定大小的文件,并且这些其他格式的 dataURL 中 mediaType 均是 image/png。
因此,我们在客户端要压缩时应对 JPG 文件进行低质量压缩(toDataURL(filetype, quality/100)),其他格式只使用(toDataURL()),随后将 dataURL 转为 Blob,对比 Blob 和源文件的大小,优先上传较小的文件。
用到的莱娜图出自
www.lenna.org原图为 TIF 格式,其他格式的「莱娜图」在 blade254353074/image-compress,以供测试。
(2). 如果图片是已经压缩过的,会不会造成重复压缩问题?
结论:当压缩算法不同时会重复压缩,但没有较大的质量损耗。
- 对一张原图 toDataURL 压缩后,再对压缩图进行 toDataURL 压缩,如此递归,压缩文件的大小不会再改变,即没有变化。
- 对原图用 ps 低质量另存后,对其进行 toDataURL 压缩后。文件大小和对原图压缩有区别,且感官「画质」也有且些许区别(噪点增多),但没有过大的质量损耗。
说明:压缩率和压缩算法有关系,采用不同的压缩算法,结果会不同。W3C 制定的压缩算法和 PhotoShop 的压缩算法不同。
(3). 会不会压缩后,反而文件变大?
结论:可能会,如果一个 JPEG 图片已经用同一个算法压缩到 10% 质量的话,再压缩为 30% 质量,文件会变大,但图像「感官质量」并不会提高。
(4). 压缩效率是不是很低?
结论:压缩的质量越低,压缩速度就越快。
测试结果:手机(iPhone 5s)在压缩 2MB 的 JPEG 时,toDataURL(type, quality)
- 92% 质量时为 220ms 左右;
- 30% 质量为 130ms 左右。
(dataURL2Blob(compressedImageDataURL)
现在,我们有了压缩过的 DataURL(Base64 String),并且能把它转为 Blob 对象,接口是接受 multipart 格式数据的,所以我们要把 Blob 添加到 FormData,再用 XHR2 来上传数据。 但是,在 Android 4.3 以下这样发出去的 Request Body 是空的,原因是:这是个 BUG(https://code.google.com/p/android/issues/detail?id=39882)。
这个 Bug 从 3.0 一直持续到 4.3,4.4 因为包含了应对这种情况的 Chrome Blink 引擎,所以就不会出现这种情况了。 文中提到的对 3.0 - 4.3 的权宜之计是把 Blob 转成 ArrayBuffer
那么问题来了,如何把 Blob 转换为 ArrayBuffer?
一个简单的办法是:用 FileReader 的 readAsArrayBuffer(dataURLtoBlob(compressedImageDataURL)) 来获取 ArrayBuffer,可我们的文件上传接口是遵循 multipart/form-data 规范的,Request Body 里只有二进制数据流的话,接口也得做改动。
因此我们需要「曲线救国」:手动构造一个 multipart 格式的 Request Body。
因为我们要传的是文件,所以需要将 compressedImageDataURL 用 window.atob 解码为二进制字符串(即文件二进制内容),再构造为 multipart 格式。 有了 multipart 格式的数据后,将它转为 ArrayBuffer 发出去即可。
五、用 window.atob 解码 Base64 字符串为 binaryString
兼容性:atob 除了 IE9 以外,其他所有浏览器均支持。
根据 [W3C] base64-utility-methods,atob 字面意思是 ASCII to Binary,实际作用是对 Base64 编码的字符串进行解码,将每个 Base64 字符转换为范围在 U+0000 - U+00FF 的字符,这些 Unicode 字符每个都代表一个 0x00 - 0xFF 的二进制字节。
因此,atob 的参数必须符合 Latin1(兼容 ASCII 的编码) 字符范围。
data URIs 的语法结构为:
JavaScript
data:[<mediatype>][;base64],<data>
data:[<mediatype>][;base64],<data>
所以我们只需要对 <data>
JavaScript
// 解码前将 `data:image/png;base64,` 去除
var pureBase64ImageData =
compressedImageDataURL.replace(
/^data:(image\/.+);base64,/,
function ($0, $1) {
contentType = $1
return ''
}
)
// atob
binaryString = atob(pureBase64ImageData)
// 解码前将 `data:image/png;base64,` 去除
var pureBase64ImageData =
compressedImageDataURL.replace(
/^data:(image\/.+);base64,/,
function ($0, $1) {
contentType = $1
return ''
}
)
// atob
binaryString = atob(pureBase64ImageData)
其实就是把 Base64 字符串转为 BinaryString(二进制字符串):
这样,二进制字符串有了,终于可以拼接 multipart 了。
六、将 binaryString 构造为 multipart/form-data 格式
根据规范 [RFC1867] Form-based File Upload in HTML,我们需要发送这样格式的数据:
...
Content-Type: multipart/form-data; boundary=customBoundary
...
--customBoundary
Content-Disposition: form-data; name="file"; filename="filename.jpg"
Content-Type: image/jpeg
... contents of filename.jpg ...
--customBoundary--
这个 multipart 格式需要注意几点:
- FileContent 是我们之前用 atob 解码的 binaryString;
- boundary 是每个 field 之间的分隔字串,可以自定义,但不要和 field 内容冲突;
- 在 Payload 中行之间需要用 CRLF 分隔,CR(Carriage Return,回车),LF(Line Feed,换行),即
\r\n
- ;
- Payload 末尾也需要用 CRLF 来结束。
所以,我们要这么做:
JavaScript
multipartString = [
'--' + boundary,
'Content-Disposition: form-data; name="file"; filename="' + (file.name || 'blob') + '"',
'Content-Type: ' + contentType,
'', binaryString,
'--' + boundary + '--', ''
].join('\r\n')
multipartString = [
'--' + boundary,
'Content-Disposition: form-data; name="file"; filename="' + (file.name || 'blob') + '"',
'Content-Type: ' + contentType,
'', binaryString,
'--' + boundary + '--', ''
].join('\r\n')
关于 multipart 更详细的介绍 —— [W3C] Form content types
七、把 multipart 格式的字符串转换为 ArrayBuffer
XHR2 可以发送的二进制数据有 ArrayBuffer, Blob, File,同时接口又需要数据保持 multipart 格式。我们没有压缩过的 File 对象,添加了 Blob 的 FormData 也不能用,所以只好用 ArrayBuffer 了。
ArrayBuffer(缓冲数组)是一种用于呈现通用、固定长度的二进制数据的类型。
这一个步骤要用到 Uint8Array,但 Typed Arrays(二进制数组)支持率稍低:
不过 Android 3.0 是一个平板用的系统,用的人很少,所以不用管了。
JavaScript
function string2ArrayBuffer (string) {
var bytes = Array.prototype.map.call(string, function (c) {
return c.charCodeAt(0) & 0xff
})
return new Uint8Array(bytes).buffer
}
arrayBuffer = string2ArrayBuffer(multipartString)
function string2ArrayBuffer (string) {
var bytes = Array.prototype.map.call(string, function (c) {
return c.charCodeAt(0) & 0xff
})
return new Uint8Array(bytes).buffer
}
arrayBuffer = string2ArrayBuffer(multipartString)
用 ajax/fetch 上传压缩过的图片
现在我们有上面第四步得到的 compressedImageBlob 和第七步得到的 ArrayBuffer。在不考虑 Android 4.3 以下系统时,可以直接用 xhr2 发送添加了 Blob 的 FormData:
JavaScript
var formData = new FormData()
var xhr = new XMLHttpRequest()
// 用第四步得到的 compressedImageBlob
var blobFile = compressedImageBlob
formData.append('file', blobFile, file.name)
xhr.open('POST', url, true)
// 如果跨域请求需要 Cookies 的话,带上 credentials
xhr.withCredentials = true
xhr.addEventListener('load', function() { /* xhr.responseText */ })
xhr.send(formData)
var formData = new FormData()
var xhr = new XMLHttpRequest()
// 用第四步得到的 compressedImageBlob
var blobFile = compressedImageBlob
formData.append('file', blobFile, file.name)
xhr.open('POST', url, true)
// 如果跨域请求需要 Cookies 的话,带上 credentials
xhr.withCredentials = true
xhr.addEventListener('load', function() { /* xhr.responseText */ })
xhr.send(formData)
对于要支持 Android 4.3- 的需求来说,要发送 multipart 格式的 ArrayBuffer 需要对 Request Headers 做一点小的改动,即添加一个 Content-Type: multipart/form-data; boundary=customBoundary
JavaScript
var xhr = new XMLHttpRequest()
xhr.open('POST', url, true)
xhr.withCredentials = true
// boundary 为第六步构造 multipart 格式时用到的 customBoundary
// multipart 格式规定,两处 boundary 必须保持一致
xhr.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + boundary)
xhr.addEventListener('load', function() { /* xhr.responseText */ })
// 第七步得到的 arrayBuffer
xhr.send(arrayBuffer)
var xhr = new XMLHttpRequest()
xhr.open('POST', url, true)
xhr.withCredentials = true
// boundary 为第六步构造 multipart 格式时用到的 customBoundary
// multipart 格式规定,两处 boundary 必须保持一致
xhr.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + boundary)
xhr.addEventListener('load', function() { /* xhr.responseText */ })
// 第七步得到的 arrayBuffer
xhr.send(arrayBuffer)
不过,我们在做移动端页面时,基本都会去用 Zepto.ajax,这里我踩了个坑(用zepto1.1.6发ajax在特定安卓机出现INVALID_STATE_ERR: DOM Exception 11异常 #6),在下面的评论找到了解决办法,所以结合 ArrayBuffer 在这里也发下:
JavaScript
Zepto.ajax({
url: url,
type: 'POST',
processData: false,
contentType: 'multipart/form-data; boundary=' + boundary,
beforeSend: function (xhr, settings) {
try {
xhr.withCredentials = true
} catch (e) {
var nativeOpen = xhr.open
xhr.open = function () {
var result = nativeOpen.apply(xhr, arguments)
xhr.withCredentials = true
return result
}
}
},
success: function (res, status, xhr) {},
error: function (xhr, errorType, error) {}
})
Zepto.ajax({
url: url,
type: 'POST',
processData: false,
contentType: 'multipart/form-data; boundary=' + boundary,
beforeSend: function (xhr, settings) {
try {
xhr.withCredentials = true
} catch (e) {
var nativeOpen = xhr.open
xhr.open = function () {
var result = nativeOpen.apply(xhr, arguments)
xhr.withCredentials = true
return result
}
}
},
success: function (res, status, xhr) {},
error: function (xhr, errorType, error) {}
})
上传效果如下:
Fetch 版(虽然支持了 Fetch 也就支持了直接发 FormData with Blob,不过给没用过 Fetch 同学看一下比较完整的用法):
JavaScript
fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'multipart/form-data; boundary=customFileboundary'
},
credentials: 'include',
body: arrayBuffer
})
.then(response => {
const { status } = response
if (
response.ok &&
(
status >= 200 &&
status < 300
) ||
status === 304
) {
return response
} else {
const error = new Error(response.statusText)
error.response = response
throw error
}
})
.then(response => {
if (
response.status === 204 ||
response.headers.get('Content-Type').indexOf('application/json') === -1
) {
return response
}
return response.json()
})
.then(res => {
// success
console.log(res)
})
.catch(error => {
// error
const { response } = error
if (response && response.headers.get('Content-Type').indexOf('application/json') > -1) {
response
.json()
.then(err => {
console.log(err)
})
} else {
console.error(error)
}
})
.then(() => {
// complete
})
fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'multipart/form-data; boundary=customFileboundary'
},
credentials: 'include',
body: arrayBuffer
})
.then(response => {
const { status } = response
if (
response.ok &&
(
status >= 200 &&
status < 300
) ||
status === 304
) {
return response
} else {
const error = new Error(response.statusText)
error.response = response
throw error
}
})
.then(response => {
if (
response.status === 204 ||
response.headers.get('Content-Type').indexOf('application/json') === -1
) {
return response
}
return response.json()
})
.then(res => {
// success
console.log(res)
})
.catch(error => {
// error
const { response } = error
if (response && response.headers.get('Content-Type').indexOf('application/json') > -1) {
response
.json()
.then(err => {
console.log(err)
})
} else {
console.error(error)
}
})
.then(() => {
// complete
})
最后
需要查看每步运行情况的可以访问 DEMO:Browser side image compression demo, 需要进行上传测试的:
Bash
$ git clone https://github.com/blade254353074/image-compress.git
$ npm install
$ npm run server
# Open http://localhost:8080/
参考
- blade254353074/image-compress
- [MDN] URL
- [MDN] <img> - Supported image formats
- [MDN] FileReader
- [MDN] HTMLCanvasElement.toDataURL()
- The Lenna Story - www.lenna.org
- 如何在移动web上上传文件.. - AlloyTeam Blog
- Issue 39882: Browser: Trying to send a Blob with XHR2 sends the request with an empty body
- Send a JPEG Blob with AJAX on Android - Ionuț Colceriu
- CR, LF, CR/LF区别与关系 - JeremyWei
- [W3C] base64-utility-methods
- Latin-1
- [MDN] data URIs
- [RFC1867] Form-based File Upload in HTML
- [W3C] Form content types
- [MDN ArrayBuffer]
- [MDN Uint8Array]