Constructable Stylesheets:让 Web Components 的样式只解析一次,所有实例共享
页面里有几十个 Web Components,每个 Shadow Root 都塞了同一份样式。
一开始觉得没什么。组件多了之后,内存开始悄悄涨。CSS 在每个实例里都被完整解析了一遍。
浏览器其实早就给了原生方案:Constructable Stylesheets + adoptedStyleSheets。同一份 CSS 只解析一次,所有 Shadow Root 共享同一个解析结果。
最常见的写法,问题在哪
很多人习惯在每个 Shadow Root 里塞一个 <style> 标签:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button { background: var(--primary); }
</style>
<button>Click</button>
`;
}
}看起来没毛病。但页面里要是有 100 个 <my-button>,浏览器就得老老实实把同一份 CSS 解析 100 次。每次解析都会创建一棵完整的 CSSOM 树,内存就这么一点点被吃掉了。
更头疼的是,想改个主题色,你得保证每个 Shadow Root 里的样式都能同步更新。组件一多,维护成本直接起飞。
Constructable Stylesheets 是什么
浏览器提供了一个 API,让你直接在 JavaScript 里创建样式表对象,不需要 <style> 标签:
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
button { background: var(--primary); }
`);这个 sheet 就是一个独立的 CSSStyleSheet 对象,不在 DOM 里,纯粹活在内存中。
然后你可以把它“挂”到任意多个 Shadow Root 上:
this.shadowRoot.adoptedStyleSheets = [sheet];重点在这儿:不管挂到多少个 Shadow Root,CSS 都只解析一次。每个 Shadow Root 拿到的是同一个 sheet 对象的引用,不是副本。
内存和性能差多少
| 方式 | 解析次数 | 内存占用 |
|---|---|---|
| 每个 Shadow Root 塞 <style> | N 次 | N 份 CSSOM 树 |
| adoptedStyleSheets 共享 | 1 次 | 1 份 CSSOM 树 |
组件少的时候感知不明显。但如果你在做组件库,或者页面里有几十上百个自定义元素,差距就会累积得很快。
还有一个更实在的好处:改样式的时候,所有 Shadow Root 自动同步。因为大家共享的是同一个 sheet 对象,调一次 sheet.replaceSync() 就全生效了:
// 一行代码,所有组件的样式同时更新
sheet.replaceSync(`
button { background: hotpink; }
`);Lit 在背后帮你做了什么
如果你用 Lit 写组件,可能已经写过这种代码:
class MyButton extends LitElement {
static styles = css`
button { background: var(--primary); }
`;
}这个 static styles 看起来就是个语法糖,但 Lit 背后其实干了不少活:先把 css 标签模板字符串包装成一个 CSSResult 对象,等第一个组件实例挂载到 DOM 的时候才真正创建 CSSStyleSheet,后续所有实例直接共享同一个 sheet。
也就是说,Lit 已经帮你把 Constructable Stylesheets 用上了,不需要你手动处理。不过知道它底下在做什么,能帮你理解为什么 Lit 管样式比“每个 Shadow Root 塞 style 标签”高效得多。
原生写法(不用框架)
不用 Lit 的话,原生写法也不复杂:
// 在模块级别创建一次
const buttonSheet = new CSSStyleSheet();
buttonSheet.replaceSync(`
button {
background: var(--primary);
padding: 8px 16px;
}
`);
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// 每个实例只是挂载同一个 sheet
this.shadowRoot.adoptedStyleSheets = [buttonSheet];
this.shadowRoot.innerHTML = `<button><slot></slot></button>`;
}
}这里有个坑要注意:sheet 对象必须在模块级别创建,不能放在 constructor 里。要是在 constructor 里 new CSSStyleSheet(),那每个实例都会创建一个新的 sheet,共享就白搭了。
全局 token 注入
Constructable Stylesheets 还有一个挺实用的场景:全局设计 token 注入。
以前大家习惯在 <head> 里加个 <style> 定义 CSS 变量。用 adoptedStyleSheets 的话,可以完全交给 JavaScript 来管:
const tokenSheet = new CSSStyleSheet();
tokenSheet.replaceSync(`
:root {
--primary: #5c73f2;
--primary-fg: white;
--radius: 6px;
}
`);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, tokenSheet];好处很明确:不用在 HTML 里写 <style> 标签,切换主题直接 tokenSheet.replaceSync() 就行,所有 Shadow Root 都能访问到这些变量。微前端或者需要动态换肤的场景,这套方案用着挺干净。
边界和问题
这个 API 不是万能的,有几个地方得留心。
SSR 序列化
Constructable Stylesheets 活在 JavaScript 内存里,没法直接序列化到 HTML。服务端渲染的时候,需要退回到 <style> 标签。Lit SSR 就是这么处理的:服务端用 <style>,客户端激活后再切到 adoptedStyleSheets。不用框架的话,自己做 SSR 时别忘了这层回退。
动态更新有限制
sheet.replaceSync() 是同步的,直接替换整个样式表。想“只改其中某一条规则”,API 倒也支持(sheet.cssRules[i].style.setProperty()),但用起来比较底层,不如直接 replaceSync 来得痛快。更省心的做法是:把样式拆成多个 sheet,需要动态更新的部分单独放一个。
浏览器兼容性
主流浏览器都支持了(Chrome/Edge 从 73 开始,Firefox 从 101,Safari 从 16.4)。如果你还要覆盖更老的浏览器,做好检测和降级就行。
最后
这个 API 的价值跟组件数量正相关。只写几个自定义元素的话,不用刻意上。但如果项目里 Web Components 越来越多,样式管理开始让你头疼,它是个值得了解的原生工具。浏览器只解析一次,你不用为每个实例付额外的内存税。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!