為什麼需要CSS沙箱
在 qiankun 微前端框架中,由於每個子應用的開發和部署都是獨立的,將主/子應用的資源整合到一起時,容易出現樣式衝突的問題
因此,需要 CSS 沙箱來解決樣式衝突問題,實現主應用以及各子應用之間的樣式隔離,確保各自的樣式獨立執行,互不干擾
工程化手段
既然 CSS 沙箱是用來解決樣式衝突的問題,那如果我透過工程化手段確保每個樣式選擇器名稱都是唯一的,這樣是不是就不需要 CSS 沙箱了?
使用工程化手段來生成唯一的 CSS 類名,常見解決方案有:
- BEM:不同專案用不同的字首或命名規則來確保類名唯一性,避免樣式衝突,詳見 BEM命名規範
- CSS Module:透過構建工具配置(詳見 webpack 啟用 css-loader)在構建過程中自動生成唯一的類名。對了,vue3 中
<style module>
標籤也會被編譯為 CSS Module,詳見 Vue.js - 單檔案元件|CSS功能 - CSS-in-JS: 在 JS 中定義 CSS 樣式塊,注入到 DOM 中,詳見 CSS-in-JS 指南
但是這些方案都存在一些問題:
- 歷史包袱:對於老舊專案,尤其是那些未採用現代工程化手段的專案,修改現有程式碼以支援新的樣式管理方案(如 BEM 或 CSS-in-JS)需要大量的重構工作
- 第三方庫:即使你確保了自己的樣式選擇器唯一,第三方庫的樣式仍可能會導致衝突
顯然,工程化手段只能解決一部分問題,在實際應用中,可能需要結合使用工程化手段和 CSS 沙箱,以應對不同的樣式管理需求
乾坤沙箱
乾坤目前存在三種 CSS 隔離機制,分別是動態樣式隔離、影子DOM沙箱和作用域沙箱
- 動態樣式隔離:qiankun 預設開啟,可以確保單例項場景子應用之間的樣式隔離,但是無法確保主應用跟子應用、或者多例項場景的子應用樣式隔離
- 影子DOM沙箱(Shadow DOM):手動開啟 ,qiankun 會為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全域性造成影響
- 作用域沙箱(Scope CSS):手動開啟 ,qiankun 會改寫子應用所新增的樣式,為所有樣式規則增加一個特殊的選擇器規則來限定其影響範圍
你可能想問,開關在呢❓如何手動開啟我想要的沙箱機制❓❓❓
在這個 乾坤API - start({ }) 中,有一個可選引數
sandbox
,用於控制是否開啟沙箱以及開啟哪種沙箱
- true:預設值,開啟動態樣式隔離
- { strictStyleIsolation: true }:開啟影子DOM沙箱
- { experimentalStyleIsolation: true }:開啟作用域沙箱
動態樣式隔離
乾坤會預設開啟此沙箱
可以確保單例項場景子應用之間的樣式隔離,但是無法確保主應用跟子應用、或者多例項場景子應用之間的樣式隔離
實現原理是當子應用被載入時,其對應的樣式會被注入到頁面中;當子應用被解除安裝時,qiankun 會自動移除其樣式,確保頁面的樣式環境保持乾淨
動態樣式隔離雖然可以提供很好的隔離效果,但往往存在一些限制條件,所以在現實的使用中基本無法單獨滿足使用者的需求
對於新的子應用,使用動態樣式隔離 + 工程化手段兩種方案結合的方式,基本能夠解決樣式衝突的問題
Shadow DOM 沙箱
手動開啟,開啟程式碼如下
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([...]) // 註冊子應用
start({
sandbox: { strictStyleIsolation: true } // 開啟 Shadow DOM 沙箱
})
這種模式下 qiankun 會為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全域性造成影響
Shadow DOM是什麼?
Shadow DOM 是 Web Components 技術的一部分,它允許開發者建立一個封閉的 DOM 樹,這個 DOM 樹的樣式和指令碼與頁面的主 DOM 樹是隔離的。透過 Shadow DOM,可以確保子應用的樣式和指令碼不會影響到主應用或其他子應用,從而避免衝突和干擾
Shadow DOM,可以理解為是存在於 DOM 中的 DOM
記住!影子DOM 是獨立存在的 DOM,有自己的作用域集,外部的配置不會影響到內部,內部的配置也不會影響外部
影子 DOM 允許將隱藏的 DOM 樹附加到常規 DOM 樹中的元素上——這個影子 DOM 始於一個影子根,在其之下你可以用與普通 DOM 相同的方式附加任何元素
這裡有一些影子 DOM 術語:
- 影子宿主(Shadow host): 影子 DOM 附加到的常規 DOM 節點
- 影子樹(Shadow tree): 影子 DOM 內部的 DOM 樹
- 影子邊界(Shadow boundary): 影子 DOM 終止,常規 DOM 開始的地方
- 影子根(Shadow root): 影子樹的根節點
說了這麼多,那如何建立建立影子 DOM ?
我們可以呼叫宿主上的 attachShadow() 來建立影子 DOM
我們結合 乾坤小demo 實際演示一下,影子DOM到底有什麼作用?
ok!我們建立了一個 qiankun 專案,現在主應用和子應用根節點類名相同,都是 .App
,主應用根節點背景色設定為黑色,子應用根節點背景色設定為紅色
由於 qiankun 預設的動態樣式隔離機制存在缺陷,無法確保主應用和子應用之間的樣式隔離,我們發現,子應用汙染了主應用的背景色樣式
啟用 Shadow DOM沙箱隔離機制,Later~,一切正常
實現原理
這裡我們實現一下 Shadow DOM 沙箱機制的核心邏輯,對應乾坤的原始碼在createElement
方法,可以看這裡 - Shadow DOM沙箱原始碼
其原理也很簡單,就是將子應用模板包裹在 Shadow DOM 中,使其形成一個獨立的樣式作用域,確保其樣式隔離
<body>
<div id="root">qiankun 是一個基於 single-spa 的微前端實現庫</div>
<script>
// 子應用的模版字串
const template = `<div id="qiankun-xxx">
<div id="app">Shadow DOM 沙箱</div>
<style>div{color:red}</style>
</div>`
function createElement(appContent) {
const containerElement = document.createElement('div')
containerElement.innerHTML = appContent
const appElement = containerElement.firstChild // 影子宿主(template模版字串轉換成了真實的dom)
const shadow = appElement.attachShadow({ // 影子DOM(呼叫宿主上的 attachShadow() 來建立影子 DOM)
mode: 'open',
})
shadow.innerHTML = appElement.innerHTML // 給Shadow DOM附加宿主節點下的內容
appElement.innerHTML = ''
return appElement
}
document.body.appendChild(createElement(template))
</script>
</body>
雖然 Shadow DOM 是一個強大的技術 ,但在某些場景下,它並不是一個完美的解決方案
比如,越界的 DOM 操作,在實際應用中,子應用可能會有操作主文件 DOM 的需求,比如動態地向主文件document
新增全域性元件、彈窗等。這些操作會建立 Shadow DOM 之外的元素,Shadow DOM 的內部樣式也就無法對這些元素生效
基於 ShadowDOM 的嚴格樣式隔離並不是一個可以無腦使用的方案,大部分情況下都需要接入應用做一些適配後才能正常在 ShadowDOM 中執行起來(比如 react 場景下需要解決這些 問題,使用者需要清楚開啟了
strictStyleIsolation
意味著什麼 - 摘抄自 qiankun 文件
Scope CSS (Scoped CSS)
手動開啟,開啟程式碼如下
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([...]) // 註冊子應用
start({
sandbox: { experimentalStyleIsolation: true } // 開啟作用域沙箱
})
這是 qiankun 一個實驗性的樣式隔離特性,它的核心思想是透過給子應用中的所有樣式選擇器新增一個唯一的字首選擇 div[data-qiankun="xxx"]
,來限制這些樣式的作用範圍
對於一個選擇器,如果需要限制它的作用範圍,可以使用組合選擇器的方式。在當前選擇器A前面加一個選擇器B,使得選擇器A只作用在選擇器B內部的節點
改寫後的程式碼會表達為如下結構
// 假設 registerMicroApps 方法註冊的子應用 name 是 react16
.app-main {
font-size: 14px;
}
// 改寫後
div[data-qiankun="react16"] .app-main {
font-size: 14px;
}
實現原理
提取和解析樣式:當一個子應用被載入時,qiankun 會提取子應用中的所有 <style>
標籤內嵌樣式和 <link>
標籤引入的外部樣式,並對其進行解析,獲取所有的 CSS 規則
重寫樣式規則:qiankun 給每個子應用的包裹容器新增唯一識別符號 data-qiankun
屬性,值為透過 registerMicroApps
API 註冊子應用的 name
;然後修改子應用的樣式選擇器,新增字首選擇器 div[data-qiankun="xxx"]
,重寫選擇器
由於作用域沙箱不能直接修改
link
標籤引入的外部樣式,所以會把link
外部樣式轉化為style
內嵌樣式,再給其新增字首
對應乾坤原始碼的入口是createElement
方法,可以看這裡 - Scope CSS沙箱原始碼
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appName: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
const appElement = containerElement.firstChild as HTMLElement;
/**
* CSS樣式衝突的處理方式
* 1. shadowDOM
* 2. scoped CSS
*/
if (strictStyleIsolation) {
// ... shadowDOM 沙箱邏輯
}
if (scopedCSS) {
// 常量 css.QiankunCSSRewriteAttr = 'data-qiankun'
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
// 給子應用的包裹容器新增 data-qiankun 屬性,值為透過 registerMicroApps 註冊子應用的 name
appElement.setAttribute(css.QiankunCSSRewriteAttr, appName);
}
// 遍歷子應用的所有樣式,修改其樣式選擇器,新增字首選擇器 div[data-qiankun="xxx"]
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appName);
});
}
return appElement;
}
不足的話,應該是解析子應用的 style 樣式,併為每個選擇器新增字首。這一過程在子應用的載入和渲染時會增加額外的計算開銷,尤其是在樣式表很大或者包含大量選擇器的情況下,可能會影響頁面的初始載入效能
沙箱方案
實際的工作中,選擇合適的沙箱方案需要根據具體的場景和需求來決定。 以下是一些常見的場景及其對應的沙箱選擇
單例項模式
單例項模式指的是一次僅載入一個子應用的場景,這種模式下子應用之間不會併發執行,避免了同時多個應用執行導致的衝突
在這種模式下,動態樣式隔離+ 工程化手段(如 BEM 命名規範、CSS Modules)通常就能滿足大部分需求。因為在單例項模式中,不需要擔心子應用之間的樣式和指令碼衝突問題。
多例項模式
在多例項模式下,多個子應用可能同時載入和執行,子應用之間的樣式和指令碼容易產生衝突
在這種模式下,需要更強的隔離性。可以使用 作用域沙箱(Scoped CSS Sandbox)+ Shadow DOM 沙箱 的組合
參考文件
GitHub - careyke/frontend_knowledge_structure: qiankun中CSS沙箱的實現
究竟什麼是Shadow DOM?
使用影子 DOM - Web API | MDN