Declarative Shadow DOM SSR 输出后为什么自定义元素没有升级
来源:17golang原创
时间:2026-10-13 10:57:54 387浏览 收藏
我在把组件从纯客户端渲染改成 SSR 时,遇到过一个很容易误判的现象:服务端返回的 HTML 里已经有 ,页面也能看到一部分结构,但自定义元素的 constructor 和 connectedCallback 都没有按预期执行。这个问题通常不是“Shadow DOM 没渲染”,而是元素还没有被当前注册表升级,或者升级过程中构造函数已经抛错。
排查 Declarative Shadow DOM SSR 升级问题,先测customElements.get()是否有定义,再测 SSR 节点是否属于该定义的实例,最后检查 shadow tree 使用的CustomElementRegistry是否和define()调用方一致。只把脚本标签往前挪,解决不了注册表不一致。
官方参考:https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM
我先把问题拆成两个指标
我后来不再用“页面看起来有没有内容”判断升级是否成功,而是固定记录两个布尔指标:一是注册表里有没有类定义,二是 SSR 节点是否已经拥有这条类原型链。第一个指标回答“有没有注册”,第二个指标回答“这个具体节点有没有升级”。两者都为真,才继续看生命周期回调和组件内部渲染。

服务端初始内容
// 只读指标,不修改 SSR 节点;null 表示当前全局注册表还没有定义
const Definition = customElements.get("user-card");
const element = document.querySelector("user-card");
console.table({
registered: Boolean(Definition),
hasShadowRoot: Boolean(element?.shadowRoot),
upgraded: Boolean(Definition && element instanceof Definition),
});
这里的三个字段分别对应三个阶段:registered=false 表示客户端还没有调用 define();hasShadowRoot=true 只说明声明式 Shadow DOM 被解析了;upgraded=false 则说明当前元素仍是普通的未知元素,不能因为它已经显示内部内容就当作组件已接管。MDN 对 upgrade() 的说明也强调,未注册时元素只是普通 HTMLElement,升级后才会得到自定义原型和生命周期行为。
SSR 输出正常,不代表自定义元素已经升级
Declarative Shadow DOM 的关键是 template 上的 shadowrootmode。浏览器解析 HTML 后,会把模板内容包进连接到宿主元素的 shadow root,DOM 中不再保留原来的 template 节点。这个过程和自定义元素注册是两条相互独立的链路:前者负责产生 shadow tree,后者负责让 获得类定义。
所以你可能同时看到“内部标题已经显示”和“组件方法没有执行”。这并不矛盾:shadow root 已经存在,但 user-card 本身仍然可能处于未升级状态。排查时先把这两个指标分开,能避免在 CSS 或 SSR 模板上反复改动。
// 用生命周期日志区分“未注册”和“构造后未连接”两类现象
class UserCard extends HTMLElement {
constructor() {
super();
console.debug("user-card: constructor");
}
connectedCallback() {
// connectedCallback 适合做依赖属性和子树已经就绪后的初始化
console.debug("user-card: connected", this.isConnected);
}
}
customElements.define("user-card", UserCard);
// define() 后再读取实例状态,观察已有 SSR 节点是否被升级
const current = document.querySelector("user-card");
console.table({
upgraded: current instanceof UserCard,
connected: current?.isConnected ?? false,
});
对于已经连接到文档的元素,注册定义后浏览器通常会自动安排升级反应;升级时会调用构造函数,已有观测属性可能触发 attributeChangedCallback(),元素已连接时还会触发 connectedCallback()。如果这些日志完全没有出现,优先回到注册表和定义加载时机,而不是先改组件内部模板。
把 define() 放到解析时序可达的位置
我第一次修复时只做了一件事:把注册组件的模块放到 SSR 页面必定加载的入口,并确保它不会等到某个交互事件才执行。这里的重点不是追求“越早越好”,而是让 define() 在页面需要组件之前可达。动态 import、按路由懒加载和脚本依赖顺序,都可能让 SSR 节点在一段时间内保持未知状态。

...
// user-card-definition.js:定义模块只负责注册一次,并暴露一个可等待的完成点
export class UserCard extends HTMLElement {
connectedCallback() {
// SSR 内容已存在时,避免重复创建第二个 shadow root
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).textContent = "客户端补充内容";
}
}
}
if (!customElements.get("user-card")) {
// 先注册,再让页面其他模块通过 whenDefined() 等待
customElements.define("user-card", UserCard);
}
export const userCardReady = customElements.whenDefined("user-card");
示例里还保留了 customElements.get() 防护,避免热更新或重复加载模块时再次定义同名元素。组件的构造函数应该只做轻量、与元素自身状态兼容的初始化;依赖属性或子树的工作更适合放到 connectedCallback()。如果 SSR 已经创建了 shadow root,客户端不能无条件再次调用 attachShadow()。
注册表不一致时,提前定义也没有用
这是真正容易漏掉的一层。普通 SSR 的声明式 shadow root 使用文档关联的注册表;如果组件来自 scoped custom element registry,或者模板声明了让 shadow root 暂不绑定注册表的配置,那么 shadow tree 里的自定义元素可能不会去查全局 window.customElements。此时你在全局注册表调用 customElements.define(),并不等于 shadow tree 中的节点也能找到这个定义。
换句话说,故障指标会呈现出一种很有迷惑性的组合:customElements.get("user-card") 有值,但 shadow tree 里的 user-card 仍然不是该构造函数的实例。遇到这个组合,应该检查组件到底属于哪个 CustomElementRegistry,而不是继续调整脚本标签顺序。
// 用同一个注册表创建 shadow root,保证内部自定义元素查到同一份定义
const registry = new CustomElementRegistry();
class CardBadge extends HTMLElement {
connectedCallback() {
// 这里的回调只表示该 registry 下的元素已连接
this.textContent = "已升级";
}
}
registry.define("card-badge", CardBadge);
const host = document.createElement("section");
document.body.append(host);
const shadow = host.attachShadow({
mode: "open",
customElementRegistry: registry,
});
// 该节点由 shadow root 关联的 registry 解析和升级
shadow.innerHTML = " ";
如果采用声明式 shadow root 的 scoped registry 方案,模板和后续初始化必须遵循同一套注册表设计。不要把“全局注册成功”当成“所有 shadow tree 都能使用”;注册表的作用域是升级判断的一部分。
异步加载时用 whenDefined() 留下完成点
对于确实需要异步加载组件定义的页面,我会把“定义模块加载完成”和“组件业务数据加载完成”拆开。whenDefined() 只能说明指定名称的定义已经注册,不能保证网络数据、内部模板或业务初始化成功;但它足以作为升级排查的第一道完成点。
// 等待注册完成,再读取 SSR 节点;错误单独上报,避免把未定义误记成接口失败
async function waitForUserCard() {
try {
await customElements.whenDefined("user-card");
const element = document.querySelector("user-card");
const UserCard = customElements.get("user-card");
console.table({
registered: Boolean(UserCard),
upgraded: Boolean(UserCard && element instanceof UserCard),
connected: Boolean(element?.isConnected),
});
} catch (error) {
// 保留原始错误,区分模块加载失败与组件构造函数异常
console.error("等待 user-card 定义失败", error);
}
}
waitForUserCard();
如果定义已经存在但 instanceof 仍为假,就去查注册表作用域和元素实际所在的 shadow tree;如果 instanceof 为真但组件没有内容,再看构造函数是否抛错、connectedCallback() 是否提前返回,以及 SSR shadow root 是否被重复初始化。
我最后保留的定位表
| 观测结果 | 更可能的原因 | 下一步 |
|---|---|---|
get() 为 null | 定义模块没加载、执行顺序过晚或注册失败 | 确认入口脚本、模块错误和 define() 是否执行 |
shadow root 存在但 instanceof 为假 | 节点尚未升级,或 shadow tree 使用另一份注册表 | 对齐定义时机与 CustomElementRegistry 作用域 |
instanceof 为真但无业务内容 | 构造函数、连接回调或重复 attachShadow 处理失败 | 保留生命周期日志,检查回调中的异常和重复初始化 |
| 同名元素二次定义报错 | 模块重复加载或热更新没有幂等保护 | 用 customElements.get() 做注册前判断 |
这套表格比单看首屏耗时更适合定位升级故障:它把“HTML 已经出来”“定义已经注册”“节点已经升级”“组件已完成业务初始化”四个状态拆开。指标越具体,SSR 和客户端渲染之间的责任边界就越清晰。
几个容易混淆的追问
声明式 Shadow DOM 会自动调用自定义元素的 connectedCallback 吗?
只有当对应自定义元素已经按正确的注册表完成升级,并且处于连接状态时,才会进入相应生命周期反应。shadow root 被解析本身不等价于宿主和内部节点都完成组件升级。
注册完成后还需要手动调用 customElements.upgrade() 吗?
普通已连接的 SSR 元素通常会在定义注册后自动升级。upgrade() 更适合需要在节点插入文档前处理的离线子树;它也必须使用与元素关联的注册表,不能绕过作用域问题。
为什么只看页面上有没有文字不够?
声明式 shadow root 可以先让静态内容显示出来,而自定义类还未接管节点。要判断升级,至少同时观察 customElements.get() 和 element instanceof Definition。
组件可以在 constructor 里读取 SSR 子节点吗?
不建议把依赖属性和子树的初始化都放进 constructor。按照 Web Components 的常见生命周期约束,这类工作应尽量放到 connectedCallback(),并对已有 shadow root 做幂等判断。
我现在处理这类问题的顺序是:先确认声明式 shadow root 是否存在,再确认当前注册表的定义和实例原型,最后才看组件内部代码。这样既能保留 SSR 的首屏结构,也能避免把一个注册时序问题误修成模板或样式问题。
-
130 收藏
-
414 收藏
-
163 收藏
-
137 收藏
-
160 收藏
-
327 收藏
-
文章 · 前端 | 1小时前 | 前端 · javascript · SyntaxError Web Components customElements.whenDefined Custom Elements 自定义元素名称223 收藏
-
222 收藏
-
440 收藏
-
120 收藏
-
308 收藏
-
222 收藏
-
103 收藏
-
253 收藏
-
158 收藏
-
474 收藏
-
111 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习