ShadowDOM 中样式隔离和继承
如果你了解 Web Component 那么你一定知道 Shadow DOM,Shadow DOM 是用于创建一个与外部隔离的 DOM Tree,在微前端中比较常见,可以在内部定义任何样式也不会污染外部的样式。假如你使用 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 = 'I am in the Shadow DOM tree.'
// 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">
I'm in the Root DOM tree.
</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">
I'm in the Root DOM tree.
</p>
<root.div>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Shadow DOM tree.
</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 },
}),
)
}
return reactNodes
}
export const Component = () => {
const [stylesElements] = useState<ReactNode[]>(cloneStylesElement)
return (
<>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Root DOM tree.
</p>
<root.div>
<head>{stylesElements}</head>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Shadow DOM tree.
</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)
}}
>
Update Host Styles
</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()
}
}, [])
// ..
}
效果如下:
生产环境中的问题
上面的例子中,我们只对 <style />
做了处理,一般在开发环境中,CSS 都是使用动态注入 <style />
的,而在生产环境中大部分的 CSS 都会编译成静态的 CSS 文件,使用 <link rel="stylesheet" />
的方式注入。
当我们把上面的代码稍作修改之后:
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
}
发现可以正常注入了,但是又出现了样式异步加载导致的布局变动。
这是因为每次使用 <ShadowDOM />
都会创建一个新的 <link />
而 link 标签会去异步加载 CSS 样式,导致在刚开始的时候样式没有载入显示的是浏览器默认的样式,导致出现布局和样式抖动。
解决这个办法我们必须改变方式,不再使用 <link />
的注入,而是使用 <style />
。
通过 document.styleSheets
这个 API,可以获取到当前的所有的生效或者没生效的 stylesheet。然后拿到里面的 cssText
。
方法如下:
const cacheCssTextMap = {} as Record<string, string>
function getLinkedStaticStyleSheets() {
const $links = document.head
.querySelectorAll('link[rel=stylesheet]')
.values() as unknown as HTMLLinkElement[]
const styleSheetMap = new WeakMap<
Element | ProcessingInstruction,
CSSStyleSheet
>()
const cssArray = [] as { cssText: string; ref: HTMLLinkElement }[]
for (const sheet of document.styleSheets) {
if (!sheet.href) continue
if (!sheet.ownerNode) continue
styleSheetMap.set(sheet.ownerNode, sheet)
}
for (const $link of $links) {
const sheet = styleSheetMap.get($link)
if (!sheet) continue
if (!sheet.href) continue
const hasCache = cacheCssTextMap[sheet.href]
if (!hasCache) {
if (!sheet.href) continue
const rules = sheet.cssRules || sheet.rules
let cssText = ''
for (const rule of rules) {
cssText += rule.cssText
}
cacheCssTextMap[sheet.href] = cssText
}
cssArray.push({
cssText: cacheCssTextMap[sheet.href],
ref: $link,
})
}
return cssArray
}
这里为了后续的性能,还做了一下缓存,根据每个静态 CSS 文件的 Href 作为索引。
然后修改 cloneStylesElement
为:
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 },
}),
)
}
getLinkedStaticStyleSheets().forEach(({ cssText }) => {
const key = `link-${i++}`
reactNodes.push(
createElement('style', {
key,
dangerouslySetInnerHTML: { __html: cssText },
}),
)
})
return reactNodes
}
避免重渲染
直到这里,大部分的问题都已经解决了,但是如果你使用 React 的话,还需要考虑重渲染问题,<style />
的重渲染可能会导致布局抖动。
我们知道 React 组件的 key
可以决定组件的卸载周期,而 props
可以决定组件的重渲染。
恒定 Key
在上面的例子中,我们使用 style-${i++}
索引去做 Key,后续很有可能导致索引变化而组件被重建。因此我们需要一个更加稳定的 Key。
我们可以根据 ownerNode
去决定 Key。ownerNode
是对 HTMLLinkElement | HTMLStyleElement
的引用,因此只要该样式存在就是恒定的。
const weakMapElementKey = new WeakMap<
HTMLStyleElement | HTMLLinkElement,
string
>()
/// ....
let key = weakMapElementKey.get($style)
if (!key) {
key = nanoid(8)
weakMapElementKey.set($style, key)
}
reactNodes.push(
createElement('style', {
key,
dangerouslySetInnerHTML: { __html: cssText },
}),
)
恒定 props
在 React 中,如果你使用 dangerouslySetInnerHTML
去设置 HTML,那么它本身就是不稳定的 props
。我们知道 dangerouslySetInnerHTML={{ __html: '' }}
传入的是一个不稳定的对象。在上面的例子中也是如此。
因此我们需要创建一个稳定的 MemoedDangerousHTMLStyle
组件。
const MemoedDangerousHTMLStyle: FC<
{
children: string
} & React.DetailedHTMLProps<
React.StyleHTMLAttributes<HTMLStyleElement>,
HTMLStyleElement
> &
Record<string, unknown>
> = memo(({ children, ...rest }) => (
<style
{...rest}
dangerouslySetInnerHTML={useMemo(
() => ({
__html: children,
}),
[children],
)}
/>
))
直到这里,大部分工作就结束了。
完成代码参考:
后记
既然是这样,那么你为什么还要用 ShadowDOM 呢。因为在 ShadowDOM 你可以注入任何污染全局的样式都不会影响宿主的样式。
这个方案其实很简单,在任何框架中甚至原生都是适用的,这个本身就是一个 Vanilla JS 的解决方案,不依赖任何框架。
而我只想说的是,不要被现代前端各式各样的工具链,插件让思维禁锢了,遇到一点点问题就想从框架出发或者插件,殊不知这只是个普通的 DOM 操作而已,所以就有了笑话,现在的前端开发连写个 jQuery 的 DOM 遍历都不知道了。