登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

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 解析、声明式 Shadow DOM、CustomElementRegistry 与自定义元素生命周期的静态关系图
图1: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 节点在一段时间内保持未知状态。

Declarative Shadow DOM 中定义过晚与定义可达两种升级路径的静态对比图
图2:定义时机影响升级是否及时,但注册表不一致时仅提前 define() 也不能解决问题。这是静态说明图,不是截图或运行证据。

// 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 的首屏结构,也能避免把一个注册时序问题误修成模板或样式问题。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>