Shadow DOM、模板和槽

Web 功能: Shadow DOM <slot>

影子根是附加到宿主元素的封装 DOM 子树:其内部样式不会泄露,页面样式也不会泄露(继承的属性除外)。模板可高效构建树;插槽可将 Light DOM 内容投影到树中。

附加 shadow root

this.attachShadow({ mode: 'open', delegatesFocus: true });

delegatesFocus: true 会使聚焦宿主将焦点移至 Shadow Tree 内的第一个可聚焦元素,将 :focus 样式路由到宿主,并在与表单关联的自定义元素 (static formAssociated = true) 上将 <label> 点击转发到内部控件。每当组件封装可聚焦控件时,请设置此属性。在几乎所有情况下,都应使用 mode: 'open',以便开发者工具、页面脚本和服务器水合可以访问 element.shadowRoot(请参阅 Web 组件方向)。

模板:高效构建树

一次性定义 <template>(模块范围或静态字段),并为每个实例克隆它。克隆已解析的 fragment 比在每次构造时重新解析 innerHTML 字符串要快得多。

const template = document.createElement('template');
template.innerHTML = `
  <div class="card">
    <slot name="header"></slot>
    <div class="body"><slot></slot></div>
    <slot name="footer"></slot>
  </div>
`;

class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // MANDATORY: deep-clone the template content so each instance gets its own nodes.
    this.shadowRoot.append(template.content.cloneNode(true));
  }
}
customElements.define('my-card', MyCard);

保持模板精简。深度嵌套的“div 汤”和多层嵌套的 shadow root 都会增加可测量的样式解析和布局成本,从而对大规模性能造成重大影响。优先选择扁平结构和语义元素。

槽位:令人惊喜的部分

默认槽和具名槽的工作方式与文档中所述一致,但以下行为可能会带来困扰:

  • 空格也算作字符。元素之间的文本节点(包括纯空白字符)会被分配到默认槽,因此默认槽很少真正“空置”。
  • 系统会保留 DOM 顺序,而不是槽顺序。无论填充哪个槽,投影节点都会出于 :first-child/:last-child 目的保留其原始 Light DOM 顺序。::slotted(*:first-child) 规则定位到第一个 DOM 子级,而不是该槽位的第一个子级,因此通常不会与您预期的内容匹配。
  • slotchange 是升级时间信号。当分配的节点发生变化(包括初始分配)时,它会触发,并且是可靠的方法,可用于在升级后(当它们在 connectedCallback 期间可能不存在时)了解 Light DOM 子节点已到达。
this.shadowRoot.querySelector('slot[name="header"]')
  .addEventListener('slotchange', (e) => {
    const nodes = e.target.assignedElements();
    this.toggleAttribute('has-header', nodes.length > 0);
  });

将样式表加载到 shadow root 中

方法 优点 缺点
模板中的 <style> 简单、完全封装、无需 JS 即可运行。 每个实例都会重复,大规模使用时会使 DOM 膨胀。
<link rel="stylesheet"> 可缓存的外部文件。 额外请求;存在无样式内容闪烁的风险。
adoptedStyleSheets 所有实例共享一个 CSSStyleSheet;大规模运行时速度最快。 需要 JS;构造的样式表不支持 @import。

对于一个组件的多个实例,最好使用共享的构造样式表:

const sheet = new CSSStyleSheet();
sheet.replaceSync(`:host { display: block; }`);

class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // The same sheet object is reused by every instance; no per-instance parse.
    this.shadowRoot.adoptedStyleSheets = [sheet];
  }
}