如何在所有子自定义元素连接完成后拥有一个“connectedCallback”函数

32

我正在使用 Web Components v1。

假设有两个自定义元素:

parent-element.html

<template id="parent-element">
    <child-element></child-element>
</template>

子元素.html

<template id="child-element">
<!-- some markup here -->
</template>

我试图在parent-element中使用connectedCallback来初始化整个父/子DOM结构,当它被附加时,这需要与child-element中定义的方法进行交互。

然而,似乎在customElementconnectedCallback触发时,child-element没有被正确定义:

parent-element.js

class parent_element extends HTMLElement {
    connectedCallback() {
        //shadow root created from template in constructor previously
        var el = this.shadow_root.querySelector("child-element");
        el.my_method();
    }
}

由于el是一个HTMLElement,而不是像预期的一个子元素,因此这种方法行不通。

我需要一个回调函数,用于在其模板中的所有自定义子元素正确附加后调用parent-element

这个问题中的解决方案似乎不起作用;在child-elementconnectedCallback()内部,this.parentElementnull

ilmiont


我遇到了一个类似的问题,不同的是尽管子元素的connectedCallback()已经被调用,但父元素仍然无法访问子元素的ShadowRoot,直到父元素被插入DOM中。幸运的是,在Chrome中,当父元素被移除时,子元素会触发disconnectedCallback()。 - Neil
7个回答

20

在您的ShadowDOM模板中使用slot元素。

以一种可以在任何上下文中生存的方式构建自定义元素,例如作为子元素或父元素,而不需要依赖于其他自定义元素。这种方法将为您提供一个模块化的设计,您可以在任何情况下利用自定义元素。

但是您仍然希望在存在子元素时执行某些操作,例如选择它们或调用子元素上的方法。

Slot元素

为了解决这个问题,引入了<slot>元素。使用slot元素,您可以在ShadowDOM模板中创建占位符。只需将一个元素作为子元素放置在您的自定义元素中,该占位符就可以被使用。子元素将被放置在<slot>元素的位置。

但是如何知道占位符是否已填充元素?

Slot元素可以监听一个称为slotchange的唯一事件。当一个元素(或多个元素)被放置在slot元素的位置上时,此事件将被触发。

在事件的监听器中,您可以使用HTMLSlotElement.assignedNodes()HTMLSlotElement.assignedElements()方法访问占位符中的所有元素。这些方法返回一个包含放置在slot中的元素的数组。
现在,您可以等待子元素被放置在插槽中,并对存在的子元素进行操作。
这种方法允许您仅操作DOM并保留ShadowDOM,让它完成其工作。就像您处理常规HTML元素一样。 < h3>事件是否会等待所有子元素连接? 是的,slotchange事件在调用自定义元素的所有connectedCallback方法后触发。这意味着在监听事件时不会出现竞争条件或缺少设置。

class ParentElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <h2>Parent Element</h2>
      <slot></slot>
    `;
    console.log("I'm a parent and have slots.");
    
    // Select the slot element from the ShadowDOM..
    const slot = this.shadowRoot.querySelector('slot');
    
    // ..and listen for the slotchange event.
    slot.addEventListener('slotchange', (event) => {
      // Get the elements assigned to the slot..
      const children = event.target.assignedElements();
      
      // ..loop over them and call their methods.
      children.forEach(child => {
        if (child.tagName.toLowerCase() === 'child-element') {
          child.shout()
        }
      });
    });
  }
  
  connectedCallback() {
    console.log("I'm a parent and am now connected");
  }
}

customElements.define('parent-element', ParentElement);

class ChildElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <h3>Child Element</h3>
    `;
  }
  
  connectedCallback() {
    console.log("I'm a child and am now connected.");
  }

  shout() {
    console.log("I'm a child and placed inside a slot.");
  }

}

customElements.define('child-element', ChildElement);
<parent-element>
  <child-element></child-element>
  <child-element></child-element>
  <child-element></child-element>
</parent-element>


1
太好了!slotchange 非常有用! - Eric
1
酷!您可以安全地删除此页面上的所有其他答案。 - Lloyd
对于具有子自定义元素的非延迟脚本,slotchange 在解析文档时会被多次调用。在您的示例中,如果脚本未被延迟,则 shout 会被调用 12 次,并且在每个 slotchange 事件中,event.target.assignedNodes() 是不同的。如果脚本被延迟(例如通过设置 <script defer><script type="module">),则只有一个 slotchange 事件,并且 shout 会被调用 3 次。 - emccorson

8
< p > 在connectedCallback中存在时间问题。在其任何自定义元素子级被升级之前,它将首次被调用。<child-element>只有在调用connectedCallback时才是HTMLElement。

为了访问升级后的子元素,您需要在超时中执行此操作。

运行下面的代码并观察控制台输出。当我们尝试调用子方法时,它会失败。同样,这是因为Web组件创建的方式以及connectedCallback被调用的时间。

但是,在setTimeout中调用子方法可以正常工作。这是因为你给予子元素足够的时间来升级到你的自定义元素。

如果您问我,这有点愚蠢。我希望还有另一个函数在所有子元素升级后调用。但是我们只能利用已有的功能。

class ParentElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = '<h2>Parent Element</h2><child-element></child-element>';
  }
  
  connectedCallback() {
    let el = this.shadowRoot.querySelector("child-element");
    console.log('connectedCallback', el);
    try {
      el.childMethod();
    }
    catch(ex) {
      console.error('Child element not there yet.', ex.message);
    }
    setTimeout(() => {
      let el = this.shadowRoot.querySelector("child-element");
      console.log('setTimeout', el);
      el.childMethod();
    });
  }
}

customElements.define('parent-element', ParentElement);


class ChildElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = '<h3>Child Element</h3>';
  }

  childMethod() {
    console.info('In Child method');
  }
}

customElements.define('child-element', ChildElement);
<parent-element></parent-element>


如果元素有非常多的子节点,它会工作吗? - Guseyn Ismayylov
1
我为了知道我的子元素何时被创建并准备好,做的一件事是提供了一组事件,让子元素在升级时通知父元素。这可能发生在它们的 connectedCallback 函数中。然后我就知道所有的子元素都已经准备好了。 - Intervalia
也考虑到了这一点。类似这样的代码可以解决问题:new CustomEvent('child:initialized', { bubbles: true, composed: true }); 然后在父元素中添加一个事件监听器,以执行依赖于子自定义元素的代码。 - undefined

5

经过进一步的工作,我有了一个解决方案。

当然,在子元素中使用this.parentElement是不起作用的;它在影子DOM的根部!

我的当前解决方案,对于我的特定情况来说还可以,如下所示:

parent-element.js

init() {
    //Code to run on initialisation goes here
    this.shadow_root.querySelector("child-element").my_method();
}

child-element.js

connectedCallback() {
    this.getRootNode().host.init();
}

在子元素中,我们获取根节点(模板影子DOM),然后获取其宿主——父元素,并调用init(...),此时父元素可以访问子元素并且已完全定义。
这种解决方案有几个缺点,因此我不会将其标记为接受的方案:
1)如果需要等待多个子元素或更深层嵌套,则编排回调将变得更加复杂。
2)我担心对于child-element的影响,如果我想在独立的环境中使用此元素(即在与parent-element完全分离的地方),则必须修改它以明确检查getRootNode().host是否是parent-element的实例。
因此,这种解决方案现在可行,但感觉不好,我认为需要在整个DOM结构完成初始化时,包括其影子DOM中嵌套的自定义元素时,在父级上触发回调。

4
抱歉,如今的现代浏览器(如Netscape Navigator或IE 4)不支持 parentElement,请勿使用。 - gilbert-v
这是一个有趣的解决方案 - 感谢您提供。 - rpivovar

4

如果您想避免由 setTimeout 延迟引起的任何视觉故障,可以使用 MutationObserver

class myWebComponent extends HTMLElement 
{
      connectedCallback() {

        let childrenConnectedCallback = () => {
            let addedNode = this.childNodes[(this.childNodes.length - 1)];
            //callback here
        }

        let observer = new MutationObserver(childrenConnectedCallback);
        let config = { attributes: false, childList: true, subtree: true };
        observer.observe(this, config);

        //make sure to disconnect
        setTimeout(() => {
            observer.disconnect();
        }, 0);

     }
}

2
请看CustomElementRegistry.upgrade()。
https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/upgrade
它会强制调用传递的节点的所有自定义子元素的构造函数。您甚至可以在构造函数内部调用此函数!
class parent_element extends HTMLElement {
    connectedCallback() {
        customElements.upgrade (this); //<-- this solves your problem
        //shadow root created from template in constructor previously
        var el = this.shadow_root.querySelector("child-element");
        el.my_method();
    }
}

1
对我来说,这是最好的解决方案。它解决了实际问题,而不是减轻它,并且无需重写整个代码。这似乎正是“upgrade()”被设计出来的情况。 - Torben
这应该是被接受的答案。我可以确认,在父自定义元素的构造函数中,当您添加从文本模板实例化的子自定义元素后,单行调用 customElements.upgrade(this); 将完全使子元素水合 - 它们将作为您在 customElements.define 中指定的元素类的实例可用,甚至允许您调用在那里实现的自定义实例方法。 - Arbo

0

我们遇到了非常相关的问题,即在自定义元素(v1)的connectedCallback中无法使用子元素。

起初,我们尝试通过一种非常复杂的方法来修复connectedCallback,这种方法也被Google AMP团队使用(结合使用mutationObserver和检查nextSibling),最终导致了https://github.com/WebReflection/html-parsed-element

不幸的是,这也带来了自己的问题,因此我们回到了始终强制执行升级案例的做法(即仅在页面末尾包含注册自定义元素的脚本)。


-1
document.addEventListener('DOMContentLoaded', defineMyCustomElements);

您可以延迟定义类,直到 dom 加载完成后再进行。


我还没有在多页设计之外进行测试,但至少在单个案例中解决了我的问题。为什么这是一个被踩的解决方案? - DWR
这是我们团队近3年来一直在使用的解决方案。从未出现过任何问题的情况(请参见我上面的回答)。 - connexo
可能被踩是因为没有解释或示例如何使用它。把我的 customElements.define( 代码放在里面肯定不起作用。 - GirkovArpa

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接