ארכיטקטורה לפיתוח צד-לקוח ב 2016
פוסט זה כולל טיפ קצר בנושא פיתוח Front End. אם אתם רוצים ללמוד יותר לעומק על פיתוח Front End מהבסיס ועד הנושאים המתקדמים תשמחו לשמוע שבניתי קורס וידאו מקיף בנושא זה הכולל מעל 50 שיעורי וידאו והמון תרגול מעשי.
למידע נוסף והצטרפות לקורס בקרו בדף קורס Front End באתר.
בעוד שאת התחביר של JavaScript יחסית קל ללמוד, פיתוח ארכיטקטורה ליישומי צד-לקוח היא משימה יותר מורכבת. הסביבה אינה מגדירה ארכיטקטורה סטנדרטית ל״אפליקציית ווב״ (ובכלל, מהי אפליקציית ווב), והפיתוחים ביכולות הדפדפנים ובשפה עצמה יצרו דרכים שונות לכתוב קוד צד-לקוח לאורך השנים.
אני חושב שזו אחת הסיבות לפופולריות של Frameworks ולחיפוש אחר ספריה אחת שתתן תשובה לכל השאלות. החיפוש אחר הדרך הנכונה לפתור בעיה ב Angular, React, Redux או Durandal הוא בעצם חיפוש אחר ארכיטקטורה לאפליקציה שלנו. לכן ולפני שמתחילים לכתוב יישום צד-לקוח גדול, כדאי להתעמק קצת יותר ברעיונות ובארכיטקטורה שאותן Frameworks מתבססות עליה.
1. רכיבי בסיס Components
בעבר, כשדפדפנים היו איטיים ורוב הקוד רץ בצד השרת, תפקידו של קוד צד הלקוח היה להוסיף פונקציונאליות לתוך עמוד HTML קיים. קוד ה JavaScript איתר אלמנטים באמצעות getElementById והדביק פונקציות שיקראו בתגובה לאירועים על אלמנטים אלו באמצעות addEventListener. אם היה צריך לשמור מידע הוא היה נשמר בתוך האלמנטים, ופונקציות טיפול שנקראו בהמשך יכלו לקרוא את המידע משם.
כך לדוגמא הקוד הבא עבור הטמעת Spoiler Alerts בעמוד:
$('.spoiler').html(
`Spoiler Alert <button>Click To Reveal</button>`
);
$('.spoiler').on('click', 'button', function (ev) {
var spoiler = ev.target.closest('.spoiler');
spoiler.textContent = spoiler.dataset.text;
});
עם הזמן וכמות הקוד העסק הזה נהפך קשה לתחזוקה. מתכנתים מימשו שוב ושוב את אותה ההתנהגות בהקשרים שונים במקום להגדיר קוד גנרי, פונקציות שנקראו ממקומות אחרים קלקלו את המידע ב DOM וכך יצרו באגים שהיה קשה לאתר אותם, ועל בדיקות יחידה בכלל אין מה לדבר.
בתוכניות קטנות ועמודים שכוללים יחסית מעט פונקציונאליות אפשר לחיות עם הגדרת הקוד באופן זה. בתוכניות גדולות יותר אנו מחפשים אבסטרקציות שיעזרו לנו לבודד קטעי קוד שחוזרים על עצמם.
כיום הרכיב הפשוט ביותר המקובל נקרא פקד. זה שם שאני בחרתי, וכל ספרית פיתוח השתמשה בשם שונה עבור אותו הדבר, כך ב Angular יש לנו את ה Directives, ב React את ה Components, ב Backbone את ה Views ול Durandal יש Widgets. כולם שמות שונים לאותו הדבר: יחידה בסיסית בממשק המשתמש שאפשר להטמיע במספר מקומות בעמוד. היחידה מגדירה ממשק כלפי קוד חיצוני שרוצה להטמיע אותה אבל היא סגורה בתוך עצמה ומחזיקה את הלוגיקה של עצמה כדי לפעול.
קוד ה HTML של הפקד יכול להיות מוגדר מתוך קוד ה JavaScript, בקובץ תבנית נפרדת או בקובץ ה HTML ממש. הנה דוגמא המייצרת את אותו Spoiler Alert רק שהפעם תוך שמוש בכתיב פקדים:
היתרון המרכזי בגישה זו הוא היכולת לשמור מידע פרטי עבור כל אחד מהמופעים של הפקד, קל לשתף את קוד הפקד עם מתכנתים אחרים או במקומות שונים במערכת וכמובן קל לכתוב בדיקות יחידה.
קוד ה HTML הראשי מכיל עוגנים ליצירת הפקדים שלנו, למשל בקוד הדוגמא כל אלמנט עם הקלאס spoiler יהפוך ל Spoiler Alert. המעבר מכתיב פונקציונאלי לפקדים מאוד מזכיר כתיבת jQuery Plugins: לוקחים פונקציונאליות שחוזרת על עצמה, מייצרים עבורה עוגן ב HTML ומעבירים את האלמנטים הרלוונטים לפונקצית הבנאי של הפקד.
2. שתוף מידע בין רכיבים באמצעות אב משותף
המעבר לפקדים עזר לסדר את הקוד אבל עדיין השאיר מספר בעיות כשרוצים לבצע אותו לעמוד מלא, המרכזית שבהן היא איך לשתף מידע בין הפקדים. ל jQuery היתה תשובה קלה: כל פקד (פלאגין בשפתם) ידביק את עצמו ל DOM Element עליו הוא פועל, וכך פקד אחר יוכל לפנות לאותו DOM Element לקבל מידע. גישה זו מניחה שהאלמנטים עצמם תמיד מופיעים ב DOM וזמינים לכולם כדי שנוכל להאזין לאירועים עליהם או לקרוא מהם מידע.
ברגע שרוצים ללכת קצת יותר רחוק עם הארכיטקטורה ולהגביל את הגישה לאלמנט רק לפקד שאכן מנהל את האלמנט – וזה חשוב לצורך תחזוק ממשקים טובים בין הפקדים – נדרשת שיטה אחרת להעביר מידע ולתקשר בין הפקדים.
הגישה הראשונה שספריות צד-לקוח הציעו היא תקשורת באמצעות פקד-אב משותף: רכיב אחד ששני הפקדים מכירים מאחר והוא זה שיצר אותם. בדוגמת ה Spoiler Alerts נניח שנרצה להוסיף פקד שיראה כמה Spoiler Alerts פתוחים בעמוד. בגישת פקד האב המשותף נצטרך לייצר שני פקדים נוספים, האחד יציג את מספר התיבות שנחשפו והשני ייצור את כל התיבות ואת אותו פקד תצוגה:
שימו לב לקוד המעניין מתוך קוד פקד האב המשותף:
constructor (el) {
// ...
for (el of spoilerElements) {
this.spoilers.push(new SpoilerAlert(el, this));
}
this.displayPanel = new DisplayPanel(displayDiv, this);
}
הבנאי גם יוצר את האלמנטים הפנימיים וגם מעביר אליהם קישור לאותו אב משותף שישמש לתקשורת בהמשך. לגישה זו יש יתרון בולט על השמוש הפשוט בהרבה ש jQuery עשתה ב DOM כדי לשתף מידע: ניתקנו את התלות הישירה בין שני האלמנטים. החליפו את רכיב ה SpoilerAlert ברכיב אחר שיודע לקבל רכיב-אב בבנאי ולהודיע לו על שינויים, ופקד התצוגה ימשיך לעבוד ולהציג את הערכים הנכונים.
אפילו יותר טוב, במקום להעביר את ה parent עצמו לפקדי הילדים, העבירו רק את הפונקציות ממנו שהם צריכים. לפקד ה display שלחו אוביקט מידע שכולל כמה ספוילרים נחשפו וכמה ספוילרים סך הכל יש בעמוד, ולפקד הספוילר שלחו רק את הפונקציה אתה הוא יכול להודיע שנחשף. הנה הקוד המעודכן:
class DisplayPanel {
constructor (el, data) {
this.el = el;
this.data = data;
this.update();
}
update () {
var { revealed, total } = this.data;
this.el.innerHTML = `Revealed boxes: ${revealed} / ${total}`;
}
}
class SpoilerAlert {
constructor (el, notifyCallback) {
this.el = el;
this.el.innerHTML = `Spoiler Alert <button>Click To Reveal</button>`;
this.el.querySelector('button').addEventListener('click', this.reveal.bind(this));
this.spoilerText = el.dataset.text;
this.notifyCallback = notifyCallback;
}
reveal (ev) {
this.el.textContent = this.spoilerText;
this.notifyCallback();
}
}
class ParagraphWithSpoilers {
constructor (el) {
const displayDiv = document.createElement('div');
displayDiv.classList.add('display');
el.insertBefore(displayDiv, el.firstChild);
const spoilerElements = Array.from(el.querySelectorAll('.spoiler'));
this.spoilers = [];
for (el of spoilerElements) {
this.spoilers.push(new SpoilerAlert(el, this.notifyReveal.bind(this)));
}
this.total = this.spoilers.length;
this.revealed = 0;
this.displayPanel = new DisplayPanel(displayDiv, this);
}
notifyReveal () {
this.revealed += 1;
this.displayPanel.update();
}
}
new ParagraphWithSpoilers(document.querySelector('p'));
פקדי אב-משותף גם מהווים את הבסיס לחיבור חזק יותר בין הרכיבים השונים בעמוד. באמצעותם אפשר לצמצם את מספר העוגנים ב HTML וגם את קוד ה main של התוכנית. במקום ליצור את כל הרכיבים של העמוד ניצור רק מספר רכיבי-אב ראשיים והם כבר יצרו את היררכית הפקדים שתחתם.
יתרון נוסף של כתיב זה הוא היכולת לשלוט איזה פקדי-ילדים יוצגו בכל רגע על המסך. קחו לדוגמא רכיב של טאבים שבכל רגע צריך להציג טאב פעיל אחר. שורת הטאבים תהיה פקד-אב משותף וכל טאב יהיה פקד בפני עצמו שידע להציג או להסתיר את עצמו. מבחינת העולם החיצון יצרנו פקד יחיד (פקד הטאבים הראשי), אך פקד זה נעזר במספר תתי-פקדים ומציג רק אחד מהם בכל רגע על המסך. פעולות משתמש יכולות להביא לשינוי הפקד המוצג על המסך, וכך יוצרים תחושה של אינטרקציה ושינוי בלי לעזוב את העמוד. בג'ימייל למשל אפשר לחשוב על פקד התוכן המרכזי ככזה פקד-אב, בתוכו יש לפעמים רשימת הודעות ופעמים אחרות תצוגת הודעה ספציפית.
באנגולר1 פקדי-אב כאלו נקראים Controllers, אבל ההפרדה בין Controllers ל Directives לא ממש מתחברת, ולכן עם הפיתוח של אנגולר והצעידה לכיוון גירסא 2 כתיב ה Directives הפך מרכזי יותר עד שבאנגולר2 נמחקו לחלוטין ה Controllers.
לעומתה React וגם Backbone לא כללו הפרדה זו. כל פקד יכול להחזיק תתי-פקדים ותמיד אפשר להשתמש בפקד כדי לתקשר בין תתי הפקדים שהוא יוצר.
3. שתוף מידע בין רכיבים באמצעות Service
שיתוף המידע באמצעות אב-משותף עובד כשמדובר בפקדים קרובים מבחינת מיקומם בהיררכית ה HTML, כלומר כשאפשר ליצור אותם מתוך אותו הפקד. אבל מה לגבי פקדים שמופיעים או נעלמים מהמסך וכך חיים בזמנים שונים? או שנמצאים במקומות מרוחקים בהיררכיה ולייצר עבורם פקד אב-משותף יהיה מסורבל?
גישה שניה לתקשורת בין פקדים היא ה Service, או איזשהו רכיב Singleton שכל הפקדים מכירים אותו ויכולים לשמור עליו מידע ולקבל ממנו עדכונים. קחו לדוגמא את החלק במערכת שמגדיר העדפות משתמש: זהו מידע שמשפיע על הרבה פקדים המפוזרים בכל המערכת, יכול להתעדכן מכל מיני מקומות וצריך להיות משותף ואחיד בכל הפקדים המשתמשים בו.
ב Flux ה Stores לקחו תפקיד זה (עם איחוד שלהם ב Redux), ובאנגולר אלו ה Services. אבל האמת היא ש JavaScript מאפשרת הגדרה של משתנים יחידניים גלובליים בצורה קלה מאוד, למשל מודול העדפות משתמש המוגדר בקוד הבא בקובץ user_preferences.js:
class UserPreferences {
constructor () {
this.data = new Map();
}
setFavoriteColor (color) {
this.data.favoriteColor = color;
}
getFavoriteColor () {
return this.data.favoriteColor || 'blue';
}
}
export default new UserPreferences();
לאחר מכן כל קובץ אחר בתוכנית שצריך גישה ל UserPreferences יכול לבצע import ולקבל את אותו המופע:
import userPreferences from 'user_preferences';
console.log(userPreferences.getFavoriteColor());
שירותים גלובליים כאלו, בין אם נקראים Stores, Services או פשוט אוביקטים, הם הבסיס לשיתוף מידע נושאי בין פקדים שונים באפליקציה. חשבו על גישה לפרטי המשתמש ב Github: זה לא משנה איזה פקד באפליקציה צריך את המידע, ומצד שני מספר פקדים או מספר מופעים של אותו פקד תמיד צריכים את אותו המידע.
4. דפים ביישום
יש לנו עמוד מתפקד המורכב מפקדים, ויש לנו היררכיה בין פקדים אלו. המידע של היישום נשמר כמאפייני מידע של הפקדים ובנוסף כמאפיינים של אוביקטים גלובלים שכל הפקדים יכולים לגשת אליהם. עמוד זה הינו דף באפליקצית ה web שלנו.
יש אפליקציות שלעולם לא משנות את הדף הפעיל. יישום Google Docs לדוגמא תמיד מראה לנו מסך עריכת מסמך וכל השינויים קורים במסך זה. לעומת זאת בפייסבוק המעבר מהפיד לצפיה בדף של קבוצה משנה המון דברים על העמוד ואף את שורת הכתובת של הדף, כך שרענון הדף יוביל לטעינת המיקום החדש.
מבחינה טכנולוגית אין הכרח שיישום הבנוי ממספר דפים באמת יטען מחדש את כל תוכן ה HTML, ורבים (כולל אתר זה) לא עושים זאת כדי לשפר ביצועים. מבחינה רעיונית אני משתמש במילה דף לציין משאב המיוצג על ידי שורת כתובת מסוימת ושאפשר להגיע אליו ישירות בטעינה מחדש של אותה שורת כתובת.
בראיה הקלאסית של הרשת כל דף מיוצג על ידי קובץ HTML נפרד שמגיע מהשרת. לרוב לא מדובר בקובץ שנשמר על מערכת הקבצים של השרת, אלא במידע שמיוצר באופן דינמי ברגע שגולש מפנה את הדפדפן לכתובת של אתר. מידע זה מורכב מבסיס HTML, קבצי CSS וקבצי JavaScript. כשהמידע מגיע ללקוח הדפדפן מחבר את החלקים ומריץ את ה JavaScript כדי לבנות את הפקדים.
מעבר דף אם כך נגרם כתוצאה מניווט (לחיצה על קישור או ישירות מקוד JavaScript) לעמוד אחר. מעבר העמוד מאפס את כל זכרון ה JavaScript והדף החדש מתחיל מאפס ובונה את כל הפקדים שלו מההתחלה.
במידה ושני דפים צריכים לשתף מידע ניתן להשתמש ב local storage או לשמור מידע זה בצד השרת וכך לקבל אותו ישירות לתוך ה HTML במעבר לדף הבא.
ניתוב צד-שרת הוא הבחירה הקלה יותר ברוב המקרים מאחר והוא מאוד מפשט את הארכיטקטורה של היישום. כל דף כולל קוד main מסוים שיוצר את הפקדים הרלוונטים לאותו הדף, שינויים בעמוד שמשאירים את הגולש באותו הדף מתבצעים באמצעות תקשורת בין הפקדים הקיימים, ושינויים שמחייבים מעבר לדף אחר מביאים לטעינה מחדש והרצת קוד ה main של הדף החדש.
ביישום כזה נשתדל לשלוח את כל המידע כחלק מעמוד ה HTML הראשוני שנשלח לגולש, ונאפשר לקוד ה JavaScript על העמוד לשלוח בקשות Ajax עבור מידע ספציפי התלוי בפעולות גולש (לדוגמא חיפוש טיסה לפריז ישלח בקשת Ajax לשרת שתחזיר את רשימת כל הטיסות). הקוד עבור בקשות Ajax אלו יכול להכתב בקוד הפקדים או באחד ה Services.
קוד ה HTML הבא יעזור להמחיש את הרעיון. בהנחה שקיבלנו אותו מעמוד בשם home.php ושקיים פקד בשם HomePage בקוד ה JavaScript שיודע להציג את המידע של דף הבית וליצור תתי-פקדים לפי הצורך כל מה שצריך כדי לקבל את עמוד הבית הוא שורות שנראות בערך כך:
<!DOCTYPE html>
<html
<body>
<main></main>
<script src="dist/app.js"></script>
<script>
window.root = new HomePage(document.querySelector('main'));
</script>
</body>
</html>
והקובץ app.js כבר כולל את כל קוד ה JavaScript של היישום מוכן להפצה לאחר איחוד קבצי המקור השונים באמצעות webpack או כלי מקביל.
5. ניתוב צד-לקוח
החסרון בניתוב צד-שרת הוא שבכל מעבר דף יש צורך לטעון מחדש את כל משאבי העמוד. כשמדובר בעמודי Web כבדים או שנדרש הרבה קוד JavaScript לצורך אתחולם סיכוי טוב שנרצה להימנע מזה. דרך מקובלת להימנע מניתוב צד-שרת היא להתיחס לכל היישום שלנו כאל פקד ראשי יחיד ולתוכן שלו כאל תתי-פקדים. כך מעבר עמוד הוא בסך הכל החלפה של הפקד שכרגע מוצג על המסך.
מאחר ודפדפנים תומכים בשינוי שורת הכתובת באופן תכנותי באמצעות History API, מתכנתים החלו להשתמש בסכימה הבאה לניתוב צד-לקוח:
- נגדיר קוד טיפול כללי לאירועי click שיבדוק אם הלחיצה היתה על אלמנט a שה href שלו הוא פנימי לאתר.
- בכל לחיצה כזו נזהה מהו ה href שנלחץ ונחפש בטבלא שהוגדרה מבעוד מועד (נקראת טבלת ניתוב) איזה פקד יש להציג עבור הכתובת.
- נודיע לפקד הראשי שלנו שהיה שינוי כתובת ועליו להציג כעת את הפקד המתאים לכתובת החדשה.
- נשנה את הכתובת בשורת הכתובת של הדפדפן באמצעות history.pushState
בנוסף, נצטרך לוודא שקוד צד השרת יודע לטפל בכל הכתובות שאנו מגדירים בצד הלקוח, כך שכשגולש יטען מחדש את העמוד הוא יקבל את אותו העמוד אליו הגיע דרך ניתוב צד הלקוח.
בניית ניתוב צד-לקוח ליישום מורכבת משמעותית מניתוב צד-שרת. היא דורשת פיתוח קוד מסונכרן בצד השרת ובצד הלקוח, והרבה יותר לוגיקה בעת מעבר העמודים. הספריות Backbone, Angular, Embed ו Durandal (וכמובן אורליה שהחליפה אותה) מציעות כולן פתרונות ניתוב צד-לקוח כחלק מקוד הספריה. ריאקט מפורסמת בגישתה להשמיט את רכיב הניתוב מה שהפך את הספריה react-router למאוד פופולרית בקרב מתכנתי ריאקט.
בנוסף יש המון ספריות ייעודיות לנושא ניתוב צד-לקוח. הספריה angular-ui-router הציעה פתרון ניתוב משוכלל יותר לאנגולר1, וכאן באתר אני משתמש בספריה nighthawk שהיא גירסת דפדפן לספרית הניתוב של שרת express.
מבחינת המתכנת ניתוב צד-לקוח יחייב אתכם להגדיר בקוד ה JavaScript מיפוי בין שורת כתובת לקוד להרצה כשנכנסים לשורת כתובת מסוימת. בהנחה שיש לכם פקדים עבור כל אחד מהדפים הקוד הבא ממחיש איך נראה ניתוב צד-לקוח (דוגמא מתוך ספרית nighthawk):
var router = require('nighthawk')();
// Register your routes
router.get('/', function(req, res) {
window.root = new HomePage(document.querySelector('main'));
});
router.get('/about, function(req, res) {
window.root = new AboutPage(document.querySelector('main'));
});
router.listen();
6. יתרונות ארכיטקטורה מבוססת פקדים
ההבדל המרכזי בין ארכיטקטורה מבוססת פקדים לארכיטקטורה שמורכבת מפונקציות גלובליות הוא היכולת שלנו לבודד קטעי קוד ולהשתמש בהם בהקשרים שונים. יכולת זו היא שמאפשרת להגדיר פעם אחת רכיב של תפריט עליון, שורת טאבים, נגן וידאו מותאם אישית או כל רכיב אחר שאתם צריכים ליישום שלכם.
כשלוקחים את כתיב הפקדים עד לקצה, אפשר לבנות על בסיסו ארכיטקטורה מלאה בה כל מה שמוצג על המסך הוא פקד, וכל המידע שעובר בין הפקדים עובר דרך פקדים אחרים או דרך אוביקטים ביישום שלכם. את שני סוגי הדברים אפשר לכתוב בצורה סגורה, להחליף במימוש מתקדם יותר בהמשך (אם שמרתם על הממשק) וכמובן לבדוק במנותק משאר היישום.
ארכיטקטורה כזו דורשת יותר קוד לתוכניות פשוטות, אבל מאפשרת גדילה טובה יותר של הקוד וקל יותר לסנכרן את העבודה בין מספר מתכנתים.
כל ה Frameworks המודרניות לפיתוח צד-לקוח מיישמות ארכיטקטורה זו במידה כזו או אחרת. הבחירה ב Framework ספציפי (אם בכלל) צריכה להתבצע על סמך נוחות העבודה שלכם עם אותה ספריה. חשוב להבין שההבדלים אינם תהומיים ובסוף כולם באים לפתור את אותן הבעיות ובאותה הדרך. מסיבה זו כדאי גם להיות זהירים אם מתכוונים לבחור Framework כדי לקבל מהמומחים שכתבו אותו את הדרך הנכונה לפתור דברים. אין דרך נכונה. בחרו את הכלים שעובדים הכי טוב ליישום שלכם.