示例
原生的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 });
});
}