本文由體驗技術團隊 TinyVue 項目成員岑灌銘同學創作。
前言
微前端是一種多個團隊通過獨立發布功能的方式來共同構建現代化 web 應用的技術手段及方法策略,每個應用可以選擇不同的技術棧,獨立開發、獨立部署。
TinyVue
組件庫的跨技術棧能力與微前端十分契合,往期我們也有文章,指導如何在wujie
微前端中使用TinyVue
組件庫,文章鏈接:https://mp.weixin.qq.com/s/ZqDXemh0GfnQpWACdzXdig
目前許多對微前端有需求的用戶已經在使用wujie
和TinyVue
開發了,在使用了一段時間后,合作企業用戶和個人用戶反饋了組件庫一些問題。經過一番交流、溝通與定位,最終發現是用戶接入了微前端框架后,在特定場景下導致的一系列問題,在非微前端應用中,組件庫運行良好。
復現問題后,通過一系列排查與分析,最終總結出了四個問題:
- absolute 定位的彈出元素錯位,且頁面滾動不會重新定位
- fixed 定位的彈出元素錯位
- 彈出元素位置發生翻轉
- 表格中的 select 點擊后,下拉選項出現后馬上消失
對于以上問題,TinyVue
組件庫做了相應的適配以及給用戶提供了解決方案,最終使得 TinyVue 組件良好運行在wujie
微前端中。
首先來簡單介紹一下wujie
微前端實現原理,wujie
微前端是采用iframe
+webcomponet
的實現方式。通過iframes
實現js
沙箱能力。子應用的實例instance
在iframe
內運行,dom
在主應用容器下的webcomponent
內,通過代理 iframe
的document
到webcomponent
,可以實現兩者的互聯。
想要了解更多可以查看,無界微前端介紹:https://wujie-micro.github.io/doc/guide/
接下來展開說一下,收集總結的四個問題~
問題總結
問題一:absolute 定位的彈出元素錯位,且頁面滾動不會重新定位。
“彈出元素錯位”錯誤原因分析:
打開控制臺,審查元素查看樣式,看到element.sytle
的第一直覺是transfrom
的偏移量計算不正確,順著這個線索排查計算錯誤的原因。
排查前先簡單介紹一下TinyVue
組件庫這個偏移量的計算規則:
1.找到彈出元素的 offsetParent(父定位元素),如果沒有則返回body
。
2.使用 getBoundingClientRect 計算 offsetParent 以及引用元素(圖中的輸入框,簡稱為reference
)距離視口的位置信息。
3.以彈出元素放右邊為例,transform
的左偏移量的計算規則為reference.left - offsetParent.left + reference.width
因為彈出元素的position
設置為absolute
,所以彈出元素的定位是根據其offsetParent
計算位置的,沒有offsetParent
則是根據視口來計算位置。
上述例子中,彈出元素的offsetParent
為 null,因此默認返回了body
作為其offsetParent
,絕大部分情況下,body
和視口左側和上側是對齊的,因此用body
計算的偏移量,在視口上也適用。
在微前端中,子應用的body
可能相對于視口有偏移。彈出元素的偏移量實際是根據body
計算的,但他是非定位元素,最終導致的元素錯位。
解決方案:
既然計算規則是根據body
計算的,那么將子應用將body
設置為position: relative
將其變為定位元素即可。
滾動不會重新定位原因分析:
首先還是簡單介紹組件庫這部分邏輯:
1.通過parentNode
向上查找引用元素(輸入框)的可滾動的祖先元素(如果沒有配置冒泡則返回第一個可滾動祖先元素,否則返回所有可滾動祖先元素)
2.為步驟1
獲取到的元素加上滾動方法的監聽。
3.祖先元素滾動時重新計算彈出元素的位置,使彈出元素跟隨引用元素。
但是在wujie
微前端中,子應用的document
再往上查找就是null
了。而滾動條在主應用當中。因此主應用的滾動無法被監聽到。
解決方案:
將子應用將body
設置為position: relative
同樣也解決了上述問題。設置后,只有當子應用內滾動條滾動后才需要重新計算。
問題二: fixed 定位的彈出元素錯位。
在修復的問題一的情況下,依舊有部分情況會出現彈出元素錯位的 bug。并且下圖中可以看到,彈出元素從右邊翻轉到了左邊。
原因分析:
表單元素在modal
中,modal
是fixed
定位,因此表單輸入框也是fixed
定位。由于引用元素是fixed
定位,所以彈出元素與之相對應也應該使用fixed
定位。
組件庫邏輯對于fixed
定位的彈出元素偏移量的計算,在問題一提到的步驟下還增加了部分特殊處理。下面代碼是計算偏移量邏輯其中較為關鍵的一段代碼:
/*** @description 計算彈出元素的偏移量* @param el 引用元素* @param parent 彈出元素的祖先定位元素* @param fixed 彈出元素是否絕對定位* @returns 用于計算偏移量的相關信息*/
const getOffsetRectRelativeToCustomParent = (el: HTMLElement,parent: HTMLElement,fixed: boolean) => {let { top,left,width,height } = getBoundingClientRect(el)let parentRect = getBoundingClientRect(parent)if (fixed) {let { scrollTop,scrollLeft } = getScrollParent(parent)parentRect.top += scrollTopparentRect.bottom += scrollTopparentRect.left += scrollLeftparentRect.right += scrollLeft}let rect = {top: top - parentRect.top,left: left - parentRect.left,bottom: top - parentRect.top + height,right: left - parentRect.left + width,width,height}return rect
}
已上述代碼為例,上述邏輯Modal
彈窗情況下,parent
和scrollParent
都是body
。
21-30行代碼的目的是,為了解決在body
在滾動后,parentRect.top
為負數,需要加上scrollTop
才是相對視口的偏移量。
但是上面的計算邏輯有個大前提,那就是body
的左側和上側和視口一致,上面這段不太嚴謹的邏輯經過漫長的迭代,直到在微前端中’暴雷’。
解決方案:
當position
設置為fixed
后,彈出元素在絕大多數情況都是相對視口定位了,但是也有特殊情況,以下是 mdn 文檔的截圖:
為了兼容上述的特殊情況,新增了getAdjustOffset
方法,此方法計算相對于視口的修正偏移量,設置 top 和 left 為0,使用getBoundingClientRect
計算出來的結果不為0的話,多出來的偏移量就是因為上述的 css 樣式影響了,
獲取這個修正偏移量后,后續的計算只需要加上這個偏移量,彈出元素和reference
元素的位置就能夠正確對應上了。
以下是修改后的相關核心代碼:
/** 設置transform等樣式后,fixed定位不再相對于視口,* 使用1乘1px透明元素獲取fixed定位相對于視口的修正偏移量。
**/
const getAdjustOffset = (parent: HTMLElement) => {const placeholder = document.createElement('div')setStyle(placeholder, {opacity: 0,position: 'fixed',width: 1,height: 1,top: 0,left: 0,'z-index': '-99'})parent.appendChild(placeholder)// 正常應返回 { transform: translateY( 0, left: 0 }// 否則就是被特殊的css樣式影響了const result = getBoundingClientRect(placeholder)parent.rem)oveChild(placeholder)return result
}/*** @description 計算彈出元素的偏移量* @param el 引用元素* @param parent 彈出元素的祖先定位元素* @param fixed 彈出元素是否絕對定位* @returns 用于計算偏移量的相關信息*/
const getOffsetRectRelativeToCustomParent = (el: HTMLElement,parent: HTMLElement,fixed: boolean,popper: HTMLElement
) => {let { top,left,width,height} = getBoundingClientRect(el)// 如果是fixed定位,需計算要修正的偏移量。if (fixed) {if (popper.parentElement) {const { top: adjustTop,left: adjustLeft} = getAdjustOffset(popper.parentElement)top -= adjustTopleft -= adjustLeft}return {top,left,bottom: top + height,right: left + width,width,height}}let parentRect = getBoundingClientRect(parent)let rect = {top: top - parentRect.top,left: left - parentRect.left,bottom: top - parentRect.top + height,right: left - parentRect.left + width,width,height}return rect
}
問題三:彈出元素位置發生翻轉
在問題二的截圖中除了彈出元素錯位問題,還有另外一個問題:彈出元素發生了翻轉。
原因分析:
彈出類的元素,存在一個邊界檢測邏輯,當計算出彈出元素超出邊界后,為了展示的完整性和美觀,會自動將元素翻轉。
在用戶沒有特定配置的情況下,默認的邊界為’視口’,下面是關于邊界計算邏輯的節選:
/** 計算邊界邏輯 */
const getBoundaries = (data: UpdateData,padding: number,boundariesElement: string | HTMLElement) => {// ... other codeelse if (boundariesElement === 'viewport') {let offsetParent = getOffsetParent(this._popper)let scrollParent = getScrollParent(this._popper)let offsetParentRect = getOffsetRect(offsetParent)let isFixed = data.offsets.popper.position === 'fixed'let scrollTop = isFixed ? 0 : getScrollTopValue(scrollParent)let scrollLeft = isFixed ? 0 : getScrollLeftValue(scrollParent)const docElement = window.document.documentElementboundaries = {top: 0 - (offsetParentRect.top - scrollTop),right: docElement.clientWidth - (offsetParentRect.left - scrollLeft),bottom: docElement.clientHeight - (offsetParentRect.top - scrollTop),left: 0 - (offsetParentRect.left - scrollLeft)}
}// ... other code
}
可以看到,視口的邊界計算邏輯和window.document.documentElement
也就是html
有關。組件庫運行在子應用中,因此這里也就是子應用的html
。但在子應用中,html
的寬高可能會比真實視口小得多,導致邊界計算被約束在子應用范圍當中,觸發了翻轉邏輯,導致了錯誤的翻轉。
解決方案: 組件庫對外暴露一個全局配置,用戶在子應用中可以引入全局配置,將主應用的 window
賦值給全局配置的 viewportWindow
用于邊界判斷。
import globalConfig from '@opentiny/vue-renderless/common/global'// 需要判斷是否在子應用當中
if (window.__POWERED_BY_WUJIE__) {// 子應用中可以通過window.parent獲取主應用的windowglobalConfig.viewportWindow = window.parent
}
getBoundaries 方法也相對應做一下修改
/** 計算邊界邏輯 */
const getBoundaries = (data: UpdateData,padding: number,boundariesElement: string | HTMLElement) => {// ... other code// 新增代碼const viewportWindow = globalConfig.viewportWindow || windowconst docElement = viewportWindow.document.documentElementboundaries = {top: 0 - (offsetParentRect.top - scrollTop),right: docElement.clientWidth - (offsetParentRect.left - scrollLeft),bottom: docElement.clientHeight - (offsetParentRect.top - scrollTop),left: 0 - (offsetParentRect.left - scrollLeft)}// ... other code
}
問題四:表格中的select點擊后,下拉選項出現后馬上消失
原因分析:
當開啟表格編輯狀態時,表格默認處于顯示狀態,當點擊表格某一行時,會進入到編輯狀態。當點擊表格此行外的其他區域,表格就會清除編輯狀態,進入顯示狀態。
是否點擊外部是通過監聽document
的點擊事件,當點擊任意元素后,都會被冒泡捕獲,組件庫使用點擊事件的event.target
來判斷用戶是否點擊了表格編輯行以外的元素。
正常情況下,點擊select,event.target
能夠找select
對應的元素,可以正常的判斷select
元素是在對應的容器中,則不會切換至顯示狀態。
在wujie
微前端下,點擊select
,event.target
找到的是wujie-app
。這個問題是瀏覽器原生的處理,詳情可以參考:https://javascript.info/shadow-dom-events 此時wujie-app
不在對應的容器內,認為點擊了對應行以外的區域,因此切換至顯示狀態,下拉選項消失。
解決方案:
組件庫加入兼容邏輯,獲取 event.target
的方式修改成: (e.target.shadowRoot && e.composed) ? (e.composedPath()[0] || e.target) : e.target
。
加入兼容邏輯后,無論組件是否運行在微前端中,點擊事件都能找到真實點擊的dom
元素,因此問題也就解決了。
結語
總體而言,上述遇到的問題主要原因有兩個,其一是 wujie 微前端中,子應用的window
和視口window
不是同一個。其二是webcomponent
內部元素事件冒泡被外部元素捕獲時,event.target
會被代理到webcomponent
跟元素上導致的目標判斷錯誤。
針對問題一,整體的解決思路是要么將作用范圍限定在子應用當中,例如問題一解決方案,給子應用body
加上樣式position: relative
。要么是通過類似依賴注入的方式,讓相關邏輯可以正確地獲取到主應用的window
。
針對問題二,思路就非常明確了,目標就是要找到正確的event.target
,通過加上兼容代碼后,無論是否在webcompoent
中,都能正確返回event.target
當然以上提到的問題,已經在@opentiny/vue
的3.13.0
新版本發布修復了,歡迎下載使用~
關于 OpenTiny
OpenTiny 是一套企業級 Web 前端開發解決方案,提供跨端、跨框架、跨版本的 TinyVue 組件庫,包含基于 Angular+TypeScript 的 TinyNG 組件庫,擁有靈活擴展的低代碼引擎 TinyEngine,具備主題配置系統TinyTheme / 中后臺模板 TinyPro/ TinyCLI 命令行等豐富的效率提升工具,可幫助開發者高效開發 Web 應用。
歡迎加入 OpenTiny 開源社區。添加微信小助手:opentiny-official 一起參與交流前端技術~更多視頻內容也可關注B站、抖音、小紅書、視頻號
OpenTiny 也在持續招募貢獻者,歡迎一起共建
OpenTiny 官網:https://opentiny.design/
OpenTiny 代碼倉庫:https://github.com/opentiny/
TinyVue 源碼:https://github.com/opentiny/tiny-vue
TinyEngine 源碼: https://github.com/opentiny/tiny-engine
歡迎進入代碼倉庫 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~
如果你也想要共建,可以進入代碼倉庫,找到 good first issue標簽,一起參與開源貢獻~