本文由體驗技術團隊 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 += scrollTop
parentRect.bottom += scrollTop
parentRect.left += scrollLeft
parentRect.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 -= adjustTop
left -= 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 code
else 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.documentElement
boundaries = {
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獲取主應用的window
globalConfig.viewportWindow = window.parent
}
getBoundaries 方法也相對應做一下修改
/** 計算邊界邏輯 */
const getBoundaries = (
data: UpdateData,
padding: number,
boundariesElement: string | HTMLElement) => {
// ... other code
// 新增程式碼
const viewportWindow = globalConfig.viewportWindow || window
const docElement = viewportWindow.document.documentElement
boundaries = {
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標籤,一起參與開源貢獻~