CSS SandBox

袋鼠雲數棧UED發表於2022-06-22

引言

本篇文章主要介紹的是關於CSS Sandbox的一些事情,為什麼要介紹這個呢?在我們日常的開發中,樣式問題其實一直是一個比較耗時的事情,一方面我們根據 UI 稿不斷的去調整,另一方面隨著專案越來越大可能哪一次開發就發現——誒,我的樣式怎麼不起作用了,亦或是怎麼被另一個樣式所覆蓋了。原因可能有很多:

  • 不規範的命名導致重複
  • 為了簡單,直接新增全域性樣式的修改
  • 樣式的不合理複用
  • 多個專案合併時,每個子專案都有自己的獨立樣式和配置,可能在自己專案中不存在這樣的問題,但是合併以後互相影響造成了樣式汙染
  • 第三方框架引入
  • ……

CSS Sandbox正式為了隔離樣式,從而解決樣式汙染的問題

應用場景

通過上述我們瞭解了樣式汙染產生的原因,從中我們也可以總結一下哪些場景時我們需要使用CSS Sandbox進行樣式隔離呢

  • 微前端場景下的父子以及子子應用
  • 大型專案以及複雜專案的樣式衝突
  • 第三方框架以及自定義主題樣式的覆蓋
  • ……

常見的解決方案

既然說了這麼多樣式汙染產生的原因和應用場景,那我們該如何解決他們呢,目前有以下幾種解決方案,其實解決的核心還是不變的——使CSS選擇器作用的Dom元素唯一

file

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 會動態生成一個選擇器

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;
}

file

打包工具會同時把 style.text 以及 text 編譯成獨一無二的值

優缺點

| 優點 | • 學習成本較低,不依賴於人工約束

• 基本上能 100%解決樣式汙染問題

• 方便實現模組的複用
缺點• 只能在構建時使用,依賴於 css-loader 等

• 可讀性差,在控制檯除錯時出現 hash 值不方便除錯 |

Shadow DOM

它可以將一個隱藏且獨立的 DOM 附加到一個元素上。當我們用 Shadow DOM 包裹一個元素後,其內樣式不會對外部樣式造成影響,外部樣式也不會對其內部造成影響

file

// 建立一個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>
  );
}

file

外部樣式無法影響 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>
  );
}

file

但是也有例外,除了[: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;

file

問題

正由於shadow dom內的樣式只會應用於內部,如果我們在 shadow dom 內部用了類似於antdModal這些建立於document.body下的彈窗或者其他元件時,無法應用於antd的樣式,需要把antd的樣式放到上一層中。

優缺點

| 優點 | • 不需要引入額外的包,瀏覽器原生支援

• 嚴格隔離
缺點• 在某些場景下可能出現樣式失效的問題,如上問題中的 shadow dom 內建立了全域性的 Modal

淺析 QianKun 中的 CSS SandBox

上面我們講解了一些實現樣式隔離的基本方案,那作為一個比較成熟的微前端框架QianKun中又是怎麼實現樣式隔離方案的呢,以下的原始碼解析是在v2.6.3的版本上研究的,首先通過看文件可以發現

file

在 QianKun 中 CSS SandBox 有兩種模式:

  • strictStyleIsolation——嚴格沙箱模式
  • experimentalStyleIsolation——實驗性沙箱模式

strictStyleIsolation

需要注意的是該方案不是一個無腦的解決方案,開啟後需要進行一定的適配

下面我們來詳細介紹下該模式:

我們設定strictStyleIsolationtrue時,QianKun採用的是Shadow DOM方案,核心就是為每個微應用包裹上一個 Shadow DOM 節點。接下來我們看下是怎麼實現的

先來個流程圖我們有個大致的概念:

file

  • **registerMicroApps**:註冊子應用,同時呼叫 single-spa 中的registerApplication進行註冊
  • **loadApp**:載入子應用,初始化載入子應用的 Dom 結構,建立樣式沙箱和 JS 沙箱等,同時返回不同階段的生命週期
  • **createElement**:樣式沙箱的具體實現,主要分為兩種strictStyleIsolationexperimentalStyleIsolation

file

file

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:微應用的註冊資訊

file

  • lifeCycles:微應用的一些生命週期鉤子

file

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 解析

file

沒錯我們拿到了templateexecScriptsassetPublicPath,這裡我們不對後兩個進行講解,聚焦到template上:

對比下子應用原來的 HTML 結構

file

可以發現我們拿到的templatelink標籤變成style標籤註釋了script的 HTML 模版,其中就有我們需要的子應用的 Dom 結構。

拿到以後 QianKun 裡又在template上包裹了一層 Div 形成一個新的 HTML 結構的模版字串,這是為什麼呢?主要是為了在主應用中標識該節點下的內容為子應用,當然在後面我們也需要它進行特別的處理,這個後面講到的時候再說。因此我們現在拿到的appContent長成這個樣子:

file

這個 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 的內容,即:

file

但是問題又來了, 目前的appContent是模版字串嘞,我們咋辦?這邊 QianKun 的處理方案是:

file

這只是個大致流程,下面讓我們跟著這樣的思想看下程式碼裡處理:

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>會被去掉

file

最後我們再來看下子應用掛載到主應用的 Dom 結構:

file

筆者在實踐的過程中也遇到了一些問題:

1、微應用中使用相對路徑引入圖片出現載入資源 404 的問題,這邊筆者沒有進行過多的嘗試可以參考下官方的:https://qiankun.umijs.org/zh/faq#為什麼微應用載入的資源會-404

2、還有一個問題就是 react 中動態開啟 Modal 失效的問題,原因可以看下‣,大概看了下和 React 的事件機制有關,即使是設定彈窗預設開啟,也會出現之前上面提到的,樣式丟失的問題

experimentalStyleIsolation

我們設定experimentalStyleIsolationtrue時,QianKun採用的是Runtime css transformer 動態載入/解除安裝樣式表方案,為子應用的樣式表增加一個特殊的選擇器從而限定影響範圍,類似以下結構:

// 假設應用名是 react16
<style>
    .app-main {
      font-size: 14px;
    }
</style>

<style>
    div[data-qiankun="react16"] .app-main {
      font-size: 14px;
    }
<style>

先來通過流程圖瞭解下大致流程

file

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

file

styleNodes

file

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:

file

進入 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樣式表的資料結構

file

通過這個結構我們其實下一步想要做的事情很清楚了

就是重寫每一條cssRules並且通過字串拼接賦值給style標籤

file

但是我們得注意兩點:

  • 選擇器不同我們的處理方式也不同
  • 對選擇器的匹配規則的處理

讓我們看看 rewrite 具體進行了什麼操作,這裡主要分為兩塊

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;
  }

動態新增樣式的思考?

那麼通過 JS 動態新增的stylelink或者script標籤是不是也需要執行在相應的CSS或者JS沙箱中呢,新增這些標籤的常見方法無疑是createElementappendChildinsertBefore,那其實我們只要對他們設定監聽就可以了

dynamicAppend就是用來解決上面的問題的,它暴露了兩個方法

  • patchStrictSandbox:QianKun JS 沙箱模式的多例模式

file

patchStrictSandbox

export function patchStrictSandbox(
  appName: string,
    // 返回包裹子應用的那一塊Dom結構
  appWrapperGetter: () => HTMLElement | ShadowRoot,
  proxy: Window,
  mounting = true,
  scopedCSS = false,
  excludeAssetFilter?: CallableFunction,
){
  ...
let containerConfig = proxyAttachContainerConfigMap.get(proxy);
  if (!containerConfig) {
    containerConfig = {
      appName,
      proxy,
      appWrapperGetter,
      dynamicStyleSheetElements: [],
      strictGlobal: true,
      excludeAssetFilter,
      scopedCSS,
    };
        // 建立了代理物件和子應用配置資訊Map關係
    proxyAttachContainerConfigMap.set(proxy, containerConfig);
  }

    // 重寫Document.prototype.createElement
  const unpatchDocumentCreate = patchDocumentCreateElement();

    // 重寫appendChild、insertBefore
  const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
    (element) => elementAttachContainerConfigMap.has(element),
    (element) => elementAttachContainerConfigMap.get(element)!,
  );
  ...
}
  • 重寫Document.prototype.createElement
  • 重寫appendChildinsertBefore

patchDocumentCreateElement

function patchDocumentCreateElement() {
    // 記錄createElement是否被重寫
  const docCreateElementFnBeforeOverwrite = docCreatePatchedMap.get(document.createElement);

  if (!docCreateElementFnBeforeOverwrite) {
    const rawDocumentCreateElement = document.createElement;
        // 重寫Document.prototype.createElement
    Document.prototype.createElement = function createElement<K extends keyof HTMLElementTagNameMap>(
      this: Document,
      tagName: K,
      options?: ElementCreationOptions,
    ): HTMLElement {
      const element = rawDocumentCreateElement.call(this, tagName, options);
            // 判斷建立的是否為style、link和script標籤
      if (isHijackingTag(tagName)) {
        const { window: currentRunningSandboxProxy } = getCurrentRunningApp() || {};
        if (currentRunningSandboxProxy) {
                    // 獲取子應用的配置資訊
          const proxyContainerConfig = proxyAttachContainerConfigMap.get(currentRunningSandboxProxy);
          if (proxyContainerConfig) {
            // 建立新元素element和子應用配置的對應關係
            elementAttachContainerConfigMap.set(element, proxyContainerConfig);
          }
        }
      }

      return element;
    };

    if (document.hasOwnProperty('createElement')) {
            // 重寫
      document.createElement = Document.prototype.createElement;
    }

    docCreatePatchedMap.set(Document.prototype.createElement, rawDocumentCreateElement);
  }
}

function isHijackingTag(tagName?: string) {
  return (
    tagName?.toUpperCase() === LINK_TAG_NAME ||
    tagName?.toUpperCase() === STYLE_TAG_NAME ||
    tagName?.toUpperCase() === SCRIPT_TAG_NAME
  );
}
  • 重寫document.createElement
  • 建立新元素 element 和子應用配置的對應關係elementAttachContainerConfigMap

patchHTMLDynamicAppendPrototypeFunctions

export function patchHTMLDynamicAppendPrototypeFunctions(
  isInvokedByMicroApp: (element: HTMLElement) => boolean,
  containerConfigGetter: (element: HTMLElement) => ContainerConfig,
) {
  // 當appendChild和insertBefore沒有被重寫的時候
  if (
    HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
    HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
    HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
  ) {
    HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
    }) as typeof rawHeadAppendChild;
    HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawBodyAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
    }) as typeof rawBodyAppendChild;

    HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
      containerConfigGetter,
      isInvokedByMicroApp,
    }) as typeof rawHeadInsertBefore;
  }}
  • 當 appendChild、appendChild 和 insertBefore 沒有被重寫的時候進行重寫

getOverwrittenAppendChildOrInsertBefore

function getOverwrittenAppendChildOrInsertBefore(opts: {
  rawDOMAppendOrInsertBefore: <T extends Node>(newChild: T, refChild?: Node | null) => T;
  isInvokedByMicroApp: (element: HTMLElement) => boolean;
  containerConfigGetter: (element: HTMLElement) => ContainerConfig;
}) {
  return function appendChildOrInsertBefore<T extends Node>(
    this: HTMLHeadElement | HTMLBodyElement,
    newChild: T,
    refChild: Node | null = null,
  ) {
    let element = newChild as any;
    const { rawDOMAppendOrInsertBefore, isInvokedByMicroApp, containerConfigGetter } = opts;
    // 當不是style、link或者是script標籤的時候或者在元素的建立找不到對應的子應用配置資訊時,走原生的方法
    if (!isHijackingTag(element.tagName) || !isInvokedByMicroApp(element)) {
      return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
    }

    if (element.tagName) {
      // 獲取當前子應用的配置資訊
      const containerConfig = containerConfigGetter(element);
      const {
        appName,
        appWrapperGetter,
        proxy,
        strictGlobal,
        dynamicStyleSheetElements,
        scopedCSS,
        excludeAssetFilter,
      } = containerConfig;

      switch (element.tagName) {
        case LINK_TAG_NAME:
        case STYLE_TAG_NAME: {
          let stylesheetElement: HTMLLinkElement | HTMLStyleElement = newChild as any;
          const { href } = stylesheetElement as HTMLLinkElement;
          // 配置項不需要被劫持的資源
          if (excludeAssetFilter && href && excludeAssetFilter(href)) {
            return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
          }

          // 掛載的dom結構,即子應用的dom結構
          const mountDOM = appWrapperGetter();

          // 如果開啟了實驗性的樣式沙箱模式
          if (scopedCSS) {
            // exclude link elements like <link rel="icon" href="favicon.ico">
            const linkElementUsingStylesheet =
              element.tagName?.toUpperCase() === LINK_TAG_NAME &&
              (element as HTMLLinkElement).rel === 'stylesheet' &&
              (element as HTMLLinkElement).href;
            // 對於link標籤進行樣式資源下載,並進行樣式的重寫
            if (linkElementUsingStylesheet) {
              const fetch =
                typeof frameworkConfiguration.fetch === 'function'
                  ? frameworkConfiguration.fetch
                  : frameworkConfiguration.fetch?.fn;
              stylesheetElement = convertLinkAsStyle(
                element,
                (styleElement) => css.process(mountDOM, styleElement, appName),
                fetch,
              );
              dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement);
            } else {
              css.process(mountDOM, stylesheetElement, appName);
            }
          }

          // 重寫以後的style標籤
          dynamicStyleSheetElements.push(stylesheetElement);
          const referenceNode = mountDOM.contains(refChild) ? refChild : null;
          return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
        }
    ...
}
  • patchLooseSandbox:QianKun JS 沙箱模式的單例模式和快照模式下

file

export function patchLooseSandbox(
  appName: string,
  appWrapperGetter: () => HTMLElement | ShadowRoot,
  proxy: Window,
  mounting = true,
  scopedCSS = false,
  excludeAssetFilter?: CallableFunction,
): Freer {
  let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];

  const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
    // 判斷當前微應用是否執行
        () => checkActivityFunctions(window.location).some((name) => name === appName),
    // 返回微應用的配置資訊
        () => ({
      appName,
      appWrapperGetter,
      proxy,
      strictGlobal: false,
      scopedCSS,
      dynamicStyleSheetElements,
      excludeAssetFilter,
    }),
  );
}

由於是單例模式修改的還是全域性的 window 去掉了對document.createElement的重寫,不需要建立微應用和新建元素的一一對應

相關文章