OpenTiny Vue 元件庫適配微前端可能遇到的4個問題

發表於2024-02-26

本文由體驗技術團隊 TinyVue 專案成員岑灌銘同學創作。

前言

微前端是一種多個團隊透過獨立釋出功能的方式來共同構建現代化 web 應用的技術手段及方法策略,每個應用可以選擇不同的技術棧,獨立開發、獨立部署。

TinyVue元件庫的跨技術棧能力與微前端十分契合,往期我們也有文章,指導如何在wujie微前端中使用TinyVue元件庫,文章連結:https://mp.weixin.qq.com/s/ZqDXemh0GfnQpWACdzXdig

目前許多對微前端有需求的使用者已經在使用wujieTinyVue開發了,在使用了一段時間後,合作企業使用者和個人使用者反饋了元件庫一些問題。經過一番交流、溝通與定位,最終發現是使用者接入了微前端框架後,在特定場景下導致的一系列問題,在非微前端應用中,元件庫執行良好。

復現問題後,透過一系列排查與分析,最終總結出了四個問題:

  1. absolute 定位的彈出元素錯位,且頁面滾動不會重新定位
  2. fixed 定位的彈出元素錯位
  3. 彈出元素位置發生翻轉
  4. 表格中的 select 點選後,下拉選項出現後馬上消失

對於以上問題,TinyVue元件庫做了相應的適配以及給使用者提供瞭解決方案,最終使得 TinyVue 元件良好執行在wujie微前端中。

首先來簡單介紹一下wujie微前端實現原理,wujie微前端是採用iframe+webcomponet的實現方式。透過iframes實現js沙箱能力。子應用的例項instanceiframe內執行,dom在主應用容器下的webcomponent內,透過代理 iframedocumentwebcomponent,可以實現兩者的互聯。

想要了解更多可以檢視,無界微前端介紹: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中,modalfixed定位,因此表單輸入框也是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彈窗情況下,parentscrollParent都是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微前端下,點選selectevent.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/vue3.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🌟TinyEngineTinyVueTinyNGTinyCLI~
如果你也想要共建,可以進入程式碼倉庫,找到 good first issue標籤,一起參與開源貢獻~

相關文章