引言 本篇文章主要介紹的是關於CSS Sandbox的一些事情,為什麼要介紹這個呢?在我們日常的開發中,樣式問題其實一直是一個比較耗時的事情,一方面我們根據 UI 稿不斷的去調整,另一方面隨著項目越來越大可能哪一次開發就發現——誒,我的樣式怎麼不起作用了,亦或是怎麼被另一個樣式所覆蓋了。原因可能有很 ...
引言
本篇文章主要介紹的是關於CSS Sandbox
的一些事情,為什麼要介紹這個呢?在我們日常的開發中,樣式問題其實一直是一個比較耗時的事情,一方面我們根據 UI 稿不斷的去調整,另一方面隨著項目越來越大可能哪一次開發就發現——誒,我的樣式怎麼不起作用了,亦或是怎麼被另一個樣式所覆蓋了。原因可能有很多:
- 不規範的命名導致重覆
- 為了簡單,直接添加全局樣式的修改
- 樣式的不合理復用
- 多個項目合併時,每個子項目都有自己的獨立樣式和配置,可能在自己項目中不存在這樣的問題,但是合併以後互相影響造成了樣式污染
- 第三方框架引入
- ……
而CSS Sandbox
正式為了隔離樣式,從而解決樣式污染的問題
應用場景
通過上述我們瞭解了樣式污染產生的原因,從中我們也可以總結一下哪些場景時我們需要使用CSS Sandbox
進行樣式隔離呢
- 微前端場景下的父子以及子子應用
- 大型項目以及複雜項目的樣式衝突
- 第三方框架以及自定義主題樣式的覆蓋
- ……
常見的解決方案
既然說了這麼多樣式污染產生的原因和應用場景,那我們該如何解決他們呢,目前有以下幾種解決方案,其實解決的核心還是不變的——使CSS選擇器作用的Dom元素唯一
Tips:當我們在實際的開發中可以根據項目的實際情況進行選擇
CSS in JS
看名字是不是感覺很高級,直譯下就是用 JS 去寫 CSS 樣式,而不是寫在單獨的樣式文件里。例如:
<p style='color:red'>css in js</p>
這和我們傳統的開發思想很不一樣,傳統的開發原則是關註點分離
,就比如我們常說的不寫行內樣式
、行內腳本
,即 HTML、JS、CSS 都寫在對應的文件里。
關於 CSS in JS 不是一個新興的技術,他的熱度主要出現於一些 Web 框架的發展,比如說:React,它所支持的 jsx 語法,可以讓我們在一個文件中同時寫 js、html 和 css,並且組件
內部管理自己的樣式、邏輯,組件化開發的思想深入人心。
const style = {
color: 'red'
}
ReactDOM.render(
<p style={style}>
css in js
</h1>,
document.getElementById('main')
);
每個組件的樣式由自身的 style 決定,不依賴也不影響外部,從這一點來看確實實現了樣式隔離的效果。
關於Css in js
的庫也有很多,比如說:
- styled-components
- polished
- ······
其中 styled-components 會動態生成一個選擇器
import styled from 'styled-components'
function App() {
const Title = styled.h1`
font-size: 1.5em;
text-align: center;
color: palevioletred;
`;
return (
<div>
<Title>Hello World, this is my first styled component!</Title>
</div>
);
}
優缺點
| 優點 | • 沒有作用域的樣式污染問題(主要指的是通過寫內行樣式以及生成唯一的 CSS 選擇器)
• 減少了無用樣式的堆積,刪除組件即刪除對應的樣式
• 通過導出定義的樣式變數方便進行復用和重構 |
| --- | --- |
| 缺點 | • 內聯樣式不支持偽類和選擇器等寫法
• 代碼的可讀性比較差,違背了關註點分離的原則
• 運行時會消耗性能,動態生成 CSS(我們在寫 CSS 時其實還是 js)
• 不能結合一些 CSS 預處理器,無法進行預編譯 |
樣式約定
通過約應用的命名首碼實現統一的開發和維護,比如說 BEM 的命名方式,通過對塊、元素以及修飾符三者的命名來規範的描述一個組件
.dropdown-menu__item-button--disabled
優缺點
| 優點 | • 樣式隔離
• 語義化強,組件可讀性高 |
| --- | --- |
| 缺點 | • 命名太長
• 依賴於開發者的命名 |
預處理器
通過 CSS 預處理器可以處理很多獨特的語法格式,比如:
- 可嵌套性
body {
with: 20px;
p {
color: red;
}
}
- 父選擇器
body {
with: 20px;
&:hover {
with: 30px;
}
}
- 屬性繼承
.dev {
width: 200px;
}
span {
.dev
}
通過這些特殊的語法讓 CSS 更容易解讀和維護
一些常見的市場上的預處理器
- Sass
- Less
- Stylus
- PostCss
優缺點
| 優點 | • 可讀性較好,方便理解和維護 DOM 結構
• 利用嵌套等方式,也可以大幅度解決樣式污染的問題 |
| --- | --- |
| 缺點 | •需要增加額外的包,藉助相關編譯工具 |
Tips:通常與類似於 BEM 的命名方式結合,可以達到提高開發效率,增強可讀性以及復用的效果
CSS Module
顧名思義就是將 CSS 進行模塊化處理,編譯好後可以避免樣式被污染的問題,不過依賴於Webpack需要配置css-loader
等打包工具,以下是我在create-react-app
創建的項目中運行,由於其已經在 webpack 配置了css-loader
,因此在此篇文章中不展示具體配置
index.ts 文件
import style from './style.module.css'
function App() {
return (
<div>
<p className={style.text}>Css Module</p>
</div>
);
}
style.module.css 文件
.text {
color: red;
}
// 等同於
:local(.text) {
color: blue;
}
// 還有一種全局模式,此時不會進行編譯
:global(.text) {
color: blue;
}
打包工具會同時把 style.text 以及 text 編譯成獨一無二的值
優缺點
| 優點 | • 學習成本較低,不依賴於人工約束
• 基本上能 100%解決樣式污染問題
• 方便實現模塊的復用 |
| --- | --- |
| 缺點 | • 只能在構建時使用,依賴於 css-loader 等
• 可讀性差,在控制台調試時出現 hash 值不方便調試 |
Shadow DOM
它可以將一個隱藏且獨立的 DOM 附加到一個元素上。當我們用 Shadow DOM 包裹一個元素後,其內樣式不會對外部樣式造成影響,外部樣式也不會對其內部造成影響
// 創建一個shadow dom,我這裡是通過ref去拿附著的節點,一般可以用document去拿
import './App.css'; // 定義了shadow-text的樣式
function App() {
const divRef = useRef(null)
useEffect(() => {
if(divRef?.current) {
const { current } = divRef
const shadow = current.attachShadow({mode: 'open'}); // mode用來控制能否用js獲取shaow dom內的元素
shadow.innerHTML = '<p className="shadow-text">Here is some new text</p>';
}
}, [])
return (
<div>
<div ref={divRef} className='shadow-host'></div>
</div>
);
}
外部樣式無法影響 shadow dom 內部的樣式
我們再來看下 shadow dom 內部得樣式會影響外部樣式嗎?
function App() {
useEffect(() => {
if(divRef?.current) {
const { current } = divRef
const shadow = current.attachShadow({mode: 'open'});
shadow.innerHTML = '<style>.shadow-h1 { color: red } </style><p class="shadow-h1">Here is some new text</p>';
}
}, [])
return (
<div>
<Title>Hello World, this is my first styled component!</Title>
<h1 className='shadow-h1'>lalla1</h1>
<div ref={divRef} className='shadow-host'></div>
</div>
);
}
但是也有例外,除了[:focus-within](https://developer.mozilla.org/zh-CN/docs/Web/CSS/:focus-within)
import { useEffect, useRef } from 'react'
import './App.css'; // .shadow-host:focus-within { background-color: yellow;}
function ShadowExample() {
const divRef = useRef(null)
useEffect(() => {
if(divRef?.current) {
const { current } = divRef
const shadow = current.attachShadow({mode: 'open'});
shadow.innerHTML = '<input class="shadow-h1"/>';
}
}, [])
return (
<div>
<p>Css Module</p>
<div ref={divRef} className='shadow-host'></div>
</div>
);
}
export default ShadowExample;
問題
正由於shadow dom
內的樣式只會應用於內部,如果我們在 shadow dom 內部用了類似於antd
的Modal
這些創建於document.body
下的彈窗或者其他組件時,無法應用於antd
的樣式,需要把antd
的樣式放到上一層中。
優缺點
| 優點 | • 不需要引入額外的包,瀏覽器原生支持
• 嚴格隔離 |
| --- | --- |
| 缺點 | • 在某些場景下可能出現樣式失效的問題,如上問題中的 shadow dom 內創建了全局的 Modal |
淺析 QianKun 中的 CSS SandBox
上面我們講解了一些實現樣式隔離的基本方案,那作為一個比較成熟的微前端框架QianKun
中又是怎麼實現樣式隔離方案的呢,以下的源碼解析是在v2.6.3
的版本上研究的,首先通過看文檔可以發現
在 QianKun 中 CSS SandBox 有兩種模式:
strictStyleIsolation
——嚴格沙箱模式experimentalStyleIsolation
——實驗性沙箱模式
strictStyleIsolation
需要註意的是該方案不是一個無腦的解決方案,開啟後需要進行一定的適配
下麵我們來詳細介紹下該模式:
我們設置strictStyleIsolation
為true
時,QianKun
採用的是Shadow DOM
方案,核心就是為每個微應用包裹上一個 Shadow DOM 節點。接下來我們看下是怎麼實現的
先來個流程圖我們有個大致的概念:
**registerMicroApps**
:註冊子應用,同時調用 single-spa 中的registerApplication
進行註冊**loadApp**
:載入子應用,初始化載入子應用的 Dom 結構,創建樣式沙箱和 JS 沙箱等,同時返回不同階段的生命周期**createElement**
:樣式沙箱的具體實現,主要分為兩種strictStyleIsolation
和experimentalStyleIsolation
registerMicroApps:註冊子應用
export function registerMicroApps<T extends ObjectType>(apps: Array<RegistrableApp<T>>,lifeCycles?: FrameworkLifeCycles<T>,) {
...
registerApplication({
name,
app: async () => {
...
// 載入微應用的具體方法,暴露bootstrap、mount、unmount等生命周期以及一些其他配置信息
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
...
},
// 子應用的激活條件
activeWhen: activeRule
...
});
});
}
調用 single-spa 的 registerApplication 對應用進行註冊,並且在應用激活的時候調用 app 的回調,其中最主要的是loadApp
載入微應用的具體方法
一些參數的說明:
apps
:微應用的註冊信息
lifeCycles
:微應用的一些生命周期鉤子
loadApp:載入子應用
function loadApp (app: LoadableApp<T>, configuration: FrameworkConfiguration = {},lifeCycles?: FrameworkLifeCycles<T>) {
...
/**
* 將操作權交給主應用控制,返回結果涉及CSS SandBox和JS SandBox
* template --template的為link替換為style註釋script的HTML模版
* execScripts --腳本執行器,讓指定的腳本(scripts)在規定的上下文環境中執行,只做瞭解暫時不講
* assetPublicPath -- 靜態資源地址,只做瞭解暫時不講
*/
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// 給子應用包裹一層div後的子應用html模版, 是模版字元串的形式
const appContent = getDefaultTplWrapper(appInstanceId)(template);
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
// 是否開啟了嚴格模式
strictStyleIsolation,
// 是否開啟實驗性的樣式隔離
scopedCSS,
// 根據應用名生成的唯一值,唯一則為appName,不唯一則為appName_[count]為具體數量,重覆會count++
appInstanceId,
);
...
// 下麵還有一些生命周期的處理方法
}
Q1:到現在不知道還有沒有人記得我們開啟嚴格樣式模式是需要做啥?
!!!把子應用的 Dom 結構放到 Shadow dom 中與主應用隔離,防止樣式污染
Q2:那我們咋拿到子應用的 Dom 結構呢?
沒錯就是通過import-html-entry
庫的import-html-entry
方法,有興趣給看下關於import-html-entry 解析
沒錯我們拿到了template
、execScripts
和assetPublicPath
,這裡我們不對後兩個進行講解,聚焦到template
上:
對比下子應用原來的 HTML 結構
可以發現我們拿到的template
是link
標簽變成style
標簽註釋了script
的 HTML 模版,其中就有我們需要的子應用的 Dom 結構。
拿到以後 QianKun 里又在template
上包裹了一層 Div 形成一個新的 HTML 結構的模版字元串,這是為什麼呢?主要是為了在主應用中標識該節點下的內容為子應用,當然在後面我們也需要它進行特別的處理,這個後面講到的時候再說。因此我們現在拿到的appContent
長成這個樣子:
這個 div 的 id 是唯一的哈!!!
那我們現在是不是已經做好了前期準備,現在我們需要進入最後一個步驟,把子應用的這個 Dom 結構掛載到一個 shadow dom 上,這就要用到createElement
方法。
進入createElement
方法前我們先來看下目前的參數值:
- appContent:包裹了一層 id 唯一的 div,具體如上所示
- strictStyleIsolation:
true
- scopedCSS:
false
- appInstanceId:
react16
createElement:添加 shadow dom
那我們現在如何去創建一個 shadow dom,在前面關於 shadow dom 的講解中我們知道,創建一個 shadow dom 我們需要兩個東西:
一、掛載的 Dom 節點
二、需要添加到 shadow dom 的內容
那我們從哪裡去找呢,根據傳進來的參數吧,我們無疑是要對appContent
進行處理了,回顧下appContent
有什麼,包裹了一層 div 的子應用的 HTML 模版是吧,自然而然的我們就可以以外面的 div 為掛載的 dom 節點,拿子應用的 HTML 模版為需要添加到 shadow dom 的內容,即:
但是問題又來了, 目前的appContent
是模版字元串嘞,我們咋辦?這邊 QianKun 的處理方案是:
這隻是個大致流程,下麵讓我們跟著這樣的思想看下代碼里處理:
function createElement(appContent: string,strictStyleIsolation: boolean,scopedCSS: boolean,appInstanceId: string) {
...
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
const appElement = containerElement.firstChild as HTMLElement;
// 嚴格樣式沙箱模式
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
// 創建shadow dom節點
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// 相容低版本
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
...
// 此處省略了開啟experimentalStyleIsolation的處理方法
...
return appElement;
}
這裡有個很有意思的是:
appContent 以 innerHTML 變成 dom 結構後,HTML 模版中的<html>
、<head>
以及<body>
會被去掉
最後我們再來看下子應用掛載到主應用的 Dom 結構:
筆者在實踐的過程中也遇到了一些問題:
1、微應用中使用相對路徑引入圖片出現載入資源 404 的問題,這邊筆者沒有進行過多的嘗試可以參考下官方的:https://qiankun.umijs.org/zh/faq#為什麼微應用載入的資源會-404
2、還有一個問題就是 react 中動態打開 Modal 失效的問題,原因可以看下‣,大概看了下和 React 的事件機制有關,即使是設置彈窗預設開啟,也會出現之前上面提到的,樣式丟失的問題
experimentalStyleIsolation
我們設置experimentalStyleIsolation
為true
時,QianKun
採用的是Runtime css transformer
動態載入/卸載樣式表方案,為子應用的樣式表增加一個特殊的選擇器從而限定影響範圍,類似以下結構:
// 假設應用名是 react16
<style>
.app-main {
font-size: 14px;
}
</style>
<style>
div[data-qiankun="react16"] .app-main {
font-size: 14px;
}
<style>
先來通過流程圖瞭解下大致流程
createElement:給最外層增加 data-qiankun 屬性,並且獲取所有 style 標簽
function createElement(appContent: string, strictStyleIsolation: boolean, scopedCSS: boolean,appInstanceId: string) {
...
if (scopedCSS) {
// 給最外層設置data-qiankun的屬性
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
// 獲取所有的style標簽,進行遍歷
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
...
}
export const QiankunCSSRewriteAttr = 'data-qiankun';
我們來看下設置完屬性後的屬性後的 appElement
styleNodes
css.process 詳細處理
/**
* 實例化ScopedCSS
* 生成根元素屬性選擇器[data-qiankun="應用名"]首碼
*/
export const process = (
appWrapper: HTMLElement,
stylesheetElement: HTMLStyleElement | HTMLLinkElement,
appName: string,
): void => {
// 實例化ScopedCSS
if (!processor) {
processor = new ScopedCSS();
}
...
// 一些空值的處理
const mountDOM = appWrapper;
if (!mountDOM) {
return;
}
const tag = (mountDOM.tagName || '').toLowerCase();
if (tag && stylesheetElement.tagName === 'STYLE') {
// 生成首碼,根元素標簽名[data-qiankun="應用名"]
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);
}
};
- prefix:
div[data-qiankun="react16"]
- stylesheetElement:
進入 processor.process 看看對它進行了什麼操作
// 重寫樣式選擇器以及對於空的style節點設置MutationObserver監聽,原因可能存在動態增加樣式的情況
process(styleNode: HTMLStyleElement, prefix: string = '') {
// 當style標簽有內容時進行操作
if (styleNode.textContent !== '') {
// styleNode.textContent為style標簽內的內容
const textNode = document.createTextNode(styleNode.textContent || '');
// swapNode為創建的空的style標簽
this.swapNode.appendChild(textNode);
// 獲取樣式表
const sheet = this.swapNode.sheet as any;
// 從樣式表獲取cssRules該值是標準的,把樣式規則從偽數組轉化成數組
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
// 通過遍歷和正則重寫每個選擇器的首碼
const css = this.rewrite(rules, prefix);
// 將處理後的重寫後的css放入原來的styleNode中
styleNode.textContent = css;
// 清理工具人swapNode
this.swapNode.removeChild(textNode);
return;
}
//對空的樣式節點進行監聽,可能存在動態插入的問題
const mutator = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i += 1) {
// mutation為變更的每個記錄MutationRecord
const mutation = mutations[i];
// 判斷該節點是否應處理過
if (ScopedCSS.ModifiedTag in styleNode) {
return;
}
if (mutation.type === 'childList') {
const sheet = styleNode.sheet as any;
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
const css = this.rewrite(rules, prefix);
styleNode.textContent = css;
// 增加處理節點的標識
(styleNode as any)[ScopedCSS.ModifiedTag] = true;
}
}
});
// 監聽當前的style標簽,當styleNode為空的時候,以及變更的時候,比如引入的antd樣式文件
mutator.observe(styleNode, { childList: true });
}
Q1:為什麼在style
標簽有內容的時候使用this.swapNode
這個工具人,而在監聽的時候不使用?
還記得我們是需要乾什麼嗎?
改寫style
標簽內的樣式規則
因此這裡就通過style.sheet.cssRules
方式去獲取 style 標簽里的每一條規則進行重寫,我們來看下sheet
樣式表的數據結構
通過這個結構我們其實下一步想要做的事情很清楚了
就是重寫每一條cssRules
並且通過字元串拼接賦值給style
標簽
但是我們得註意兩點:
- 選擇器不同我們的處理方式也不同
- 對選擇器的匹配規則的處理
讓我們看看 rewrite 具體進行了什麼操作,這裡主要分為兩塊
- 一對選擇器的類型進行判斷
CSSRule.type
private rewrite(rules: CSSRule[], prefix: string = '') {
let css = '';
rules.forEach((rule) => {
switch (rule.type) {
// 普通選擇器類型
case RuleType.STYLE:
css += this.ruleStyle(rule as CSSStyleRule, prefix);
break;
// @media選擇器類型
case RuleType.MEDIA:
css += this.ruleMedia(rule as CSSMediaRule, prefix);
break;
// @supports選擇器類型
case RuleType.SUPPORTS:
css += this.ruleSupport(rule as CSSSupportsRule, prefix);
break;
default:
css += `${rule.cssText}`;
break;
}
});
return css;
}
- 二是進行正則替換
特殊的
// 處理類似於@media screen and (min-width: 900px) {}
private ruleMedia(rule: CSSMediaRule, prefix: string) {
const css = this.rewrite(arrayify(rule.cssRules), prefix);
return `@media ${rule.conditionText} {${css}}`;
}
// 處理類似於@supports (display: grid) {}
private ruleSupport(rule: CSSSupportsRule, prefix: string) {
const css = this.rewrite(arrayify(rule.cssRules), prefix);
return `@supports ${rule.conditionText} {${css}}`;
}
普通的
// prefix為"div[data-qiankun="react16"]"
private ruleStyle(rule: CSSStyleRule, prefix: string) {
// 根選擇器,比如body、html以及:root
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
// 根組合選擇器,類似於 html body{...}
const rootCombinationRE = /(html[^\w{[]+)/gm;
// 獲取選擇器
const selector = rule.selectorText.trim();
// 獲取樣式文本,比如"html { font-family: sans-serif; line-height: 1.15; text-size-adjust: 100%; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); }"
let { cssText } = rule;
// 對根選擇器(body、html、:root)進行判斷,替換成prefix
if (selector === 'html' || selector === 'body' || selector === ':root') {
return cssText.replace(rootSelectorRE, prefix);
}
// 對於根組合選擇器進行匹配
if (rootCombinationRE.test(rule.selectorText)) {
const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;
// 對於非標準的兄弟選擇器轉換時進行忽略,置空處理
if (!siblingSelectorRE.test(rule.selectorText)) {
cssText = cssText.replace(rootCombinationRE, '');
}
}
// 普通選擇器匹配
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
// selectors為類似於.link{
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// 處理類似於div,body,span { ... },含有根元素的
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
const whitePrevChars = [',', '('];
// 將其中的根元素替換為首碼保留,或者(
if (m && whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
// 直接把根元素替換成首碼
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, '')}`;
}),
);
return cssText;
}