Constructable Stylesheets:我在 Shadow DOM 重复样式里看到的价值

Web Components 常用 Shadow DOM 做样式隔离。

问题是,如果页面里有几十个同类组件,每个 Shadow Root 都塞一份相同的 <style>,浏览器就要重复解析这些 CSS。

组件越多,重复成本越明显。

我第一次注意到这个问题,是在看一个自定义元素列表页。页面上几十个卡片组件,每个组件的 Shadow DOM 里都有一段几乎一样的 <style>。功能没问题,但从工程角度看很别扭:同一份样式被重复塞了很多次,主题更新也很难统一处理。

那次其实是排查一个滚动卡顿才翻到这块的。列表一次渲染两三百个卡片,首屏白屏时间比预期长不少。我在 Performance 面板里录了一段,发现 Recalculate Style 和 Parse Stylesheet 的占比高得不正常。点开具体的 stylesheet 解析事件,每一条都是同一段 .card { ... },区别只是 owner 不同。换句话说,浏览器把同一份 CSS 老老实实地解析了两三百遍。

这里有个容易被忽略的细节:Shadow DOM 里的 <style> 不会像页面级 <link> 那样命中浏览器对样式表的缓存。每个 Shadow Root 是独立的样式作用域,innerHTML 里塞进去的 <style> 对引擎来说就是一段全新的待解析文本。所以哪怕一万个组件的 CSS 字节级完全一致,引擎也没有把它们去重的义务。这跟 <img> 同一个 src 只下载一次的直觉是相反的,我一开始就栽在这个直觉上。

内存上也有代价。每份解析后的样式表都是独立的 CSSOM 对象,组件越多占用越大。在低端安卓机上这点会被放大,我当时在一台红米千元机上实测,列表组件从普通 <style> 换成共享样式表之后,常驻内存掉了大概十几兆,滚动也跟手了。

传统写法的问题

很多自定义元素会这样写:

1class UserCard extends HTMLElement {
2  connectedCallback() {
3    const shadow = this.attachShadow({ mode: "open" });
4    shadow.innerHTML = `
5      <style>
6        .card {
7          padding: 12px;
8          border: 1px solid #ddd;
9        }
10      </style>
11      <div class="card">用户卡片</div>
12    `;
13  }
14}

每创建一个组件实例,就会插入并解析一份样式。

这类写法适合 demo,但不太适合组件库。组件数量一多,重复样式不仅增加解析成本,也让样式更新变得分散。你想改一个 token、一个间距、一个主题色,可能要确认每个组件是不是都复制了一份。

还有个隐蔽的坑:用 innerHTML 拼模板时,<style> 是写在字符串里的,CSS 和 HTML 结构混在一起,没法走构建工具的 CSS 处理流程。autoprefixer、minify、PostCSS 这些插件都对这段字符串无能为力,它就是一坨纯文本,连 lint 都扫不到。我们团队后来约定,凡是 Shadow DOM 里的样式都不准直接拼在 innerHTML 里,就是被这个问题逼出来的。

另外用 innerHTML 还有重排的隐患。如果你在 connectedCallback 里反复改 innerHTML,浏览器要重新解析整段 HTML 加 CSS,组件如果有内部状态(比如已经绑过事件的子节点)也会被一并销毁。把样式和结构拆开之后,样式归样式、结构归结构,这类问题自然就少了。

Constructable Stylesheets 怎么做

可以先创建一个可复用的样式对象:

1const sheet = new CSSStyleSheet();
2
3sheet.replaceSync(`
4  .card {
5    padding: 12px;
6    border: 1px solid #ddd;
7  }
8`);

然后在多个 Shadow Root 中共享:

1class UserCard extends HTMLElement {
2  connectedCallback() {
3    const shadow = this.attachShadow({ mode: "open" });
4    shadow.adoptedStyleSheets = [sheet];
5    shadow.innerHTML = `<div class="card">用户卡片</div>`;
6  }
7}

这样同一份样式可以被多个组件实例复用。无论挂到多少个 Shadow Root 上,这份 CSS 只解析一次,得到的也是同一个 CSSOM 对象。前面说的两三百遍重复解析,就这么一行干掉了。

这套 API 好在能直接在控制台跑出可观察的结果,不用搭组件就能验证。先做个能力检测,返回布尔:

1'adoptedStyleSheets' in document;  // 支持的环境 → true

然后整条链路都能在控制台一步步看:

1const sheet = new CSSStyleSheet();
2sheet.replaceSync('p { color: red }');
3sheet.cssRules.length;              // → 1,规则确实被解析进了 CSSOM
4sheet.cssRules[0].cssText;          // → "p { color: red; }"
5
6document.adoptedStyleSheets = [sheet]; // 应用到整个文档
7// 此刻页面里所有 <p> 立刻变红,sheet 只解析了这一次

replaceSync 是同步的,跑完 cssRules.length 立刻就是 1;想验证 replace 是异步的也很直观——它返回的是 Promise:

1const s = new CSSStyleSheet();
2s.replace('p { color: blue }') instanceof Promise; // → true
3s.replace('p { color: blue }').then(() => console.log(s.cssRules.length)); // 异步打出 1

最能体现价值的一点:把同一个 sheet 同时赋给多个 Shadow Root 的 adoptedStyleSheets,它们共享的是同一个对象引用(shadowA.adoptedStyleSheets[0] === shadowB.adoptedStyleSheets[0]true),CSS 只在内存里留一份、只解析一次。组件越多,这个内存与解析的节省越明显。

这里要分清 replaceSyncreplace 两个方法。replaceSync 是同步的,传入的 CSS 里不能有 @import,碰到会直接抛错;replace 返回 Promise,允许 @import,但你得 await 它解析完才能用。绝大多数组件库场景我都用 replaceSync,因为我们本来就不该在组件样式里用 @import(那是又一次网络请求),同步写法也更省心。真要用到 @import 再换 replace

1const sheet = new CSSStyleSheet();
2await sheet.replace(`@import url("base.css"); .card { padding: 12px; }`);

还有一点容易踩:adoptedStyleSheets 在老一点的实现里是个被冻结的数组,不能直接 push。想追加样式表得用赋值的方式重建数组:

1// 早期实现里这样会静默失败或报错
2shadow.adoptedStyleSheets.push(sheet);
3
4// 稳妥的写法
5shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet];

新规范放开了可变性,但为了兼容性我一律用展开赋值,免得在不同浏览器上行为不一致。

更重要的是,它让样式从“字符串片段”变成了“可管理对象”。你可以在模块级创建样式,再让多个组件共享它。

1const baseSheet = new CSSStyleSheet();
2baseSheet.replaceSync(`
3  :host {
4    box-sizing: border-box;
5    font: inherit;
6  }
7`);
8
9const cardSheet = new CSSStyleSheet();
10cardSheet.replaceSync(`
11  .card {
12    padding: 12px;
13    border: 1px solid var(--border-color, #ddd);
14  }
15`);

组件里再组合:

1shadow.adoptedStyleSheets = [baseSheet, cardSheet];

这种写法比每个组件拼一大段模板字符串更像组件库的样式管理。baseSheet 放重置和公共变量,每个组件再叠一张自己的表,分层很清楚。后面的表覆盖前面的,顺序就是优先级,跟普通 CSS 的层叠规则一致,不用来回猜谁盖过谁。

我们后来把这套抽成了一个小工具,让样式定义能配合构建工具。简单说就是写独立的 .css 文件,用 import 拿到文本,再喂给 replaceSync

1// card.styles.js —— 配合构建插件把 .css 转成字符串导出
2import cardCss from "./card.css?inline";
3
4let cardSheet;
5export function getCardSheet() {
6  if (!cardSheet) {
7    cardSheet = new CSSStyleSheet();
8    cardSheet.replaceSync(cardCss);
9  }
10  return cardSheet;
11}

注意这里做了懒加载加单例:样式表只在第一次用到时创建一次,之后所有组件拿到的都是同一个引用。这样既能享受共享,CSS 又能正常走 PostCSS、autoprefixer,开发体验跟写普通样式没差别。?inline 这种写法 Vite 原生支持,webpack 用 asset/source 或者 raw-loader 也能达到同样效果。

它适合什么场景

Constructable Stylesheets 适合:

  • Web Components 组件库
  • 大量重复组件实例
  • 需要运行时更新主题的组件
  • 希望减少 Shadow DOM 内重复 style 的场景

如果只是页面里偶尔一个自定义元素,收益不会特别明显。

我会优先在这些场景考虑它:

  • 自定义元素数量多
  • Shadow DOM 样式重复明显
  • 有一套共享设计 token
  • 主题需要运行时切换
  • 组件会被多个业务系统复用

反过来,如果只是业务页面里一两个 Web Component,普通 <style> 可能更直观。不要为了新 API 把简单问题复杂化。

主题切换是一个很实际的场景

Constructable Stylesheets 的一个好处,是共享样式对象可以被更新。

比如主题变化时,你可以替换主题样式:

1const themeSheet = new CSSStyleSheet();
2
3function applyTheme(theme) {
4  themeSheet.replaceSync(`
5    :host {
6      --text-color: ${theme.textColor};
7      --border-color: ${theme.borderColor};
8      --surface-color: ${theme.surfaceColor};
9    }
10  `);
11}

多个 Shadow Root 采用同一个 themeSheet 后,主题更新就不需要逐个组件找 <style> 再改字符串。

这里有个值得说的细节:replaceSync 是直接替换整张表的内容,不是增量打补丁。所以频繁调用它去做高频动画(比如跟着鼠标改某个变量)是不划算的,每次都要重新解析一遍。我的经验是,themeSheet 只在真正切主题(亮色/暗色、换皮肤)这种低频操作上用,高频变化的值还是走 inline style 或者 element.style.setProperty 改 CSS 变量更合适。

还有个真实踩过的坑:主题表如果只放 :host 上的变量定义,那它必须挂到每个用到这些变量的 Shadow Root 上才生效。变量不会自动穿透 Shadow 边界向下传——准确说 CSS 自定义属性是能继承穿透的,但前提是它定义在某个能影响到组件的祖先节点上。如果你只把 themeSheet 挂在某几个组件而漏了另一些,就会出现一部分组件换肤、一部分没换的诡异画面。我们当时的解决办法是把主题变量统一定义在 document 级别(用 document.adoptedStyleSheets,它也支持),让变量从根上往下继承,组件内部只负责消费 var(--xxx),这样一次切换全站生效:

1const rootTheme = new CSSStyleSheet();
2rootTheme.replaceSync(`
3  :root {
4    --text-color: #1a1a1a;
5    --border-color: #ddd;
6    --surface-color: #fff;
7  }
8`);
9document.adoptedStyleSheets = [...document.adoptedStyleSheets, rootTheme];
10
11function applyTheme(theme) {
12  rootTheme.replaceSync(`
13    :root {
14      --text-color: ${theme.textColor};
15      --border-color: ${theme.borderColor};
16      --surface-color: ${theme.surfaceColor};
17    }
18  `);
19}

当然,真实项目里仍然要考虑主题来源、回退值和安全性,不要把未经处理的用户输入直接拼进 CSS。哪怕是改 CSS 变量,拼字符串也有注入风险——一个精心构造的值理论上能闭合声明再插入别的规则。来自用户或接口的颜色值,我会先用一个白名单正则校验(比如只允许 # 加十六进制、rgb()、有限的关键字),不合法就回退到默认值,而不是直接拼进去。

和普通 CSS 的关系

它不是替代普通 CSS 文件。

更准确地说,它是为 Shadow DOM 场景提供更高效的样式共享方式。对于普通页面样式,继续使用 CSS 文件、CSS Modules 或框架内样式方案即可。

这一点很重要。很多 React/Vue 业务系统不需要为了样式复用去引入 Web Components,更不需要为了 Constructable Stylesheets 改造现有样式体系。

它解决的是 Shadow DOM 里的样式共享问题,不是所有 CSS 架构问题。

兼容和降级要提前处理

如果组件库面向多个运行环境,使用前要确认目标浏览器是否支持 adoptedStyleSheets

比较稳的方式是封装一个创建样式的入口:

1function applyStyles(shadow, cssText) {
2  if ("adoptedStyleSheets" in shadow && "CSSStyleSheet" in window) {
3    const sheet = new CSSStyleSheet();
4    sheet.replaceSync(cssText);
5    shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet];
6    return;
7  }
8
9  const style = document.createElement("style");
10  style.textContent = cssText;
11  shadow.appendChild(style);
12}

这样支持的环境走共享样式,不支持的环境还能回退到 <style>。组件库最怕的是新能力只在开发机上好用,到了真实用户环境里直接失效。

回退路径有个性能上的取舍要心里有数:走 <style> 分支时,每个组件又回到了「各自插一份、各自解析」的老路,共享的好处就没了。所以回退是为了「能用」而不是「最优」。好在主流浏览器对 adoptedStyleSheets 的支持已经相当普遍,需要回退的多半是一些老旧 WebView 或者特定的内嵌环境。我一般会在埋点里加一个标记,统计真实流量里有多少比例走了回退分支,数据小到一定程度就可以考虑把回退代码删掉,减少维护负担。

如果项目里用了 polyfill(比如早期 Lit 自带的那套),还要留意一个行为差异:polyfill 往往是用普通 <style> 模拟出来的,adoptedStyleSheets 的引用共享、运行时 replaceSync 更新这些特性在 polyfill 下不一定完全等价。我吃过一次亏,本地用真实 API 测主题切换好好的,到了需要 polyfill 的环境主题就是不刷新,最后发现是 polyfill 没把后续的 sheet 更新同步到已挂载的节点上。所以涉及运行时更新的功能,一定要在真实的降级环境里也跑一遍,别只信本地。

调试时的几个实用技巧

刚用这个 API 时最不适应的是调试。普通 <style> 在 Elements 面板里看得见,Constructable Stylesheet 不在 DOM 树里,新手很容易以为样式没生效。

实际上它可以通过脚本访问。在控制台里可以直接读某个 Shadow Root 挂了哪些表:

1$0.shadowRoot.adoptedStyleSheets.forEach((s) => {
2  console.log([...s.cssRules].map((r) => r.cssText).join("\n"));
3});

cssRules 是可枚举的,调试时把它打印出来基本就能定位问题。想临时改某条规则验证效果,也能直接操作 CSSOM,比如 sheet.cssRules[0].style.padding = "20px",改完所有共享这张表的组件会一起变,这点用来做样式联调反而比改 DOM 还方便。

另外 DevTools 较新的版本已经能在 Styles 面板里识别 adopted stylesheet 的来源了,会标注样式来自构造样式表而不是某个 <style>,这点比早些年友好多了。

Constructable Stylesheets 的核心价值是复用样式对象。

当你在 Web Components 中遇到大量重复 Shadow DOM 样式时,它能减少重复解析,让组件库的样式管理更清楚。

它给我最大的改变其实不只是性能,而是让 Shadow DOM 里的样式终于变回了「一等公民」——能被构建工具处理、能被脚本读写、能在运行时统一更新。性能是顺带的红利。但它也不是银弹,普通业务页面、框架内的样式方案该怎么写还怎么写,别因为学了个新 API 就到处套。判断标准很简单:你是不是真的在 Shadow DOM 里,是不是真的有重复样式或者运行时主题的诉求。是,就值得上;不是,普通 <style> 一行 innerHTML 反而最省事。