天天看點

如何解決 iframe 無法觸發 clickOutside

前言

在公司的一次小組分享會上, 組長 給我們分享了一個他在項目中遇到的一個問題。在一個嵌入 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 的元素。

需要注意的是,元素變成 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/[email protected]/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 } = 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 事件,将 HTML 屬性 tabindex 設定為 -1 可以将元素變成 focusable 元素。浏覽器會給 focusable 元素加上預設的高亮樣式,如果你不需要這種樣式,可以将 CSS 屬性 outline 設定為 none。

相關連結

原文釋出時間為:2018年07月02日

作者:掘金

本文來源:

掘金 https://juejin.im/post/5b371a8a6fb9a00e5326f06c

如需轉載請聯系原作者

繼續閱讀