You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Web Components:ConnectedCallBack前后设置属性引发的渲染异常问题咨询

Web Components:ConnectedCallBack前后设置属性引发的渲染异常问题咨询

我使用Web Components有段时间了,但最近遇到了一个让我困惑的问题,想请教大家。

先铺垫下背景知识

  • Attributes 用来给Web组件传递文本类型的变量
  • Properties 可以传递复杂类型的变量
  • Web组件的生命周期方法 connectedCallback() 会在元素被添加到DOM时触发

我的实现情况

我做了两个组件:parent-element 和 child-element:

  • 两个组件都定义了一个property,用来接收包含文本属性的对象(最终要在页面显示这个文本),每个property都配了getter和setter
  • 每个组件都有render()方法,只有当对应的property被设置时,才会生成HTML元素并添加到DOM

我准备了两段代码来演示问题,大家可以在“Full page”模式下运行,这样能看到完整的浏览器输出和控制台日志。


情况1:先把parent添加到DOM,再设置property

<html>
<head></head>
<body>
    <app id="app"></app>
    <script>
        customElements.define('parent-element', class extends HTMLElement {
            set parentProperty(value) {
                console.log('PARENT: SET PROPERTY');
                this._parentProperty = value;
                this.render();
            }

            get parentProperty() {
                return this._parentProperty;
            }

            constructor() {
                super();
                console.log('parent: constructor');
            }

            connectedCallback() {
                console.log('parent: connectedCallback');
                this.render();
            }

            render() {
                console.log(`parent: render ${(this.parentProperty ? 'with' : 'no')} property`);
                if (this.parentProperty) {
                    let templateEl = document.createElement("template");
                    templateEl.innerHTML = `<p>${this.parentProperty.value}</p><child-element id="child"></child-element>`;
                    this.appendChild(templateEl.content.cloneNode(true));

                    let childEl = this.querySelector('#child');
                    childEl.childProperty = { value: 'child text' };
                }
            }
        });

        customElements.define('child-element', class extends HTMLElement {
            set childProperty(value) {
                console.log('CHILD: SET PROPERTY');
                this._childProperty = value;
                this.render();
            }

            get childProperty() {
                return this._childProperty;
            }

            constructor() {
                super();
                console.log('child: constructor');
            }

            connectedCallback() {
                console.log('child: connectedCallback');
                this.render();
            }

            render() {
                console.log(`child: render ${(this.childProperty ? 'with' : 'no')} property`);
                if (this.childProperty) {
                    let templateEl = document.createElement("template");
                    templateEl.innerHTML = `<p>${this.childProperty.value}</p>`;
                    this.appendChild(templateEl.content.cloneNode(true));
                }
            }
        });

        let appEl = document.querySelector('#app');
        let parentEl = document.createElement('parent-element');
        appEl.appendChild(parentEl);
        parentEl.parentProperty = { value: 'parent text' };
    </script>
</body>
</html>

这段代码的执行顺序是:

let parentEl = document.createElement('parent-element'); // 创建parent实例
appEl.appendChild(parentEl); // 把parent添加到DOM
parentEl.parentProperty = { value: 'parent text' }; // 设置parent的property

浏览器输出:

parent text
child text

控制台日志:

parent: constructor
parent: connectedCallback
parent: render no property
PARENT: SET PROPERTY
parent: render with property
child: constructor
child: connectedCallback
child: render no property
CHILD: SET PROPERTY
child: render with property

这个流程是符合预期的:添加parent到DOM时触发connectedCallback,此时property还没设置,所以render()没输出;设置property后触发setter,再次调用render(),此时生成内容并设置child的property,child也正常渲染。


情况2:先设置parent的property,再添加到DOM

我只是调换了设置property和添加到DOM的顺序:

<html>
<head></head>
<body>
    <app id="app"></app>
    <script>
        customElements.define('parent-element', class extends HTMLElement {
            set parentProperty(value) {
                console.log('PARENT: SET PROPERTY');
                this._parentProperty = value;
                this.render();
            }

            get parentProperty() {
                return this._parentProperty;
            }

            constructor() {
                super();
                console.log('parent: constructor');
            }

            connectedCallback() {
                console.log('parent: connectedCallback');
                this.render();
            }

            render() {
                console.log(`parent: render ${(this.parentProperty ? 'with' : 'no')} property`);
                if (this.parentProperty) {
                    let templateEl = document.createElement("template");
                    templateEl.innerHTML = `<p>${this.parentProperty.value}</p><child-element id="child"></child-element>`;
                    this.appendChild(templateEl.content.cloneNode(true));

                    let childEl = this.querySelector('#child');
                    childEl.childProperty = { value: 'child text' };
                }
            }
        });

        customElements.define('child-element', class extends HTMLElement {
            set childProperty(value) {
                console.log('CHILD: SET PROPERTY');
                this._childProperty = value;
                this.render();
            }

            get childProperty() {
                return this._childProperty;
            }

            constructor() {
                super();
                console.log('child: constructor');
            }

            connectedCallback() {
                console.log('child: connectedCallback');
                this.render();
            }

            render() {
                console.log(`child: render ${(this.childProperty ? 'with' : 'no')} property`);
                if (this.childProperty) {
                    let templateEl = document.createElement("template");
                    templateEl.innerHTML = `<p>${this.childProperty.value}</p>`;
                    this.appendChild(templateEl.content.cloneNode(true));
                }
            }
        });

        let appEl = document.querySelector('#app');
        let parentEl = document.createElement('parent-element');
        parentEl.parentProperty = { value: 'parent text' };
        appEl.appendChild(parentEl);
    </script>
</body>
</html>

执行顺序变成:

parentEl.parentProperty = { value: 'parent text' }; // 先设置property
appEl.appendChild(parentEl); // 再添加到DOM

浏览器输出:

parent text
child text
parent text

控制台日志:

parent: constructor
PARENT: SET PROPERTY
parent: render with property
parent: connectedCallback
parent: render with property
child: constructor
child: connectedCallback
child: render no property
child: constructor
child: connectedCallback
child: render with property

可以看到parent被渲染了两次,页面上出现了两个parent text,同时有两个child实例被创建,但第一个child渲染时没有文本,第二个才有。

我的问题

  1. 为什么第一个被渲染的child元素没有显示文本?
  2. 如何避免这种重复渲染的问题,确保无论先设置property还是先添加到DOM,都能正确渲染一次?

问题解答

嘿,Tom,这个问题其实和Web Components的生命周期以及DOM挂载时机密切相关,咱们一步步拆解清楚:

问题1:第一个child没有文本的原因

核心问题出在自定义元素的升级时机:当元素还在内存中(未添加到DOM)时,浏览器不会自动将它升级为你定义的自定义元素,它只是一个普通的HTMLElement。

当你先设置parent的property时,parent还没被挂载到DOM,此时调用render()创建的child元素也处于内存中——这时候你给它设置childProperty,并不会触发你定义的setter方法,只是给这个普通元素加了一个无关的属性而已,_childProperty根本没被赋值。

等你把parent添加到DOM时,这个内存中的child才会被升级为自定义元素,触发connectedCallback并调用render(),但此时它的_childProperty是undefined,所以渲染时显示“no property”,自然没有文本。

而第二个child是在parent已经挂载到DOM之后创建的,此时浏览器会立即将它升级为自定义元素,设置childProperty时能正常触发setter,所以能正确渲染文本。

问题2:如何避免重复渲染

重复渲染的根源是render()每次调用都会追加新元素,而不是替换旧内容,同时connectedCallback和property setter都会触发render()。解决这个问题可以从两点入手:

  1. 渲染前清空现有内容
    在每个组件的render()方法开头,先清空自身的内容,这样每次渲染都会替换掉旧内容,不会出现重复的文本:
// parent组件的render方法
render() {
    console.log(`parent: render ${(this.parentProperty ? 'with' : 'no')} property`);
    if (this.parentProperty) {
        this.innerHTML = ''; // 先清空旧内容
        let templateEl = document.createElement("template");
        templateEl.innerHTML = `<p>${this.parentProperty.value}</p><child-element id="child"></child-element>`;
        this.appendChild(templateEl.content.cloneNode(true));

        let childEl = this.querySelector('#child');
        customElements.upgrade(childEl); // 手动升级内存中的自定义元素
        childEl.childProperty = { value: 'child text' };
    }
}

// child组件的render方法
render() {
    console.log(`child: render ${(this.childProperty ? 'with' : 'no')} property`);
    if (this.childProperty) {
        this.innerHTML = ''; // 先清空旧内容
        let templateEl = document.createElement("template");
        templateEl.innerHTML = `<p>${this.childProperty.value}</p>`;
        this.appendChild(templateEl.content.cloneNode(true));
    }
}
  1. 手动升级内存中的自定义元素
    使用customElements.upgrade()方法,在内存中创建child元素后手动升级它,这样即使没挂载到DOM,也能成为你的自定义元素实例,设置property时能正常触发setter。

经过这两个修改后,无论你先设置property还是先添加到DOM,组件都只会渲染一次,child元素也能正常显示文本。

总结一下关键点:

  • 未挂载到DOM的自定义元素不会自动升级,需手动调用customElements.upgrade()
  • 渲染时先清空现有内容,避免重复追加
  • 合理控制render()的触发时机,减少不必要的重复调用

备注:内容来源于stack exchange,提问作者TomOdul

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.13 20:14:49