团队版
按月计费
如果组件的首屏内容必须跟着 SSR HTML 一起到达,优先考虑 Declarative Shadow DOM;如果组件主要依赖浏览器里的数据和交互,客户端调用 attachShadow() 更直接。两者不是“新旧 API 二选一”,而是把 Shadow Root 的创建时机交给不同一方:前者交给 HTML 解析,后者交给 JavaScript。
选择标准可以压缩成一句话:首屏先要内容,就让服务端把 Shadow DOM 写进 HTML;首屏只需要一个空壳、等数据到齐再组装,就在客户端创建 Shadow Root。无论选哪条路,都要提前写清楚升级、重复创建和不支持 DSD 时的降级规则。
。attachShadow()。从调用方体验看,Shadow Root 的创建者就是这套组件的“初始化接口”。Declarative Shadow DOM 把结构放进服务器返回的 HTML,浏览器解析到 时创建 Shadow Root;客户端方案则先输出宿主元素,再由 JavaScript 调用 attachShadow() 并填充子节点。
这个差别会直接影响首屏时序。DSD 可以让组件内部的静态内容跟着 HTML 一起进入解析过程;客户端方案需要等脚本运行到初始化代码之后,才会把内部结构挂进去。交互数据不一定因此变快,但“用户何时能看到可读内容”会变得不同。

| 调用方目标 | 更合适的默认方案 | 原因 |
|---|---|---|
| 首屏先展示标题、价格、摘要等静态内容 | Declarative Shadow DOM | 内容随 HTML 到达,脚本可以后置处理交互 |
| 组件必须等待浏览器数据或用户状态 | 客户端 attachShadow | 创建时即可把数据请求、事件绑定和渲染放在同一个初始化路径 |
| 同一结构需要多个客户端实例共享初始化逻辑 | 客户端 attachShadow | 自定义元素类可以集中管理实例生命周期 |
| 支持 SSR,同时希望脚本只负责增强 | DSD + 客户端升级 | 把静态结构和行为绑定拆开,避免首屏依赖脚本 |
当组件有稳定的首屏骨架时,可以在服务端输出带 shadowrootmode 的模板。下面的示例只负责声明一个可读的卡片结构;按钮行为和动态数据不写进这段 HTML,而留给后续的自定义元素升级。
团队版
按月计费
这里的关键不是模板标签本身,而是“结构先到、行为后挂”。如果脚本因为网络慢暂时没有执行,静态标题和价格仍有机会先进入页面;如果页面完全依赖实时数据,就不要为了使用 DSD 而伪造一个不可靠的首屏内容。
如果服务端没有足够信息生成组件内部结构,或者组件一出现就要根据浏览器状态决定布局,客户端创建会更容易维护。把创建、渲染、事件监听和清理集中在自定义元素生命周期中,也能避免把初始化代码散落在页面脚本里。
class PriceCard extends HTMLElement {
connectedCallback() {
// 中文说明:只在当前实例还没有 Shadow Root 时创建,避免重复初始化。
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
const title = document.createElement("h2");
title.textContent = this.getAttribute("plan") || "团队版";
const button = document.createElement("button");
button.type = "button";
button.textContent = "选择方案";
// 中文说明:事件绑定放在 Shadow Tree 内,组件销毁时跟随节点一起释放。
button.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("plan-select", { bubbles: true }));
});
root.append(title, button);
}
}
customElements.define("price-card", PriceCard);
这条路径的代价也很明确:在脚本执行前,宿主元素可能只是一个空节点;如果要避免布局跳动,需要在 Shadow Root 创建前就给宿主设置尺寸或准备轻量的 light DOM 占位。
DSD 的首屏结构和组件行为是两个状态。解析完成只能说明 Shadow Root 已经有内容,不代表事件已经绑定,也不代表自定义元素类已经注册。客户端升级时应把这两个状态分别处理:
class SearchBox extends HTMLElement {
connectedCallback() {
// 中文说明:DSD 可能已经创建了 root;没有时才走客户端创建分支。
const root = this.shadowRoot || this.attachShadow({ mode: "open" });
if (!root.querySelector("form")) {
// 中文说明:只有没有服务端结构时才补最小客户端模板。
root.innerHTML = ``;
}
const form = root.querySelector("form");
if (!form || form.dataset.bound === "true") return;
form.dataset.bound = "true";
// 中文说明:标记已绑定,组件重复连接时不会重复注册提交事件。
form.addEventListener("submit", (event) => {
event.preventDefault();
this.dispatchEvent(new CustomEvent("search-submit", { bubbles: true }));
});
}
}
customElements.define("search-box", SearchBox);
示例里的判断故意保持保守:只要已经有 form 就复用它。真实项目中还应把 Shadow Root 的 mode、模板版本和事件协议当作组件契约,避免服务端和客户端各自认为自己拥有初始化权。
Declarative Shadow DOM 依赖浏览器对 shadowrootmode 的支持。遇到不支持它的环境, 本身不会自动变成普通可见子节点,因此不能把 DSD 当成唯一内容来源。常见的选择有三种:
attachShadow(),把首屏占位与真实内容分开设计。注意不要只在开发环境里判断“页面看起来有内容”。应把最低浏览器矩阵、无脚本状态、脚本延迟状态和组件升级后的事件状态写成同一份契约。这样切换方案时,调用方只需要关注内容是否存在、事件是否可用,而不用猜内部到底是 DSD 还是客户端创建。

如果你在做 SSR 页面里的内容组件,默认从 DSD 开始:让结构进入 HTML,事件与数据请求再由自定义元素升级完成。如果你在做完全由客户端数据驱动的交互部件,默认使用 attachShadow(),并主动给宿主元素预留稳定尺寸。
工程上最容易出问题的不是 API 写错,而是没有声明初始化所有权。发布前至少列出四列:首屏是否必须有内容、脚本延迟时显示什么、浏览器不支持 DSD 时怎么退化、组件重复连接时谁负责复用 Root。四列都能回答清楚,DSD 和 attachShadow() 的选择通常就不会再靠偏好争论。
不能。DSD 解决的是结构如何随 HTML 到达,attachShadow() 仍适合客户端数据驱动、动态创建和统一生命周期管理。
Shadow Root 有内容不等于行为已就绪。注册自定义元素后,仍可以补事件、状态同步、属性观察和动态数据。
不能一概而论。它增加了等待脚本初始化的阶段,但如果组件内容必须依赖客户端数据,直接在客户端创建往往更符合实际的调用时序。
template shadowrootmode 怎么在闭合阴影根中保留焦点委托