Constructable Stylesheets:让 Web Components 的样式只解析一次,所有实例共享

更新日期: 2026-04-28 阅读: 22 标签: 解析

页面里有几十个 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 越来越多,样式管理开始让你头疼,它是个值得了解的原生工具。浏览器只解析一次,你不用为每个实例付额外的内存税。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://fly63.com/article/detial/13721

相关推荐

Flutter JSON解析与复杂模型转换技巧

其实转换成model类是有好处的,转换后可以减少上线后APP崩溃和出现异常,所以我们从这节课开始,要制作model类模型,然后用model的形式编辑UI界面。比如现在从后台得到了一串JSON数据:

js预解析

js解析器:浏览器中专门用来读取js脚本,找内容——根据关键词:var function 参数,全部找到之后预解析结束;逐行解析代码,正式读取代码时,会先从之前解析库中查找

nodejs的http.createServer过程解析

下面是nodejs创建一个服务器的代码。接下来我们一起分析这个过程。发现_http_server.js也没有太多逻辑,继续看lib/net.js下的代码。至此http.createServer就执行结束了,我们发现这个过程还没有涉及到很多逻辑,并且还是停留到js层面。

JS的预解析

JS的预解析是指在代码执行之前,JavaScript引擎会先对代码进行一次扫描,将变量声明和函数声明提升到当前作用域的顶部,以便在代码执行时能够正确地访问这些变量和函数。这个过程也被称为“提升”。

JS 做一个简单的 Parser

前些天偶然看到以前写的一份代码,注意有一段尘封的代码,被我遗忘了。这段代码是一个简单的解析器,当时是为了解析日志而做的。最初解析日志时,我只是简单的正则加上分割,写着写着,我想,能不能用一个简单的方案做个解析器

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!