NutUI 组件 popup 源码分析

前言

NutUI专题文来了。今日早读文章由京东用户体验设计部@杨凯旋投稿分享。

京东用户体验设计部-前端开发部现有前端开发人员 50 左右,主要为京东零售集团、京东健康提供 WEB 前端开发、APP RN开发,小程序开发、小游戏开发、H5开发等能力支持。

正文从这开始~~

NutUI 是一套京东风格的移动端 Vue 组件库,生态系统覆盖面广,支持按需加载、主题定制、多语言等,功能强大。目前 40+ 京东项目正在使用,设计精美,风格统一。在开发组件库的过程中,NutUI 是如何处理组件间的层级关系的呢?今天就给大家解析 NutUI 中具有处理层级关系的公共组件 popup。

image

什么是 popup

它是一个公共组件,很多带有弹出层的组件都是基于这个组件开发的。封装这个组件首先是解决了重复造轮子的问题,避免多个组件都要开发这个公共功能,不过它的优势不仅仅于此,还有如下优势:

1.动态处理层级关系,保证后面触发的弹窗显示在最上面。

2.当多个弹窗组件同时显示时候,保持一个遮罩层,并动态处理遮罩层关系。

本文主要包含三方面的内容

  • 动态处理层级关系

  • 遮罩层管理

  • 滚动穿透问题

动态处理层级关系
为什么要处理层级关系

大家在开发组件的时候或多或少都会遇到层级问题,如在当前页面最上方显示弹框,Toast 提示等等。那么该如何定义的它们的 zIndex 从而保证当前要显示的内容在页面最上方的呢?

平常开发的时候可能是根据业务做的具体调整 zIndex ,保证外层的组件 zindex 值最大。但是实际情况可能要复杂,随着不同开发者接手,各种业务需求迭代,在实际的开发过程中可能会出现各种的问题,很容易出现”牵一发而动全身“,改了一个 zIndex 引出了很多其他问题,甚至会出现设置 zIndex 无效的情况。下面我们来梳理下关于处理层级问题的多种解决方案。

如何设置zIndex
PLAN A

有人说可以把所有组件的 zIndex 统一,这样遵循 “后来居上” 的法则,只要开发者在代码中调整组件的顺序就能保证层级关系。

层叠水平一致、层叠顺序相同的时候,在DOM流中处于后面的元素会覆盖前面的元素。

这样处理确实能解决一部分问题,但还是有很多隐患存在。比如哪个弹窗显示在最外层可能是由用户点击顺序决定的,并不是一成不变,并且每个人的开发习惯不同,有时候很难统一,基于这种情况我们可以采用统一动态生成 zIndex 的方式:

在组件库中,我们可以对组件库的zIndex值做一个统一的处理,每次调用都动态 + 1。

1.  `let zIndex =  2000;`

2.  `function getZIndex(){`

3.  `return  ++zIndex`

4.  `}`

然后每次调用的时候都动态赋值

1.  `this.$el.style.zIndex = getZIndex();`

在组件库中可以用这种方式对 zIndex 变量统一管理,但是并不适用于所有开发情况,比如代码中充斥着各类型的组件,zIndex 没有进行统一管理。那么可以采用 PLAN B 动态去计算最大的 zIndex 。

PLAN B

这里介绍采用遍历 DOM 节点去动态计算最大 zIndex 值的方式。

1.  `getZIndex(){`

2.  `return  [...document.all].reduce((r, e)  =>  Math.max(r,  +window.getComputedStyle(e).zIndex ||  0),  0)`

3.  `}`

这个方法可以让你获得当下最大的 zIndex ,然后你在这个基础上 +1 就可以了。

层叠上下文(stacking context)

在实际开发中,光设置 zIndex 也许还不够,哪怕我们把 zIndex 设置成最大,也不一定会显示到最上面。因为 zIndex 的只在当前的层叠上下文中才会起作用。所以光设置 zIndex 是不全面的,还要考虑层叠上下文的因素。我们先来看以下代码:

1.  `
 
 
` 2. ` ` 3. `
` 4. `
` 5. ``

image

这时候图片会在遮罩层的上面,原因是他们属于一个层叠上下文中,所以 ”谁大谁上“ 。但是我们在 img 的父级别加一个 position:relative;z-index:1999; 那么就完全不同了。

1.  `
 
 
` 2. ` ` 3. `
` 4. `
`

image

这时我们发现图片跑到了遮罩层的下面。因为 img 的父级开启了新的层叠上下文,跟 overlay 相比较的 zindex 不再是 img 的 2001 而是其父级的 1999。

为了避免父级元素的影响,我们可以把遮罩层和图片放到一个层级下。只要保持一个层叠上下文中,就能保证 ”谁大谁上“ 。

回到我们的 popup 组件开发中,为了避免 zIndex 设置不生效的问题,我们也可以把遮罩层组件与弹窗组件并列放置,来避免上述问题。

1.  `
 
 
` 2. ` ` 3. `
` 4. `
`

解决了组件间层叠问题,那么多个层叠的组件均包含遮罩层的问题又显露出来,下面我们来看下关于遮罩层的管理。

遮罩层管理
为什么要进行管理

当多个带有遮罩层的组件同时显示的时候,我们需要对遮罩层进行统一管理,首先是避免多层遮罩层同时显示的情况,并且要动态调整遮罩层的 zIndex 。具体内容可以先看看下面的例子。

image

如上面的动态图所示,点击“展示弹出层” 可以看到显示一个遮罩层+弹窗,弹窗上面有两个单元格。这时候再次点击 “选择配送” 还是保持一个遮罩层,只不过它的层级发生了变化,提升到了两个单元格的之上,当选择完毕退出 “选择配送” 弹窗的时候遮罩层又还原了上一次的层级。下面我们来看下设计思路。

设计思路

层级模块管理的设计思路如下图所示:

image

1.Overlay 组件负责显示遮罩层。

2.overlay-manager 负责动态生成 zIndex 和管理 Overlay 组件,控制 Overlay 保持一个实例,并动态更新 zIndex 等。

3.popup 调用 overlay-manager 的各种方法,获取最新的 zIndex ,控制遮罩层和弹窗的显示。

4.其他组件调用 popup 组件来完成具体业务组件。

下面我们先来说下 overlay-manager 的具体实现思路。

利用 stack 储存组件内容

首先还是先说下什么是栈(stack),它是一种先进后出的数据结构,非常适合当下的场景。因为用户面对屏幕总是先去处理最上面弹出的内容,然后一层一层向下处理。

我们用一个数组实现这种方法。

1.  `let stack =  [];`

2.  `stack.push(obj);`

3.  `stack.pop()`

下面我们用代码来实现功能(缩减版)

1.  `let modalStack =  [];`

3.  `let _zIndex =  2000;`

5.  `const overlayManager =  {`

6.  `// 获取最新的zIndex`

7.  `get zIndex()  {`

8.  `return  ++_zIndex;`

9.  `},`

10.  `// 获取最外层的弹窗组件实例`

11.  `get topStack()  {`

12.  `return modalStack[modalStack.length -  1];`

13.  `},`

14.  `// 打开遮罩层 1.在 modalStack 加入最新的弹窗组件实例和配置 2.调用更新遮罩层组件方法`

15.  `openModal(vm, config)  {`

16.  `modalStack.push({`

17.  `vm,`

18.  `config`

19.  `});` 

20.  `// 更新遮罩层内容`

21.  `this.updateOverlay();`

22.  `},`

23.  `//关闭遮罩层 1.在 modalStack 中移除最后加入的组件实例和配置 2.调用更新遮罩层组件方法`

24.  `closeOverlay(vm)  {`

25.  `if  (modalStack.length)  {`

26.  `modalStack.pop();`

27.  `}`

28.  `// 更新遮罩层内容`

29.  `this.updateOverlay();`

30.  `},`

31.  `}`

整体思路如下图所示:

image

image

下面我们来看看,如何在 popup 使用 overlay-manager 的方法。

1.  `import overlayManager from "./overlay-manager.js";`

2.  `export  default  {`

3.  `props:  {`

4.  `value:  {`

5.  `type:  Boolean,`

6.  `default:  false,`

7.  `}`

8.  `},`

9.  `watch:  {`

10.  `value(val)  {`

11.  `val ?  this.open()  :  this.close();`

12.  `}`

13.  `},`

14.  `methods:  {`

15.  `open()  {`

16.  `const config =  {`

17.  `zIndex:overlayManager.zIndex,` 

18.  `};`

19.  `//渲染遮罩层`

20.  `this.renderOverlay(config);`

21.  `//为当前组件的zIndex赋值`

22.  `this.$el.style.zIndex = overlayManager.zIndex;`

23.  `},`

24.  `close(){`

25.  `//...`

26.  `},`

27.  `renderOverlay(){`

28.  `overlayManager.openModal(this);`

29.  `},`

30.  `}`

31.  `}`

在 popup 中控制弹窗和遮罩层的显示,并赋值 zIndex ,下面我们来说下如何动态更新遮罩层。

动态更新遮罩层

在上面小节中的最后在 open 和 close 的方法中都调用了 updateOverlay 函数,在说这个函数之前我们先要用 vue 实例化一个 overlay 组件,然后参数透传。

1.  `function mount(Component)  {`

2.  `const instance =  new  Vue({` 

3.  `props:  Component.props,`

4.  `render(h)  {` 

5.  `return h(Component,  {`

6.  `props:this.$props`

7.  `});`

8.  `},`

9.  `}).$mount();`

10.  `return instance;`

11.  `}`

实例化之后需要保持单例,然后动态更新这个 vue 对象挂载的位置和 props 来控制遮罩层是否显示和显示层级。

1.  `updateOverlay()  {`

2.  `const  { clickHandle, topStack }  = overlayManager;`

3.  `if  (!overlay)  {`

4.  `overlay = mount(overlayComponent);`

5.  `}` 

6.  `if  (topStack)  {`

7.  `const  { vm, config }  = topStack;`

8.  `const el = vm.$el;`

9.  `el && el.parentNode && el.parentNode.nodeType !==  11`

10.  `? el.parentNode.appendChild(overlay.$el)`

11.  `: document.body.appendChild(overlay.$el);`

13.  `Object.assign(overlay, config,  {`

14.  `value:  true,`

15.  `});`

17.  `}  else  {`

18.  `overlay.value =  false;`

19.  `}`

20.  `},`

动态更新遮罩层的位置,这个功能我们采用了 appendChild 方法,方式实现自动移动节点到新的位置的功能,是利用了它以下特性:

appendChild() 方法可向节点的子节点列表的末尾添加新的子节点, 如果文档树中已经存在了 newchild,它将从文档树中删除,然后重新插入它的新位置。

遮罩层管理的基本设计思路就是这些,难道这样就大功告成了吗?本着好人做到底,送佛送到西的精神,我们再来看看还有哪些问题,譬如说——滚动穿透问题。

滚动穿透问题
问题描述

当遮罩层显示并且滚动页面,可以透过遮罩层影响到下面的内容,如下图所示:

image

从图中可以看出,向下滚动页面,后面的背景也会跟着滚动,那么问题来了,上面明明有遮罩层,为什么还会影响到层级排在下面的内容呢?

我觉得这个问题可以从 DOM 事件流中找到原因,因为 DOM 中的事件不会只停留在目前对象上,会经历捕获阶段、目标阶段和冒泡阶段,从而会影响到其他元素。下面我们来看下具体的解决方案。

PLAN A

最简单的解决方案,就是在body上加一个样式

1.  `.nut-overflow-hidden {`

2.  `overflow: hidden;`

3.  `}`

这种解决方式并不是禁止滚动穿透,而是让背景不能滚动了,穿透功能其实还在。经过我的测试,这种方式在 pc 和 Android 运行正常,但是在 ios 上失效。为了找到在 Android 和 ios 都管用的方法,我们继续探究。

PLAN B

我们还可以使用另一个方法禁止滚动,添加 position ;当弹窗时我们在 body 标签上加上如下 class。

1.  `.nut-fixed{`

2.  `position:  fixed;`

3.  `}`

这个方法的原理跟 PLAN A 差不多,而且这个方法经过我测试,发现在 pc 、Android 和 ios 上同时都正常,不过马上就发现了一个很致命的副作用。

image

当点击弹窗时,页面会瞬间跳到顶部,为了更好的用户提体验,我们需要继续探究。

PLAN C

回到事件本身中来,换种思维方式,通过阻止 touchmove 事件来解决问题,下面我们来实践下,在遮罩层组件中加入 preventDefault

1.  `

经过测试发现在遮罩层滑动确实不会引起背景层的滚动,但是忽略的弹窗区域,在弹框区域滚动依旧会引起背景层的滚动,弹窗元素和遮罩元素在同一个父级下属于兄弟级别,所以遮罩元素的 preventDefault 自然也不会影响弹窗元素。

image

看到这里也许有人会说,直接弹窗元素中加入 preventDefault 就可以了跟遮罩层保持一样,但是弹窗输入主要展示区域,它本身就可能存在长文滚动的状态,如果把它滚动禁止,那么正常的功能就受到了影响。那么怎样在背景和弹窗都可滚动的状态下处理好这个问题呢?

image

经过一番研究,我发现了以下规律(纯个人见解,如果有误欢迎在评论区批评指正)。

在弹窗内滑动屏幕首先处理的是弹窗内的滚动,当滑动到尽头的时候,就会触发背景层的的滚动。根据这个现象我们做出以下解决方案。

当弹窗向某个方法上滚动且有滚动区间的时候允许滚动,如果没有滚动区间就禁止滚动。听起来也许有些拗口,换句说话就是保持弹窗正常滚动,禁止正在弹窗滚动之外的内容滚动。

下面我们来看下实现:

判断手势方向

要判断用户是在上滑还是在下滑,首先是记录滑动开始的位置,然后在根据滚动的最后位置来确定滑动方向。

1.  `document.addEventListener('touchstart',this.touchStart);`

2.  `document.addEventListener('touchmove',this.touchMove);` 

4.  `//...`

5.  `touchStart(event)  {` 

6.  `this.startY = event.touches[0].clientY;`

7.  `},`

9.  `touchMove(event)  {`

10.  `const touch = event.touches[0];` 

11.  `this.deltaY = touch.clientY -  this.startY;`

12.  `}`

根据 deltaY 判断方向,如果大于 0 代表向上滑动,反之代表向下。

image

获取弹窗内滚动元素

第二步我们要找到弹窗滚动元素,然后结合用户手势去判断是否当下是否已经滚动到了尽头。找到这个滚动元素以后。再通过该元素的滚动位置去判断什么时候应该禁止滚动。

1.  `getScroller(el)  {`

2.  `let node = el;` 

3.  `while  (`

4.  `node &&`

5.  `node.tagName !==  'HTML'  &&`

6.  `node.nodeType ===  1` 

7.  `)  {`

8.  `const  { overflowY }  = window.getComputedStyle(node);`

10.  `if  (/scroll|auto/i.test(overflowY))  {`

11.  `return node;`

12.  `}` 

14.  `node = node.parentNode ;`

15.  `}` 

16.  `}`

超出滚动区域外禁止滚动
1.  `const el =  this.getScroller(event.target);`

2.  `const  { scrollHeight, offsetHeight, scrollTop }  = el ? el :  this.$el;`

我们需要这三个值,scrollHeight, offsetHeight, scrollTop。

  1. scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。

  2. offsetHeight 是一个只读属性,它返回该元素的像素高度,高度包含该元素的垂直内边距和边框,且是一个整数。

  3. scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数。

我先来处理向上滑动手势的判断,判断方向 + 是否滑到顶部,当两个条件同时满足的时候禁止。

1.  `if((this.deltaY >  0  && scrollTop ===  0  )`

2.  `event.preventDefault();` 

3.  `}`

接下来我们再判断向下滑动并且滑动到底部的时候。逻辑同上

1.  `if(this.deltaY <  0  && scrollTop + offsetHeight >= scrollHeight){`

2.  `event.preventDefault();` 

3.  `}`

这样我们就在 ios 上完成了禁止滚动穿透功能。

总结

本文从两个实现角度和一个常见问题的解决方案去说明 popup 公用组件实现原理,为了让大家能够能清晰的了解逻辑主干,在示例中缩减了很多的代码。在文章中包含了很多我个人学习思考的过程,希望能对大家有所帮助。最后,附上官网的地址,NutUI 组件库目前在持续的优化和迭代中,欢迎大家使用,并提出宝贵意见。

官网地址 https://nutui.jd.com
留言地址https://github.com/jdf2e/nutui/issues

服务推荐

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值