רדוקס מציעה דרך חדשה לגמרי לחשוב על פיתוח צד-לקוח
פוסט זה כולל טיפ קצר בנושא פיתוח Front End. אם אתם רוצים ללמוד יותר לעומק על פיתוח Front End מהבסיס ועד הנושאים המתקדמים תשמחו לשמוע שבניתי קורס וידאו מקיף בנושא זה הכולל מעל 50 שיעורי וידאו והמון תרגול מעשי.
למידע נוסף והצטרפות לקורס בקרו בדף קורס Front End באתר.
ספריית JavaScript חדשה בשם רדוקס (redux) מציעה דרך חדשה לחשוב על פיתוח ממשק המשתמש. במקום להתחיל מהצגת ממשק על המסך וחיבור פעולות לממשק, רדוקס תעזור לכם להתחיל מהגדרת הפונקציונליות ולהגיע לבניית הממשק כשרוב הקוד כבר כתוב. בואו נראה איך זה קורה והאם זה שווה את המאמץ.
1. רדוקס על קצה המזלג
חשבו על היישום שלכם, אבל עזבו את הממשק בצד לרגע. במקום כפתורים נסו לחשוב על המידע שהיישום מחזיק (נקרא למידע זה ״מצב״ מכאן והלאה) והפעולות שישנו מידע זה. ניקח כדוגמא יישום צ׳ט המחולק לחדרי שיחה. אפשר לייצג את המידע שהיישום מחזיק באמצעות האובייקט הבא:
var initialState = {
rooms: {
all: {},
},
messages: {
activeRoom: undefined,
activeRoomId: undefined,
all: [],
},
username: "Anonymous",
};
עבור יישום צ׳ט מרובה חדרים, עלינו לדעת מהם החדרים הזמינים אליהם נוכל להצטרף. נרצה גם לדעת באיזה חדר אנו נמצאים כרגע ומהי רשימת ההודעות בחדר זה. פיסת מידע נוספת היא שם המשתמש הנוכחי.
שימו לב שאובייקט המידע לא מתוכנן להחזיק את כל ההודעות בכל החדרים, אלא רק את ההודעות בחדר השיחה בו אני נמצא כרגע. זו החלטה לוגית שתשפיע על כל מבנה היישום (ואתה גם כל החלטה אחרת על מבנה אובייקט המידע הזה). ככל שהיישום יותר מסובך כך ההחלטה מה לשמור ובאיזה אופן הולכת להיות יותר קשה.
הדבר השני שרדוקס רוצה שתחליטו עליו לפני שתגיעו לכתוב אפילו שורת קוד אחת הוא הפעולות אותן תרצו לבצע על המידע, ועל איזה חלק מהמידע כל פעולה תשפיע. כך למשל ביישום הצ׳ט נוכל להגדיר את הפעולות ״הצטרפות לחדר שיחה״, ״שינוי שם משתמש״, ״שליחת הודעה״ ו״יצירת חדר שיחה״.
ברדוקס פעולה היא לא רק משהו שמגיע כתוצאה מפעולה של משתמש בממשק אלא כל דבר שמשנה את מצב היישום. לכן גם ״עדכון רשימת חדרים״ ו״עדכון רשימת הודעות״ נחשבות פעולות במערכת.
בשלב השני אנו נחלק את הפעולות לחלקים בעץ המידע עליו הן יכולות להשפיע. פעולת ״עדכון רשימת חדרים״ למשל תשפיע על השדה rooms באובייקט המידע, וכך גם ״יצירת חדר שיחה חדש״.
לעומתן הפעולות ״שליחת הודעה״, ״הצטרפות לחדר״ ו ״עדכון רשימת הודעות״ כולן ישפיעו על שדה messages של אובייקט המידע.
החלוקה עצמה כמובן תלויה ביישום שלכם ובאופן בו בחרתם לשמור את המידע.
2. עכשיו אנחנו מוכנים לראות קצת קוד
אחרי שהחלטתם על מבנה המידע והפעולות אפשר להתחיל להשתמש ברדוקס כדי לבנות את קוד התוכנית. רדוקס מציעה את העסקה הבאה: אנו נגדיר את הפעולות האפשריות ביישום וכיצד כל פעולה משפיעה על מצב התוכנית. חלק מהפעולות רק ישנו את אובייקט המצב, ופעולות אחרות יכולות גם לבצע דברים ואז לשנות את המצב. כך פעולת ״הודעה חדשה״ ביישום הצ׳ט תשלח לשרת את ההודעה החדשה ולא תשנה את המצב כלל (המצב ישתנה כשנקבל מהשרת עדכון על ההודעה החדשה ששלחנו).
לעומתה פקודת ״שינוי שם משתמש״ משנה באופן מיידי את אובייקט המצב ולא משפיע על התוכנית מעבר לזה.
נמשיך לקוד שמגדיר את הפעולות:
var actions = {
receivedRooms: (res) => ({ type: RECEIVED_ROOMS, payload: res }),
setActiveRoom: (room_id) => ({ type: SET_ACTIVE_ROOM, payload: room_id }),
createRoom: (room) => ({ type: CREATE_ROOM, payload: room }),
receivedMessage: (text) => ({ type: RECEIVED_MESSAGE, payload: text }),
say: (text) => ({ type: SAY, payload: { text: text, from: store.getState().username }}),
setUsername: (name) => ({ type: SET_USERNAME, payload: name }),
};
השתמשתי בפונקציות חץ של ES6 כדי להקליד פחות. האובייקט actions מורכב מפונקציות, כל אחד מחזירה אובייקט עם מאפיין type ומאפיין payload. אובייקט חוזר כזה מייצג פעולה. כך למשל הקוד הבא ייצור פעולה עבור שינוי שם:
var actName = actions.setUsername("ynon");
3. Reducer: טיפול בפעולה
אז בינתיים יש לנו מידע ופעולה שצריכה לשנות את המידע. ה Reducer הוא פונקציה המחברת את שני הרכיבים הללו יחד. הנה דוגמא לפונקציה כזו שיודעת לטפל בשינוי שם משתמש:
function userinfo(state, action) {
switch(action.type) {
case SET_USERNAME:
return action.payload;
default:
return state;
}
}
פונקציית reducer מקבלת את החלק הרלוונטי עבורה באובייקט המצב ואובייקט פעולה כלשהו. הפונקציה תבדוק האם היא יודעת לטפל בפעולה זו, ואם כן תחזיר אובייקט חדש שמייצג את המצב לאחר ביצוע הפעולה. כך בדוגמא עבור שינוי שם משתמש אובייקט המצב הוא פשוט שם המשתמש החדש.
הנה אחד יותר מורכב עבור טיפול בהודעה חדשה מהשרת:
function messages(state, action) {
switch(action.type) {
case RECEIVED_MESSAGE:
return Object.assign({}, state, { all: state.all.concat(action.payload)});
default:
return state;
}
}
כל המידע ברדוקס הינו Immutable ולכן לפונקציית צמצום אסור לשנות את אובייקט המצב המקורי, ובמקום היא מחזירה את המצב החדש. הקוד בדוגמא מטפל בפעולת הודעה חדשה שהגיעה משרת. במצב כזה אנו לוקחים את אובייקט המצב הקודם (למעשה רק את שדה messages שלו), ומחליפים את מערך ההודעות בשדה all במערך חדש הכולל את כל ההודעות הישנות ובנוסף את ההודעה החדשה. הפונקציה Object.assign אגב הגיעה מ ES6, היא מקבילה ל extend של Underscore או jQuery.
בשביל להפעיל את כל ה Reducers האלה אתם תצטרכו לכתוב Reducer אחד ראשי שיקרא בכל פעם שתגיע פעולה חדשה למערכת ותפקידו יהיה להפעיל כל Reducer משני על החלק המתאים לו בעץ. הנה קוד של פונקציה כזו לדוגמא:
function myApp(state = initialState, action) {
return {
...state,
rooms: rooms(state.rooms, action),
messages: messages(state.messages, action),
username: userinfo(state.username, action)
};
}
פעולת השלוש נקודות בתחילת האובייקט הגיעה גם היא מ ES6. בעברית היא אומרת לקחת את כל התוכן של אובייקט state ולהוסיף עליו את השדות שמגיעים אחרי: המפתח rooms יקבל את תוצאת הפונקציה הראשונה, messages את השניה ו username את השלישית. כל אחת מפונקציות אלו היא Reducer שמקבל חלק מתוך עץ המידע ואת הפעולה.
כך ה Reducer החיצוני מצליח להחזיר אובייקט מצב ראשי חדש לאחר כל פעולה משנה מצב.
4. מחסן המידע
בתור ספריה דמוית פלוקס, רדוקס מציעה אוביקט מחסן המחבר בין מצב היישום לבין ממשק המשתמש. המחסן ייצר אירוע בעקבות כל שינוי במצב היישום. ברדוקס יש רק מחסן אחד ראשי מאחר ויש עץ מידע אחד שמייצג את כל מצב היישום. בשביל ליצור את המחסן אתם רק צריכים להגיד מיהו ה Reducer הראשי שלכם באופן הבא:
var store = Redux.createStore(myApp);
אחרי שיש מחסן אפשר לקבל ממנה עדכונים בכל פעם שמשהו משתנה כדי לרנדר מחדש את ממשק המשתמש באמצעות פונקציית subscribe:
this.unsubscribe = store.subscribe(this.storeDataChanged);
המחסן הוא גם הדרך שלנו להשפיע על מצב היישום. בכל פעם שקורה משהו שמשפיע על מצב היישום (למשל משתמש ביצע פעולה בממשק או הודעה חדשה הגיעה מהשרת) נפעיל את הפונקציה dispatch של המחסן ונעביר לה את פרטי הפעולה שיש לבצע. הפונקציה dispatch מקבלת פעולה ומעבירה אותה ל Reducer הראשי במחסן וכך מביאה לשינוי מצב היישום. בסוף הטיפול בפעולה ובמידת הצורך המחסן יעדכן את המאזינים שלו בשינוי שהתבצע. כך שינוי שם משתמש בדוגמת הצ׳ט:
store.dispatch(actions.setUsername(username));
להלן הקוד המלא שהוצג עד כה, בצירוף ממשק משתמש פשוט מבוסס ריאקט:
5. שיפור הקוד ומעבר ל Pure Reducers
היישום עובד אך כבר אפשר לראות פוטנציאל לקושי תחזוקתי בעתיד. כך לדוגמא הפונקציה rooms המנהלת את המידע הרלוונטי עבור חדרים ומעבר ביניהם:
function rooms(state, action) {
switch(action.type) {
case RECEIVED_ROOMS:
return Object.assign({}, state, { all: action.payload } );
case CREATE_ROOM:
setTimeout(function() {
server.child('rooms').push({ name: action.payload, messages: [] });
}, 0);
return state;
default:
return state;
}
}
טיפול בפעולת RECEIVED_ROOMS הוא ברור: קיבלת רשימת חדרים? הרשימה משפיעה על שדה all באובייקט המצב הרלוונטי לחדרים ומשנה את רשימת החדרים למידע שהגיע בפעולה. אבל מה לגבי פעולת CREATE_ROOM? פעולה זו לא משנה את מצב היישום כלל אלא רק שולחת בקשה לשרת ה Firebase ליצירת חדר חדש. בהמשך ממילא כשיווצר החדר נקבל פעולת RECEIVED_ROOMS ונעדכן את המצב שישקף את החדר החדש. במקום לראות קוד שמחזיר מצב חדש אנו רואים כאן קריאת API. בלי להתכוון יצרנו צימוד בין ה API שלנו והעבודה מול Firebase לבין הטיפול בשינויי המצב ביישום. ככל שרכיבים אלו הופכים תלויים אחד בשני התחזוקה של הקוד ואיתור השגיאות הופכים קשים יותר.
פתרון רדוקסי יהיה להפריד את הלוגיקה הקשורה ליישום לתוך ה Action, כך שקוד הטיפול בפעולה יישאר פשוט. עד כה ראינו רק Actions שהם אובייקטים פשוטים אך באמצעות קסם שנקרא Middleware אפשר להגדיר גם פונקציה בתור Action. כך למשל יכולה להיראות פעולת Create Room במבנה זה:
createRoom: function(room) {
// A function action is called a "thunk". It is called
// by redux and can use state and dispatch to create new
// state changing actions or perform API calls
return function(dispatch, getState) {
setTimeout(function() {
server.child('rooms').push({ name: room, messages: [] });
}, 0);
}
},
ומה לגבי ה Reducer? מאחר ופעולת Create Room לא משנה את מצב היישום, היא כלל לא תהיה חלק מפונקציית ה Reducer. כעת הפונקציה rooms תיראה כך:
function rooms(state, action) {
switch(action.type) {
case RECEIVED_ROOMS:
return Object.assign({}, state, { all: action.payload } );
default:
return state;
}
}
מוזמנים להמשיך לקריאת הקוד המלא. כשאתם קוראים שימו לב לפעולת setActiveRoom. פעולה זו גם משנה את מצב היישום וגם מייצרת קריאת API. בשביל לבצע זאת פונקציית הפעולה שלה קוראת ל disptatch ומוסיפה פעולה ״רגילה״ לאחר שהופעלה.
6. אז מה בעצם הרווחנו בכל הסיפור הזה?
די הרבה האמת. הנה כמה נקודות:
זוכרים את אובייקט המידע שהתחלנו אתו? בכל רגע נתון אפשר לראות את כל מצב היישום (כולל כל ההודעות, חדר השיחה הנוכחי, שם המשתמש) במקום אחד. פשוט כתבו את השורה הבאה בחלון ה Console בדפדפן:
store.getState()
רוצים לבדוק או לשחזר תרחיש? אין קל מזה. זכרו שכל דבר שיכול לקרות במערכת מיוצג על ידי פעולה, ולכן כל בדיקה או סימולציה פשוט מורכבת מאוסף של פקודות dispatch. רדוקס גם מגיעה עם מודולים לדיבג שמאפשרים לרשום כל שינוי באובייקט המצב ללוג, וכך תמיד אתם יכולים לראות מה התקלקל וכתוצאה מאיזו פעולה.
מאחר וכל מבנה היישום משתמש במידע Immutable, קל מאוד לשלב את הספריה עם ריאקט ולשפר ביצועים באמצעות PureRenderMixin. גם אם אתם לא עובדים בריאקט העובדה שהמידע הוא Immutable אומרת שקל מאוד לחזור למצב קודם של היישום, למשל עבור Undo.
רדוקס הופך את הקערה: במקום לכתוב קודם את הממשק ואז לראות איך לחבר אליו פעולות, ברדוקס אנו מתחילים מהמידע שאנו מנהלים ומה עושים עם מידע זה. היפוך זה מאפשר חיזוי. כל מצב היישום תמיד שמור באותו המקום, כל שינוי תמיד עובר דרך פונקציה אחת ראשית ומחלחל פנימה לפונקציית ה Reducer הרלוונטית וכך תמיד אפשר לשים נקודת עצירה ולמצוא מה התקלקל. כשתגיעו לכתוב את הממשק זה יהיה אחרי שרוב קוד המערכת כתוב ועובד, ורק צריך לחבר אליו כפתורים. הפרדה זו בין הממשק לפעולות מאוד בריאה וקשה להגיע אליה בסביבות אחרות.