Home > Software engineering >  How to properly handle Javascript custom element (Web Component) with children elements?
How to properly handle Javascript custom element (Web Component) with children elements?

Time:09-14

I have a Custom Element that should have many HTML children. I had this problem when initializing it in class' constructor (The result must not have children). I understand why and know how to fix it. But exactly how I should design my class around it now? Please consider this code:

class MyElement extends HTMLElement {
  constructor() {
    super();
  }  
  
  // Due to the problem, these codes that should be in constructor are moved here
  connectedCallback() {
    // Should have check for first time connection as well but ommited here for brevity
    this.innerHTML = `<a></a><div></div>`;
    this.a = this.querySelector("a");
    this.div = this.querySelector("div");
  }
  
  set myText(v) {
    this.a.textContent = v;
  }
  
  set url(v) {
    this.a.href = v;
  }
}

customElements.define("my-el", MyElement);

const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el); // connectedCallback is not called yet since it's not technically connected to the document.

el.myText = "abc"; // Now this wouldn't work because connectedCallback isn't called
el.url = "https://www.example.com/";

Since MyElement would be used in a list, it's set up beforehand and inserted into a DocumentFragment. How do you handle this?

Currently I am keeping a list of pre-connected properties and set them when it's actually connected but I can't imagine this to be a good solution. I also thought of another solution: have an init method (well I just realized nothing prevents you from invoking connectedCallback yourself) that must be manually called before doing anything but I myself haven't seen any component that needs to do that and it's similar to the upgrade weakness mentioned in the above article:

The element's attributes and children must not be inspected, as in the non-upgrade case none will be present, and relying on upgrades makes the element less usable.

CodePudding user response:

You need (a) DOM to assign content to it

customElements.define("my-el", class extends HTMLElement {
  constructor() {
    super().attachShadow({mode:"open"}).innerHTML=`<a></a>`;
    this.a = this.shadowRoot.querySelector("a");
  }
  
  set myText(v) {
    this.a.textContent = v;
  }
});

const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el);
el.myText = "abc"; 

document.body.append(frag);

Without shadowDOM you could store the content and process it in the connectedCallback

customElements.define("my-el", class extends HTMLElement {
  constructor() {
    super().atext = "";
  }
  connectedCallback() {
    console.log("connected");
    this.innerHTML = `<a>${this.atext}</a>`;
    this.onclick = () => this.myText = "XYZ";
  }
  set myText(v) {
    if (this.isConnected) {
      console.warn("writing",v);
      this.querySelector("a").textContent = v;
    } else {
      console.warn("storing value!", v);
      this.atext = v;
    }
  }
});

const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el);
el.myText = "abc";

document.body.append(frag);

CodePudding user response:

Custom elements are tricky to work with.

The shadowDOM

if the shadowDOM features and restrictions suits your needs, you should go for it, it's straightforward :

customElements.define('my-test', class extends HTMLElement{
    constructor(){
        super();
        this.shadow = this.attachShadow({mode: 'open'});
        const div = document.createElement('div');
        div.innerText = "Youhou";
        this.shadow.appendChild(div);
    }
});

const myTest = document.createElement('my-test');
console.log(myTest.shadow.querySelector('div')); //Outputs your div.

More about it there

Without shadowDOM

Sometimes, the shadowDOM is too restrictive. It provides a really great isolation, but if your components are designed to be used in an application and not be distributed to everyone to be used in any project, it can really be a nightmare to manage.

Keep in mind that the solution I provide below is just an idea of how to solve this problem, you may want to manage much more than that, especialy if you work with attributeChangedCallback, if you need to support component reloading or many other use cases not covered by this answer.

If, like me, you don't want the ShadowDOM features, and there is many reasons not to want it (cascading CSS, using a library like fontawesome without having to redeclare the link in every component, global i18n mechanism, being able to use a custom component as any other DOM tag, and so on), there is some clue :

Create a base class that will handle it in the same way for all components, let's call it BaseWebComponent.

class BaseWebComponent extends HTMLElement{
    //Will store the ready promise, since we want to always return
    //the same
    #ready = null;

    constructor(){
        super();
    }

    //Must be overwritten in child class to create the dom, read/write attributes, etc.
    async init(){
        throw new Error('Must be implemented !');
    }

    //Will call the init method and await for it to resolve before resolving itself. 
    //Always return the same promise, so several part of the code can
    //call it safely
    async ready(){
        //We don't want to call init more that one time
        //and we want every call to ready() to return the same promise.
        if(this.#ready) return this.#ready
    
        this.#ready = new Promise(resolve => resolve(this.init()));
    
        return this.#ready;
    }

    connectedCallback(){
        //Will init the component automatically when attached to the DOM
        //Note that you can also call ready to init your component before
        //if you need to, every subsequent call will just resolve immediately.
        this.ready();
    }
}

Then I create a new component :

class MyComponent extends BaseWebComponent{
    async init(){
        this.setAttribute('something', '54');
        const div = document.createElement('div');
        div.innerText = 'Initialized !'; 
        this.appendChild(div);
    }

}

customElements.define('my-component', MyComponent);

/* somewhere in a javascript file/tag */

customElements.whenDefined('my-component').then(async () => {
    const component = document.createElement('my-component');
    
    //Optional : if you need it to be ready before doing something, let's go
    await component.ready();
    console.log("attribute value : ", component.getAttribute('something'));

    //otherwise, just append it
    document.body.appendChild(component);
});

I do not know any approach, without shdowDOM, to init a component in a spec compliant way that do not imply to automaticaly call a method.

You should be able to call this.ready() in the constructor instead of connectedCallback, since it's async, document.createElement should create your component before your init function starts to populate it. But it can be error prone, and you must await that promise to resolve anyway to execute code that needs your component to be initialized.

CodePudding user response:

Since there are many great answers, I am moving my approach into a separate answer here. I tried to use "hanging DOM" like this:

class MyElement extends HTMLElement {

  constructor() {
    super();
    
    const tmp = this.tmp = document.createElement("div"); // Note in a few cases, div wouldn't work
    this.tmp.innerHTML = `<a></a><div></div>`;
    
    this.a = tmp.querySelector("a");
    this.div = tmp.querySelector("div");
  }  
  
  connectedCallback() {
    // Should have check for first time connection as well but ommited here for brevity
    // Beside attaching tmp as direct descendant, we can also move all its children
    this.append(this.tmp);
  }
  
  set myText(v) {
    this.a.textContent = v;
  }
  
  set url(v) {
    this.a.href = v;
  }
}

customElements.define("my-el", MyElement);

const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el); // connectedCallback is not called yet since it's not technically connected to the document.

el.myText = "abc"; // Now this wouldn't work because connectedCallback isn't called
el.url = "https://www.example.com/";

document.body.append(frag);

It "works" although it "upsets" my code a lot, for example, instead of this.querySelector which is more natural, it becomes tmp.querySelector. Same in methods, if you do a querySelector, you have to make sure tmp is pointing to the correct Element that the children are in. I have to admit this is probably the best solution so far.

CodePudding user response:

I'm not exactly sure about what makes your component so problematic, so I'm just adding what I would do:

class MyElement extends HTMLElement {
  #a = document.createElement('a');
  #div = document.createElement('div');
  
  constructor() {
    super().attachShadow({mode:'open'}).append(this.#a, this.#div);
    console.log(this.shadowRoot.innerHTML);
  }  
  
  set myText(v) { this.#a.textContent = v; }
  
  set url(v) { this.#a.href = v; }
  
}

customElements.define("my-el", MyElement);

const frag = new DocumentFragment();
const el = document.createElement("my-el");
el.myText = 'foo'; el.url= 'https://www.example.com/';
frag.append(el);

document.body.append(el);

  • Related