--- id: frontend-custom-elements-lifecycle title: Custom Element Lifecycle — connect / disconnect / observe category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [frontend, web-components, vibe-coding] tech_stack: { language: "TS / Lit", applicable_to: ["Frontend"] } applied_in: [] aliases: [Custom Element lifecycle, connectedCallback, observedAttributes, MutationObserver, IntersectionObserver] --- # Custom Element Lifecycle > Custom element 의 lifecycle = 정밀해야. **constructor → attributeChanged → connectedCallback → disconnected**. Re-attach, MutationObserver, IntersectionObserver — 흔한 함정. ## 📖 핵심 개념 - constructor: DOM 접근 X (아직 안 붙음). - connectedCallback: DOM 에 attach. - disconnectedCallback: detach (remove, navigation). - attributeChangedCallback: observed attribute 변경. ## 💻 코드 패턴 ### Lifecycle 순서 ```ts class MyEl extends HTMLElement { constructor() { super(); console.log('1. constructor'); // 안 됨: this.innerHTML, this.parentElement } static observedAttributes = ['name', 'count']; attributeChangedCallback(name: string, oldVal: string, newVal: string) { console.log(`2. attribute "${name}" ${oldVal} → ${newVal}`); // constructor 후 attribute parse 시점 } connectedCallback() { console.log('3. connected'); // 여기서 DOM render OK this.render(); } disconnectedCallback() { console.log('4. disconnected'); // cleanup: listener, timer, observer } } ``` ### Re-attach (같은 element 다시) ```html ``` ```ts const el = document.querySelector('my-el')!; el.remove(); // → disconnectedCallback document.body.appendChild(el); // → connectedCallback (다시!) ``` → Cleanup + setup 매번 호출. State 유지하려면 instance 변수에. ### Constructor 의 함정 ```ts class Bad extends HTMLElement { constructor() { super(); this.innerHTML = '

Hi

'; // ❌ Spec 위반 // upgrade 시 (이미 attribute / children) 깨짐 } } ``` → Render 는 connectedCallback 에서. ### Initial render (Lit 처럼) ```ts class MyEl extends HTMLElement { private _rendered = false; connectedCallback() { if (this._rendered) return; // re-attach 시 skip this._rendered = true; this.attachShadow({ mode: 'open' }); this.shadowRoot!.innerHTML = `

Hello

`; } } ``` ### Observed attributes ```ts class Counter extends HTMLElement { static observedAttributes = ['count']; attributeChangedCallback(name: string, oldVal: string, newVal: string) { if (name === 'count') { this.shadowRoot!.querySelector('span')!.textContent = newVal; } } // Property → attribute reflect get count() { return Number(this.getAttribute('count')); } set count(v: number) { this.setAttribute('count', String(v)); } } // 사용 el.count = 5; // → attribute 'count="5"' → callback ``` → String 만 (attribute). Object 는 property 만. ### Property vs Attribute ``` Attribute: HTML 의 string (data-* 친화). Property: JS 의 임의 type (object, function). - 단순: 둘 다 — reflect. - 복잡 (object): property 만. Lit: @property() — property + attribute reflect @property({ attribute: false }) — property 만 ``` ### MutationObserver (children 변경) ```ts class List extends HTMLElement { private mo?: MutationObserver; connectedCallback() { this.mo = new MutationObserver((mutations) => { // children 변경 시 this.update(); }); this.mo.observe(this, { childList: true, subtree: true }); } disconnectedCallback() { this.mo?.disconnect(); } } ``` ### Slot change event ```ts this.shadowRoot!.innerHTML = ``; const slot = this.shadowRoot!.querySelector('slot')!; slot.addEventListener('slotchange', () => { const assigned = slot.assignedElements(); // ... }); ``` → Light DOM 의 children 변경 시. ### IntersectionObserver (viewport) ```ts class LazyImg extends HTMLElement { private io?: IntersectionObserver; connectedCallback() { this.io = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { this.querySelector('img')!.src = this.dataset.src!; this.io?.disconnect(); } }); this.io.observe(this); } disconnectedCallback() { this.io?.disconnect(); } } ``` ### ResizeObserver (size) ```ts class Container extends HTMLElement { private ro?: ResizeObserver; connectedCallback() { this.ro = new ResizeObserver(([entry]) => { const w = entry.contentRect.width; this.classList.toggle('narrow', w < 300); }); this.ro.observe(this); } disconnectedCallback() { this.ro?.disconnect(); } } ``` → Container query 의 fallback / 보강. ### Event listener cleanup ```ts class Btn extends HTMLElement { private ac?: AbortController; connectedCallback() { this.ac = new AbortController(); this.addEventListener('click', this.handle, { signal: this.ac.signal }); document.addEventListener('keydown', this.handleKey, { signal: this.ac.signal }); } disconnectedCallback() { this.ac?.abort(); // 모든 listener 한 번에 } handle = () => { ... }; handleKey = (e: KeyboardEvent) => { ... }; } ``` → AbortController 가 cleanup 의 simple. ### adoptedCallback (frame 이동) ```ts adoptedCallback() { // iframe / new document 로 이사 // 거의 안 씀 } ``` ### Upgrade (lazy define) ```ts // HTML 가 먼저 // // 나중 정의 customElements.define('my-el', MyEl); // → 기존 element 자동 upgrade (constructor + connected 다 발생) ``` ### whenDefined ```ts await customElements.whenDefined('my-el'); const el = document.querySelector('my-el'); // 안전하게 method 호출 ``` ### Element internals (form, AOM) ```ts class MyInput extends HTMLElement { static formAssociated = true; internals_: ElementInternals; constructor() { super(); this.internals_ = this.attachInternals(); } set value(v: string) { this.internals_.setFormValue(v); } get form() { return this.internals_.form; } get validity() { return this.internals_.validity; } formResetCallback() { this.value = ''; } formDisabledCallback(disabled: boolean) { // ... } formStateRestoreCallback(state, mode) { // ... } } ``` ### Lit 의 lifecycle (다름) ```ts class MyEl extends LitElement { connectedCallback() { super.connectedCallback(); /* setup */ } disconnectedCallback() { super.disconnectedCallback(); /* cleanup */ } // Reactive willUpdate(changedProps: Map) { // render 직전 } updated(changedProps: Map) { // render 후 (DOM 갱신) if (changedProps.has('value')) { // ... } } firstUpdated() { // 첫 render 후 } } ``` ### React effect 비유 ``` React useEffect → Lit updated / firstUpdated useLayoutEffect → 거의 같음 useEffect cleanup → disconnectedCallback ``` ## 🤔 의사결정 기준 | 작업 | Hook | |---|---| | State 초기화 | constructor | | DOM render | connectedCallback | | Children 변경 감지 | MutationObserver | | Viewport / lazy | IntersectionObserver | | Size 반응 | ResizeObserver | | Cleanup | disconnectedCallback (AbortController) | | Attribute 반응 | attributeChangedCallback | | Form 통합 | ElementInternals + formAssociated | ## ❌ 안티패턴 - **constructor 안 DOM**: spec 위반. - **disconnectedCallback 안 cleanup**: 누수. - **Re-attach 시 중복 setup**: idempotent flag. - **observed 안 한 attribute 가정**: callback 안 옴. - **Object property 를 attribute 로**: string 만. - **MutationObserver 계속 도는 callback**: subtree 큰 = 성능. - **`this.parentElement` in constructor**: null. ## 🤖 LLM 활용 힌트 - Lifecycle 4 단계 이해 핵심. - AbortController = cleanup 의 simple. - Lit 가 boilerplate 제거. - Re-attach 흔함 — idempotent. ## 🔗 관련 문서 - [[Frontend_Web_Components_Deep]] - [[Web_IntersectionObserver_Patterns]] - [[React_useEffect_Pitfalls]]