המרת קוד אתר ToCode לריאקט ורדוקס
פוסט זה כולל טיפ קצר בנושא פיתוח Front End. אם אתם רוצים ללמוד יותר לעומק על פיתוח Front End מהבסיס ועד הנושאים המתקדמים תשמחו לשמוע שבניתי קורס וידאו מקיף בנושא זה הכולל מעל 50 שיעורי וידאו והמון תרגול מעשי.
למידע נוסף והצטרפות לקורס בקרו בדף קורס Front End באתר.
בשבועות האחרונים עבדתי על המרת קוד צד הלקוח של אתר ToCode למבנה של Single Page Application באמצעות הטכנולוגיות React ו Redux. קוד צד השרת היה ונשאר Ruby on Rails. התוצאה היא שיפור ניכר בזמני הטעינה וזמני התגובה של כל האתר וקוד משמעותית יותר נקי וקל לתחזוקה.
פוסט זה מסכם את כל הלקחים, הבחירות והטעויות שעשיתי במהלך השכתוב וכמובן טיפים עבור מי ששוקל לבצע שדרוג כזה.
1. ארכיטקטורה: Rails, React ו Redux
נקודת ההתחלה שלי היא הספריה המצוינת react_on_rails. ספריה זו כבר מיישמת את השילוב בין ריאקט לריילס באמצעות helper המשלב פקד ריאקט לתוך קוד של Rails View. הקוד נראה בערך כך:
<%= react_component(“App", @appstate, prerender: true) %>
המאפיין appstate הינו משתנה רובי רגיל שמוגדר ב Controller.
אני משתמש בפקד ריאקט ראשי יחיד בשם App. באמצעות webpack אני טוען מימוש אחד של הפקד עבור רינדור בצד השרת ומימוש שונה עבור רינדור בצד הלקוח. בצורה כזו קל להפריד בין קוד שרלוונטי רק לצד מסוים, למשל ניהול היסטוריית ניווט בדפדפן לא רלוונטי כשמרנדרים את הפקד בצד השרת. ברובי יש באתר Layout יחיד בשם application שמרנדר תמיד את אותו פקד ריאקט. בחירת העמודים עצמה נעשית בספריית React Router. אין שימוש ב Views כלל מלבד ה Layout.
רינדור פקד בתור נקודת כניסה מהווה אתגר כשרוצים לשלב את רדוקס, מאחר וספריית רדוקס דורשת לייצר אוביקט מצב (store) במנותק מהפקדים, ולהעביר את אוביקט המצב לפקדים. הבעיה היא שהפקד הראשי הוא זה שמקבל את המשתנים מקוד השרת, ולכן אם ניצור את אוביקט המצב מחוץ לפקד לא ניתן יהיה להעביר את מצב היישום הראשוני מריילס לרדוקס. הנסיון הראשון שלי להתמודד עם זה היה יצירה של משתנה גלובלי ב JS בשם appstate וקריאה שלו מהקוד שיוצר את ה store. הקוד יצא קצת מסורבל ובסוף העברתי את בניית אוביקט המצב הראשונית לתוך קוד הפקד הראשי כך שהיום הוא נראה בערך כך:
const ClientRouterApp = (props) => {
const history = createHistory();
const store = createStore(props);
syncReduxAndRouter(history, store);
const reactComponent = (
<Provider store={store}>
<Router history={history} children={routes(store)} {...props} />
</Provider>
);
return reactComponent;
};
אפשר לראות ש props שהועברו מרובי משמשים את הפונקציה createStore כדי ליצור את אוביקט ה store הראשוני כך שכבר יכיל את כל המידע שצריך בשביל לרנדר את ממשק המשתמש. הרינדור מבוצע בצד שרת תחילה באמצעות node.js ולאחר מכן התוצאה נשלחת ומרונדרת שוב בצד הלקוח כדי להוסיף את הטיפול באירועים.
מבחינת קוד ריילס אין צורך ב Views כלל. כל Controller מייצר אוביקט JSON עם כל הנתונים ומעביר אותו הלאה לריאקט כך שישמש בתור מצב ראשוני של היישום. הוספתי ל ApplicationController קוד שמאתחל אוביקט זה עם המידע שמשותף לכל ה Controllers, למשל המידע הדרוש לציור תפריטי הניווט, וכל Controller מוסיף על אוביקט זה את המידע הרלוונטי עבורו.
בריילס יש מנגנון מאוד יפה לטיפול גנרי בבקשות כך שכשלקוח מבקש עמוד עם Content Type של JSON הוא יקבל את אוביקט המידע בתצוגת JSON וכשלקוח מבקש את אותו העמוד עם Content Type של HTML ריילס ישלח את קוד ה HTML המרונדר של העמוד. הפונקציה הבאה מתוך ApplicationController מיישמת רעיון זה:
def render_appstate
respond_with(@appstate) do |format|
format.html do
render :text => nil, :layout => true
end
end
end
עכשיו מכל אחד מה Controllers צריך רק להפעיל את render_appstate במקום את render. לדוגמא טיפול בפעולת ״אודות״ נראה כך:
def about
set_title(t('titles.about'))
@text = TextBox.find_by_name('page-about')
@appstate.deep_merge!({
:about => {
:text => @text.content
}
:layout => {
:headerText => @text.header
}
})
render_appstate
end
המידע במשתנה appstate יתגלגל להיות המצב הראשוני של אוביקט המידע (ה Redux Store) ביישום וישמש להצגת המידע בעמוד.
2. ספריית React Router ומעבר בין עמודים
בגרסאות קודמות של האתר השתמשתי ב Turbo Links של ריילס בשביל לעבור בין עמודים. למי שלא מכיר מדובר בספריה שהופכת כל קישור לבקשת Ajax ומחליפה באופן אוטומטי רק את גוף המסמך. זה חוסך להוריד את קבצי ה JS וה CSS מחדש בכל מעבר עמוד.
הגירסא הנוכחית כבר משתמשת ב React Router (כרגע גירסא 1.0.2) לנהל את בחירת העמוד הנוכחי ומעברי העמודים. הדוגמאות מסבירות יפה איך לעבור בין עמודים סטטיים או איך להשתמש ב componentDidMount ו componentDidUpdate כדי להפעיל בקשות Ajax. אבל, ביישום מלא חיפשתי משהו יותר גנרי— הרי כל פקד יצטרך לבקש את המידע הראשוני לצורך הצגתו באמצעות פניית Ajax לכתובת הרלוונטית ולכן נראה מיותר לכתוב את הקוד בכל פקד ופקד.
אפשרות אחת שלדעתי תעבוד בסדר זה להשתמש ב Mixin או בירושה כדי לשתף קוד בין הפקדים, ולכתוב בפונקציות componentDidMount ו componentDidUpdate של פקד האב את הקוד המתאים ששולח בקשת Ajax לשרת ומושך את המידע לפי ה URL (זכרו שכל פניה ל URL עם בקשה למידע כ JSON מחזירה את אותו משתנה appstate שמשמש ממילא כמצב ראשוני אם היינו גולשים ישירות לעמוד). לא בטוח למה אבל זה לא מה שעשיתי— במקום, השתמשתי במאפיין onEnter של ReactRouter.Route כדי להפעיל פונקציה בכניסה לכל עמוד. פונקציה זו מתחילה את בקשת ה Ajax ומנהלת במעברי העמודים בצורה חיצונית לפקדים. היתרון כאן הוא שאפשר קודם להביא את המידע ורק אז להחליף את העמוד המוצג (יש שיטענו שזה דוקא חסרון כי עדיף היה להראות אינדיקציית טעינה לפרק הזמן בין העמודים). החסרון הוא ש onEnter נקראת רק במעבר לרכיב חדש ולא בהחלפת פרמטרים ברכיב קיים, למשל כתוצאה ממעבר לדף הבא ברשימת הפוסטים בבלוג. מקומות אלו דרשו טיפול פרטני.
עוד יתרון של ניווט בצד לקוח שהוא מאפשר במצבים מסוימים לעשות Redirect בלי חלופת הודעות מיותרת עם השרת. באתר ToCode בכל פעם שמשתמש מחובר טוען את עמוד הבית אני רוצה להפנות אותו ישירות לעמוד הקורסים. הפונקציה הבאה משמשת ליצירת קוד טיפול באירוע כניסה לעמוד הראשי עבור משתמשים רשומים:
function onEnterRootForLoggedInUsers(onEnter) {
return (nextState, transition) => {
if (nextState.location.action === 'POP') {
// initial page load - redirect to /bundles
transition(null, '/bundles');
} else {
return onEnter(nextState, transition);
}
};
}
הקריאה ל transition משנה את העמוד הנוכחי בלי לפנות לשרת ובלי תקשורת מיותרת.
ודבר נוסף: ספריית React Router מתעדכנת בקצב מאוד מהיר והרבה פעמים התיעוד והדוגמאות ברשת לא עומדים בקצב העדכון של הספריה. תוסיפו לזה ממשק לא הכי אינטואיטיבי והתוצאה היא הרבה תסכול בזמן הפיתוח. בינתיים הם כבר הספיקו להוציא גירסא 2.0 והתוכנית כרגע להוציא את גירסא 3.0 בעוד כשלושה חודשים שגם תשבור חלק מהתאימות אחורה. הייתי שמח לבחור פתרון ניווט בוגר יותר אך לצערי לא מצאתי אחד.
3. תמיכה בריבוי שפות
לריילס יש פתרון i18n מעולה שנותן אפשרות להגדיר את כל הטקסטים בקבצי Locale ואז ריילס יודע לבחור את המחרוזות המתאימות באמצעות פונקציית תרגום. הגישה הנאיבית (אך קצת מעיקה) היא לייצר ב Controller את כל המחרוזות ולשלוח אותן לקוד ה JS כחלק מאוביקט המצב של היישום.
מהר מאוד הגישה הזאת התחילה להעיק וניפחה את קוד הקונטרולרים, אז הוספתי את הפונקציה הבאה לקוד של ApplicationController:
def add_locales(*categories)
locales = I18n.backend.send(:translations)[:he]
locales = locales.select {|k,v| categories.include?(k) } if categories
@appstate[:i18n] ||= {}
@appstate[:i18n].merge!(locales)
end
ועכשיו אפשר לשלוח את המידע בקבוצות, וכל קטגוריה היא ענף מתוך העץ שמוגדר ב Locale YAML. זה עדיין רחוק מלהיות מושלם: אין טיפול במחרוזות המכילות מספרים או פורמט של תאריכים ושעות. את אלו צריך לייצר לבד בקוד ה Controllers, ועדיין בשביל הצרכים של האתר הקוד נותן פתרון מעולה ודי גנרי.
4. ספריות צד-לקוח שמחייבות דפדפן (לדוגמא jQuery)
קוד האתחול של jQuery ואולי של ספריות נוספות מוודא שאכן הקוד רץ בתוך דפדפן. זה לא בעיה בדרך כלל כי ממילא אין טעם לטעון jQuery בלי DOM, הבעיה שכשאנו משתפים קוד בין השרת ללקוח אנו משתפים גם תלויות. מספיק לכתוב את השורה:
import $ from 'jquery';
בקובץ js שדרוש לצורך רינדור ראשוני בצד השרת כדי ש webpack יטען את ספריית jQuery, או לפחות ינסה לטעון כי בצד השרת זה כמובן ייכשל.
מרגע שמבינים את הבעיה הפתרון הוא עדכון הגדרות webpack בצד השרת כך שלא באמת יטען את מודול jQuery אבל גם לא ייכשל. מסתבר שמישהו כבר כתב Webpack Loader שעושה את זה ונקרא Null Loader. כך טוענים אותו עבור jQuery:
module: {
loaders: [
{test: /\.jsx?$/, loader: 'babel-loader', exclude: /node_modules/},
// React is necessary for the client rendering:
{test: require.resolve('react'), loader: 'expose?React'},
{test: require.resolve('react-dom/server'), loader: 'expose?ReactDOMServer'},
{test: /jquery/, loader: 'null'},
],
},
הספריה react_on_rails כבר מגיעה עם קובץ הגדרות webpack נפרד לצד השרת אליו יש להוסיף את ה Loader.
5. שילוב APIs צד-שלישי
בנוגע לספריות צד-שלישי התיאוריה לצערי יש פער בין התיאוריה לעולם האמיתי. בתיאוריה מספיק לאתחל את קוד הספריה בפונקציה componentDidMount ואולי לרענן בפונקציה componentDidUpdate. למשל באתר זה אני משתמש בספריית Prism כדי לצבוע קטעי קוד בפוסטים, ולכן הפונקציות המתאימות מ blog_post.jsx נראות כך:
componentDidMount() {
super.componentDidMount();
Prism.highlightAll();
}
componentDidUpdate(prevProps, prevState) {
super.componentDidUpdate(prevProps, prevState);
if (prevProps.location !== this.props.location) {
Prism.highlightAll();
}
}
אבל זה כמובן החלק הקל. הבעיות מתחילות בספריות השומרות State או ספריות שמשנות את ה DOM מתחת לרגליים של ריאקט. באתר ToCode ספריית נגן הוידאו של Wistia דרשה טיפול מיוחד וכך גם הספריה jQuery Draggable.
6. אז מה בעצם הרווחנו מכל הסיפור?
היתרונות הגדולים של ההמרה הם שיפורי ביצועים וקוד נקי יותר. בגזרת הביצועים זמן טעינת עמוד בגישה ישירה ל URL ירד משתיים וחצי שניות בממוצע לקצת מעל שניה וזמן מעבר בין עמודים הוא בערך רבע מזה. בזכות הנקיון בקוד והעובדה שאין יותר Views גם היה יותר קל לזהות ולתקן על הדרך בעיות ביצועים בקוד צד השרת.
בגזרת הקוד השיפור המשמעותי הוא דבר אחד פחות לדאוג לגביו, כלומר מחיקת ה Views מהיישום. בעבר כל שינוי בעמוד השפיע על שלושה מקומות: על ה Controller שאוסף את המידע, על ה View שמציג אותו מצד השרת ועל קוד ה JavaScript שטיפל באירועים הקשורים לאותו פריט מידע. הוצאת ה View מהמשוואה ואיחודו עם קוד ה JavaScript מצמצמת את מספר הקבצים שצריך לעדכן עם כל שינוי בממשק, מה שאומר שינויים מהירים יותר ועם פחות נזק סביבתי.
אם יש מהקוראים עוד אנשים שמשלבים ריאקט וריילס אשמח לשמוע בתגובות איך הולך לכם, באיזה אתגרים אתם נתקלתם והאם יש רעיונות אחרים (טובים יותר?) לחלק מהנקודות כאן.