声明式 Shadow DOM 落地:卡片组件 SSR 直出、样式穿透与 hydration 时序

2026-10-18 0 259

我们这个后台系统有七八个页面在用 “信息卡片” 这个组件。最开始它就是一个普通的 div,带一堆 .card-header、.card-body、.card-title 之类的 class。随着业务扩张,有人图方便直接在自己的页面里给 .card-body 加样式覆盖内边距,有人把 .card-title 挪去写别的组件的标题,两边样式开始打架。

某天早上我看到第 6 个页面里出现了 .card-title-2 这种命名的时候,我决定换思路:迁到 Web Components,用 Shadow DOM 隔离样式。整件事断断续续做了两周,踩了不少坑,这篇文章就是复盘。

如果你也在考虑给后台组件加 Web Components,且页面是服务端直出的,这篇文章里关于 Declarative Shadow DOM 的部分应该对你有用——尤其是 SSR 直出这块,很多教程讲得都太浅,一上生产就出问题。

先决定用哪种 Web Components 姿势

Web Components 有三种落地方式。

方式一:完全 JS 驱动。customElement 的 connectedCallback 里 this.attachShadow({mode: 'open'}),然后 innerHTML 塞模板。这是最常见的写法,几乎所有教程都是这么写的。

方式二:Lit 或者 Stencil 之类的框架。本质上是方式一的封装,帮你处理了属性响应、模板渲染、SSR 之类的事情。

方式三:Declarative Shadow DOM。在 HTML 里直接写 <template shadowrootmode="open">...</template>,浏览器解析到这个标签之后自动创建 shadow root,不需要任何 JS。

方式一的问题是,如果你页面是 SSR 的,用户看到的是”先渲染一个空壳,JS 加载完全部重画”。这个闪烁在弱网下能持续一两秒,体验很差。而且 SEO 爬虫看不到 shadow 内容(Google 现在能看到了,但很多其它场景不行)。

方式二是另一种技术栈选择,我们团队没人有 Lit 的经验,短期内不想引入新的框架成本。

所以我选了方式三。SSR 直出 shadow DOM,浏览器原生解析,JS 只负责升级交互。这一条选择决定了后面所有的坑形状。

基础:一个不用 JS 的卡片

最简版的声明式 Shadow DOM 长这样:

<my-card>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        border-radius: 8px;
        background: #fff;
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
      }
      .header {
        padding: 16px 20px;
        border-bottom: 1px solid #eef0f2;
        font-weight: 600;
      }
      .body {
        padding: 20px;
      }
    </style>
    <div class="header"><slot name="title"></slot></div>
    <div class="body"><slot></slot></div>
  </template>

  <h3 slot="title">本月订单概览</h3>
  <p>共 1,284 笔订单,环比增长 12%。</p>
</my-card>

就这么一把,浏览器打开就是渲染好的卡片。<template shadowrootmode="open"> 是被识别为”需要变成 shadow root”的特殊模板。里面的 style 是作用域隔离的,外面的 .body class 写不进来,里面的样式也不会泄漏出去。

它和 <template> 的区别很简单:普通 template 里面的内容不渲染,只是当数据模板用;带 shadowrootmode 的 template 里的内容会在解析时直接成为 shadow root 的内容。

浏览器支持方面,Chrome 从 90 开始有,Firefox 从 123 开始有,Safari 从 16.4 开始有,现在(2025 年)基本可以放心用。如果你需要兼容更老的浏览器,要么加载 polyfill,要么就用 JS 版 attachShadow 兜底,这个我们后面会讲。

服务端直出:模板里带完整结构

真正落地到项目上的时候,这个组件不可能是硬编码在页面里的。它是后台系统里的一个通用组件,每个页面通过后端模板渲染。

我们后端用的是 PHP,模板引擎是 Twig。抽出一个 Twig 模板大概是这样的:

{# templates/components/card.html.twig #}
<my-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; border-radius: 8px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
      .header { padding: 16px 20px; border-bottom: 1px solid #eef0f2; font-weight: 600; }
      .body { padding: 20px; }
    </style>
    <div class="header"><slot name="title"></slot></div>
    <div class="body"><slot></slot></div>
  </template>

  <h3 slot="title">{{ title }}</h3>
  <div>{{ body|raw }}</div>
</my-card>

然后页面里这样用:

{% include 'components/card.html.twig' with {
  title: '本月订单概览',
  body: '共 1,284 笔订单,环比增长 12%。'
} %}

HTML 输出到浏览器之后,浏览器解析 <template shadowrootmode="open"> 这个标签,把里面的内容变成 shadow root。所以你打开 devtools 看到的 my-card 已经带上了 shadow root,并且样式和结构都有了。

这就是声明式的好处:没有 hydration 的”先渲染空壳”阶段。浏览器解析完 HTML 就能看到完整内容,JS 加载不加载都不影响视觉。这个对比在弱网下特别明显,我们改造之前首屏大概有两三秒的空白,改完之后是”看到什么就是什么”。

坑一:样式继承会断,一开始会很不适应

改造过程中第一个问题就来了。原来 .card-title 是直接继承外面的 font-size: 18px 的。现在 title 变成了 shadow DOM 里的内容,外面的字体规则完全管不上了。

Shadow DOM 的样式隔离是双向的。外面能影响里面的只有两件事:

一是继承属性。像 color、font-family、font-size 这些可继承属性,会穿透 shadow 边界。

二是 CSS 自定义属性。也就是 --xxx。这个是可以穿透的,也是最推荐的自定义方式。

而像 padding、background、border 这些非继承属性,外面完全影响不到里面。这也是为什么会有 :host 选择器——它是让你从 shadow 内部选择外面的宿主元素。

处理的办法是把”业务想控制的样式”用 CSS 变量暴露出去:

<style>
  .header {
    padding: var(--card-header-padding, 16px 20px);
    border-bottom: 1px solid var(--card-border-color, #eef0f2);
  }
  .body {
    padding: var(--card-body-padding, 20px);
  }
  :host {
    --card-border-color: #eef0f2;
    --card-header-padding: 16px 20px;
    --card-body-padding: 20px;
  }
</style>

然后在业务页面里就能覆盖:

my-card.compact {
  --card-body-padding: 12px 16px;
  --card-header-padding: 12px 16px;
}

这样样式隔离的边界是清晰的:业务只通过变量控制,组件内部实现完全黑盒。这个设计做下来虽然多写了几行代码,但后面再没人因为 “内边距一不小心被改了” 而跑来找我。

要注意一个细节:默认值也写在 :host 里,不要写成 var(--card-body-padding, 20px) 里带字面量。这样业务直接改 :host 上的变量就能覆盖,而不用非得到使用方覆盖。两种方式都行,但 :host 里的写法更清晰一些。

坑二:::slotted 的选择器有边界

还有一个问题,就是插槽内容的样式。假设 <p slot="title"> 里带了 <strong>,我们想在组件里给 strong 加个颜色。直觉写法是:

::slotted(strong) {
  color: #d9291c;
}

这个能生效。但如果你写:

::slotted(p strong) {
  color: #d9291c;
}

不会生效。::slotted() 只匹配直接叫 slot 的直接子元素,内部的更深层结构是拿不到的。这是设计上的限制,不是 bug。

想控制插槽内部样式有两种办法:一是告诉使用方”这个位置只接受简单的元素,别嵌太深”;二是在组件里再套一层 <slot> 引导结构,让使用方必须按你的 model 输出。

我们项目的做法是后者。title slot 只要求输出纯文本或者 <strong>/<span class="xxx"> 这些简单标签,太复杂的内容让使用方自己包一个 div 并加自己的类。这样组件的边界清晰,也少了很多跨层次的样式依赖。

坑三:client-side customElement 定义时不要重复 attachShadow

接下来要让卡片也能响应一些交互,比如点击 header 折叠 body。这时候就要定义一个 client-side 的 customElement:

class MyCard extends HTMLElement {
  connectedCallback() {
    // 注意!声明式 Shadow DOM 已经有了,不要重新 attach
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' });
      this.shadowRoot.innerHTML = '...'; // 兜底逻辑
    }

    this.header = this.shadowRoot.querySelector('.header');
    this.header.addEventListener('click', () => this.toggle());
  }

  toggle() {
    this.toggleAttribute('collapsed');
  }
}

customElements.define('my-card', MyCard);

这里关键的一行是 if (!this.shadowRoot) 判断。因为有 Declarative Shadow DOM 的 my-card 在解析 HTML 的时候就已经有 shadow root 了,如果你直接 attachShadow 会抛错(”Shadow root cannot be created on a host which already hosts a shadow tree”)。

很多人不用判断,直接写 attachShadow,然后本地测试正常(因为是 JS 版本),一上生产就报错。这是声明式方案最容易踩的坑。而且报错信息挺隐晦的,不是一句”不能重复创建”,而是 InvalidStateError,光看错误信息不一定能立刻反应过来。

对应的一个补充技巧:如果想实现”没 JS 也能工作”的渐进式增强,需要在 CSS 里给 collapsed 状态一个默认样式:

:host([collapsed]) .body {
  display: none;
}

这样即使 JS 没执行,只要 HTML 里带了 collapsed 属性,body 就是折叠的。JS 只负责点击切换这个属性。整个组件的初始状态不依赖 JS,交互才依赖 JS。

坑四:属性读取时机——attributeChangedCallback 的时机在 SSR 场景下很微妙

这是四个坑里最隐蔽的一个。

给组件加一个 title-length 属性,表示标题最多显示多少个字符。我们在 attributeChangedCallback 里读取处理:

class MyCard extends HTMLElement {
  static observedAttributes = ['title-length'];

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'title-length' && oldValue !== newValue) {
      this.applyTitleLimit(parseInt(newValue, 10));
    }
  }
}

本地开发一切正常。上线之后,发现对于服务端直出的页面,第一次 rendering 的时候 attributeChangedCallback 并不会被触发,因为声明式 Shadow DOM 在 customElement 定义之前就已经解析完了,属性在解析时就存在,等到 customElement 升级的时候,oldValue 和 newValue 都是 null,不会触发回调。

所以 attributeChangedCallback 在升级前后有语义差异:

升级前已经存在的属性,oldValue 是 undefined/null,但会以 (name, null, newValue) 的形式触发一次。

升级后追加的属性,才是真正的变化。oldValue 是旧值。

等等,我上面说的”不会触发”是错的。实际上按照规范,customElement 升级时会为每个已存在的 observedAttributes 调用一次 attributeChangedCallback,传参是 (name, null, value)。所以问题不是”不触发”,而是”触发时机在 connectedCallback 之前,此时 shadowRoot 可能还没同步读取”。

更准确的说法是:升级时 attributeChangedCallback 会先触发,然后才是 connectedCallback。如果你在 attributeChangedCallback 里操作 shadowRoot 内的节点,它们是存在的(因为声明式 Shadow DOM 已经有了),但如果涉及到一些依赖布局的操作(比如读取 offsetHeight 来算文字截断),就会拿到 0,因为 DOM 还没渲染成可视化状态。

这个问题我们踩了两次坑。第一次是尝试在 attributeChangedCallback 里做文字宽度测量,全是 0;第二次是加了一个 console.log 才发现触发顺序和 FPM 项目里的经验不一样。

最后的处理方式:attributeChangedCallback 只负责存状态,实际的操作放在 connectedCallback 里,或者用 requestAnimationFrame 延迟一帧。

connectedCallback() {
  // 这里才做需要布局的操作
  this.measureAndClamp();
}

attributeChangedCallback(name, old, value) {
  if (name === 'title-length' && this.isConnected) {
    // 已连接状态下变化,才即时处理
    this.measureAndClamp();
  }
  // 初次升级时不做操作,交给 connectedCallback
}

this.isConnected 是个很好用的属性,用来区分”首次升级”和”运行时变化”。

坑五:polyfill 的加载顺序决定了首屏

浏览器兼容性这一层我们处理得比较保守,项目还要支持一些老版本的内网浏览器。用的是官方推荐的做法:把声明式 Shadow DOM 的 polyfill 放在 <head> 里同步加载。这个 polyfill 会把浏览器不认识的 <template shadowrootmode> 自动处理成 attachShadow。

但这里有个坑:polyfill 加载之前,页面对老浏览器来说就是一堆 template 标签,什么都不会渲染。所以 polyfill 必须在 head 里同步加载,不能 defer,不能 async。这个我之前理解是反的——以为 defer 减少阻塞更重要。

实际上对于声明式 Shadow DOM 场景,首屏必须指望 polyfill 尽早介入,否则用户看到的就是”白板”。

后来我想了一个折中方案:如果检测到浏览器支持声明式 Shadow DOM,就完全不加载 polyfill;不支持才加载。检测方式很简单:

if (!('shadowRootMode' in HTMLTemplateElement.prototype)) {
  document.write('<script src="/polyfill/declarative-shadow-dom.js"></script>');
}

用 document.write 是有点脏,但它保证了 synchronous 执行——比动态创建 script 标签可靠。这段代码放在 head 里执行,老浏览器会立刻加载 polyfill,新浏览器直接跳过,没有额外的请求。

这个方案不是完美的,但在我们实际场景里跑了两个月,没出过问题。

流式渲染与 Declarative Shadow DOM

我们后台系统后来上了 streaming SSR,一大好处是首屏可以更早开始。传统 SSR 是”服务端全部渲染完再一起发过来”,streaming 是”渲染到哪发到哪”。我们的卡片组件天生适合 streaming——每个卡片独立,只要数据准备好就能立刻发出去。

声明式 Shadow DOM 和 streaming 配合得挺好。<template shadowrootmode="open"> 内部的内容是完整的,浏览器解析到 </template> 就能立刻创建 shadow root,不需要等整个页面加载完。这意味着用户会看到”卡片一个一个出现”的效果,而不是等到所有数据加载完才一起显示。

但有一个坑:template 的闭合标签必须完整出现,如果因为网络问题导致 template 内容被截断,浏览器会一直等待,直到收到 </template> 才开始渲染。也就是说,如果一个卡片的内容特别大,你需要等它整个渲染完才能显示,而不是”边渲染边显示”。

处理方式是控制单个卡片的内容大小,单个卡片内部不要超过几十 KB 的 HTML。如果有些卡片数据特别大,可以在服务端把它拆成一个骨架 + 一个异步填充的部分。骨架部分仍然是声明式 shadow DOM,数据部分用 fetch 加载后填进去。

改造前后的对比

改造前:全部页面共享一套 card 相关的 CSS,大概 200 多行,散落在三个文件里。每个页面都有自己的覆盖,class 命名靠人盯,已经出现过两次样式冲突事故。

改造后:卡片组件的 HTML 模板是一个文件,组件自己的 CSS 全在 shadow 内部。业务页面只能用 CSS 变量控制几个暴露出来的尺寸和颜色。全站搜索 .card-xxx 的 class 几乎清零。

性能数据对我们来说不是主要收益点。实际上文件总大小没有减少太多,因为每个页面的 HTML 里都重复包含了 <style> 段。如果页面上有 20 个卡片,就是 20 份 shadow 内部的样式,gzip 之后勉强能压下来,但确实不如全局 CSS 高效。

如果你页面上同一种组件出现特别多,可以考虑用 Constructable Stylesheets(CSSStyleSheet + adoptedStyleSheets)来共享样式表,而不是每个 shadow root 都塞一份 <style>。这个我们暂时没做,因为实测下来 20 个卡片带来的体积增量还能接受。

真正明显的收益是维护体验。组件现在是”一个文件搞定”,改样式不用再担心影响别的页面,加功能不用再靠命名约定区分。这个变化带来的效率提升其实比性能数据更让我满意。

什么时候不该上 Declarative Shadow DOM

虽然这次改造整体是成功的,但我也得说清楚不推荐的情况。

如果你的项目没有 SSR。完全是个 SPA,那实际上用不用声明式 Shadow DOM 差别不大,因为初始 HTML 就是个空壳,反正要等 JS 加载。这种情况下 attachShadow 更简单直接。

如果你的组件非常小、只有一两个样式。比如一个按钮、一个图标,那就是过度封装了。web component 有额外的开销(升级、处理 shadow root),简单的东西还是普通 class 更划算。

如果你们团队还没接触过 Web Components。这套东西的心理模型和普通前端组件不一样(样式隔离、插槽、生命周期),上手成本不低。我是因为之前做过几个 web component 项目才敢下手,第一次接触的团队可能会在 ::slotted 这些概念上卡很久。

如果你需要用到大量第三方 UI 库的组件。比如你们用了 Ant Design、Element Plus 这些,它们的很多组件是不开 shadow DOM 的,混在一起会有样式穿透问题。这种情况要么打包成 shadow 内部使用,要么就别用 shadow,会两边不讨好。

最后一点经验

改造过程中最有用的一句话是:把 shadow 边界当作一个 API 来设计。哪些属性是公开的、哪些是私有的、通过什么方式修改样式,这些决定要写下来,让业务方知道”能改什么、怎么改”。否则 shadow DOM 会从”保护组件的墙”变成”挡在维护者和使用者之间的一堵墙”。

我们项目里最终的”对外 API”大概是三条:title 属性用于标题文本、slot="title" 用于富文本标题、--card-* 变量用于样式定制。除了这三样,其它都是黑盒。写在文档里,写在组件 README 里。

Shadow DOM 不是银弹,它解决的是样式冲突、深度封装这类问题,代价是让”跨组件沟通”变复杂了。如果你的项目还没到这个复杂度,用 BEM 加 CI 里的 stylelint 检查可能就够了。

但如果你的项目已经到了”不敢删任何一个 class”的地步,那升级到 shadow DOM 的日子确实该排上计划了。

声明式 Shadow DOM 落地:卡片组件 SSR 直出、样式穿透与 hydration 时序
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html 声明式 Shadow DOM 落地:卡片组件 SSR 直出、样式穿透与 hydration 时序 https://www.taomawang.com/web/html/2960.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务