微前端如何做樣式隔離?
問題示例
className 命名重復導致的樣式沖突
我們先創建一個問題,驗證樣式沖突的存在:
在主應用和子應用上分別使用 div 元素插入一段標題,兩個 div 元素使用相同的 class 名 title,分別在 class 中設置文字顏色,主應用 color 值為 yellow,子應用為 red。
由于子應用的樣式晚于主應用加載,所以主應用的樣式會被覆蓋。
以上問題在同時加載多個子應用時也會存在:各個應用之間也可能存在同名的 className 或者給相同條件的選擇器添加了樣式, 那么最終只有優先級最高的樣式才會生效。要確保應用之間的樣式不會互相影響,就需要對應用間的樣式進行隔離。
html、body 標簽的樣式沖突
html 、 body 標簽, 在各個應用中都是唯一的元素,其樣式必然會對主應用的樣式產生影響。
解決方案
為了以上樣式沖突問題,通常有以下兩種思路:
- 通過樣式命名 & 樣式優先級解決
- 通過宿主環境隔離來達到樣式隔離
樣式命名 & 樣式優先級
假設各個應用之間的樣式 className 都是全局唯一的, 那么不同 className 下的樣式就一定不會發生沖突。
再加上樣式優先級來配合解決,就能解決標簽選擇器的樣式沖突:
- 例如在原 className 、標簽選擇器前面再添加一個 selector
- 標簽選擇器 + 屬性選擇器
子應用改造
這里需要處理的樣式也分為以下兩種:
UI 組件庫等引入的全局樣式
默認情況下,UI 組件庫的 prefixCls 都是相同的,不過它們提供了 ConfigProvider 可以用來修改 UI 組件庫全局樣式的 prefixCls:全局化配置 ConfigProvider - Ant Design[1] 全局配置 ConfigProvider | ArcoDesign[2]
自定義樣式
通過 BEM、CSS Modules、 CSS in JS 等手段來獲得與其他應用不同的選擇器名,來規避樣式沖突。
或者直接使用 postcss 的插件,在編譯階段給所有樣式添加 prefix selector。
https://github.com/RadValentin/postcss-prefix-selector
主應用在運行時統一轉換樣式
如果不想或者無法干涉子應用的打包配置時,我們也可以通過主應用在運行時給所有樣式規則添加 prefix selector,來提升樣式優先級。
比如 A 應用的類選擇器 .title,在轉換后變成 #garfish_app_id_xxx .title,#garfish_app_id_xxx 是子應用最外層元素的 id,故保證該應用下的樣式優先級變高,并讓其只作用在當前應用下。
當然, 僅使用以上手段并不能解決子應用 html、 body 標簽給主應用帶來的樣式影響,細心的你可能已經發現,garfish 會為每個子應用創建一個假的 html 與 body 元素,然后對應子元素的 html 、body 樣式都會應用到這個假的 html、 body元素上。
Garfish
在 garfish 中是以插件來支持運行時轉換樣式的:
具體的源碼實現在這里:
https://github.com/modern-js-dev/garfish/tree/1f83e8fb35fd2ac12785fc7410015c3cd23c3bd2/packages/css-scope
優點
支持大部分樣式隔離需求,能夠同時處理 UI 組件庫的全局樣式與自定義樣式,比較省心。
缺點
運行時處理樣式,會有一定性能損耗
如果其他子應用或者主應用中使用了 !important ...
宿主環境隔離
Shadow DOM
附加并隱藏在常規 DOM 下的節點叫做 Shadow DOM —— 它以 Shadow root 節點為起始根節點,在這個根節點的下方,可以是任意元素,就和普通的 DOM 元素一樣,它可以通過方法添加子節點、設置屬性,以及為節點添加自己的樣式,隱藏的 DOM 樣式和其余 DOM 是完全隔離的,類似于 iframe 的樣式隔離效果。
如何創建
可以使用 shadowHostElement.attachShadow() 方法來將一個 shadow root 附加到調用方法的元素上。它接受一個配置對象作為參數,該對象有一個 mode 屬性,值可以是 open 或者 closed:
open 表示可以通過頁面內的 JavaScript 來獲取 Shadow DOM,例如使用 Element.shadowRoot 屬性:
如果將 mode 設置為 closed,那么elementRef.shadowRoot 將會返回 null。
瀏覽器中的某些內置元素就是如此,例如<video>,就包含了不可訪問的 Shadow DOM。
為 shadow DOM 添加樣式
我們可以通過創建<style> 元素為 Shadow DOM 添加樣式,也可以通過創建<link> 元素引用外部樣式表。
Shadow DOM 的事件模型
當一個事件從 Shadow DOM 中冒泡出來時,事件的 target 屬性就會調整為 shadow DOM 的宿主。
有些事件甚至不會冒泡到 Shadow DOM 之外。
以下這些事件是會冒泡出去的:
- Focus Events:blur, focus, focusin, focusout
- Mouse Events:click, dblclick, mousedown, mouseenter, mousemove, etc.
- Wheel Events:wheel
- Input Events:beforeinput, input
- Keyboard Events:keydown, keyup
- Composition Events:compositionstart, compositionupdate, compositionend
- DragEvent:dragstart, drag, dragend, drop, etc.
如果 shadow dom 的模式為 open,調用event.composedPath()就會返回一個數組——包含事件冒泡經過的所有元素。
Garfish
在 garfish 中使用也非常簡單,只需要一行配置即可開啟:
https://github.com/modern-js-dev/garfish/blob/main/packages/utils/src/container.ts#L37
優點
完全隔離 CSS 樣式。
缺點
- 在使用一些 antd Select 組件的時候(很多情況下都是將 open 后的元素默認添加到了 document.body 上 )這個時候它就跳過了陰影邊界,逃逸到主應用里面,導致樣式丟失,這時候就需要去子應用中手動修正該彈出元素的掛載節點(例如使用 antd select 的 getPopupContainer)。
- 會與 react v17 之前的事件代理機制產生沖突[3]
React v16 會各種事件處理函數代理到 document ,但是根據 Shadow DOM 的事件模型,從 Shadow DOM 中冒泡出來的事件 target 都會被調整成 shadow host, 導致 react v16 無法通過 event.target 找到對應的元素并觸發事件。
garfish 源碼中的這部分就是在做 retarget:
https://github.com/modern-js-dev/garfish/blob/main/packages/utils/src/container.ts#L42
https://github.com/modern-js-dev/garfish/blob/1f83e8fb35fd2ac12785fc7410015c3cd23c3bd2/packages/utils/src/dispatchEvents.ts#L74
React v17 不再將事件代理到 document 上,而是將事件代理到了 root Element 上,從而規避了這個問題( root element 也還在 shadow tree 中 )。
關于 react v17 事件代理的更多內容可以看看下面的文章:
https://reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation
- 兼容性[4]還行,需要考慮
- Iconfont fontface
- ..
總結
樣式隔離實現起來不復雜,各種方案都有其局限性。目前比較穩定的方案還是使用 css Modules 之類的工具配合團隊之間協商好樣式前綴,從樣式命名 & 優先級上解決問題。
(主應用的樣式依然可以影響到子應用,優先級也可能會被 !important 等操作被破壞,不過大多數場景下足夠了)
但從長期來看,通過 Shadow DOM 完全隔離樣式還是很香的,也希望 Shadow DOM 與其他框架、組件庫結合使用的暗坑早日被填補完畢。