לפני כמה ימים הראיתי כאן התלבטות ארכיטקטורה בריאקט - הבחירה בין שמירת סטייט מקומי, שהופכת את הקומפוננטה ליותר גמישה, לשמירת סטייט במעלה העץ שמאפשרת שיתוף מידע בין מספר קומפוננטות.
אני רוצה היום להראות איך הייתי בונה מנגנון דומה בלי ריאקט ודרך זה להבין טוב יותר את אילוצי הארכיטקטורה של ריאקט והייחודיות שלהם לעומת עבודה פשוטה עם 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