vue组件库之popup弹窗组件

8,361 阅读2分钟

业务背景

在做活动时经常需要实现各种各样的弹窗,有一些常见的问题需要处理,包含:

  • 滑动穿透问题:滑动弹窗元素导致背景元素滚动
  • 多弹窗层级问题:当有多弹窗时,最新的弹窗永远在最上层
  • 出现/消失过渡动画

实现

已发布npm包欢迎使用反馈和star~

npm install jdc-popup -S

github: github.com/winniecjy/j…
demo: winniecjy.github.io/jdc-popup/d…
demo

滑动穿透问题

初始的解决方案

打开浮层时fixed底部元素,同时为了保持body的位置与打开浮层前一致,设置top偏移为当前scrollTop;
关闭浮层时恢复底部元素状态和滚动高度;

controlledBgScrolled() {
    let bgEle = document.getElementById('app');
    // 打开浮层
    if (this.showPopup) {
        let top = document.documentElement.scrollTop
          || window.pageYOffset
          || document.body.scrollTop;
        this.scrollTop = top;
        bgEle.style.position = 'fixed';
        bgEle.style.top = `-${top}px`;
        bgEle.style.height = '100%';
    } 
    else {
        bgEle.style.position = 'relative';
        bgEle.style.top = '';
        bgEle.style.height = '100%';
        document.documentElement.scrollTop = this.scrollTop; 
        window.pageYOffset = this.scrollTop;
        document.body.scrollTop = this.scrollTop;
    }
}

这个方案的缺点是:

  1. 打开/关闭弹窗瞬间,可以看到有闪动
  2. 在APP内嵌页面,与APP原生有几率发生冲突

使用库实现

测试提出bug的时间比较紧急,所以直接引入了已有的库解决了该问题,经过测试,ios8+,android4.4+下没有发现问题。
库的源码比较清晰,之前的个人的思路都是想要一套代码兼容各端,这个库是将问题细分,对不同端进行了不同的处理,更容易去兼容。这三种方案都无法完美兼容所有端,详细可以查看参考[2]。

PC端

PC端实现比较简单,通过在body设置overflow: hidden就OK了。

const $body = document.querySelector('body')
const bodyStyle = { ...$body.style }
const scrollBarWidth = window.innerWidth - document.body.clientWidth
// 打开浮层时
$body.style.overflow = 'hidden'
$body.style.boxSizing = 'border-box'
$body.style.paddingRight = `${scrollBarWidth}px`
// 关闭浮层时,恢复原始设置
['overflow', 'boxSizing', 'paddingRight']
.forEach((x: OverflowHiddenPcStyleType) => {
    $body.style[x] = bodyStyle[x] || ''
}

Android端

Android端的实现与个人的方案思路基本一致,打开弹层时将底部元素fixed并设置top偏移,关闭浮层时恢复现场,注意到底部元素必须同时设置html和body。

const scrollTop = $html.scrollTop || $body.scrollTop
const htmlStyle = { ...$html.style }
const bodyStyle = { ...$body.style }

// 打开浮层时,fixed底部
$html.style.height = '100%'
$html.style.overflow = 'hidden'
$body.style.top = `-${scrollTop}px`
$body.style.width = '100%'
$body.style.height = 'auto'
$body.style.position = 'fixed'
$body.style.overflow = 'hidden'

// 关闭浮层时,恢复现场
$html.style.height = htmlStyle.height || ''
$html.style.overflow = htmlStyle.overflow || ''
['top', 'width', 'height', 'overflow', 'position']
.forEach((x: OverflowHiddenMobileStyleType) => {
  $body.style[x] = bodyStyle[x] || ''
})

window.scrollTo(0, scrollTop)

iOS端

iOS端在打开浮层时,禁用了底部的touchmove事件,如果弹窗内部元素需要可滚动,则通过另外的函数自行处理。关闭浮层时移除所有事件监听。经测试,该方案在Android下弹窗滚动到边界时,底部元素有几率出现滚动。该库在iPhone 6p下初次打开无法滚动。

/***  打开浮层时,处理浮层和底部元素的滚动事件 ***/
// 1. targetElement为需要滚动的元素容器,处理其滚动
if (targetElement && lockedElements.indexOf(targetElement) === -1) {
    targetElement.ontouchstart = (event) => {
        initialClientY = event.targetTouches[0].clientY
    }
    targetElement.ontouchmove = (event) => {
        if (event.targetTouches.length !== 1) return
        // 手动处理滚动
        handleScroll(event, targetElement)
    }
    // 记录可滚动元素
    lockedElements.push(targetElement)
}
const handleScroll = (event, targetElement) => {
    const clientY = event.targetTouches[0].clientY - initialClientY
    if (targetElement) {
        const { scrollTop, scrollHeight, clientHeight } = targetElement
        // 向上滚动时 且 已经到达顶部
        const isOnTop = clientY > 0 && scrollTop === 0
        // 当向下滚动 且 已经到达底部
        const isOnBottom = clientY < 0
                          && scrollTop + clientHeight + 1 >= scrollHeight
        if (isOnTop || isOnBottom) {
            return preventDefault(event)
        }
    }
    event.stopPropagation()
    return true
}

// 2. 禁止document的touchMove事件
if (!documentListenerAdded) {
    document.addEventListener(
      'touchmove',
      preventDefault,
      eventListenerOptions)
    documentListenerAdded = true
}
/***  关闭浮层时,移除时间监听 ***/
if (targetElement) {
    const index = lockedElements.indexOf(targetElement)
    if (index !== -1) {
        targetElement.ontouchmove = null
        targetElement.ontouchstart = null
        lockedElements.splice(index, 1)
    }
}
if (documentListenerAdded) {
    document.removeEventListener(
      'touchmove',
      preventDefault,
      eventListenerOptions)
    documentListenerAdded = false
}

ui组件库都是怎么实现的?

用库可以较好的完成这个需求,但是对于滑动穿透这样的小问题,每次都引入库解决有点小题大做。基于上述考虑,选取了京东的nutui/有赞的vant/饿了么的mintui对比其实现方案,对比如下:

组件库实现思路实现形式
vanttouch事件处理mixins,抽取了复杂复用逻辑
minttouch事件处理mixins,抽取了复杂逻辑,但实现方案上依赖组件结构,复用性不强
nutfixed底层背景组件内部函数,简洁易读,复用性不强

对比了一下三个方案,并实际测试(iOS8+/Android4+),还是无法兼容所有机型,最终的还是按照库的基本思路包装了一下组件。
另外加入overscroll-behavior虽然兼容性不佳,但是安卓原生浏览器和chrome浏览器仍然有部分支持。

// 对于半透明蒙层阻止滚动
mask.addEventListener(
  'touchmove', 
  this.preventDefault,
  { capture: false, passive: false },
  false);
// 对可滚动元素容器手动处理滚动
onTouchMove(event, targetElement) {
    ...
    if (targetElement) {
        const {
            scrollTop,
            scrollHeight,
            clientHeight
        } = targetElement
        // 向上滚动时 且 已经到达顶部
        const isOnTop = this.deltaY > 0 && scrollTop === 0
        // 当向下滚动 且 已经到达底部
        const isOnBottom = this.deltaY < 0
                          && scrollTop + clientHeight + 1 >= scrollHeight
        if (isOnTop || isOnBottom) {
            this.preventDefault(event)
        }
    }
    event.stopPropagation()
    return true
}
// 关闭弹窗时,移除所有事件
...

多弹窗层级问题

当前后打开两个弹窗,用户的预期是按照打开的先后顺序,越后打开的弹窗在越上层,简而言之就是新弹窗永远在最上层。可以通过记录当前出现过的最大zIndex,新弹窗zIndex = zIndex+1。 另外滑动穿透问题在多弹窗情况下也需要处理,对于非当前最高层级弹窗,不应当收到滚动影响。

this.$el.style.zIndex = context.zIndex + 1; 
context.zIndex += 1;

扩展

业务上的弹窗样式一般比较复杂,如果只是简单通用的弹窗样式,想要解决滑动穿透问题,可以通过Vue.extend扩展,将弹窗组件直接挂载到document下(element-ui中就使用了类似的做法),不会和主体内容互相影响,调用起来也更加灵活。

参考

[1] tua-scroll-body-lock:tuateam.github.io/tua-body-sc…
[2] 滑动穿透(锁body)终极探索]:juejin.cn/post/684490…
[3] vant-popup]:blog.csdn.net/riddle1981/…
[4] NUTUI:github.com/jdf2e/nutui

广告时间

飞书是字节跳动旗下办公套件产品,其将即时沟通、在线协作、音视频会议、日历、云盘、工作台等功能进行了深度整合,为用户提供一站式协作体验。目前,飞书服务的客户已经覆盖了科技互联网、信息技术、制造、建筑地产、企业服务、教育、媒体等多个领域。欢迎投递字节跳动飞书团队,有海量前端后端HC~扫描二维码或者点击链接投递,认准飞书团队👍~
【校招】内推码: HZNVPHS,投递链接: job.toutiao.com/s/JaeUCoc
【社招】投递链接: job.toutiao.com/s/JaevUNo
社招 校招