前言
在公司的一次小組分享會上,組長給我們分享了一個他在專案中遇到的一個問題。在一個嵌入 iframe 的系統中,當我們點選 Dropdown 展開後,再去點選 iframe 發現無法觸發 Dropdown 的 clickOutside 事件,導致 Dropdown 無法收起。
為什麼無法觸發 clickOutside
目前大多數的 UI 元件庫,例如 Element、Ant Design、iView 等都是通過滑鼠事件來處理, 下面這段是 iView 中的 clickOutside 程式碼,iView 直接給 Document 繫結了 click 事件,當 click 事件觸發時候,判斷點選目標是否包含在繫結元素中,如果不是就呼叫繫結的函式。
bind (el, binding, vnode) {
function documentHandler (e) {
if (el.contains(e.target)) {
return false;
}
if (binding.expression) {
binding.value(e);
}
}
el.__vueClickOutside__ = documentHandler;
document.addEventListener('click', documentHandler);
}
複製程式碼
但 iframe 中載入的是一個相對獨立的 Document,如果直接在父頁面中給 Document 繫結 click 事件,點選 iframe 並不會觸發該事件。
知道問題出現在哪裡,接下來我們來思考怎麼解決?
給 iframe 的 body 元素繫結事件
我們可以通過一些特殊的方式給 iframe 繫結上事件,但這種做法不優雅,而且也是存在問題的。我們來想想一下這樣一個場景,左邊是一個側邊欄(導航欄),上面是一個 Header 裡面有一些 Dropdown 或是 Select 元件,下面是一個頁面區域。但這些頁面有的是嵌入 iframe,有些是當前系統的頁面。如果使用這種方法,我們在切換路由的時候就要不斷的去判斷這個頁面是否包含 iframe,然後繫結/解綁事件。但如果 iframe 和當前系統不是同域,那麼這種做法是無效的。
新增遮罩層
我們可以通過給 iframe 新增一個透明遮罩層,點選 Dropdown 的時候顯示透明遮罩層,點選 Dropdown 之外的區域或遮罩層,就關閉遮罩層並派發 clickOutside 事件,這樣雖然可以觸發 clickOutside 事件,但存在一個問題,如果使用者點選的區域正好是 iframe 頁面中的某個按鈕,那麼第一次點選是不會生效的,這種做法對於互動不是很友好。
通過 focusin 與 focusout 事件
其實我們可以換一種思路,為什麼一定要用滑鼠事件呢?focusin 與 focusout 事件就很適合處理當前這種情況,當我們點選非繫結的元素時觸發 focusout 事件,如果是就新增一個定時器,延時呼叫我們繫結的函式。當我們點選繫結元素例如 Dropdown 會觸發 focusin 事件,這時候我們判斷目標是否包含在繫結元素中,如果包含在繫結元素中就清除定時器。
不過使用 focusin 與 focusout 事件需要解決一個問題,那就是要將繫結的元素變成 focusable,那麼怎麼將元素變成focusable 呢?通過將 tabindex 屬性置為 -1 , 該元素就變成可由程式碼獲取焦點。需要注意的是,元素變成 focusable 後,當它獲取焦點瀏覽器會給它加上高亮樣式,如果不需要這種樣式可以將 outline 設定為 none。
不過這種方法雖然很棒,但是也存在一些問題,瀏覽器相容性,下面是 MDN 給出的瀏覽器相容情況,Firefox 低版本不相容。
使用 focus-outside 庫
focus-outside 是為了解決上述問題所建立的倉庫。使用起來也非常方便,它只有兩個方法,bind 與 unbind,它不依賴任何其他庫,並且支援為多個元素繫結一個函式。
為什麼要給多個元素繫結一個函式,這麼做是為了相容 Element,因為 Element 的 Dropdown 會被插入 body 元素中,它的按鈕和容器是分離的,當我們點選按鈕顯示 Dropdown,當我們點選 Dropdown 區域,這時候按鈕會失去焦點觸發 focusout 事件。事實上我們並不希望這時關閉 Dropdown,所以我將它們視為同一個繫結源。
這裡說明下 Element 為什麼要將彈出層放在 body 中,如果直接掛在父元素下,會受到父元素樣式的影響。比如父元素有 overflow: hidden,彈出選單就有可能被隱藏掉。
簡單使用
// import { bind, unbidn } from 'focus-outside'
// 建議使用下面這種別名,防止和你的函式命名衝突了。
import { bind: focusBind, unbind: focusUnbind } from 'focus-outside'
// 如果你是使用 CDN 引入的,應該這麼引入
// <script src="https://unpkg.com/focus-outside@0.4.0/lib/index.js"></script>
const { bind: focusBind, unbind: focusUnbind } = FocusOutside
const elm = document.querySelector('#dorpdown-button')
// 繫結函式
focusBind(elm, callback)
function callback () {
console.log('您點選了 dropdown 按鈕外面的區域')
// 清除繫結
focusUnbind(elm, callback)
}
複製程式碼
注意
前面說到過元素變成 focusable 後,當它獲取焦點瀏覽器會給它加上高亮樣式,如果你不希望看到和這個樣式,你只需要將這個元素的 CSS 屬性 outline 設定為 none。focsout-outside 0.5 的版本新增 className 引數,為每個繫結的元素新增類名,預設類名是 focus-outside,執行 unbind 函式時候會將這個類名從元素上刪除 。
<div id="focus-ele"></div>
// js
const elm = document.querySelector('#focus-ele')
// 預設類名是 focus-outside
focusBind(elm, callback, 'my-focus-name')
// css
// 如果你需要覆蓋所有的預設樣式,可以在這段程式碼放在全域性 CSS 中。
.my-focus-name {
outline: none;
}
複製程式碼
在 vue 中使用
// outside.js
export default {
bind (el, binding) {
focusBind(el, binding.value)
},
unbind (el, binding) {
focusUnbind(el, binding.value)
}
}
// xx.vue
<template>
<div v-outside="handleOutside"></div>
</template>
import outside from './outside.js'
export default {
directives: { outside },
methods: {
handleOutside () {
// 做點什麼...
}
}
}
複製程式碼
在 Element 中使用
<el-dropdown
ref="dropdown"
trigger="click">
<span class="el-dropdown-link">
下拉選單<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu
ref="dropdownContent"
slot="dropdown">
<el-dropdown-item>黃金糕</el-dropdown-item>
<el-dropdown-item>獅子頭</el-dropdown-item>
<el-dropdown-item>螺螄粉</el-dropdown-item>
<el-dropdown-item>雙皮奶</el-dropdown-item>
<el-dropdown-item>蚵仔煎</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
import { bind: focusBind, unbind: focusUnbind } from 'focus-outside'
export default {
mounted () {
focusBind(this.$refs.dropdown.$el, this.$refs.dropdown.hide)
focusBind(this.$refs.dropdownContent.$el, this.$refs.dropdown.hide)
},
destoryed () {
focusUnbind(this.$refs.dropdown.$el, this.$refs.dropdown.hide)
focusUnbind(this.$refs.dropdownContent.$el, this.$refs.dropdown.hide)
}
}
複製程式碼
在 Ant Design 中使用
import { Menu, Dropdown, Icon, Button } = antd
import { bind: focusBind, unbind: focusUnbind } = 'focus-outside'
function getItems () {
return [1,2,3,4].map(item => {
return <Menu.Item key={item}>{item} st menu item </Menu.Item>
})
}
class MyMenu extends React.Component {
constructor (props) {
super(props)
this.menuElm = null
}
render () {
return (<Menu ref="menu" onClick={this.props.onClick}>{getItems()}</Menu>)
}
componentDidMount () {
this.menuElm = ReactDOM.findDOMNode(this.refs.menu)
if (this.menuElm && this.props.outside) focusBind(this.menuElm, this.props.outside)
}
componentWillUnmount () {
if (this.menuElm && this.props.outside) focusUnbind(this.menuElm, this.props.outside)
}
}
class MyDropdown extends React.Component {
constructor (props) {
super(props)
this.dropdownElm = null
}
state = {
visible: false
}
render () {
const menu = (<MyMenu outside={ this.handleOutside } onClick={ this.handleClick } />)
return (
<Dropdown
ref="divRef"
visible={this.state.visible}
trigger={['click']}
overlay={ menu }>
<Button style={{ marginLeft: 8 }} onClick={ this.handleClick }>
Button <Icon type="down" />
</Button>
</Dropdown>
)
}
componentDidMount () {
this.dropdownElm = ReactDOM.findDOMNode(this.refs.divRef)
if (this.dropdownElm) focusBind(this.dropdownElm, this.handleOutside)
}
componentWillUnmount () {
if (this.dropdownElm) focusUnbind(this.dropdownElm, this.handleOutside)
}
handleOutside = () => {
this.setState({ visible: false })
}
handleClick = () => {
this.setState({ visible: !this.state.visible })
}
}
ReactDOM.render(
<MyDropdown/>,
document.getElementById('container')
)
複製程式碼
總結
iframe 元素無法觸發滑鼠事件,在 iframe 中觸發 clickOutside, 更好的做法是使用 focusin 與 focusout 事件。將 tabindex 設定為 -1 可以將元素變成 focusable 元素。