מה זה Tearing ב React ולמה שיהיה לכם אכפת
גירסה 18 של ריאקט הכניסה לשימוש מנגנון חדש שנקרא Concurrent Mode. אם אתם בונים אפליקציית ריאקט חדשה ב Vite או create-react-app, המנגנון מופעל כברירת מחדל. אם אתם משדרגים אפליקציה ישנה לריאקט 18 אתם תקבלו הודעה שמבקשת מכם להחליף את פקודת ה ReactDOM.render בפקודה בשם ReactDOM.createRoot כדי להפעיל את המנגנון. מנגנון ה Concurrent Mode אמור לעזור לפתור בעיות ביצועים שנובעות מ render-ים ארוכים מדי.
מנגנון Concurrent Mode מוסיף פונקציה בשם startTransition
שמאפשרת לנו לסמן שעדכון מסוים הוא חשוב וצריך להפסיק render-ים פחות חשובים ומהר מהר לעשות render חדש רק בשביל השינוי הזה. לכאורה פיצ'ר נחמד ולא מזיק שאפילו יכול לעזור לביצועים, אבל האמת קצת יותר מורכבת והיא עלולה לקפוץ עליכם בהפתעה.
את המושג tearing בהקשר של בעיית UI לא מצאתי בתיעוד של ריאקט או בהכרזה על ריאקט 18, אלא רק מפוסטים אחרים שדיברו על הבעיה והרצאה מצוינת ביוטיוב של Daishi Kato. אם יש לכם 20 דקות פנויות שווה להקשיב לו: https://www.youtube.com/watch?v=oPfSC5bQPR8.
בחזרה ל Tearing - בהקשר של ריאקט המושג מתאר מצב בו Concurrent Mode גורם ל UI להיות לא קונסיסטנטי בגלל שינוי משתנים גלובאליים. הבעיה היא כזאת:
יש לנו כמה קומפוננטות שמושפעות ממשתנה גלובאלי - זה יכול להיות ref, משתנה ששמור ב Store חיצוני, או אפילו התאריך או השעה הנוכחיים שנלקחים מהדפדפן.
באמצע שריאקט עושה render לקומפוננטות האלה, פתאום הוא מגלה שיש שינוי יותר דחוף כי מישהו קרא ל startTransition. אז ריאקט עוצר הכל והולך לעשות את ה render היותר דחוף שלו. בואו נגיד שהיו לי 6 קומפוננטות שמושפעות ממשתנה גלובאלי, וריאקט רינדר 3 מהן לפני שהיה צריך לעצור.
תוך כדי ה render היותר דחוף, הערך של המשתנה הגלובאלי משתנה.
כשריאקט חוזר לרנדר את 3 הקומפוננטות שנשארו, הוא מרנדר אותן לפי הערך החדש של אותו משתנה גלובאלי.
ריאקט מציג על המסך את כל 6 הקומפוננטות במכה אחת, למרות שהן רונדרו בזמנים שונים ועם ערכים שונים של המשתנה הגלובאלי. על המסך אנחנו רואים ממשק לא קונסיסטנטי. חצי מהממשק מתבסס על ערך ישן של המשתנה הגלובאלי, והחצי השני על הערך החדש.
דאישי הראה בהרצאה דוגמה מעניינת, אחרי שצמצמתי אותה קצת הגעתי לקוד הזה שממחיש בדיוק את הבעיה:
import { useState, useTransition } from 'react'
let lastMouseX = 0;
window.addEventListener('mousemove', (e) => {
lastMouseX = e.offsetX;
});
function MouseTracker() {
const start = performance.now();
while (performance.now() - start < 20);
return (
<p>{lastMouseX}</p>
);
}
function App() {
const [count, setCount] = useState(0)
const [isPending, startTransition] = useTransition();
return (
<div className="App">
<p>isPending = {JSON.stringify(isPending)}</p>
<button onClick={() => {
startTransition(() => {
setCount(c => c + 1)
});
}}>{count}</button>
<MouseTracker />
<MouseTracker />
<MouseTracker />
<MouseTracker />
<MouseTracker />
<MouseTracker />
</div>
)
}
export default App
יש לנו משתנה גלובאלי בשם lastMouseX
שהערך שלו משתנה כל פעם שמזיזים את העכבר. יש לנו גם 6 קומפוננטות מסוג MouseTracker
שפשוט מציגות את הערך. בשביל שה render שלהן ייקח זמן ולריאקט תהיה הזדמנות לעשות כמה דברים במקביל הוספתי קוד של המתנה בתוך קוד הקומפוננטה. בעולם האמיתי זה יכול להיות חישוב ארוך או מסובך.
בגירסאות ישנות של ריאקט לא משנה מה היינו עושים תמיד היינו מקבלים את אותו ערך של lastMouseX
מופיע 6 פעמים. בזמן שריאקט עסוק ב render, הדפדפן לא מפעיל את ה callback שמטפל בטיפול באירוע. רק אחרי שה render מסתיים לדפדפן יש הזדמנות לעדכן את המשתנה הגלובאלי ואנחנו נקבל את הערך החדש בפעם הבאה שנלחץ על הכפתור שמרנדר מחדש את הקומפוננטה.
בגירסא 18 של ריאקט לפני Concurrent Mode, או אם לא קוראים ל startTransition
, ריאקט מבצע את ה render "במכה אחת" כמו בגירסאות הישנות, ושוב לדפדפן אין הזדמנות לשנות את הערך של המשתנה הגלובאלי בין רינדור של קומפוננטות.
בגירסא 18 של ריאקט, אם מוסיפים קריאה ל startTransition
כמו שהוספתי בדוגמה, הדפדפן עוצר את הרינדור באמצע כדי לטפל בלחיצה על הכפתור. זה נותן לדפדפן הזדמנות לטפל באירוע mousemove
באמצע ה render ולעדכן את המשתנה הגלובאלי. התוצאה היא ש-6 המופעים של MouseTracker
לא מציגים את אותו ערך.
צריך להגיד - הבעיה היא לא ש-6 הקומפוננטות מציגות טקסט שונה בזמנים שונים. לפעמים זה הגיוני שממשק יתעדכן בזמן אמת וחלקים מסוימים בו יתעדכנו יותר מהר מאחרים. הבעיה שלנו כאן היא שמבחינת המשתמש כל 6 המספרים מוצגים בדיוק באותו זמן, כלומר משתמש לוחץ על הכפתור לשינוי הערך של count, ריאקט מתחיל עבודה חישובית וכשהוא מסיים אותה הוא כותב את כל 6 המספרים למסך במכה אחת. במצב כזה זה לא הגיוני להציג ערך שונה בכל קומפוננטה.
פיתרון? לא להשתמש במשתנים גלובאליים מתוך קומפוננטות אלא להוציא אותם החוצה ל Store מסודר, ולוודא שהספריה שאתם משתמשים בה לניהול הסטייט הגלובאלי משתמשת בפונקציה חדשה של ריאקט 18 בשם useSyncExternalStore
. פונקציה זו בעצם לוקחת מה Store בתחילת ה render עותק של אותו משתנה גלובאלי ודואגת להעביר אותו ב render לכל הקומפוננטות שמסתמכות עליו. אם אתם סקרנים איך זה עובד מאחורי הקלעים שווה לצפות בהרצאה של דאישי.