從零開始寫一個微前端框架-樣式隔離篇

cangdu發表於2021-08-05

前言

自從微前端框架micro-app開源後,很多小夥伴都非常感興趣,問我是如何實現的,但這並不是幾句話可以說明白的。為了講清楚其中的原理,我會從零開始實現一個簡易的微前端框架,它的核心功能包括:渲染、JS沙箱、樣式隔離、資料通訊。由於內容太多,會根據功能分成四篇文章進行講解,這是系列文章的第三篇:樣式隔離篇。

通過這些文章,你可以瞭解微前端框架的具體原理和實現方式,這在你以後使用微前端或者自己寫一套微前端框架時會有很大的幫助。如果這篇文章對你有幫助,歡迎點贊留言。

相關推薦

開始

前兩篇文章中,我們已經完成了微前端的渲染和JS沙箱功能,接下來實現微前端的樣式隔離。

問題示例

我們先建立一個問題,驗證樣式衝突的存在。在基座應用和子應用上分別使用div元素插入一段文字,兩個div元素使用相同的class名text-color,分別在class中設定文字顏色,基座應用為red,子應用為blue

由於子應用是後來執行的,它的樣式覆蓋了基座應用,產生了樣式衝突。

樣式隔離實現原理

要實現樣式隔離必須對應用的css進行改造,因為基座應用無法控制,我們只能對子應用進行修改。

先看一下子應用被渲染後的元素構造:

子應用的所有元素都被插入到micro-app標籤中,且micro-app標籤具有唯一的name值,所以通過新增屬性選擇器字首micro-app[name=xxx]可以讓css樣式在指定的micro-app內生效。

例如:
.test { height: 100px; }

新增字首後變為:
micro-app[name=xxx] .test { height: 100px; }

這樣.test的樣式只會影響到name為xxx的micro-app的元素。

渲染篇中我們將link標籤引入的遠端css檔案轉換為style標籤,所以子應用只會存在style標籤,實現樣式隔離的方式就是在style標籤的每一個CSS規則前面加上micro-app[name=xxx]的字首,讓所有CSS規則都只能影響到指定元素內部。

通過style.textContent獲取樣式內容是最簡單的,但textContent拿到的是所有css內容的字串,這樣無法針對單獨規則進行處理,所以我們要通過另外一種方式:CSSRules

當style元素被插入到文件中時,瀏覽器會自動為style元素建立CSSStyleSheet樣式表,一個 CSS 樣式表包含了一組表示規則的 CSSRule 物件。每條 CSS 規則可以通過與之相關聯的物件進行操作,這些規則被包含在 CSSRuleList 內,可以通過樣式表的 cssRules 屬性獲取。

形式如圖:

所以cssRules就是由單個CSS規則組成的列表,我們只需要遍歷規則列表,並在每個規則的選擇器前加上字首micro-app[name=xxx],就可以將當前style樣式的影響限制在micro-app元素內部。

程式碼實現

建立一個scopedcss.js檔案,樣式隔離的核心程式碼都將放在這裡。

我們上面提到過,style元素插入到文件後會建立css樣式表,但有些style元素(比如動態建立的style)在執行樣式隔離時還沒插入到文件中,此時樣式表還沒生成。所以我們需要建立一個模版style元素,它用於處理這種特殊情況,模版style只作為格式化工具,不會對頁面產生影響。

還有一種情況需要特殊處理:style元素被插入到文件中後再新增樣式內容。這種情況常見於開發環境,通過style-loader外掛建立的style元素。對於這種情況可以通過MutationObserver監聽style元素的變化,當style插入新的樣式時再進行隔離處理。

具體實現如下:

// /src/scopedcss.js

let templateStyle // 模版sytle

/**
 * 進行樣式隔離
 * @param {HTMLStyleElement} styleElement style元素
 * @param {string} appName 應用名稱
 */
export default function scopedCSS (styleElement, appName) {
  // 字首
  const prefix = `micro-app[name=${appName}]`

  // 初始化時建立模版標籤
  if (!templateStyle) {
    templateStyle = document.createElement('style')
    document.body.appendChild(templateStyle)
    // 設定樣式表無效,防止對應用造成影響
    templateStyle.sheet.disabled = true
  }

  if (styleElement.textContent) {
    // 將元素的內容賦值給模版元素
    templateStyle.textContent = styleElement.textContent
    // 格式化規則,並將格式化後的規則賦值給style元素
    styleElement.textContent = scopedRule(Array.from(templateStyle.sheet?.cssRules ?? []), prefix)
    // 清空模版style內容
    templateStyle.textContent = ''
  } else {
    // 監聽動態新增內容的style元素
    const observer = new MutationObserver(function () {
      // 斷開監聽
      observer.disconnect()
      // 格式化規則,並將格式化後的規則賦值給style元素
      styleElement.textContent = scopedRule(Array.from(styleElement.sheet?.cssRules ?? []), prefix)
    })

    // 監聽style元素的內容是否變化
    observer.observe(styleElement, { childList: true })
  }
}

scopedRule方法主要進行CSSRule.type的判斷和處理,CSSRule.type型別有數十種,我們只處理STYLE_RULEMEDIA_RULESUPPORTS_RULE三種型別,它們分別對應的type值為:1、4、12,其它型別type不做處理。

// /src/scopedcss.js

/**
 * 依次處理每個cssRule
 * @param rules cssRule
 * @param prefix 字首
 */
 function scopedRule (rules, prefix) {
  let result = ''
  // 遍歷rules,處理每一條規則
  for (const rule of rules) {
    switch (rule.type) {
      case 1: // STYLE_RULE
        result += scopedStyleRule(rule, prefix)
        break
      case 4: // MEDIA_RULE
        result += scopedPackRule(rule, prefix, 'media')
        break
      case 12: // SUPPORTS_RULE
        result += scopedPackRule(rule, prefix, 'supports')
        break
      default:
        result += rule.cssText
        break
    }
  }

  return result
}

scopedPackRule方法種對media和supports兩種型別做進一步處理,因為它們包含子規則,我們需要遞迴處理它們的子規則。
如:

@media screen and (max-width: 300px) {
  .test {
    background-color:lightblue;
  }
}

需要轉換為:

@media screen and (max-width: 300px) {
  micro-app[name=xxx] .test {
    background-color:lightblue;
  }
}

處理方式也十分簡單:獲取它們的子規則列表,遞迴執行方法scopedRule

// /src/scopedcss.js

// 處理media 和 supports
function scopedPackRule (rule, prefix, packName) {
  // 遞迴執行scopedRule,處理media 和 supports內部規則
  const result = scopedRule(Array.from(rule.cssRules), prefix)
  return `@${packName} ${rule.conditionText} {${result}}`
}

最後實現scopedStyleRule方法,這裡進行具體的CSS規則修改。修改規則的方式主要通過正則匹配,查詢每個規則的選擇器,在選擇前加上字首。

// /src/scopedcss.js

/**
 * 修改CSS規則,新增字首
 * @param {CSSRule} rule css規則
 * @param {string} prefix 字首
 */
function scopedStyleRule (rule, prefix) {
  // 獲取CSS規則物件的選擇和內容
  const { selectorText, cssText } = rule

  // 處理頂層選擇器,如 body,html 都轉換為 micro-app[name=xxx]
  if (/^((html[\s>~,]+body)|(html|body|:root))$/.test(selectorText)) {
    return cssText.replace(/^((html[\s>~,]+body)|(html|body|:root))/, prefix)
  } else if (selectorText === '*') {
    // 選擇器 * 替換為 micro-app[name=xxx] *
    return cssText.replace('*', `${prefix} *`)
  }

  const builtInRootSelectorRE = /(^|\s+)((html[\s>~]+body)|(html|body|:root))(?=[\s>~]+|$)/

  // 匹配查詢選擇器
  return cssText.replace(/^[\s\S]+{/, (selectors) => {
    return selectors.replace(/(^|,)([^,]+)/g, (all, $1, $2) => {
      // 如果含有頂層選擇器,需要單獨處理
      if (builtInRootSelectorRE.test($2)) {
        // body[name=xx]|body.xx|body#xx 等都不需要轉換
        return all.replace(builtInRootSelectorRE, prefix)
      }
      // 在選擇器前加上字首
      return `${$1} ${prefix} ${$2.replace(/^\s*/, '')}`
    })
  })
}

使用

到此樣式隔離的功能基本上完成了,接下來如何使用呢?

渲染篇中,我們有兩處涉及到style元素的處理,一個是html字串轉換為DOM結構後的遞迴迴圈,一次是將link元素轉換為style元素。所以我們需要在這兩個地方呼叫scopedCSS方法,並將style元素作為引數傳入。

// /src/source.js

/**
 * 遞迴處理每一個子元素
 * @param parent 父元素
 * @param app 應用例項
 */
 function extractSourceDom(parent, app) {
  ...
  for (const dom of children) {
    if (dom instanceof HTMLLinkElement) {
      ...
    } else if (dom instanceof HTMLStyleElement) {
      // 執行樣式隔離
+      scopedCSS(dom, app.name)
    } else if (dom instanceof HTMLScriptElement) {
      ...
    }
  }
}

/**
 * 獲取link遠端資源
 * @param app 應用例項
 * @param microAppHead micro-app-head
 * @param htmlDom html DOM結構
 */
export function fetchLinksFromHtml (app, microAppHead, htmlDom) {
  ...
  Promise.all(fetchLinkPromise).then((res) => {
    for (let i = 0; i < res.length; i++) {
      const code = res[i]
      // 拿到css資源後放入style元素並插入到micro-app-head中
      const link2Style = document.createElement('style')
      link2Style.textContent = code
+      scopedCSS(link2Style, app.name)
      ...
    }

    ...
  }).catch((e) => {
    console.error('載入css出錯', e)
  })
}

驗證

完成以上步驟後,樣式隔離的功能就生效了,但我們需要具體驗證一下。

重新整理頁面,列印子應用的style元素的樣式表,可以看到所有規則選擇器的前面已經加上micro-app[name=app]的字首。

此時基座應用中的文字顏色變為紅色,子應用為藍色,樣式衝突的問題解決了,樣式隔離生效?。

結語

從上面可以看到,樣式隔離實現起來不復雜,但也有侷限性。目前的方案只能隔離子應用的樣式,基座應用的樣式依然可以影響到子應用,這一點沒有iframe和shadowDom做的那麼完善,所以最好的方案還是使用cssModule之類的工具或團隊之間協商好樣式字首,從源頭解決問題。

相關文章