ข้ามไปยังเนื้อหา

Constructor และ connectedCallback

constructor คือจุดที่คุณตั้งค่า internal state เริ่มต้นของ element เช่น กำหนดค่า property หรือสร้าง Shadow DOM ข้อกำหนดที่สำคัญมีดังนี้:

  • ต้องเรียก super() เป็น statement แรกเสมอ
  • ห้าม อ่านหรือเขียน attribute ของ element ใน constructor
  • ห้าม แตะ children หรือ parent node ของ element ใน constructor
  • ห้าม render เนื้อหาลงใน DOM ใน constructor

เหตุผลคือเมื่อ constructor ถูกเรียก element ยังไม่ได้อยู่ใน document และ attribute ยังไม่ได้ถูกกำหนดค่า

class MyWidget extends HTMLElement {
constructor() {
super(); // MUST be first — gives you access to `this`
// Good: set up internal state
this._count = 0;
this._listeners = [];
// Good: attach shadow root
this.attachShadow({ mode: 'open' });
// BAD — do NOT do this in constructor:
// this.innerHTML = '<p>hello</p>'; // may throw
// this.getAttribute('label'); // always null here
}
}

connectedCallback จะถูกเรียกทุกครั้งที่ element ถูกแทรกเข้าไปใน document ที่เชื่อมต่ออยู่ นี่คือจุดที่ถูกต้องและปลอดภัยสำหรับ:

  • การ render เนื้อหา HTML ของ element
  • การอ่าน attribute เพื่อกำหนดพฤติกรรม
  • การลงทะเบียน event listener
  • การเริ่มต้น timer หรือ fetch ข้อมูล

สิ่งที่ต้องระวังคือ connectedCallback จะถูกเรียกซ้ำทุกครั้งที่ element ถูกแทรกเข้าไปใหม่ ถ้าย้าย element ไปยัง parent อื่นด้วย appendChild callback นี้จะถูกเรียกอีกครั้ง

class MyWidget extends HTMLElement {
constructor() {
super();
this._initialized = false;
}
connectedCallback() {
if (this._initialized) return; // guard against re-connection
this._initialized = true;
var label = this.getAttribute('label') || 'default';
this.innerHTML = '<span>' + label + '</span>';
this.addEventListener('click', this._handleClick.bind(this));
}
}
// WRONG — DOM manipulation in constructor
class BadWidget extends HTMLElement {
constructor() {
super();
this.innerHTML = '<p>' + this.getAttribute('title') + '</p>'; // title is null here!
}
}
// RIGHT — DOM manipulation in connectedCallback
class GoodWidget extends HTMLElement {
constructor() {
super();
// only internal state
this._title = '';
}
connectedCallback() {
this._title = this.getAttribute('title') || 'Untitled';
this.innerHTML = '<p>' + this._title + '</p>'; // attribute is available now
}
}

สังเกตว่า constructor ถูกเรียกก่อน แต่ไม่ได้แตะ DOM ส่วน connectedCallback จะนับ _count ขึ้นทุกครั้งที่ element ถูกแทรกเข้าไปใหม่

ตัวเลือกBenefitCost
ตั้งค่าใน constructorทำงานเร็วที่สุด ก่อน element เข้า DOM เหมาะกับ internal state ล้วนๆเข้าถึง attribute, children, parent ไม่ได้เลย เพราะ element ยังไม่ถูกแทรกเข้า document
ตั้งค่าใน connectedCallbackเข้าถึง attribute, light DOM children, parent ได้ครบ และ render ได้อย่างปลอดภัยถูกเรียกซ้ำได้หลายครั้งตลอดอายุ element ต้องเขียนโค้ดให้ทนต่อการเรียกซ้ำ (idempotent)
  • อ่าน attribute ใน constructor — attribute ยังไม่ถูกกำหนดค่าตอนที่ constructor ทำงาน ให้ย้าย logic การอ่าน attribute ไปไว้ใน connectedCallback แทน
  • แตะ children หรือ parent ใน constructor — light DOM children ยังไม่ถูก parse ครบตอนนั้น การเข้าถึง this.children หรือ this.parentNode ใน constructor อาจได้ค่าที่ไม่ครบหรือ null
  • สมมติว่า connectedCallback เรียกแค่ครั้งเดียว — ถ้าย้าย element ด้วย appendChild ไปยัง parent ใหม่ browser จะเรียก disconnectedCallback แล้วตามด้วย connectedCallback อีกครั้ง ควรเขียนให้ re-run ได้โดยไม่พังหรือซ้ำซ้อน (เช่น เช็คก่อนเพิ่ม event listener ซ้ำ)

💡 ตัวอย่างจากของจริง

Stencil (compiler ที่อยู่เบื้องหลัง component ของ Ionic Framework) เอกสาร lifecycle ของมันระบุการแบ่งงานแบบนี้ไว้ชัดเจน: เก็บเฉพาะ internal state ไว้ใน constructor และย้าย logic ที่ต้องพึ่งพา attribute/DOM ไปไว้ใน connectedCallback หรือ componentWillLoad เพื่อให้ component ทนต่อการถูก re-mount ซ้ำในแอปแบบ SPA (stenciljs.com/docs/component-lifecycle)

ทำไมจึงไม่ควรจัดการ DOM ภายใน constructor?
connectedCallback ตอบสนองต่ออะไร?
connectedCallback สามารถถูกเรียกมากกว่าหนึ่งครั้งสำหรับ element instance เดียวกันได้เมื่อใด?