或许你想要的并不是 clickOutsideClickOutside ClickOutside 常见于页面各种弹窗中,如
ClickOutside
ClickOutside 常见于页面各种弹窗中,如下拉弹窗,页面 Modal 对话框,当用户点击弹出层以外的其他区域关闭当前弹窗。很常见的一个功能实现,在各种前端框架中一般会对此功能进行封装,在 Vue 中可能是个 v-click-outside="close"
的指令,在 React 可能是个 useClickOutside(ref, close)
的 hook。ClickOutside 的实现可以十分简单:
<body>
<div class="modal">Modal</div>
<script>
const model = document.querySelector('.modal');
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
window.addEventListener('click', (event) => {
if (!model.contains(event.target)) {
toggle(false);
}
});
</script>
</body>
判断点击的元素是否在弹出容器内,如果不在则关闭弹层,这在大多数情况下都很好用,但并不完美,在一些特殊场景下会有问题。
Iframe 事件监听
先来看一个 iframe
的示例:
<body>
<div class="modal">Modal</div>
<iframe width="300" height="200"></iframe>
<script>
const model = document.querySelector('.modal');
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
window.addEventListener('click', (event) => {
if (!model.contains(event.target)) {
toggle(false);
}
});
</script>
</body>
如果弹层外部如果包含 iframe
,用户在点击 iframe
时在 window 上无法监听到 click 事件的。当然也有些解决方法,如果 iframe 中页面没跨域则可以使用 iframe.contentWindow.addEventListener
来监听;跨域的页面如果可控则可以通过 postMessage
通知父级窗口......这些方法可行但都不太通用。
键盘可访问性
另一个例子是键盘控制的问题:
<body>
<div class="modal">
<input type="text" />
</div>
<input type="text" />
<script>
const model = document.querySelector('.modal');
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
window.addEventListener('click', (event) => {
if (!model.contains(event.target)) {
toggle(false);
}
});
</script>
</body>
弹窗内部与外部都有 input
元素,此时可使用键盘的 Tab 来切换选中 input
,当输入框从弹窗内部切换到外部时,弹窗关闭是一个比较正常的行为,使用 click
事件是无法处理键盘 Tab 的切换焦点元素的。
这里就引申出一个键盘可访问性的问题,在日常功能我们可能不太关注无障碍相关的功能实现,但很多时候只需要加几行代码即可使得应用满足可访问性的需要。这里建议大家可以看一下 React 的无障碍辅助功能指引,其中也给出上述问题的解决方案,使用焦点管理替换 click
的事件监听。
FocusOutside
焦点管理
焦点管理简单来说让目标元素具有聚焦(focus)和失焦(blur)状态并可以触发相应状态的行为。以弹窗举例,focus 就是弹窗打开的状态的,blur 则是弹窗关闭的关闭状态,我们可以按照这个思路实现来时弹窗开启关闭。
model.addEventListener('focus', () => { console.log('focus'); });
model.addEventListener('blur', () => { console.log('blur'); });
实现也简单,监听 focus
和 blur
事件即可。
但直接监听 focus
与 blur
事件是不生效的,因为普通元素并不支持焦点事件,让元素支持焦点事件需要为元素设置 tabindex
属性。
它接受一个整数作为值,具有不同的结果,具体取决于整数的值:
- tabindex=负值 (通常是 tabindex=“-1”),表示元素是可聚焦的,但是不能通过键盘导航来访问到该元素,用 JS 做页面小组件内部键盘导航的时候非常有用。
tabindex="0"
,表示元素是可聚焦的,并且可以通过键盘导航来聚焦到该元素,它的相对顺序是当前处于的 DOM 结构来决定的。- tabindex=正值,表示元素是可聚焦的,并且可以通过键盘导航来访问到该元素;它的相对顺序按照 tabindex 的数值递增而滞后获焦。如果多个元素拥有相同的 tabindex,它们的相对顺序按照他们在当前 DOM 中的先后顺序决定。
model.setAttribute('tabIndex', '-1');
弹窗一类的组件一般是由按钮点击触发,这里使用 "-1" 避免被键盘直接触发。我们可以实现一个基于焦点管理的 focusOutside
<body>
<div class="modal"></div>
<input type="text" />
<iframe></iframe>
<script>
const model = document.querySelector('.modal');
model.setAttribute('tabIndex', '-1');
// 弹窗打开时自动获取焦点
setTimeout(() => {
model.focus();
});
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
let blurTimer = null;
function onFocus(event) {
clearTimeout(blurTimer);
}
function onBlur(event) {
blurTimer = setTimeout(() => {
toggle(false);
});
}
model.addEventListener('focus', onFocus);
model.addEventListener('blur', onBlur);
</script>
</body>
点击弹窗外的 iframe 与 Tab 切换都可以正常的关闭弹窗了。但这里为什么需要强调弹窗外呢,因为弹窗内部的焦点管理还需要处理。
事件冒泡
一般弹窗内部有个各式各样的组件,弹窗内如果存在可以聚焦的元素如 input 元素,在弹窗内部也是会发生焦点切换行为,看个例子:
<body>
<div class="modal">
<!-- 弹窗内部包含 input 元素 -->
<input type="text" />
</div>
<script>
const model = document.querySelector('.modal');
model.setAttribute('tabIndex', 0);
// 弹窗打开时自动获取焦点
setTimeout(() => {
model.focus();
});
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
let blurTimer = null;
function onFocus(event) {
clearTimeout(blurTimer);
}
function onBlur(event) {
blurTimer = setTimeout(() => {
toggle(false);
});
}
model.addEventListener('focus', onFocus);
model.addEventListener('blur', onBlur);
</script>
</body>
当弹窗元素内部如果包含焦点元素时,内部的焦点元素被激活时,焦点会从容器切换到子元素上,造成容器失去焦点。这很不符合直觉,和 click 事件不一样,真正的原因是:focus/blur并不支持事件冒泡!!!
几乎所有 DOM 事件都会支持冒泡,但也有些特殊事件是不支持事件冒泡的,如:
- scroll
- blur & focus
- Media 事件
- mouseleave & mouseenter
原因知道了,处理方法也十分简单:
blur/focus
改为事件捕获- 使用
focusin/focusout
事件替代(focusin/focusout
是blur/focus
的冒泡版本)
model.addEventListener('focus', onFocus, true);
model.addEventListener('blur', onBlur, true);
// or
model.addEventListener('focusin', onFocus);
model.addEventListener('focusout', onBlur);
窗口的焦点管理
焦点事件冒泡的问题处理完,再来看看弹窗容器内特殊元素 file input
和 iframe
元素:
<body>
<div class="modal">
<!-- 弹窗内部包含 input 元素 -->
<input type="file" />
<!-- 弹窗内部包含 iframe 元素 -->
<iframe width="300" height="200"></iframe>
</div>
<script>
const model = document.querySelector('.modal');
model.setAttribute('tabIndex', '-1');
// 弹窗打开时自动获取焦点
setTimeout(() => {
model.focus();
});
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
let blurTimer = null;
function onFocus(event) {
console.log('focus', event.target);
clearTimeout(blurTimer);
}
function onBlur(event) {
console.log('blur', event.target);
blurTimer = setTimeout(() => {
toggle(false);
});
}
model.addEventListener('focus', onFocus, true);
model.addEventListener('blur', onBlur, true);
</script>
</body>
点击 input file 时:
点击 iframe 时:
点击时两者都触发了元素的 blur 事件,但实际我们上我点击的区域还在弹窗内,此刻虽然失去焦点但却不应该关闭弹窗,需要特殊处理下这种情况。
当打开文件选择弹窗与点击 iframe 时,本质都是都是当前窗口的焦点切换到其他窗口上了,这时候就需要监听 window 的焦点事件了。同时可以利用 activeElement 判断当前激活的元素是否处于容器内来排除掉这些特殊情况,实现如下:
<body>
<div class="modal">
<!-- 弹窗内部包含 input 元素 -->
<input type="file" />
<!-- 弹窗内部包含 iframe 元素 -->
<iframe width="300" height="200"></iframe>
</div>
<script>
const model = document.querySelector('.modal');
model.setAttribute('tabIndex', '-1');
// 弹窗打开时自动获取焦点
setTimeout(() => {
model.focus();
});
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
let blurTimer = null;
function onFocus() {
clearTimeout(blurTimer);
}
function onBlur() {
blurTimer = setTimeout(() => {
toggle(false);
});
}
model.addEventListener('focus', onFocus, true);
model.addEventListener('blur', onBlur, true);
// 窗口时取焦点时判断,当前激活元素是否在容器内,是的话取消弹窗关闭操作
window.addEventListener('blur', () => {
if (model.contains(document.activeElement)) {
onFocus();
}
});
// 窗口重新获取焦点时判断,当前激活元素是否在容器内,不在容器内则关闭弹窗
window.addEventListener('focus', (event) => {
if (!model.contains(document.activeElement)) {
onBlur();
}
});
</script>
</body>
这里有一点需要注意,file input
在文件选择完成后焦点会自动回到 input 上,弹窗容器会重新获取焦点后续可以触发 blur 事件关闭弹窗;但点击 iframe 失去焦点时,元素已经触发 blur 事件,则后续弹窗内容的无法再触发 blur 事件来关闭弹窗。这时点击容器外区域就需要监听下 window focus 事件来判断弹窗是否需要关闭。
工具封装
我们可以对上述的功能进行一个简单的封装方便其整合到框架使用:
function createFouceOutside(target, callback) {
target.setAttribute('tabIndex', '-1');
let blurTimer = null;
// 弹窗聚焦,取消关闭弹窗
function onTargetFocus() {
clearTimeout(blurTimer);
}
// 弹窗失焦,关闭弹窗
function onTargetBlur() {
blurTimer = setTimeout(() => callback());
}
// 窗口失焦,判断是否需要取消关闭弹窗
function onWindowBlur() {
if (target.contains(document.activeElement)) {
onTargetFocus();
}
}
// 窗口聚焦,判断是否需要关闭弹窗
function onWindowFocus() {
if (!target.contains(document.activeElement)) {
onTargetBlur();
}
}
target.addEventListener('focus', onTargetFocus, true);
target.addEventListener('blur', onTargetBlur, true);
window.addEventListener('blur', onWindowBlur);
window.addEventListener('focus', onWindowFocus);
// 弹窗打开时自动获取焦点
setTimeout(() => {
target.focus();
});
// 返回一个销毁事件函数
return () => {
clearTimeout(blurTimer);
target.removeAttribute('tabIndex');
target.removeEventListener('focus', onTargetFocus, true);
target.removeEventListener('blur', onTargetBlur, true);
window.removeEventListener('blur', onWindowBlur);
window.removeEventListener('focus', onWindowFocus);
};
}
// vanilla js
const model = document.querySelector('.modal');
function toggle(open) {
model.style.display = open ? 'block' : 'none';
}
const destroy = createFouceOutside(model, () => {
toggle(false);
});
// 移除 fouceOutside
// destroy();
// react
import { useEffect } from 'react';
export function useOnClickOutside(ref, callback) {
useEffect(() => {
return createFouceOutside(ref.current, callback);
}, [ref, callback]);
}
其他
最近开发时遇到的小坑,一些页面上理所当然的行为遇到 iframe 后会有些怪异的行为,之前没有认真研究过,算时一个小总结吧~
- 采用焦点管理方式替换 clickOutside 实现可满足键盘交互
- 使用
tabIndex
为元素添加焦点事件 blur/focus
事件不会冒泡file input
选择与iframe
点击会触发当前窗口失焦事件- 使用
document.activeElement
可获取当前焦点元素
over~
参考:
转载自:https://juejin.cn/post/7056450195998900238