示例
原生的input标签无法监听取消事件, 我们通过对容器的blur事件和click事件, 以及input的change事件, 三者结合进行判断:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>选择文件的取消事件</title>
<style>
* {
font-size: large;
}
</style>
</head>
<body>
<div>
<button id="btn">选择文件</button>
</div>
<script>
addFileSelect(
btn,
/* 选择文件事件 */
(input) => {
alert('您选择了文件: ' + input.files[0].name);
},
/* 取消选择事件 */
() => {
alert('您取消了文件选择');
}
);
/**
* 为容器添加文件选择事件, 容器通常是一个按钮
*/
function addFileSelect(container, onselect, oncancel) {
// <input type="file">
let input = document.createElement('input'); input.type = 'file';
// states
let waiting = false; // 是否尚在等待选择文件
let clicked = false; // 按钮是否被点击
container.addEventListener('click', () => {
clicked = true; // 按钮被点击
input.click(); // 弹窗
waiting = true; // 等待用户选择文件, 此时按钮会失去焦点
});
container.addEventListener('blur', () => {
if (clicked && waiting) {
clicked = false; // 用户点击容器后, 容器会失去一次焦点, 此时处于waiting状态
// waiting没有被input的change事件置为false, 却触发了blur的失焦事件
} else if (waiting) { // 容器再次失去焦点, 仍旧处于waiting状态, 断言用户取消了选择
console.log('blur事件测试到用户取消了选择');
oncancel?.();
}
});
input.addEventListener('change', () => {
waiting = false; // 检测到用户选择了文件
if (input.value === '') { // 此时, 用户肯定点击了取消按钮, 否则value不会变为空串, 而且之前肯定选择过文件, 否则不会触发change事件
console.log('change事件感知到用户取消了选择');
oncancel?.();
} else {
onselect?.(input);
}
});
}
</script>
</body>
</html>
算法改进: blur的对立事件: focus
在回忆上午完成的代码时, 我发现我们需要手动点击容器之外的UI使其产生blur事件才能检测到取消事件, 但是弹窗时由于容器失去焦点导致已经产生过一次该事件了呀?
原来是系统自动将焦点放到容器上了! 当我们的文件选择框无论因为以下哪种原因关闭的时候, 容器都会自动获得blur事件:
- 用户选择了一个文件
- 用户取消了选择文件
所以我们是可以立即判断的! 加入容器的焦点事件, 发现焦点事件先于点击事件之前触发.
但是change事件可能排在最后!
focus => click => blur => 弹窗 => (A or B) => 弹窗关闭 => focus =>? change
最关键的点是什么? 我也很混乱
但是没有关系, 我还是找到了关键点, 由于change事件可能排在最后, 因此要在弹窗关闭 => focus
中判断时不能依赖input的事件.
但是此时input的值肯定已经发生了变化, 如果用户取消了选择, 那么input的值肯定是空串???
由于事件过于复杂, 实际上我们只关心点击之后的事情, 所以在容器的click事情中添加后续的事件监听器. 事件全部只监听一次:
容器点击事件 => 容器失去焦点 => 容器获得焦点 =>? input改变事件
只有input的change事件是不稳定的.
解决方案
/**
* 为容器添加文件选择事件, 容器通常是一个按钮
*/
function addFileSelect(container, onselect, oncancel) {
container.addEventListener('click', () => {
let input = document.createElement('input'); input.type='file';
input.click();
let selected = false;
let onchange = null; // 取消选择时不会触发change事件, 需要手动移除监听器
container.addEventListener('focus', () => {
console.log(input.value); // 大概先于onchange事件100ms执行, 所以一定是空串
// 当取消选择时则不会触发onchange事件
let close_time = new Date(); // 记录弹窗关闭的时间
// 轮询
(function loop() {
let crt_time = new Date(); // 查询时间
if (selected) {
onselect?.(input);
} else if (crt_time - close_time > 1000) { // 该时间不确保一定可以触发change事件
input.removeEventListener('change', onchange);
oncancel?.();
} else {
setTimeout(loop, 20);
};
})();
}, { once: true });
input.addEventListener('change', onchange = () => {
console.log('change');
selected = true;
}, { once: true });
});
}
我们甚至可以丢弃change事件, 同时基于轮询次数判断取消, 而不是基于时间:
/**
* 为容器添加文件选择事件, 容器通常是一个按钮
*/
function addFileSelect(container, onselect, oncancel) {
container.addEventListener('click', () => {
let input = document.createElement('input'); input.type='file';
input.click();
container.addEventListener('focus', () => {
console.log(input.value); // 大概先于onchange事件100ms执行, 所以一定是空串
let loop_count = 0; // 轮询次数
// 轮询
(function loop() {
if (input.value !== '') { // 不需要change事件
onselect?.(input);
} else if (++loop_count >= 10) { // 基于轮询次数的判断
oncancel?.();
} else { // 暂时无法判断, 继续轮询
setTimeout(loop, 20);
};
})();
}, { once: true });
});
}