如何優雅解決 iframe 無法觸發 clickOutside

txs1992發表於2020-01-17

前言

在公司的一次小組分享會上,組長給我們分享了一個他在專案中遇到的一個問題。在一個嵌入 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 低版本不相容。

img

使用 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 元素。

MDN focusin

MDN focusout

focus-outsie

HTML tabindex屬性與web網頁鍵盤無障礙訪問

相關文章