拖动调整大小在遇到iframe时如何保证丝滑?

566 阅读2分钟

左右拖动调节组件大小是前端页面中常见的交互手段,常常应用在menu,popover等组件上。先给这些组件一个默认宽度或高度,并且支持拖动边界调节宽度|高度。
笔者之前做的一个小需求就包含这个子功能,本来是个很小的能力,实现起来也不复杂。但因为一个特殊元素的干扰,花费了我半天时间才脱坑,这里把脱坑方法分享出来,希望给大家一点启发。

背景

产品想要在页面右侧搞一个工具栏,里面放一些常用操作,因为常见操作的内容区宽度不一,所以想增加一个拖动调整宽度的能力。然后就开始接下来的故事了。

实现

项目比较老,采用的技术栈是 Vue2 + element-ui + vue-cli。 对于工具栏,我的思路是使用基于 popover 封装一个业务组件。
关于 popover的使用大家可以查看官网,我这里贴出实现拖动调整大小的代码:

import { throttle } from 'lodash'

const MinWidth = 300
const MaxWidth = 800

function dragPopoverWidth() {
  let prevX = 0

  const mousemove = throttle((moveEvent: MouseEvent) => {
    this.hiddenMenus = Date.now()
    const $parent = this.$content?.parentElement
    if (!$parent) {
      return
    }

    let offset = prevX - moveEvent.pageX
    prevX = moveEvent.pageX
    const oldWidth = parseFloat($parent.style.width)
    const oldLeft = parseFloat($parent.style.left)
    let newWidth = oldWidth + offset
    if (newWidth >= MinWidth && newWidth <= MaxWidth) {
      // 不做任何额外操作
    } else if (newWidth < MinWidth) {
      newWidth = MinWidth
      offset = newWidth - oldWidth
    } else if (newWidth > MaxWidth) {
      newWidth = MaxWidth
      offset = newWidth - oldWidth
    }
    $parent.style.width = `${newWidth}px`
    $parent.style.left = `${oldLeft - offset}px`
  }, 100)

  const mouseup = () => {
    // 拖拽时结束时,取消禁用文本选中
    document.body.style.userSelect = 'initial'
    if (this.$contentMain) {
      this.$contentMain.style.pointerEvents = 'initial'
    }

    document.removeEventListener('mousemove', mousemove)
    document.removeEventListener('mouseup', mouseup)
  }

  if (!this.$dragBar) {
    return
  }

  // 拖动发起事件回调
  this.$dragBar.onmousedown = (event: MouseEvent) => {
    prevX = event.pageX
    // 拖拽时禁用文本选中
    document.body.style.userSelect = 'none'
    if (this.$contentMain) {
      this.$contentMain.style.pointerEvents = 'none'
    }

    document.addEventListener('mousemove', mousemove)
    document.addEventListener('mouseup', mouseup)
  }
}

关于拖动实现调整大小网上有很多现成的例子,我这里也是参考了网上的方案实现的。关键点就是监听 mousedownmousemovemouseup三个事件,在mousedown事件回调里启动拖动调整大小,监听 mousemove计算size调整大小,监听mouseup取消拖动调整大小。
看下拖动效果:

Kapture 2025-01-24 at 10.50.48.gif

额,拖动起来跟便秘一样~
怎么回事呢?问一问万能的 GPT吧:

提问:拖动调整大小功能在遇到iframe时卡住不动的原因及解决办法
回答: 拖动调整大小功能在遇到 iframe 时卡住不动,主要是由事件捕获、跨域限制、滚动影响和 iframe 内容加载未完成等原因造成的,下面为你详细分析原因及对应的解决办法:

1. 事件被 iframe 捕获
  • 原因:当鼠标在 iframe 上移动时,iframe 会捕获鼠标事件(如 mousemovemouseup),使得父页面中用于拖动调整大小的事件处理函数无法接收到这些事件,从而导致拖动操作卡住。

  • 解决办法

  • 设置 pointer-events: none:通过 CSS 将 iframe 的 pointer-events 属性设置为 none,这样 iframe 就不会捕获鼠标事件,鼠标事件会穿透 iframe 传递给父页面。

2. ### 跨域问题

...

GPT 还是牛啊,一下子就找出了问题所在并给出了解决方案。通过给 iframe 添加一个 pointer-events: none 属性,防止其捕获鼠标事件。

防御术一

改进后代码如下:

function dragPopoverWidth() {
  let prevX = 0

  const mousemove = throttle((moveEvent: MouseEvent) => {
    ...
  }, 100)

  const mouseup = () => {
    // 拖拽时结束时,取消禁用文本选中
    ...

    // 取消添加的额外属性
    const $iframe = document.querySelectorAll('iframe')
    $iframe?.forEach(item => {
      item.style.pointerEvents = 'unset'
    })

    document.removeEventListener('mousemove', mousemove)
    document.removeEventListener('mouseup', mouseup)
  }

  if (!this.$dragBar) {
    return
  }

  // 拖动发起事件回调
  this.$dragBar.onmousedown = (event: MouseEvent) => {
    prevX = event.pageX
    // 拖拽时禁用文本选中
    ...
    
    // 为iframe添加pointer-events: none属性
    const $iframe = document.querySelectorAll('iframe')
    $iframe?.forEach(item => {
      item.style.pointerEvents = 'none'
    })

    document.addEventListener('mousemove', mousemove)
    document.addEventListener('mouseup', mouseup)
  }
}

改进之后效果如下:

Kapture 2025-01-24 at 11.17.52.gif

拖动瞬间丝滑了~

防御术二

上面的方案确实解决了问题,但是直接修改 iframe 的可能会引发其他问题,比如iframe本身设置了 pointer-events,那就需要在拖动结束后还原回去,增加了一丁点麻烦。所以我又想到了第二种方案:拖动的时在拖动方向上加一个蒙层,遮住iframe,这样是不是就不用担心鼠标事件被iframe捕获了呢!
防御术二实现如下:

import { throttle } from 'lodash'

const MinWidth = 300
const MaxWidth = 800

function dragPopoverWidth() {
  let prevX = 0,
    mask: HTMLDivElement | null = null

  const mousemove = throttle((moveEvent: MouseEvent) => {
   ... 
  }, 100)

  const mouseup = () => {
    // 拖拽时结束时,取消禁用文本选中
    ...

    // 删除遮罩层
    mask?.remove()

    document.removeEventListener('mousemove', mousemove)
    document.removeEventListener('mouseup', mouseup)
  }

  if (!this.$dragBar) {
    return
  }

  // 拖动发起事件回调
  this.$dragBar.onmousedown = (event: MouseEvent) => {
    prevX = event.pageX
    // 拖拽时禁用文本选中
    ...

    const popoverEle = this.$content
    // FIX 创建遮罩层,内容区为iframe,会让拖拽失去焦点,需要添加一个遮罩层挡住iframe,让拖动连续
    mask = document.createElement('div')
    // 将遮罩元素放置到抽屉的左边边缘
    mask.style.cssText = 'height: 100%;width: 500px;position: absolute;left: -500px;top: 0;background: aqua'
    popoverEle?.append(mask)

    document.addEventListener('mousemove', mousemove)
    document.addEventListener('mouseup', mouseup)
  }
}

上面的代码只是把给 iframe 添加 pointer-events 的代码段换成了动态添加 mask这招层的代码,为了显示遮罩层,特意将其背景设置为 background: aqua。看看效果:

Kapture 2025-01-24 at 11.26.09.gif

拖动起来同样丝滑^_^

总结

  1. iframe就像页面里的一个黑洞,会把经过ta的一些事件都吸收进去(阻止事件冒泡),这会影响到一些事件监听器的处理mousemovemousedownmouseupclick...;
  2. 针对拖动场景,可以给 iframe添加一个额外的样式 pointer-events: none 来阻止其捕获鼠标事件,让事件流能够正常触发,不影响外部功能,这算是一种魔法防御;
  3. 如果说添加pointer-events: none 是通过魔法来防御,那么加遮罩层则是物理防御,既然iframe会捕获经过ta的鼠标事件,那就把你遮住,不让你接触到鼠标,这样iframe自然也就无法捕获鼠标事件了,hhh;
  4. 除了文中的popover场景,其他拖动场景也可以使用上面的两种方法。