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渲染时没有文本,第二个才有。
我的问题
- 为什么第一个被渲染的child元素没有显示文本?
- 如何避免这种重复渲染的问题,确保无论先设置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()。解决这个问题可以从两点入手:
- 渲染前清空现有内容
在每个组件的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)); } }
- 手动升级内存中的自定义元素
使用customElements.upgrade()方法,在内存中创建child元素后手动升级它,这样即使没挂载到DOM,也能成为你的自定义元素实例,设置property时能正常触发setter。
经过这两个修改后,无论你先设置property还是先添加到DOM,组件都只会渲染一次,child元素也能正常显示文本。
总结一下关键点:
- 未挂载到DOM的自定义元素不会自动升级,需手动调用
customElements.upgrade() - 渲染时先清空现有内容,避免重复追加
- 合理控制
render()的触发时机,减少不必要的重复调用
备注:内容来源于stack exchange,提问作者TomOdul

