如果你了解 Web Component 那麼你一定知道 Shadow DOM,Shadow DOM 是用於創建一個與外部隔離的 DOM Tree,在微前端中比較常見,可以在內部定義任何樣式也不會污染外部的樣式,但是也因為這個特徵導致 Shadow DOM 中也不會繼承任何外部樣式。假如你使用 TailwindCSS 或者其他組件庫自帶的樣式,在 Shadow DOM 中被應用。
例子#
我們先來創建一個簡單的 TailwindCSS 的單頁應用。
// Create a shadow DOM tree and define a custom element
class MyCustomElement extends HTMLElement {
  constructor() {
    super()
    // Attach a shadow DOM to the custom element
    const shadow = this.attachShadow({ mode: 'open' })
    // Create some content for the shadow DOM
    const wrapper = document.createElement('div')
    wrapper.setAttribute('class', 'text-2xl bg-primary text-white p-4')
    wrapper.textContent = '我在 Shadow DOM 樹中。'
    // Append the content to the shadow DOM
    shadow.append(wrapper)
  }
}
// Define the custom element
customElements.define('my-custom-element', MyCustomElement)
export const Component = () => {
  return (
    <>
      <p className="text-2xl bg-primary text-white p-4">
        我在 Root DOM 樹中。
      </p>
      <my-custom-element />
    </>
  )
}
上面的代碼運行結果如下:

上面一個元素位於 Host (Root) DOM 中,TailwindCSS 的樣式正確應用,但是在 ShadowRoot 中的元素無法應用樣式,仍然是瀏覽器的默認樣式。
方案#
我們知道打包器會把 CSS 樣式注入到 document.head 中,那麼我們只要把這些標籤提取出來同樣注入到 ShadowRoot 中去就行了。
那麼如何實現呢。
以 React 為例,其他框架也是同理。
在 React 中使用 Shadow DOM 可以借助 react-shadow 以提升 DX。
npm i react-shadow
上面的代碼可以修改為:
import root from 'react-shadow'
export const Component = () => {
  return (
    <>
      <p className="text-2xl bg-primary text-white p-4">
        我在 Root DOM 樹中。
      </p>
      <root.div>
        <p className="text-2xl bg-primary text-white p-4">
          我在 Shadow DOM 樹中。
        </p>
      </root.div>
    </>
  )
}
現在依然是沒有樣式的,接著我們注入宿主樣式。
import type { ReactNode } from 'react'
import { createElement, useState } from 'react'
import root from 'react-shadow'
const cloneStylesElement = () => {
  const $styles = document.head.querySelectorAll('style').values()
  const reactNodes = [] as ReactNode[]
  let i = 0
  for (const style of $styles) {
    const key = `style-${i++}`
    reactNodes.push(
      createElement('style', {
        key,
        dangerouslySetInnerHTML: { __html: style.innerHTML },
      }),
    )
  }
  document.head.querySelectorAll('link[rel=stylesheet]').forEach((link) => {
    const key = `link-${i++}`
    reactNodes.push(
      createElement('link', {
        key,
        rel: 'stylesheet',
        href: link.getAttribute('href'),
        crossOrigin: link.getAttribute('crossorigin'),
      }),
    )
  })
  return reactNodes
}
export const Component = () => {
  const [stylesElements] = useState<ReactNode[]>(cloneStylesElement)
  return (
    <>
      <p className="text-2xl bg-primary text-white p-4">
        我在 Root DOM 樹中。
      </p>
      <root.div>
        <head>{stylesElements}</head>
        <p className="text-2xl bg-primary text-white p-4">
          我在 Shadow DOM 樹中。
        </p>
      </root.div>
    </>
  )
}

現在樣式就成功注入了。可以看到 ShadowDOM 中已經繼承了宿主的樣式。
宿主樣式響應式更新#
現在的方式注入樣式,如果宿主的樣式發生了改變,ShadowDOM 的樣式並不會發生任何更新。
比如我加了一個 Button,點擊後新增一個樣式。
<button
  className="btn btn-primary mt-12"
  onClick={() => {
    const $style = document.createElement('style')
    $style.innerHTML = `p { color: red !important; }`
    document.head.append($style)
  }}
>
  更新宿主樣式
</button>

可以看到 ShadowDOM 沒有樣式更新。
我們可以利用 MutationObserver 去觀察 <head /> 的更新。
export const Component = () => {
  useLayoutEffect(() => {
    const mutationObserver = new MutationObserver(() => {
      setStylesElements(cloneStylesElement())
    })
    mutationObserver.observe(document.head, {
      childList: true,
      subtree: true,
    })
    return () => {
      mutationObserver.disconnect()
    }
  }, [])
  // ..
}
效果如下:

問題解決。
後記#
既然是這樣,那麼你為什麼還要用 ShadowDOM 呢。因為在 ShadowDOM 你可以注入任何污染全局的樣式都不會影響宿主的樣式。
這個方案其實很簡單,在任何框架中甚至原生都是適用的,這個本身就是一個原生的解決方案,不依賴任何框架。
而我只想說的是,不要被現代前端各式各樣的工具鏈,插件讓思維禁錮了,遇到一點點問題就想從框架出發或者插件,殊不知這只是個普通的 DOM 操作而已,所以就有了笑話,現在的前端開發連寫個 jQuery 的 DOM 遍歷都不知道了。
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/ShadowDOM-style-isolation-and-inheritance