• בלוג
  • חישוב סטטיסטיקות מונים עם DOM

חישוב סטטיסטיקות מונים עם DOM

16/06/2025

לפני כמה ימים הראיתי כאן התלבטות ארכיטקטורה בריאקט - הבחירה בין שמירת סטייט מקומי, שהופכת את הקומפוננטה ליותר גמישה, לשמירת סטייט במעלה העץ שמאפשרת שיתוף מידע בין מספר קומפוננטות.

אני רוצה היום להראות איך הייתי בונה מנגנון דומה בלי ריאקט ודרך זה להבין טוב יותר את אילוצי הארכיטקטורה של ריאקט והייחודיות שלהם לעומת עבודה פשוטה עם DOM.

האתגר שלנו בפוסט היה לבנות אוסף של מוני לחיצות שיעבדו בצורה עצמאית, ולהוסיף לידם קומפוננטה שמראה סטטיסטיקות כמו מה הערך הגדול ביותר מכל המונים ומה הקטן ביותר. ראינו שריאקט בונה את ה DOM מתוך משתנים בזיכרון (סטייט) ולכן יש חשיבות לאיזו קומפוננטה שומרת את הסטייט. זרימת המידע בריאקט היא תמיד מלמעלה למטרה, סטייט שנשמר בקומפוננטות עליונות משפיע על הצורה של הקומפוננטות במורד העץ.

האילוץ הזה לא קיים בעבודה רגילה עם DOM, שם אני יכול לטייל בעץ לכל כיוון שאני רוצה ולהתחבר למידע ואירועים של קומפוננטות שנמצאות לידי או מעליי. קובץ ה HTML של מונה הלחיצות עם הסטטיסטיקות לכן יכול להיראות פשוט כך:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app">
      <div class="counter-group">
        <counter-stats></counter-stats>
        <counter-component></counter-component>
        <counter-component></counter-component>
        <counter-component></counter-component>
      </div>
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

בשביל לחשב את הסטטיסטיקות קומפוננטת counter-stats צריכה בסך הכל לטייל צעד אחד למעלה בעץ ל counter-group ומשם לחפש את כל הילדים שהם counter-component ולקרוא את מספר הלחיצות שלהם. זאת הפונקציה:

  updateStats() {
    if (!this.counterGroup) return;

    // Get all counter components from the parent group
    const counters = this.counterGroup.querySelectorAll('counter-component');
    const counts = Array.from(counters).map(counter => {
      return parseInt(counter.dataset.count) || 0;
    });

    if (counts.length === 0) {
      this.updateStatValues(0, 0);
      return;
    }

    const minValue = Math.min(...counts);
    const maxValue = Math.max(...counts);

    this.updateStatValues(minValue, maxValue);
  }

אנחנו רוצים להפעיל את הפונקציה כל פעם שמישהו לחץ על כפתור באחד המונים, וכאן נכנס עוד כח חשוב של DOM והוא אירועים בהתאמה אישית. זה הקוד מתוך counter שמדווח על שינוי:

// Emit custom event for stats tracking
this.dispatchEvent(new CustomEvent('counter:update-count', {
  bubbles: true,
  detail: { count: this.count }
}));

בעצם כל פעם שיש שינוי אני מדווח על אירוע. מבנה האירועים של ה DOM אומר שאני לא צריך לדעת מי מקשיב לאירוע ולכל אירוע יכולים להיות מספר מאזינים. המאזין, במקרה שלנו ה counter-stats אחראי על הקוד שלו שירוץ אחרי שהאירוע התרחש. זה מאוד שונה ממנגנון הסטייט של ריאקט, שם קוד הטיפול באירוע יכול לשנות סטייט אבל ה DOM הולך להתעדכן רק אחרי שקוד הטיפול יסיים ויחזיר את השליטה לריאקט. אם תנסו לבנות מננגון דומה בריאקט ובעקבות לחיצה תלכו לקרוא את ה DOM כדי להבין מה הערך של מונה מסוים אתם תקבלו את הערך לפני השינוי (כי אחרי setState צריך לחכות שריאקט יבצע render כדי שהעדכון יגיע ל DOM).

הקוד המלא לשתי הקומפוננטות הוא:

class CounterComponent extends HTMLElement {
  constructor() {
    super();

    // Create shadow DOM with open mode for styling
    this.attachShadow({ mode: 'open' });

    // Initialize count from data attribute or default to 0
    this.count = parseInt(this.dataset.count) || 0;

    this.render();
    this.setupEventListeners();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
          margin: 10px;
        }
      </style>
      <div class="counter-container">
        <button class="counter-button">Click me!</button>
        <p class="counter-text">
          Count: <span class="counter-value">${this.count}</span>
        </p>
      </div>
    `;
  }

  setupEventListeners() {
    const button = this.shadowRoot.querySelector('.counter-button');
    button.addEventListener('click', () => this.incrementCounter());
  }

  incrementCounter() {
    this.count++;

    // Update the data attribute to persist state in DOM
    this.dataset.count = this.count;

    // Update only the counter value span, not the entire component
    const valueSpan = this.shadowRoot.querySelector('.counter-value');
    valueSpan.textContent = this.count;

    // Emit custom event for stats tracking
    this.dispatchEvent(new CustomEvent('counter:update-count', {
      bubbles: true,
      detail: { count: this.count }
    }));
  }

  // Called when component is connected to DOM
  connectedCallback() {
    // Update count from data attribute if it changed
    const dataCount = parseInt(this.dataset.count);
    if (!isNaN(dataCount) && dataCount !== this.count) {
      this.count = dataCount;
      const valueSpan = this.shadowRoot.querySelector('.counter-value');
      if (valueSpan) {
        valueSpan.textContent = this.count;
      }
    }

    // Emit initial event for stats tracking
    this.dispatchEvent(new CustomEvent('counter:update-count', {
      bubbles: true,
      detail: { count: this.count }
    }));
  }

  // Observe data-count attribute changes
  static get observedAttributes() {
    return ['data-count'];
  }

  // Handle attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'data-count' && this.shadowRoot) {
      const newCount = parseInt(newValue) || 0;
      if (newCount !== this.count) {
        this.count = newCount;
        const valueSpan = this.shadowRoot.querySelector('.counter-value');
        if (valueSpan) {
          valueSpan.textContent = this.count;
        }

        // Emit custom event for stats tracking
        this.dispatchEvent(new CustomEvent('counter:update-count', {
          bubbles: true,
          detail: { count: this.count }
        }));
      }
    }
  }
}

// Define the custom element
customElements.define('counter-component', CounterComponent);

// Counter Stats Component
class CounterStatsComponent extends HTMLElement {
  constructor() {
    super();

    // Create shadow DOM with open mode for styling
    this.attachShadow({ mode: 'open' });

    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          margin: 10px;
          padding: 15px;
          border: 2px solid #ccc;
          border-radius: 8px;
          background-color: #f9f9f9;
          font-family: Arial, sans-serif;
        }
        .stats-container {
          display: flex;
          gap: 20px;
          justify-content: center;
        }
        .stat-item {
          text-align: center;
        }
        .stat-label {
          font-weight: bold;
          color: #555;
          margin-bottom: 5px;
        }
        .stat-value {
          font-size: 1.2em;
          color: #333;
          font-weight: bold;
        }
      </style>
      <div class="stats-container">
        <div class="stat-item">
          <div class="stat-label">Min Value:</div>
          <div class="stat-value min-value">0</div>
        </div>
        <div class="stat-item">
          <div class="stat-label">Max Value:</div>
          <div class="stat-value max-value">0</div>
        </div>
      </div>
    `;
  }

  connectedCallback() {
    // Find the parent .counter-group element
    this.counterGroup = this.closest('.counter-group');

    if (this.counterGroup) {
      // Listen for counter update events
      this.counterGroup.addEventListener('counter:update-count', this.handleCounterUpdate.bind(this));

      // Calculate initial stats
      this.updateStats();
    }
  }

  disconnectedCallback() {
    if (this.counterGroup) {
      this.counterGroup.removeEventListener('counter:update-count', this.handleCounterUpdate.bind(this));
    }
  }

  handleCounterUpdate(event) {
    // Update stats whenever any counter changes
    this.updateStats();
  }

  updateStats() {
    if (!this.counterGroup) return;

    // Get all counter components from the parent group
    const counters = this.counterGroup.querySelectorAll('counter-component');
    const counts = Array.from(counters).map(counter => {
      return parseInt(counter.dataset.count) || 0;
    });

    if (counts.length === 0) {
      this.updateStatValues(0, 0);
      return;
    }

    const minValue = Math.min(...counts);
    const maxValue = Math.max(...counts);

    this.updateStatValues(minValue, maxValue);
  }

  updateStatValues(min, max) {
    // Update only the specific value spans, not the entire element
    const minSpan = this.shadowRoot.querySelector('.min-value');
    const maxSpan = this.shadowRoot.querySelector('.max-value');

    if (minSpan) minSpan.textContent = min;
    if (maxSpan) maxSpan.textContent = max;
  }
}

// Define the custom element
customElements.define('counter-stats', CounterStatsComponent);

נ.ב. גם את הקוד הזה שכנעתי את ה AI לכתוב. בוובינר ביום חמישי אני מתכנן לעבור על הפרומפטים שהשתמשתי כדי ליצור את הקוד הזה ועל עוד מספר דוגמאות לפרומפטים שיצרו לי פיצ'רים אמיתיים לפי ארכיטקטורה שאני בחרתי בפרויקטים אחרים. אשמח לראות את כולכם שם לשעה של חלומות על AI והפסקה מהמלחמה (בתקווה שלא ישלחו אותנו למקלט באמצע). אם אין לכם עדיין את הלינק לזום שלנו תוכלו לשים את המייל בתיבה כאן וזה יישלח אליכם באותו רגע:

https://tocode.ravpage.co.il/tocodeai