מובאקס (MobX) בעשר דקות
מובאקס הוא מימוש הפלאקס השני הכי פופולרי אחרי רידאקס (ויש שטוענים שבארץ מובאקס כבר עבר את רידאקס), ואולי אחת הסיבות המרכזיות לפופולריות שלו היא העובדה שהספריה מתאימה כמו כפפה ליד לאופן בו מתכנתים רגילים לחשוב על קוד. בניגוד לרידאקס שמכריח אותנו לאמץ גישה פונקציונאלית ולעבוד עם Immutable Data, במובאקס הגישה היא לרוב מונחית עצמים ואתם יכולים להחליט איך לבנות את האפליקציה שלכם בצורה הטובה ביותר עבורכם.
בואו ניקח עשר דקות (אולי אפילו פחות) כדי להוסיף את מובאקס לפרויקט ריאקט ולראות את החלקים המרכזיים של הפריימוורק.
1. החיים לפני מובאקס
בשביל הדוגמא כתבתי תוכנית ריאקט שמציגה רשימה של פריטים ומאפשרת לסמן כל פריט בתור "בוצע" או "לא בוצע". אני יודע אתם תגידו שאני לא הכי מקורי, אבל נראה לי שלמרות חוסר המקוריות נצליח ללמוד מהדוגמא משהו על ריאקט ומובאקס. לפני שהגיע מובאקס הקוד היה מחולק לקומפוננטות, כאשר כל קומפוננטה קיבלה מידע והיתה אחראית על הצגתו.
לקומפוננטה הפשוטה ביותר קראתי RemainingItems והיא הציגה את מספר הפריטים שנשאר לבצע. הנה הקוד שלה:
function RemainingItems({ items }: { items: IItem [] }) {
const remaining = items.filter(item => !item.done).length;
return (
<p>You have {remaining} items left to do</p>
)
}
רשימת הפריטים מגיעה בתור מערך וכל הלוגיקה נמצאת בתוך הקומפוננטה.
הקומפוננטה הבאה בתור היא זו שאחראית על הוספת פריט חדש. שם כבר החיים יותר מסובכים כי רשימת הפריטים שמורה במקום שמשותף לכל האפליקציה, ולכן הקומפוננטה שמוסיפה פריט חדש צריכה לקבל מבחוץ את הפונקציה שמוסיפה פריט. תכף נלך לכתוב את אותה פונקציה אבל לפני זה הנה הקוד של קומפוננטת יצירת הפריט החדש:
function NewItemBox({addItem}: { addItem: (_:string) => void}) {
const [ text, setText ] = useState('');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
addItem(text);
setText('');
}
return (
<form onSubmit={handleSubmit}>
<input
type={"text"}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type={"submit"}>Add</button>
</form>
)
}
הכתיב אם תהיתם הוא TypeScript והממשק IItem נראה כך:
interface IItem {
id: number;
name: string;
done: boolean;
}
את הצגת רשימת הפריטים הפרדתי לשתי קומפוננטות: הקומפוננטה Item שמציגה מידע על פריט בודד והקומפוננטה ItemsList שמראה רשימה של פריטים. היכולת לעדכן את סטטוס הביצוע של פריט היא משהו שמגיע מבחוץ, שוב מאחר ורשימת הפריטים מאוחסנת במקום כללי יותר באפליקציה. הנה הקוד של שתי הקומפוננטות:
function Item({ item, toggleItem }: {
item: IItem,
toggleItem: (itemId: number) => void,
}) {
return (
<li>
<label>
<input
type={"checkbox"}
checked={item.done}
onChange={() => toggleItem(item.id)} />
{item.name}
</label>
</li>
)
}
function ItemsList({ items, toggleItem }: {
items: IItem[] ,
toggleItem: (itemId: number) => void,
}) {
return (
<ul>
{items.map(item => (
<Item item={item} toggleItem={toggleItem}/>
))}
</ul>
)
}
והחלק האחרון הוא הקומפוננטה הראשית שמחזיקה את רשימת הפריטים. כאן אני צריך משתנה State בשביל רשימת הפריטים, הגדרה של הפונקציות toggleItem ו addItem ובסוף קצת UI כדי להחזיר את כל הקומפוננטות שכתבנו עד עכשיו:
function App() {
const [items, setItems] = useState<IItem []>([]);
function addItem(name: string) {
const newItem = {
id: items.length,
name,
done: false,
}
setItems([...items, newItem]);
}
function toggleItem(id: number) {
setItems(items.map(item => (
item.id === id ? {...item, done: !item.done} : item
)));
}
return (
<div className="App">
<NewItemBox addItem={addItem}/>
<ItemsList items={items} toggleItem={toggleItem} />
<RemainingItems items={items} />
</div>
);
}
2. שילוב MobX - יצירת שכבת המידע
יישום הריאקט שלנו מספיק קטן בשביל לעבוד, אבל מספיק גדול בשביל להראות לנו איך החיים הולכים להסתבך ככל שנוסיף יותר קומפוננטות. בגלל שאנחנו מעבירים את addItem ו toggleItem מהקומפוננטה App למטה לקומפוננטות הילדים, ובגלל שזהו App שמחזיק את כל המידע, כל פעם שנרצה להוסיף פעולה חדשה על רשימת הפריטים נצטרך להעביר גם את פונקציית הפעולה החדשה למטה בעץ. בכל פעם שקומפוננטה חדשה תצטרך משהו מרשימת הפריטים נצטרך להעביר אליה גם את כל הרשימה.
תבנית הפיתוח Flux היא התבנית המקובלת בעולם של ריאקט כדי לשמור מידע גלובאלי. בקצרה היא אומרת שאת המידע שמשותף להרבה קומפוננטות ביישום (כמו רשימת הפריטים במקרה שלנו) כדאי לשמור במשתנה Singleton מחוץ לכל הקומפוננטות, ורק לקרוא אותו מתוך הקומפוננטות. מתחת למילים היפות יש הרבה קוד שדואג לזה שקומפוננטה תשמור בסטייט עותק של המידע הגלובאלי, וכל פעם שיש עדכון למידע היא תשלוף את המידע החדש, תעדכן מחדש את הסטייט שלה וכך תציג את המידע המעודכן.
מובאקס הוא מימוש של תבנית Flux שמסתיר מכם את רוב קוד החיבורים כך שאתם יכולים להתמקד בכתיבת ה Business Logic של היישום שלכם. קל מאוד להוסיף MobX ליישום קיים, ואין שום בעיה לפצל את המידע בין מספר Singleton-ים שמנהלים את המידע, שבשפה המקצועית של פלאקס (ולכן גם של מובאקס) נקראים Data Stores.
נלך להוסיף את ה Data Store הראשון שלנו באמצעות יצירת קובץ חדש בתוכנית בשם items_store.ts
. ה Data Store מורכב משתי מחלקות: מחלקה עבור Item שדואגת לשמור את המאפיינים של כל פריט ברשימה, ומחלקה עבור רשימת הפריטים בכללותה שתספק לנו את הפונקציה להוספת פריט. נתחיל עם Item:
export class Item {
id: number;
@observable name = "";
@observable done = false;
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
}
וכבר אנחנו רואים משהו אחד מוזר ומשהו אחד מוכר: הדבר המוכר הוא השימוש בכתיב מונחה עצמים כדי לייצג דבר שיכול להשתנות. השדות name ו done של Item הולכים להשתנות לאורך חיי התוכנית ולכן הם נשמרים בתור שדות מידע של האוביקט. הבנאי מקבל את הערכים הראשוניים ויש לנו מבנה קלאסי של מחלקה.
הדבר המוזר הוא השימוש בכתיב ה Decorators והגדרת name ו done בתור Observable. התוצאה בפועל היא שכל פעם שניצור אוביקט מהמחלקה יוגדרו באופן אוטומטי Getters ו Setters על שדות אלה, כך שכל שינוי שלהם יעדכן באופן אוטומטי את הקומפוננטות הרלוונטיות בממשק המשתמש. או במילים אחרות אם קומפוננטה תנסה להציג את הערך של אחד השדות היא באופן אוטומטי תירשם בתור "מאזינה" על שדה זה, וכל פעם שמישהו יעדכן ערך של אותו שדה, המחלקה Item תדאג להודיע לכל המאזינים.
המחלקה השניה, ItemsList, תשתמש בטריק דומה כדי להחזיק מערך של פריטים:
export class ItemsList {
@observable todos: Item[] = [];
@computed get unfinishedTodoCount() {
return this.todos.filter(todo => !todo.done).length
}
@action
addItem(name: string) {
const newItem = new Item(this.todos.length, name);
this.todos.push(newItem);
}
}
וכאן כבר אפשר לראות עוד שני חלקים מעניינים בקוד. הפונקציה unfinishedTodoCount הוגדרה בתור ערך מחושב, ולכן נוכל לגשת אליה מתוך קוד התצוגה שלנו וכל פעם שיהיה שינוי באחד הפריטים באופן אוטומטי מובאקס יחשב את הערך החדש ויעדכן אותנו. הפונקציה addItem מוסיפה פריט חדש ומעניין לראות שאנחנו משתמשים בפונקציה push הרגילה של מערכים ב JavaScript, או לפחות במשהו שנראה כמו הפונקציה push הרגילה של מערכים. האמת היא שמתחת לפני השטח המשתנה todos של ItemsList הוא בכלל Proxy שמשתלט על כל פעולות השינוי שאנחנו עושים כדי לעדכן באופן אוטומטי את כל המאזינים.
השלב האחרון הוא יצוא הרשימה בתור Singleton ואת זה אנחנו עושים באמצעות מנגנון היצוא הרגיל של ES6:
export default new ItemsList();
3. שילוב MobX - עדכון קוד התצוגה
נחזור לקוד התצוגה ונראה איך לשלב את מובאקס בקומפוננטות שכבר כתבנו.אני מתחיל עם הקומפוננטה הראשית שם השינוי הוא המעניין ביותר:
function App() {
console.log('App::render');
return (
<div className="App">
<NewItemBox />
<ItemsListView />
<RemainingItems />
</div>
);
}
קודם הקומפוננטה הראשית החזיקה בתוך הסטייט את רשימת כל הפריטים וכל שינוי ברשימה גרר render של אותה קומפוננטה. אחרי המעבר למובאקס רשימת הפריטים נשמרת בתוך ה Data Store שיצרנו, ולכן הקומפוננטה הראשית בכלל לא מעורבת בכל ניהול הרשימה.
הקומפוננטות הקטנות יותר יודעות לשאוב את המידע ישירות מתוך ה store שיצרנו בזכות השימוש ב import בתחילת הקובץ:
import itemsStore, { Item, ItemsList } from "./mobx/items_store";
כך לדוגמא בקומפוננטה RemainingItems נוכל לקבל:
const RemainingItems = observer(function RemainingItems() {
const remaining = itemsStore.unfinishedTodoCount;
return (
<p>You have {remaining} items left to do</p>
)
});
הגישה הישירה מקומפוננטה למשתנה הגלובאלי itemsStore היא הדבר המלהיב פה מאחר ועכשיו אנחנו לא צריכים יותר להעביר מידע בין קומפוננטות רק בשביל לגשת או לעדכן את המידע הגלובאלי. כל קומפוננטה אחראית על שליפה ועדכון המידע הגלובאלי הרלוונטי עבורה.
מה לגבי הפונקציה observer? זו פונקציית הקסם של MobX שמחברת את הקומפוננטה שלכם למידע הגלובאלי, כך שבאופן אוטומטי ברגע שיהיה עדכון במידע הגלובאלי הקומפוננטה תרונדר מחדש. מובאקס יודע על איזה מאפיינים להאזין בגלל שכמו שראינו קודם הוא משתמש ב Getters ו Setters מותאמים אישית ביצירת המחלקה. כל פעם שבתוך פונקציית observer נפעיל את אחד ה Getters שיצר מובאקס (כלומר ניגש לשדה שמסומן בתור Observable), באופן אוטומטי הקומפוננטה תסומן בתור אחד המאזינים לשינויים על שדה זה.
לכן הקומפוננטה שמוסיפה פריט חדש לרשימה עכשיו יודעת לדאוג לעצמה ולהפעיל בעצמה את addItem של מחסן המידע:
function NewItemBox() {
const [ text, setText ] = useState('');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
itemsStore.addItem(text);
setText('');
}
return (
<form onSubmit={handleSubmit}>
<input
type={"text"}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type={"submit"}>Add</button>
</form>
)
}
בלי שהיינו צריכים להעביר את הפונקציה מהקומפוננטה App, ובאופן אוטומטי לכן לא תתרנדר שוב כשהמידע הגלובאלי משתנה. קומפוננטה זו לא "מאזינה" לשום מידע גלובאלי ולכן לא הייתי צריך לרשום אותה בתוך פונקציית observer.
אותו מנגנון עובד גם בקומפוננטה שמציגה פריט בודד:
const ItemView = observer(function ItemView({ item }: {
item: Item,
}) {
return (
<li>
<label>
<input
type={"checkbox"}
checked={item.done}
onChange={() => item.done = !item.done } />
{item.name}
</label>
</li>
)
});
בגלל העבודה בגישת התכנות מונחה עצמים, אני כבר לא צריך ללכת לרשימה ולהגיד לה לסמן או לבטל סימן של פריט לפי ה id שלו, ואני יכול להפעיל את הפונקציה ישירות על הפריט.
4. מה הלאה
העליתי לגיטהאב את קוד התוכנית המלא עם MobX ו TypeScript אז מוזמנים להעיף מבט או להשתמש בו כבסיס ליישום שלכם: https://github.com/ynonp/my-mobx-demo.
והתיעוד של MobX הוא כמובן מקום טוב למצוא בו עוד דוגמאות ופרטים על הפריימוורק: https://mobx.js.org/README.html.