最近,我写了一份文件上传组件的代码(Jquery😄,先别喷)。但在前端开发中,处理<input type="file">
一直是个麻烦事,主要是因为它的files
属性是只读的,我们没法用JavaScript去直接修改用户选择的文件列表。
这导致很多常见的需求,比如"带删除按钮的预览列表",实现起来都非常别扭。 如下图:

而这个组件,只用了一个我们平时不太注意的API------new DataTransfer()
,就优雅地解决了这个问题。
这篇文章,我们就来逐行分析这组件代码,从中提炼出几个能直接用在项目里的技巧。
用new DataTransfer()
,实现对input.files
的"写"操作
我们先来看这个组件里的最核心的函数:
javascript
// 代码片段 1: 核心函数
function setInputFiles(fileList) {
// 步骤 1: 创建一个空的 DataTransfer 对象
const dataTransfer = new DataTransfer();
// 步骤 2: 遍历你自己的文件数组,把文件添加到这个对象里
for (let file of fileList) {
dataTransfer.items.add(file);
}
// 步骤 3: 把这个对象的 .files 属性,赋值给 input 元素
document.getElementById('custom-upload-input').files = dataTransfer.files;
}
这段代码揭示了解决问题的关键: 虽然input.files
本身是只读的,我们不能对它进行push
或splice
操作,但浏览器允许我们用一个新的FileList
对象去整个替换它。
而new DataTransfer()
构造函数,就是那个能帮我们凭空创建出所需FileList
的工具。
这就是我们需要掌握的,也是最重要的技巧:通过创建一个DataTransfer
实例并填充它,我们可以间接地实现对<input type="file">
文件列表的程序化控制。
构建"可控的"自定义预览列表
我们再来看组件中的其他部分,它是如何构建出一个带"删除"功能的预览列表的。
javascript
// 代码片段 2: 全局变量与删除逻辑
const files = new Map(); // 用一个Map来管理我们自己的文件状态
// ... (addFileToList 函数中)
$item.find('.custom-upload-remove').on('click', function() {
$item.remove();
files.delete(id); // 1. 从我们自己的Map中删除文件
// 2. 如果文件都删完了,就清空input
if (files.size === 0) {
const dataTransfer = new DataTransfer();
document.getElementById('custom-upload-input').files = dataTransfer.files;
}
// 注意:更完整的逻辑应该是在删除后,用Map中剩余的文件去更新input
});
这里的代码非常清晰:
- 分离"数据状态"与"DOM状态" :代码没有直接操作
input.fisles
,而是创建了一个独立的files
变量(这里用Map
,非常适合通过唯一ID进行增删),作为唯一可信的数据源(Single Source of Truth)。 - UI渲染基于自己的数据状态 :页面上的文件预览列表,完全是根据
files
这个Map
来渲染的。 - 响应用户操作,先更新数据状态 :当用户点击"删除"时,代码首先操作的是
files.delete(id)
,更新我们自己维护的数据。 - 最后,同步DOM状态 :在数据状态更新后,再调用技巧一 中的
setInputFiles
函数(或者一个简化的清空逻辑),用files
中最新的文件集合,去覆盖input
的files
属性,保证两者同步。
最后就是:通过"自有状态 -> 更新UI -> 同步input"这个单向数据流,我们可以构建出任何我们想要的、文件上传交互界面。
统一处理多种上传方式,简化逻辑
在这个组件中,同时处理了用户的"点击选择"和"拖拽上传"两种情况。
javascript
// 代码片段 3: 事件处理
// 点击选择
$('#custom-upload-input').on('change', function(e) {
handleFiles(e.target.files);
});
// 拖拽上传
$('#custom-upload-area').on('drop', function(e) {
e.preventDefault();
handleFiles(e.originalEvent.dataTransfer.files);
});
这里无论是e.target.files
,还是拖拽事件中的e.originalEvent.dataTransfer.files
,它们返回的都是一个FileList
对象。
DataTransfer
这个API,其本职工作就是处理拖拽事件中的数据。event.dataTransfer
是浏览器原生提供的实例。而new DataTransfer()
允许我们自己创建一个,这恰好为我们统一这两种上传方式提供了可能性。
因此,我们:将文件处理逻辑封装在一个独立的函数(如handleFiles
)中,这个函数接收一个FileList
对象作为参数。这样,无论是点击上传还是拖拽上传,最终都可以调用这个统一的函数,避免了代码重复,让逻辑更清晰。
完整代码, 可以用在你们项目中供参考:
javascript
const files = new Map()
const allowedTypes = [
'image/jpeg','image/png','image/gif','image/jpg','image/webp',
'video/mp4','video/quicktime','video/mov','video/avi','video/mpeg'
];
const maxSize = 20 * 1024 * 1024; // 20MB
function formatSize(size) {
if (size > 1024*1024) return (size/1024/1024).toFixed(1)+'MB';
if (size > 1024) return (size/1024).toFixed(1)+'KB';
return size+'B';
}
$(document).on('submit', `#form`, function (event) {
event.preventDefault(); // 防止表单提交(为了演示)
const values = {}
// 获取表单的所有数据
for (const { name, value } of $(this).serializeArray()) {
values[name] = value
}
values.media_list = []
for (const element of files.values()) {
values.media_list.push(element)
}
console.log(values)
this.reset();
$('.thankyou').addClass('er-flex').removeClass('er-hidden');
$(this).hide();
fetch('https://demo.com/third_part/product_activation', {
'method': 'POST',
'headers': {
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
return false
});
// 点击上传区域,触发文件选择
$('#custom-upload-area').on('click', function(e) {
// 避免点击input本身时重复触发
if (e.target.id === 'custom-upload-input') return;
$('#custom-upload-input').trigger('click');
});
// 选择文件后处理
$('#custom-upload-input').on('change', function(e) {
handleFiles(e.target.files);
// 不要在这里再触发 click,否则会死循环
});
$('#custom-upload-area').on('dragover', function(e) {
e.preventDefault();
$(this).addClass('dragover');
});
$('#custom-upload-area').on('dragleave', function(e) {
e.preventDefault();
$(this).removeClass('dragover');
});
$('#custom-upload-area').on('drop', function(e) {
e.preventDefault();
$(this).removeClass('dragover');
handleFiles(e.originalEvent.dataTransfer.files);
});
function setInputFiles(fileList) {
const dataTransfer = new DataTransfer();
for (let file of fileList) {
dataTransfer.items.add(file);
}
document.getElementById('custom-upload-input').files = dataTransfer.files;
}
// 在 handleFiles 里调用
function handleFiles(fileList) {
let filesArr = [];
for (let file of fileList) {
addFileToList(file);
filesArr.push(file);
}
setInputFiles(filesArr);
}
function addFileToList(file) {
$('#custom-upload-list').find('.custom-upload-error-msg').remove();
$('#custom-upload-list').find('.error').remove();
const id = 'file_' + Math.random().toString(36).substr(2,9);
let error = '';
if (!allowedTypes.includes(file.type)) {
error = `${file.name} has invalid extension. Only jpg, jpeg, png, gif, webp, mp4, mov, avi, mpeg allowed.`;
} else if (file.size > maxSize) {
error = `${file.name} is too large. Max 20MB allowed.`;
}
if (error) {
return $('#custom-upload-list').append(`<div class="custom-upload-error-msg">${error}</div>`);
}
const isImage = file.type.startsWith('image/');
const fileContent = isImage
? `<img src="${URL.createObjectURL(file)}" alt="${file.name}" class="custom-upload-thumb"/>`
: `<video src="${URL.createObjectURL(file)}" alt="${file.name}" class="custom-upload-thumb"></video>`;
const $item = $(`
<div class="custom-upload-file${error ? ' error' : ''}" id="${id}">
<div class="text-size12 er-mb-2 er-break-all">${file.name}</div>
<div class="er-flex er-items-center">
${fileContent}
<span class="er-flex-1"></span>
<span class="custom-upload-filesize">${formatSize(file.size)}</span>
<div class="custom-upload-progress"><div class="custom-upload-progress-bar"></div></div>
<svg class="custom-upload-remove" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#d16b8a"><path d="M267.33-120q-27.5 0-47.08-19.58-19.58-19.59-19.58-47.09V-740H160v-66.67h192V-840h256v33.33h192V-740h-40.67v553.33q0 27-19.83 46.84Q719.67-120 692.67-120H267.33Zm425.34-620H267.33v553.33h425.34V-740Zm-328 469.33h66.66v-386h-66.66v386Zm164 0h66.66v-386h-66.66v386ZM267.33-740v553.33V-740Z"/></svg>
</div>
</div>
`);
$('#custom-upload-list').append($item);
uploadFile(file, id);
$item.find('.custom-upload-remove').on('click', function() {
$item.remove();
files.delete(id);
if (files.size === 0) {
const dataTransfer = new DataTransfer();
document.getElementById('custom-upload-input').files = dataTransfer.files;
}
});
}
function uploadFile(file, id) {
const $bar = $(`#${id} .custom-upload-progress-bar`);
const $item = $(`#${id}`);
// 这里用演示用的模拟上传,实际请替换为你的上传接口
const fakeUrl = 'https://httpbin.org/post';
const formData = new FormData();
formData.append('file', file);
formData.append('file_from', 'photo-contest');
$.ajax({
url: fakeUrl,
type: 'POST',
data: formData,
processData: false,
contentType: false,
xhr: function() {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
let percent = (e.loaded / e.total) * 100;
$bar.css('width', percent + '%');
}
}, false);
return xhr;
},
success: function(res) {
files.set(id, res.url);
$bar.css('width', '100%');
$item.removeClass('error');
},
error: function() {
$item.addClass('error');
$item.html(`<div class="custom-upload-error-msg">${file.name} Upload failed. Please try again.</div>`);
}
});
}
好了分析完毕🙂
我们再来回顾一下,从这段代码里,我们能学到关于new DataTransfer()
的哪些技巧:
- 核心 :利用
new DataTransfer()
来创建一个可控的FileList
对象,并将其赋值给input.files
,从而实现对这个"只读"列表的程序化"写入"。 - UI操作 :将文件状态与DOM状态分离。在JavaScript中维护一份自己的文件列表,UI交互都只操作这份自有数据,然后再将这份数据同步回
input
元素。 - 兼容:抽象出统一的文件处理函数,用它来兼容点击、拖拽等多种不同的文件来源,保持代码的整洁和可复用性。
new DataTransfer()
确实不是一个我们每天都会用到的API,但它在处理文件上传这个特定场景时,是一个非常有效且规范的解决方案。
希望这次的分析,能让你对这个API有更深入的、更贴近实战的理解。
谢谢大家❀