• בלוג
  • ריאקטיביות ו JavaScript הן שילוב מסוכן

ריאקטיביות ו JavaScript הן שילוב מסוכן

05/08/2021

בשנים האחרונות אי אפשר להתעלם מהפופולריות של Frontend Frameworks ריאקטיביות. הרעיון הבסיסי הוא שבעוד שבתכנות "רגיל" כשאנחנו שמים תוצאה של ביטוי בתוך משתנה אז המשתנה מקבל את הערך החדש וזהו. אם בעתיד חלק מהאופרנדים בביטוי ישתנו זה לא ישפיע על אותו משתנה. בתכנות ריאקטיבי אנחנו שמים בתוך משתנה "ביטוי" וערכו של הביטוי מתעדכן אוטומטית כל פעם שהחלקים שלו מתעדכנים.

כדי לראות את הקושי במימוש מנגנון ריאקטיבי ב JavaScript נתבונן בקטע הקוד הבא:

const input = document.querySelector('input');
const output = document.querySelector('output');

output.value = input.value;

זה קוד לא ריאקטיבי שלוקח ערך של שדה קלט input אחד וכותב אותו לשדה output. כל מה שכתוב ברגע ריצת הקוד בשדה הראשון מועתק לשדה השני, אבל אם התוכן ישתנה בעתיד אז לא יהיה לנו שינוי מקביל בתיבת הטקסט השניה.

גירסה "ריאקטיבית" של הקוד היתה צריכה להמשיך ולעדכן את output כל פעם ש input מתעדכן. עכשיו אנחנו יכולים לכתוב קוד שיעשה את זה בעזרת אירועים:

const input = document.querySelector('input');
const output = document.querySelector('output');

input.addEventListener('input', () => {
    output.value = input.value;
});

אבל לא היינו קוראים לגירסה המפורשת עם האירועים "ריאקטיבית", כי היא מפורשת מדי ומתירנית מדי. בתוך קוד האירוע אפשר לכתוב כל דבר ולא רק לשנות את הערך של output.

החלום של פריימוורקס ריאקטיביים ב JavaScript הוא לאפשר מעבר אוטומטי ושקוף מגירסת הביטוי לגירסת האירועים, בלי שנצטרך לבחור לאיזה אירוע להאזין ובלי שנצטרך למלא בעצמנו את קוד הטיפול באירוע.

התיאוריה של פריימוורק ריאקטיבי פשוטה: אם אני כול להחליף את document.querySelector ולייצר אוביקט שלי שעוטף את ה DOM Element, אני אוכל לזהות מתי מישהו מנסה לקרוא מידע משדה value שלי, ולייצר אירוע שיפעיל את הקוד הזה מחדש כל פעם שערך השדה מתעדכן. בפועל הפריימוורקים שבנו מנגנון כזה מסתבכים כל אחד במקרי קצה אחרים. הנה שתי דוגמאות פשוטות מ Vue ו MobX ואחריהן סיכום עם המלצה.

1. מובאקס, ריאקט ו Render Props

מובאקס הוא פרייומורק ריאקטיבי אוטומטי לניהול סטייט. זה אומר שהוא יודע לעזור לנו לכתוב ביטויים שיתעדכנו בזמן אמת כשהחלקים שלהם מתעדכנים. הנה קצת קוד לדוגמה כדי שנבין על מה מדובר:

import { makeObservable, observable, computed, action } from "mobx";

export default class ObservableText {
  text = "";

  constructor(initialValue) {
    this.text = initialValue;

    makeObservable(this, {
      text: observable,
      reset: action
    });
  }

  reset() {
    this.text = "";
  }
}

const x = new ObservableText("hello");
const y = computed(() => x.text.toUpperCase());

console.log(x.text);
console.log(y.get());
x.text = "bye bye";
console.log(y.get());

הגדרתי מחלקה בשם ObservableText ויצרתי ממנה אוביקט בשם x. לאחר מכן הגדרתי ש y יהיה התוצאה של x.text.toUpperCase().

ארבעת השורות האחרונות הן המעניינות: אני מדפיס את x, מדפיס את הערך מתוך y, אחר כך משנה את x ומדפיס מחדש את הערך מתוך y. תוצאת ההדפסה:

hello 
HELLO 
BYE BYE 

ו-הנה הריאקטיביות שלנו. y הוגדר להיות תוצאה של ביטוי, וכשאחד החלקים של הביטוי מתעדכן באופן אוטומטי גם y חושב מחדש. צריך להגיד: זה לא שב y היתה שמורה הפונקציה והיא מופעלת מחדש כל פעם שאנחנו קוראים ל get. הפונקציה עצמה שהופכת את x לאותיות גדולות נקראת כל פעם ש x מתעדכן, ולא כל פעם שאנחנו קוראים את הערך מ y. זה הקסם הריאקטיבי.

אז איפה פה הבעיה? הבעיות מתחילות במילה computed, או יותר מדויק בעובדה שאנחנו חייבים להגיד ל MobX כשאנחנו בונים ביטוי ריאקטיבי.

הנה דוגמה מתוך התיעוד של MobX:

    async fetchProjects() {
        this.githubProjects = []
        this.state = "pending"
        try {
            const projects = await fetchGithubProjectsSomehow()
            const filteredProjects = somePreprocessing(projects)
            runInAction(() => {
                this.githubProjects = filteredProjects
                this.state = "done"
            })
        } catch (e) {
            runInAction(() => {
                this.state = "error"
            })
        }
    }

הפונקציה fetchProjects משנה את המשתנים this.githubProjects ו this.state, שהם משתנים ריאקטיביים. לכן את השינוי חייבים לכתוב בתוך פונקציית computed. בעוד שהפונקציה הראשית fetchProjects מזוהה בלי בעיה בצורה אוטומטית על ידי מובאקס ומקבלת את הריאקטיביות, שתי השורות האחרונות בבלוק ה try:

this.githubProjects = filteredProjects
this.state = "done"

כתובות בתוך בלוק אסינכרוני אחרי המילה await ולכן לא מזוהות בצורה אוטומטית. בלי העטיפה ב runInAction הקוד לא היה עובד.

כשמגיעים לחבר את MobX לקוד ריאקט המילה computed הופכת לפונקציה בשם observer. רק קומפוננטות שמוגדרות עם הפונקציה הזאת יהיו ריאקטיביות ורק קוד שמוקף בפונקציה הזאת יתעדכן בזמן אמת.

ביום רגיל מספיק להפעיל את הפונקציה observer על הקומפוננטה שמשתמשת במשתנים ריאקטיביים כדי שהקסם הריאקטיבי יעבוד. אבל בחיים האמיתיים אין ימים רגילים. שימו לב לקוד הבא מתוך שאלה שהופיעה ב Github Issues של הפרויקט:

// Login.js
export class Login extends Component {
    render() {
        return (
            <Formik
                initialValues={{
                    email: '',
                    password: ''
                }}
                onSubmit={this.handleSubmit}
                render={props => {
                    return (
                        <div>
                            <ActionButton onClick={props.submitForm} disabled={this.props.store.inProgress}>
                                    Save
                            </ActionButton>
                            <LoginForm {...props} />
                        </div>
                    )
                }}
            />
        );
    }

    handleSubmit(credentials) {
        this.props.store.login(credentials)
    }
}

export default inject('store')(observer(Login));

הקומפוננטה Login היא Observer ולכן כל התוכן שלה אמור להיות ריאקטיבי. אבל כשמסתכלים טוב רואים שהקומפוננטה יוצרת קומפוננטה אחרת בשם Formik ומעבירה לה חלק מהקוד באמצעות תבנית שנקראת Render Props. בגלל ש Formik בעצמה איננה observer, הריאקטיביות לא ממשיכה פנימה לתוך קוד ה JSX שעובר ל render ולכן הקומפוננטה לא מתעדכנת כשיש שינוי במאפיינים שהיא מסתכלת עליהם.

אפילו אם הקומפוננטה Formik היתה Observer זה לא בטוח היה פותר את הבעיה כי אולי Formik לוקחת את הפונקציה הפנימית (שהעברתי לה בתוך render) ושולחת אותה לקומפוננטה אחרת. התיקון האמיתי הוא לזהות את התבנית הזאת ולהקיף את הקוד הפנימי ב Observer בעצמי - בדומה לעטיפה ב computed שראינו בדוגמה הקטנה. זה נראה כך:

export class Login extends Component {
    render() {
        const { inProgress } = this.props.store // this line causes re-render of ActionButton

        return (
            <Formik
                initialValues={{
                    email: '',
                    password: ''
                }}
                onSubmit={this.handleSubmit}
                render={props => {
                    return (
                        <Observer>
                        {() => (
                            <div>
                                <ActionButton onClick={props.submitForm} disabled={inProgress}>
                                        Save
                                </ActionButton>
                                <LoginForm {...props} />
                            </div>
                        )}
                    )
                }}
            />
        );
    }

    handleSubmit(credentials) {
        this.props.store.login(credentials)
    }
}

export default inject('store')(observer(Login));

בעוד שיחסית קל לראות את הדברים האלה כשקוראים את התיעוד או מדפדפים בין שאלות בגיטהאב, כשקוד אמיתי מפסיק להגיב כמו שצריך זה כבר הרבה יותר קשה לזהות את התבנית.

2. ריאקטיביות ב Vue

פריימוורק ריאקטיבי נוסף פופולרי הוא Vue שעובד בצורה מאוד דומה. הנה דוגמה לקוד ריאקטיבי ב Vue מתוך התיעוד שלהם:

const count = ref(1)
const plusOne = computed(() => count.value + 1)

console.log(plusOne.value) // 2

count.value += 1;
console.log(plusOne.value) // 3

אז שוב יש לנו את ה computed המפורסם ולמרות ש Vue קצת יותר טוב מ MobX בלזהות מתי קוד רץ עדיין אפשר למצוא מקרי קצה (למשל כאן). אבל אני רוצה להתעכב כאן על רעיון אחר של Vue ששונה מ MobX, והוא הרקורסיביות של הריאקטיביות.

ב Vue אם יש לי ביד אוביקט ריאקטיבי זה לא הופך את השדות שלו לריאקטיביים, וזה מייצר אינספור מצבים מעניינים. הקוד הבא מהחלק הרביעי של מדריך Vue שפרסמתי כאן ממחיש את הבעיה:


export default {
  props: ["id"],

  setup(props, context) {
    const { id } = toRefs(props);

    const { data, error, clear } = useSWR(
      () => `https://pokeapi.co/api/v2/pokemon/${id.value}`
    );

    watch(id, () => {
      clear({ broadcast: true });
    });

    return {
      data,
      error,
    };
  },
};

הקוד מושך קוד משרת מרוחק לפי מאפיין id שמתקבל בתור Property. כל הקוד הזה רץ בתוך computed, אבל העתקת שדה מהאוביקט props, שהוא בעצמו ריאקטיבי, מבטלת את הריאקטיביות של השדה. במילים אחרות הקוד עובד בזכות הפונקציה toRefs בשורה:

const { id } = toRefs(props);

שהופכת את כל השדות בתוך props לריאקטיביים בעצמם. אם בטעות נכתוב במקומה את השורה:

const { id } = props;

הקוד ייקח את id פעם אחת מ props אבל לא ימשיך לזהות שינויים בו, כי הוא מאבד את הריאקטיביות.

3. סיכום - למה זה כל כך קשה

אותו המכנה המשותף חוזר בכל הדוגמאות שראינו: הפער בין ציפיית המתכנתים לבין הפרטים הטכניים של הפריימוורק. מצד אחד פריימוורק ריאקטיבי אוטומטי מבטיח שכל עוד נעמוד בתנאים שלו הוא יצליח לזהות את כל השינויים ולבנות לבד ביטויים ריאקטיביים מהקוד שלנו, מצד שני התנאים הרבה יותר מסובכים ממה שהם מספרים בפירסומת וכשאנחנו עוברים מתוכניות דוגמה קטנות לעולם האמיתי אנחנו נתקלים בכל מקרי הקצה האלה, וקשה להבין מה בדיוק קורה ולמה דברים לא עובדים כמו בפירסומת.

בעבודה עם פריימוורקים ריאקטיביים אנחנו כל הזמן צריכים להשאיר בראש את רשימת התנאים הריאקטיביים של הפריימוורק, ולהבין אם עשינו משהו ששובר את אחד החוזים. האם עשינו פעולה מחוץ לקוד שנמצא במעקב? האם העתקנו ערך במקום פרוקסי? או ששברנו את הריאקטיביות בדרך יצירתית אחרת. הצורך לשמור בראש כל הזמן את מערכת החוקים הנסתרת של הפריימוורק הוא שהופך את ה Debugging להרבה יותר מסובך.