【问题标题】:How to properly handle Javascript custom element (Web Component) with children elements?如何正确处理带有子元素的 Javascript 自定义元素(Web 组件)?
【发布时间】:2022-09-13 23:35:47
【问题描述】:

我有一个Custom Element,它应该有很多 HTML 子级。我在课堂上初始化它时有this problemconstructor(结果不能有孩子)。我理解为什么并且知道如何解决它。但是我现在应该如何围绕它设计我的课程呢?请考虑以下代码:

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/";

由于MyElement 将在列表中使用,因此已预先设置并插入DocumentFragment。你怎么处理这个?

目前我正在保留一个预先连接的属性列表,并在实际连接时设置它们,但我无法想象这是一个好的解决方案。我还想到了另一种解决方案:有一个init 方法(好吧,我刚刚意识到没有什么可以阻止您自己调用connectedCallback)在做任何事情之前必须手动调用它,但我自己没有看到任何需要这样做的组件,它类似于上面提到的upgrade弱点文章:

不得检查元素的属性和子元素,因为在非升级情况下不会出现任何内容,并且依赖升级会使元素的可用性降低。

【问题讨论】:

  • 您需要 (a) DOM 在其中设置内容。您可以在其中创建一个带有 &lt;a&gt;&lt;/a&gt; 的 shadowDOM

标签: javascript html web-component custom-element


【解决方案1】:

您需要 (a) DOM 为其分配内容

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);

如果没有 shadowDOM,您可以存储内容并在 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);

【讨论】:

  • shadowDOM 由于存在一些差异,因此并不理想。事实上,我们甚至根本不需要 shadowDOM。我忘了提到在连接之前我什至有一个“挂起”的 DOM。虽然它有效,但它有点“扰乱”代码,因为例如你不能再做this.querySelector。我会将这个添加到问题中。
  • 我添加了一种非 shadowDOM 方法。当 this 不是 DOM 元素时,你不能做 this.querySelector
  • 抱歉,当有更多属性或组件具有更复杂的数据时,您的示例将不起作用。看看我是如何在问题中使用非附加 DOM 解决它的。但我知道我们俩都使用相同的方法。
  • 使用proxy 可能太花哨了(虽然我不能轻易地想出一个代码示例)。但基本上你必须做一些魔法,因为你想把内容塞进一个盒子里,当没有盒子的时候(还没有)。
  • 对,我猜你的方式(对于简单的组件)或我的方式(更“有条理”?)是迄今为止最简单的。
【解决方案2】:

自定义元素很难使用。

shadowDOM

如果shadowDOM 的功能和限制符合您的需求,您应该选择它,这很简单:

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

没有 shadowDOM

有时,shadowDOM 过于严格。它提供了非常好的隔离,但是如果您的组件设计为在应用程序中使用,而不是分发给每个人以便在任何项目中使用,那么管理起来真的是一场噩梦。

请记住,我在下面提供的解决方案只是关于如何解决此问题的一个想法,您可能想要管理的远不止这些,特别是如果您使用 attributeChangedCallback,如果您需要支持组件重新加载或许多其他用途此答案未涵盖的情况。

如果像我一样,你不想要 ShadowDOM 功能,并且有很多不想要它的理由(级联 CSS,使用类似 fontawesome 的库,而不必在每个组件中重新声明链接,全局 i18n 机制,能够使用自定义组件作为任何其他 DOM 标记,等等),有一些线索:

创建一个以相同方式处理所有组件的基类,我们称之为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();
    }
}

然后我创建一个新组件:

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);
});

我不知道没有 shdowDOM 的任何方法以符合规范的方式初始化组件,这并不意味着自动调用方法。

您应该能够在constructor 中调用this.ready() 而不是connectedCallback,因为它是异步的,document.createElement 应该在您的init 函数开始填充它之前创建您的组件。但它可能很容易出错,并且您必须等待该承诺解决,以执行需要初始化您的组件的代码。

【讨论】:

  • 能够将自定义组件用作任何其他 DOM 标记ShadowDOM 在这里没有设置任何障碍。与任何其他 DOM 元素一样,您使用该元素的 API.和 font-awesome 一样,使用 CSS 继承来解决问题。字体本身可以在任何 shadowDOM 中使用,无需您做任何事情;将图标声明为 CSS 自定义属性也使它们在任何影子 DOM 中可用,例如 --fa-icon-whatever: '70b'
  • @connexo 是的,您可以使用插槽,是的,您可以在每次使用组件时手动声明所有内容。是的,您可以使用与当前项目相关的所有 CSS 链接来创建模板,但是您会失去灵活性,并且您只是一遍又一遍地重复自己。它变得非常乏味,并且抹去了使用组件来编写 UI 的优点。而且,不,如果标签位于 shadowRoot 中,则不能执行 myComponent.querySelector('div')。您将不得不以不同的方式处理该节点。如果您需要在某个时候遍历 DOM 树,shadowDOM 会强制您编写不需要的复杂逻辑。
  • 而且,不,如果标签位于 shadowRoot 中,则不能执行 myComponent.querySelector('div')这正是我的示例所允许的;但我会绝不提供此功能,因为 #a#div 是组件内部,必须保持外部不可见,并且受控仅通过组件的 API.如果你不坚持这个原则,你以后就永远不能在不破坏东西的情况下改变实现;并且您的组件永远不能依赖它自己的内部结构,因为它只是无法通过例如外部 DOM 操作知道el.querySelector('div').remove()
  • 如果您需要在某个时候遍历 DOM 树,shadowDOM 会强制您编写不需要的复杂逻辑。再次表示不同意。您的内部代码/shadowDOM 与遍历无关。想想像textarea 这样的元素,它们有自己的内部影子DOM,你甚至根本无法访问。穿过这些有什么问题吗?
  • Fontawesome 甚至提供了必要的自定义属性:fontawesome.com/docs/web/style/custom
【解决方案3】:

由于有很多很好的答案,我将我的方法转移到一个单独的答案中。我尝试像这样使用“悬挂 DOM”:

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);

它“工作”虽然它“扰乱”了我的代码很多,例如,而不是更自然的this.querySelector,它变成了tmp.querySelector。同样的方法,如果你做一个querySelector,你必须确保tmp指向正确的Element,孩子们在里面。我不得不承认这可能是迄今为止最好的解决方案。

【讨论】:

    【解决方案4】:

    我不确定是什么让您的组件如此成问题,所以我只是添加我会做的事情:

    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);

    【讨论】:

    • 如果您使用影子 DOM,那么是的,这很简单。有时您可能不想要它(例如,您想要外部 CSS/外部访问)。
    • Web 组件确实不适合在没有 shadow DOM 的情况下使用。组件应该封装它的功能。封装是组件化的基本原则之一。
    • Web 组件 imo 的整个想法是他们负责他们的内部事务;外部访问应始终以受控方式发生准确提供您想要公开的内容。
    • @connexo 我不这么认为。事实是它们被称为“customElements”并允许将自定义行为附加到新标签。出于多种原因,封装过程应该是可选的。您可能希望使用 WebComponents 来...用有意义的标签组成您的 UI,这些标签提供 API 来轻松操作它们。想想一个可以显示(),隐藏()等的高级帮助工具提示。
    • 使用有意义的标签组成您的 UI,这些标签提供 API 来轻松操作它们。想想一个可以显示(),隐藏()等的高级帮助工具提示。所有这些都与 shadowDOM 与没有 shadowDOM 无关?而且这绝不是反对封装的论据吗?事实恰恰相反,这就是您要问的。例如showhide 是您的组件控制的 API,而不是其他人应该能够通过通用 DOM API 访问您的内部来进行操作。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-12-17
    • 1970-01-01
    • 2021-11-26
    • 1970-01-01
    • 2018-04-23
    • 1970-01-01
    相关资源
    最近更新 更多