# 设计一个图片上传系统的大致思路
文章参考自图片上传功能设计的时候需要考虑些什么? (opens new window)
# 先分析
# 上传前
文件格式、大小有限制吗?
一次可以上传多少文件?单张上传?批量上传?是否可以压缩包上传?
异常的文件怎么处理?已经传过的呢(覆盖还是追加)?重名怎么处理?
点击上传吗?是不是可以支持拖拽?复制粘贴上传可以吗?
上传文件需要本地预览吗
# 上传中
上传的文件要压缩吗?压缩规则是什么?(什么时候压缩?怎么压缩?)如果压缩需要存原图吗?
上传进度要显示吗?
上传中可以暂停吗?可以断点续传吗?
文件较大是否支持分片上传?
是否需要进行上传限频?
上传成功或者失败的提示?有失败最大重试次数吗?还是用户无感上传几次之后默认失败?
触发的时候,手动上传还是自动上传?
最大同时支持多少个任务同时上传?服务器支持多少个?
各任务状态如何排序与展示?
# 上传后
可以预览吗?需要缩略图吗?预览的规则是什么?直接按照比例缩放?显示中间部分?
图片是否需要懒加载?
文件存储到哪里?cdn还是自己的服务器?
上传后,支持删除吗?多图情况是否可以一键清除?是否可以重新上传?
是否可以逐个编辑?可以重命名吗?
# 图片点击上传
<input
type="file"
id="choose"
accept="image/gif,image/jpeg,image/jpg,image/png"
/>;
var filechooser = document.getElementById("choose");
filechooser.onchange = function(e) {
const files = this.files;
console.log(files);
};
# 图片拖拽上传及进度展示
关于拖放事件有些是在被拖动元素上触发的,而有些则是在放置目标上触发的
当我们拖动某个元素时,会依次触发:
- dragstart
- drag
- dragend
这三个事件都是在被拖动元素上触发的。当拖动开始时会先触发 dragstart 事件,然后在拖动的过程中会持续触发 drag 事件,当拖动停止时(无论被拖动元素是否放到了有效的放置目标)都会触发 dragend 事件
当某个元素被拖动到放置目标上,会依次触发:
- dragenter
- dragover
- dragleave 或 drop
这三个事件都是在放置目标上触发的。当元素进入放置目标时会触发 dragenter 事件,当元素在放置目标上移动时会持续触发 dragover 事件,当元素移出放置目标时会触发 dragleave 事件,当元素被放到了放置目标中会触发 drop 事件而不是 dragleave 事件
在一些浏览器中,当我们移动图片到放置目标上,松开的时候会打开这张图片,如果移动的是超链接,则会打开这个页面。我们有时候需要阻止这种默认的行为;
// 拖动的源事件对象--document
// 监听drop事件,防止浏览器中打开客户端图片
document.ondragover = function(e) {
// 阻止ondragover的默认行为(触发ondragleave)
e.preventDefault();
};
document.ondrop = function(e) {
// 阻止ondrop的默认行为(触发在当前窗口打开客户端图片)
e.preventDefault();
};
const container = document.getElementById("container");
// 若图片释放此元素上方,则需要在其中显示此图片
container.ondragover = function(e) {
// 阻止ondragover的默认行为(触发ondragleave)
e.preventDefault();
};
container.ondrop = function(e) {
// 读取拖放进来的客户端图片内容
const files = e.dataTransfer.files;
// 文件上传: 构造form数据
const form = new FormData();
Array.prototype.forEach.call(files, (ele) => {
// ele包含了如下的信息
// lastModified: 1623221527820
// lastModifiedDate: Wed Jun 09 2021 14:52:07 GMT+0800 (中国标准时间) {}
// name: "1.png"
// size: 177771
// type: "image/png"
// webkitRelativePath: ""
form.append(ele.name + "_" + Date.now(), ele);
});
const xhr = new XMLHttpRequest();
// 设置超时时间
xhr.timeout = 3000;
xhr.open("POST", "http://127.0.0.1/");
// 文件上传:进度的监听,xhr.upload.onprogress要写在xhr.send方法前面,否则event.lengthComputable状态不会改变,只有在最后一次才能获得,也就是100%的时候.
xhr.upload.onprogress = function(event) {
// 这是一个状态,表示发送的长度有了变化,可计算
if (event.lengthComputable) {
// 发送了多少字节
const percent = event.loaded / event.total;
console.log(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200 && xhr.readyState === 4) {
console.log("文件上传成功");
} else {
console.log("文件上传失败");
}
};
xhr.send(form);
};
# 图片剪切板上传
var box = document.getElementById("editor-box");
//绑定paste事件
box.addEventListener("paste", function(event) {
var data = event.clipboardData || window.clipboardData;
var items = data.items;
var fileList = []; //存储文件数据
if (items && items.length) {
// 检索剪切板items
for (var i = 0; i < items.length; i++) {
console.log(items[i].getAsFile());
fileList.push(items[i].getAsFile());
}
}
event.preventDefault(); //阻止默认行为
submitUpload(fileList);
});
# 文件取消上传
- 为取消按钮绑定事件,调用
xhr.abort()
;终止上传 - 使用
window.URL.createObjectURL
预览图片,在图片加载成功后需要清除使用的内存window.URL.revokeObjectURL(this.src)
;
# 如何进行图片预览
很多业务场景需要搞定图片/视频上传,同时又要求能够快速预览效果,避免图片或视频稍大或请求响应较慢,使用
createObjectURL()
本地预览实用性更加。
文章参考自js 图片 视频预览 URL createObjectURL() (opens new window)
读取了本地文件,实现本地预览可以使用createObjectURL
或者FileReader.readAsDataURL(file)
他们两者是有区别的:
返回值
FileReader.readAsDataURL(file)
可以得到一段base64
的字符串。URL.createObjectURL(file)
可以得到当前文件的一个内存URL
。 比如blob:http://localhost:52330/a0c0a1e3-2395-4807-8870-a32589c01f79
内存使用
FileReader.readAsDataURL(file)
的返回值是转化后的超长base64
字符串(长度与要解析的文件大小正相关)。URL.createObjectURL(file)
的返回值虽然是字符串,但是是一个url
地址。
内存清理
FileReader.readAsDataURL(file)
依照 JS 垃圾回收机制自动从内存中清理。URL.createObjectURL(file)
存在于当前doucment
内,清除方式只有unload()
事件或revokeObjectURL()
手动清除 。
执行机制
FileReader.readAsDataURL(file)
通过回调的形式返回,异步执行。
var reader = new FileReader(); reader.onload = function() { callback(reader.result); }; reader.readAsDataURL(file);
URL.createObjectURL(file)
直接返回,同步执行。
兼容性
- 兼容性兼容
IE10
以上,其他浏览器均支持。
- 兼容性兼容
其他
FileReader.readAsDataURL(file)
当多个文件同时处理时,需要每一个文件对应一个新的FileReader
对象。URL.createObjectURL(file)
依次返回无影响
# 大图片上传之如何进行图片压缩
涉及到 JS 的图片压缩,我的想法是需要用到 Canvas 的绘图能力,通过调整图片的分辨率或者绘图质量来达到图片压缩的效果,实现思路如下:
- 获取上传 Input 中的图片对象 File
- 将图片转换成 base64 格式
- base64 编码的图片通过 Canvas 转换压缩,这里会用到的 Canvas 的 drawImage 以及 toDataURL 这两个 Api,一个调节图片的分辨率的,一个是调节图片压缩质量并且输出的,后续会有详细介绍
- 转换后的图片生成对应的新图片,然后输出
# drawImage 图像压缩
context.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
简单来讲,此方法将从图像中截取一个矩形区域来画到画板中的一个矩形区域,如果两个矩形区域的数值不一样,将对图像进行缩放,甚至拉伸
就是图片对象,可以是页面上获取的 DOM 对象,也可以是虚拟 DOM 中的图片对象。
sx , sy , swidth , sheight
这 4 个参数是用来裁剪源图片的,表示图片在 canvas
画布上显示的大小和位置。sx,sy
表示在源图片上裁剪位置的 X 轴、Y 轴坐标,然后以 swidth,sheight
尺寸来选择一个区域范围,裁剪出来的图片作为最终在 Canvas 上显示的图片内容( swidth,sheight
不说明的情况下,整个矩形(裁剪)从坐标的 sx
和 sy
开始,到图片的右下角结束)。
dx , dy , dWidth , dHeight
表示在 canvas
画布上规划处一片区域用来放置图片,dx, dy
为绘图位置在 Canvas 元素的 X 轴、Y 轴坐标,dWidth, dHeight
指在 Canvas 元素上绘制图像的宽度和高度(如果不说明, 在绘制时图片的宽度和高度不会缩放)。
以下为图片绘制的实例:
context.drawImage(image, 0, 0, 100, 100);
context.drawImage(image, 300, 300, 200, 200);
context.drawImage(image, 0, 100, 150, 150, 300, 0, 150, 150);
# 图片输出
canvas 提供了两个转图片的方法:
- HTMLCanvasElement.toDataURL() (opens new window):图片转换成 base64 格式
- HTMLCanvasElement.toBlob() (opens new window):图片转换成 Blob 文件
toDataURL
调用 canvas 的 toDataURL 方法可以输出 base64 格式的图片。
canvas.toDataURL(type, encoderOptions);
- type 可选
图片格式,默认为 image/png。
- encoderOptions 可选
在指定图片格式为 image/jpeg 或 image/webp 的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92。其他参数会被忽略。encoderOptions
值越小,所绘制出的图像越模糊
toBlob
void canvas.toBlob(callback, type, encoderOptions);
属于异步方法,所以有个callback
参数。
type
参数指定图片格式;
encoderOptions
参数指定图片质量,用于压缩图片
值在 0 与 1 之间,当请求图片格式为
image/jpeg
或者image/webp
时用来指定图片展示质量。
关于 Blob:
Blob
(opens new window)对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是 JavaScript 原生格式的数据。File
(opens new window) 接口基于Blob
,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。JavaScript 中 Blob 对象 (opens new window): 这篇文章介绍了 Blob,里面也提到了大文件分割上传的实现。
一般来说,对比 Blob 文件和 base64 ,有下面几点优点:
- 二进制文件,对后端更友好
- base64 字符串一般都非常长,会有性能等问题
所以选择转化成 Blob 文件进行上传更好。把 base64 或者 Blob 文件加入 FormData
里就可以实现上传了。
# 遇到的一些坑
- PNG 转 JPEG 时 PNG 格式的透明区域会变黑色,需要先手动铺底色
- toDataURL 参数为 PNG 时不支持传图片质量,所以需要写死 image/jpeg 或 image/webp,具体可以参考 toDataURL 的 api
- formData.append 第三个参数 filename 是有浏览器兼容性问题的,如果不传就是 filename=blob,后端校验文件名可能过不去
- ajax 的 contentType 和 processData 需要传 false,这和本文关系不大直接带过
# 一个例子
// 图片压缩
function imageCompress(image, callback) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
// 定义 canvas 大小,也就是压缩后下载的图片大小
let imageWidth = image.width; //压缩后图片的大小
let imageHeight = image.height;
canvas.width = imageWidth;
canvas.height = imageHeight;
// 图片不压缩,全部加载展示
context.drawImage(image, 0, 0);
// 图片按压缩尺寸载入
// let imageWidth = 500; //压缩后图片的大小
// let imageHeight = 200;
// context.drawImage(image, 0, 0, 500, 200);
// 图片去截取指定位置载入
// context.drawImage(image,100, 100, 100, 100, 0, 0, imageWidth, imageHeight);
// 如果要求直接上传blob则使用toBlob,如果需要展示则使用toDataURL
canvas.toBlob(
function(blob) {
callback && callback(blob);
},
"image/jpeg",
0.5 // 自己调整质量
);
}
// 文件转换成图片
function filesToImage(file) {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = URL.createObjectURL(file);
image.onload = function() {
imageCompress(this, (blob) => {
resolve(blob);
});
};
image.onerror = () => {
reject();
};
});
}
var filechooser = document.getElementById("choose");
filechooser.onchange = function(e) {
const files = this.files;
const images = [];
Array.prototype.forEach.call(files, (ele) => {
console.log(ele.size, "原始文件大小");
// ele包含了如下的信息
// lastModified: 1623221527820
// lastModifiedDate: Wed Jun 09 2021 14:52:07 GMT+0800 (中国标准时间) {}
// name: "1.png"
// size: 177771
// type: "image/png"
// webkitRelativePath: ""
images.push(filesToImage(ele));
});
Promise.all(images).then((res) => {
// res为所有的图片的blob,如果上传使用File方式则需要将blob转换成file
// let files = new window.File([this.blob], file.name, {type: file.type})
console.log(res);
console.log(res[0].size, "压缩后文件大小");
});
};
# 大图片之分片
相信大家都对Blob
对象有所了解,它表示原始数据,也就是二进制数据,同时提供了对数据截取的方法slice
,而 File
继承了Blob
的功能,所以可以直接使用此方法对数据进行分段截图。
- 把大文件进行分段 比如 2M,发送到服务器携带一个标志,暂时用当前的时间戳,用于标识一个完整的文件
- 服务端保存各段文件
- 浏览器端所有分片上传完成,发送给服务端一个合并文件的请求
- 服务端根据文件标识、类型、各分片顺序进行文件合并
- 删除分片文件
function submitUpload() {
var chunkSize = 2 * 1024 * 1024; //分片大小 2M
var file = document.getElementById("f1").files[0];
var chunks = [], //保存分片数据
token = +new Date(), //时间戳
name = file.name,
chunkCount = 0,
sendChunkCount = 0;
//拆分文件 像操作字符串一样
if (file.size > chunkSize) {
//拆分文件
var start = 0,
end = 0;
while (true) {
end += chunkSize;
var blob = file.slice(start, end);
start += chunkSize;
if (!blob.size) {
//截取的数据为空 则结束
//拆分结束
break;
}
chunks.push(blob); //保存分段数据
}
} else {
chunks.push(file.slice(0));
}
chunkCount = chunks.length; //分片的个数
//没有做并发限制,较大文件导致并发过多,tcp 链接被占光 ,需要做下并发控制,比如只有4个在请求在发送
for (var i = 0; i < chunkCount; i++) {
var fd = new FormData(); //构造FormData对象
fd.append("token", token);
fd.append("f1", chunks[i]);
fd.append("index", i);
xhrSend(fd, function() {
sendChunkCount += 1;
if (sendChunkCount === chunkCount) {
//上传完成,发送合并请求
console.log("上传完成,发送合并请求");
var formD = new FormData();
formD.append("type", "merge");
formD.append("token", token);
formD.append("chunkCount", chunkCount);
formD.append("filename", name);
xhrSend(formD);
}
});
}
}
function xhrSend(fd, cb) {
var xhr = new XMLHttpRequest(); //创建对象
xhr.open("POST", "http://localhost:8100/", true);
xhr.onreadystatechange = function() {
console.log("state change", xhr.readyState);
if (xhr.readyState == 4) {
console.log(xhr.responseText);
cb && cb();
}
};
xhr.send(fd); //发送
}
# NodeJS生成预览图
- 本文链接: https://mrgaogang.github.io/interview/wechat.html
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 许可协议。转载请注明出处!