注:(1)非原創,來自https://blog.csdn.net/weixin_33985679/article/details/89699215、https://zhuanlan.zhihu.com/p/38392987
(2)focus-outside的github地址:https://github.com/txs1992/focus-outside、使用說明文檔:https://github.com/txs1992/focus-outside/releases的reademe
為什么無法觸發 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 的元素。
需要注意的是,元素變成 focusable 元素之后,當它獲取焦點的時候,瀏覽器會給它加上默認的高亮樣式,如果你不需要這種樣式可以將 outline 屬性設置為 none。
不過這種方法雖然很棒,但是也會存在一些問題,瀏覽器兼容性,下面是 MDN 給出的瀏覽器兼容情況,從圖中可以看出 Firefox 低版本不支持這個事件,所以你需要去權衡你的項目是否支持低版本的 Firefox 瀏覽器。
使用 focus-outside 庫
focus-outside 正是為了解決上述問題所創建的倉庫,代碼不到 200 行。使用起來也非常方便,它只有兩個方法,bind 與 unbind,不依賴其他第三方庫,並且支持為多個元素綁定同一個函數。
為什么要給多個元素綁定同一個函數,這么做是為了兼容 Element 與 Ant Design,因為 Element 與 Ant Design 會將 Dropdown 插入 body 元素中,它的按鈕和容器是分離的,當我們點擊按鈕顯示 Dropdown,當我們點擊 Dropdown 區域,這時候按鈕會失去焦點觸發 focusout 事件。事實上我們並不希望這時關閉 Dropdown,所以我將它們視為同一個綁定源。
這里說明下 Element 與 Ant Design 為什么要將彈出層放在 body 元素中,因為如果直接將 Dropdown 掛載在父元素下,會受到父元素樣式的影響。比如當父元素有 overflow: hidden,Dropdown 就有可能被隱藏掉。
簡單使用
// import { bind, unbidn } from 'focus-outside' // 建議使用下面這種別名,防止和你的函數命名沖突了。 import { bind: focusBind, unbind: focusUnbind } from 'focus-outside' // 如果你是使用 CDN 引入的,應該這樣使用 // <script src="https://unpkg.com/focus-outside@0.5.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.0 版本中新增 className 參數,為每個綁定的元素添加 focus-outside 默認類名,你要可以通過傳遞 className 參數自定義類名,當執行 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> <script> import outside from './outside.js' export default { directives: { outside }, methods: { handleOutside () { // 做點什么... } } } </script>
在 Element 中使用
<tempalte> <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> </template> <script> 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) } } </script>
在 Ant Design 中使用
import { Menu, Dropdown, Icon, Button } from 'antd' import { bind: focusBind, unbind: focusUnbind } from '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=