1. 程式人生 > >元件 popup 設計和原始碼剖析

元件 popup 設計和原始碼剖析

## 前言 [NutUI](http://nutui.jd.com) 是一套京東風格的移動端 Vue 元件庫,生態系統覆蓋面廣,支援按需載入、主題定製、多語言等,功能強大。目前 40+ 京東專案正在使用,設計精美,風格統一。在開發元件庫的過程中,NutUI 是如何處理元件間的層級關係的呢?今天就給大家解析 NutUI 中具有處理層級關係的**公共**元件 [popup](http://nutui.jd.com/#/Popup)。 ![](https://img12.360buyimg.com/imagetools/jfs/t1/132109/30/2584/57307/5eec1f89E8a2647a6/4952c8d6378313ac.jpg)  ## 1. 什麼是 popup 它是一個**公共**元件,很多帶有彈出層的元件都是基於這個元件開發的。封裝這個元件首先是解決了重複造輪子的問題,避免多個元件都要開發這個公共功能,不過它的優勢不僅僅於此,還有如下優勢: **1.動態處理層級關係,保證後面觸發的彈窗顯示在最上面。** **2.當多個彈窗元件同時顯示時候,保持一個遮罩層,並動態處理遮罩層關係。** **本文主要包含三方面的內容** - 動態處理層級關係 - 遮罩層管理 - 滾動穿透問題 ## 2. 動態處理層級關係 ### 2.1. 為什麼要處理層級關係 大家在開發元件的時候或多或少都會遇到層級問題,如在當前頁面最上方顯示彈框,Toast 提示等等。那麼該如何定義的它們的 zIndex 從而保證當前要顯示的內容在頁面最上方的呢? 平常開發的時候可能是根據業務做的具體調整 zIndex ,保證外層的元件 zindex 值最大。但是實際情況可能要複雜,隨著不同開發者接手,各種業務需求迭代,在實際的開發過程中可能會出現各種的問題,很容易出現”牽一髮而動全身“,改了一個 zIndex 引出了很多其他問題,甚至會出現設定 zIndex 無效的情況。下面我們來梳理下關於處理層級問題的多種解決方案。 ### 2.2. 如何設定zIndex #### 2.2.1. PLAN A 有人說可以把所有元件的 zIndex 統一,這樣遵循 **“後來居上”** 的法則,只要開發者在程式碼中調整元件的順序就能保證層級關係。 >層疊水平一致、層疊順序相同的時候,在DOM流中處於後面的元素會覆蓋前面的元素。 這樣處理確實能解決一部分問題,但還是有很多隱患存在。比如哪個彈窗顯示在最外層可能是由使用者點選順序決定的,並不是一成不變,並且每個人的開發習慣不同,有時候很難統一,基於這種情況我們可以採用統一動態生成 zIndex 的方式: 在元件庫中,我們可以對元件庫的zIndex值做一個統一的處理,每次呼叫都動態 + 1。 ```js let zIndex = 2000; function getZIndex(){ return ++zIndex } ``` 然後每次呼叫的時候都動態賦值 ```js this.$el.style.zIndex = getZIndex(); ``` 在元件庫中可以用這種方式對 zIndex 變數統一管理,但是並不適用於所有開發情況,比如程式碼中充斥著各型別的元件,zIndex 沒有進行統一管理。那麼可以採用 PLAN B 動態去計算最大的 zIndex 。 #### 2.2.2. PLAN B 這裡介紹採用遍歷 DOM 節點去動態計算最大 zIndex 值的方式。 ```js getZIndex(){ return [...document.all].reduce((r, e) => Math.max(r, +window.getComputedStyle(e).zIndex || 0), 0) } ``` 這個方法可以讓你獲得當下最大的 zIndex ,然後你在這個基礎上 +1 就可以了。 ### 2.3. 層疊上下文(stacking context) 在實際開發中,光設定 zIndex 也許還不夠,哪怕我們把 zIndex 設定成最大,也不一定會顯示到最上面。因為 zIndex 的只在當前的層疊上下文中才會起作用。所以光設定 zIndex 是不全面的,還要考慮層疊上下文的因素。 我們先來看以下程式碼: ```html ``` ![](https://img10.360buyimg.com/imagetools/jfs/t1/126810/16/3635/13613/5ed4721fEa3637126/274f8c303ff0153b.jpg) 這時候圖片會在遮罩層的上面,原因是他們屬於一個層疊上下文中,所以 ”誰大誰上“ 。但是我們在 img 的父級別加一個 ```position:relative;z-index:1999;``` 那麼就完全不同了。 ```html ``` ![](https://img12.360buyimg.com/imagetools/jfs/t1/134744/25/950/10048/5ed47224E33891d36/8992ba9fd4b64625.jpg) 這時我們發現圖片跑到了遮罩層的下面。因為 img 的父級開啟了新的層疊上下文,跟 overlay 相比較的 zindex 不再是 img 的 2001 而是其父級的 1999。 為了避免父級元素的影響,我們可以把遮罩層和圖片放到一個層級下。只要保持一個層疊上下文中,就能保證 ”誰大誰上“ 。 回到我們的 popup 元件開發中,為了避免 zIndex 設定不生效的問題,我們也可以把遮罩層元件與彈窗元件並列放置,來避免上述問題。 ```html ``` 解決了元件間層疊問題,那麼多個層疊的元件均包含遮罩層的問題又顯露出來,下面我們來看下關於遮罩層的管理。 ## 3. 遮罩層管理 ### 3.1. 為什麼要進行管理 當多個帶有遮罩層的元件同時顯示的時候,我們需要對遮罩層進行統一管理,首先是避免多層遮罩層同時顯示的情況,並且要動態調整遮罩層的 zIndex 。具體內容可以先看看下面的例子。 ![](https://img14.360buyimg.com/imagetools/jfs/t1/137160/11/72/1646076/5ec871e0E8b90c9b0/3fe509939430e4c2.gif) 如上面的動態圖所示,點選“展示彈出層” 可以看到顯示一個遮罩層+彈窗,彈窗上面有兩個單元格。這時候再次點選 “選擇配送” 還是保持一個遮罩層,只不過它的**層級**發生了變化,提升到了兩個單元格的之上,當選擇完畢退出 “選擇配送” 彈窗的時候遮罩層又還原了上一次的層級。下面我們來看下設計思路。 #### 3.1.1. 設計思路 層級模組管理的設計思路如下圖所示: ![](https://img10.360buyimg.com/imagetools/jfs/t1/131917/1/1156/290012/5ed609dfEaf26342b/36beb30d2547aa88.jpg) 1.Overlay 元件負責顯示遮罩層。 2.overlay-manager 負責動態生成 zIndex 和管理 Overlay 元件,控制 Overlay 保持一個例項,並動態更新 zIndex 等。 3.popup 呼叫 overlay-manager 的各種方法,獲取最新的 zIndex ,控制遮罩層和彈窗的顯示。 4.其他元件呼叫 popup 元件來完成具體業務元件。 下面我們先來說下 overlay-manager 的具體實現思路。 ### 3.2. 利用 stack 儲存元件內容 首先還是先說下什麼是棧(stack),它是一種先進後出的資料結構,非常適合當下的場景。因為使用者面對螢幕總是先去處理最上面彈出的內容,然後一層一層向下處理。 我們用一個數組實現這種方法。 ```js let stack = []; stack.push(obj); stack.pop() ``` 下面我們用程式碼來實現功能(縮減版) ```js let modalStack = []; let _zIndex = 2000; const overlayManager = { // 獲取最新的zIndex get zIndex() { return ++_zIndex; }, // 獲取最外層的彈窗元件例項 get topStack() { return modalStack[modalStack.length - 1]; }, // 開啟遮罩層 1.在 modalStack 加入最新的彈窗元件例項和配置 2.呼叫更新遮罩層元件方法 openModal(vm, config) { modalStack.push({ vm, config }); // 更新遮罩層內容 this.updateOverlay(); }, //關閉遮罩層 1.在 modalStack 中移除最後加入的元件例項和配置 2.呼叫更新遮罩層元件方法 closeOverlay(vm) { if (modalStack.length) { modalStack.pop(); } // 更新遮罩層內容 this.updateOverlay(); }, } ``` 整體思路如下圖所示: ![](https://img13.360buyimg.com/imagetools/jfs/t1/135053/19/2438/42244/5ee98a9dE7cf4d128/e6a21adaf4e1a462.jpg) ![](https://img10.360buyimg.com/imagetools/jfs/t1/129103/8/5080/43573/5ee98c48E1973803f/920a97cf57467203.jpg) 下面我們來看看,如何在 popup 使用 overlay-manager 的方法。 ```js import overlayManager from "./overlay-manager.js"; export default { props: { value: { type: Boolean, default: false, } }, watch: { value(val) { val ? this.open() : this.close(); } }, methods: { open() { const config = { zIndex:overlayManager.zIndex, }; //渲染遮罩層 this.renderOverlay(config); //為當前元件的zIndex賦值 this.$el.style.zIndex = overlayManager.zIndex; }, close(){ //... }, renderOverlay(){ overlayManager.openModal(this); }, } } ``` 在 popup 中控制彈窗和遮罩層的顯示,並賦值 zIndex ,下面我們來說下如何動態更新遮罩層。 ### 3.3. 動態更新遮罩層 在上面小節中的最後在 open 和 close 的方法中都呼叫了 updateOverlay 函式,在說這個函式之前我們先要用 vue 例項化一個 overlay 元件,然後引數透傳。 ```js function mount(Component) { const instance = new Vue({ props: Component.props, render(h) { return h(Component, { props:this.$props }); }, }).$mount(); return instance; } ``` 例項化之後需要保持單例,然後動態更新這個 vue 物件掛載的位置和 props 來控制遮罩層是否顯示和顯示層級。 ```js updateOverlay() { const { clickHandle, topStack } = overlayManager; if (!overlay) { overlay = mount(overlayComponent); } if (topStack) { const { vm, config } = topStack; const el = vm.$el; el && el.parentNode && el.parentNode.nodeType !== 11 ? el.parentNode.appendChild(overlay.$el) : document.body.appendChild(overlay.$el); Object.assign(overlay, config, { value: true, }); } else { overlay.value = false; } }, ``` 動態更新遮罩層的位置,這個功能我們採用了 [appendChild](https://developer.mozilla.org/zh-CN/docs/Web/API/Node/appendChild) 方法,方式實現自動移動節點到新的位置的功能,是利用了它以下特性: > appendChild() 方法可向節點的子節點列表的末尾新增新的子節點, 如果文件樹中已經存在了 newchild,它將從文件樹中刪除,然後重新插入它的新位置。 遮罩層管理的基本設計思路就是這些,難道這樣就大功告成了嗎?本著好人做到底,送佛送到西的精神,我們再來看看還有哪些問題,譬如說——滾動穿透問題。 ## 4. 滾動穿透問題 ### 4.1. 問題描述 當遮罩層顯示並且滾動頁面,可以透過遮罩層影響到下面的內容,如下圖所示: ![](https://img12.360buyimg.com/imagetools/jfs/t1/123014/10/2313/771761/5ec4dc82E680cfecc/92640bee2a04f130.gif) 從圖中可以看出,向下滾動頁面,後面的背景也會跟著滾動,那麼問題來了,上面明明有遮罩層,為什麼還會影響到層級排在下面的內容呢? 我覺得這個問題可以從 DOM 事件流中找到原因,因為 DOM 中的事件不會只停留在目前物件上,會經歷捕獲階段、目標階段和冒泡階段,從而會影響到其他元素。下面我們來看下具體的解決方案。 ### 4.2. PLAN A 最簡單的解決方案,就是在body上加一個樣式 ```css .nut-overflow-hidden { overflow: hidden; } ``` 這種解決方式並不是禁止滾動穿透,而是讓背景不能滾動了,穿透功能其實還在。經過我的測試,這種方式在 pc 和 Android 執行正常,但是在 ios 上失效。為了找到在 Android 和 ios 都管用的方法,我們繼續探究。 ### 4.3. PLAN B 我們還可以使用另一個方法禁止滾動,新增 ```position``` ;當彈窗時我們在 body 標籤上加上如下 class。 ```css .nut-fixed{ position: fixed; } ``` 這個方法的原理跟 PLAN A 差不多,而且這個方法經過我測試,發現在 pc 、Android 和 ios 上同時都正常,不過馬上就發現了一個很致命的副作用。 ![](https://img12.360buyimg.com/imagetools/jfs/t1/125167/22/3691/1105154/5ed4e991E94ef22c6/99025247c45ce1ec.gif) 當點選彈窗時,頁面會瞬間跳到頂部,為了更好的使用者提體驗,我們需要繼續探究。 ### 4.4. PLAN C 回到事件本身中來,換種思維方式,通過阻止 touchmove 事件來解決問題,下面我們來實踐下,在遮罩層元件中加入 ``preventDefault``: ```html export default { name: "nut-popup-mask", methods:{ touchmove(e){ e.preventDefault(); } } } }; ``` 經過測試發現在遮罩層滑動確實不會引起背景層的滾動,但是忽略的彈窗區域,在彈框區域滾動依舊會引起背景層的滾動,彈窗元素和遮罩元素在同一個父級下屬於兄弟級別,所以遮罩元素的 preventDefault 自然也不會影響彈窗元素。 ![](https://img10.360buyimg.com/imagetools/jfs/t1/124550/26/2404/30898/5ec5f220Eb399af50/2ebf5d61a97df684.jpg) 看到這裡也許有人會說,直接彈窗元素中加入 preventDefault 就可以了跟遮罩層保持一樣,但是彈窗輸入主要展示區域,它本身就可能存在長文滾動的狀態,如果把它滾動禁止,那麼正常的功能就受到了影響。那麼怎樣在背景和彈窗都可滾動的狀態下處理好這個問題呢? ![](https://img10.360buyimg.com/imagetools/jfs/t1/120796/17/3724/2620128/5ed4f519E11d7539c/4d82c53ed43b184f.gif) 經過一番研究,我發現了以下規律(純個人見解,如果有誤歡迎在評論區批評指正)。 在彈窗內滑動螢幕首先處理的是彈窗內的滾動,當滑動到盡頭的時候,就會觸發背景層的的滾動。根據這個現象我們做出以下解決方案。 當彈窗向某個方法上滾動且有滾動區間的時候允許滾動,如果沒有滾動區間就禁止滾動。聽起來也許有些拗口,換句說話就是保持彈窗正常滾動,禁止正在彈窗滾動之外的內容滾動。 下面我們來看下實現: #### 4.4.1. 判斷手勢方向 要判斷使用者是在上滑還是在下滑,首先是記錄滑動開始的位置,然後在根據滾動的最後位置來確定滑動方向。 ```js document.addEventListener('touchstart',this.touchStart); document.addEventListener('touchmove',this.touchMove); //... touchStart(event) { this.startY = event.touches[0].clientY; }, touchMove(event) { const touch = event.touches[0]; this.deltaY = touch.clientY - this.startY; } ``` 根據 deltaY 判斷方向,如果大於 0 代表向上滑動,反之代表向下。 ![](https://img11.360buyimg.com/imagetools/jfs/t1/125680/37/3571/6914/5ed4aba7E34918aae/13c6c1572e58a35f.jpg) #### 4.4.2. 獲取彈窗內滾動元素 第二步我們要找到彈窗滾動元素,然後結合使用者手勢去判斷是否當下是否已經滾動到了盡頭。找到這個滾動元素以後。再通過該元素的滾動位置去判斷什麼時候應該禁止滾動。 ```js getScroller(el) { let node = el; while ( node && node.tagName !== 'HTML' && node.nodeType === 1 ) { const { overflowY } = window.getComputedStyle(node); if (/scroll|auto/i.test(overflowY)) { return node; } node = node.parentNode ; } } ``` #### 4.4.3. 超出滾動區域外禁止滾動 ```js const el = this.getScroller(event.target); const { scrollHeight, offsetHeight, scrollTop } = el ? el : this.$el; ``` 我們需要這三個值,scrollHeight, offsetHeight, scrollTop。 1. scrollHeight 這個只讀屬性是一個元素內容高度的度量,包括由於溢位導致的檢視中不可見內容。 2. offsetHeight 是一個只讀屬性,它返回該元素的畫素高度,高度包含該元素的垂直內邊距和邊框,且是一個整數。 3. scrollTop 屬性可以獲取或設定一個元素的內容垂直滾動的畫素數。 我先來處理向上滑動手勢的判斷,判斷方向 + 是否滑到頂部,當兩個條件同時滿足的時候禁止。 ```js if((this.deltaY > 0 && scrollTop === 0 ) event.preventDefault(); } ``` 接下來我們再判斷向下滑動並且滑動到底部的時候。邏輯同上 ```js if(this.deltaY < 0 && scrollTop + offsetHeight >= scrollHeight){ event.preventDefault(); } ``` 這樣我們就在 ios 上完成了禁止滾動穿透功能。 ## 5. 總結 本文從兩個實現角度和一個常見問題的解決方案去說明 popup 公用元件實現原理,為了讓大家能夠能清晰的瞭解邏輯主幹,在示例中縮減了很多的程式碼。在文章中包含了很多我個人學習思考的過程,希望能對大家有所幫助。最後,附上官網的地址,NutUI 元件庫目前在持續的優化和迭代中,歡迎大家使用,並提出寶貴意見。 1.官網地址 [https://nutui.jd.com](https://nutui.jd.com) 2.留言地址[https://github.com/jdf2e/nutui/issues](https://github.com/jdf2e/nutui/issues) ![](https://img12.360buyimg.com/imagetools/jfs/t1/109412/17/9967/13061/5e7850a9E129ba892/9282c0b2999488d4.png) 以夢為馬,不負韶華,流年笑擲,未來可期,前進的道路上需要你我一起共同努力,