מדריך Vue למתחילים - חלק 4 - ממשק ההרכבות (Composition API)

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

  1. חלק 1 - קומפוננטה ראשונה

  2. חלק 2 - העברת מידע בין קומפוננטות

  3. חלק 3 - תבניות דינמיות

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

1. מהו שיתוף קוד בין קומפוננטות

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

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

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

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

2. איך זה עובד

בשביל להשתמש ב Composition API אנחנו צריכים להגדיר פונקציה אחת בשם setup. פונקציה זו מחזירה אוביקט עם כל המאפיינים שיצטרפו למאפיינים שמוחזרים מ data ובהם נוכל להשתמש בתבנית. אבל זה לא נגמר שם. הפונקציה יכולה להתחבר לאירועים שונים של הקומפוננטה ולהוסיף קוד שיקרה למשל כשהקומפוננטה מופיעה על המסך בפעם הראשונה או כשהקומפוננטה מתעדכנת.

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

נתבונן בקומפוננטה הבאה:

<template>
  <div>
    <label
      >Type some stuff:
      <input v-model="text" />
    </label>
    <p>You typed: {{ text }}</p>

    <button @click="inc">{{ clicks }}</button>
  </div>
</template>

<script>
import { ref } from "vue";

export default {
  name: "HelloWorld",
  setup() {
    const clicks = ref(0);
    function inc() {
      clicks.value += 1;
    }

    return {
      clicks,
      inc,
    };
  },
  data() {
    return {
      text: "",
    };
  },
};
</script>

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

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

    const clicks = ref(0);
    function inc() {
      clicks.value += 1;
    }

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

הפונקציה setup מחזירה את השדה והפונקציה שמעדכנת אותו, ובקוד התבנית אני פשוט משתמש במשתנה ובפונקציה כאילו הם הוגדרו בבלוק data ו methods:

    <button @click="inc">{{ clicks }}</button>

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

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

import { ref } from "vue";

export default function counter() {
  const clicks = ref(0);
  function inc() {
    clicks.value += 1;
  }

  return {
    clicks,
    inc
  };
}

את קוד הקומפוננטה ארגנתי מחדש לקוד הבא:


<template>
  <div>
    <label
      >Type some stuff:
      <input v-model="text" />
    </label>
    <p>You typed: {{ text }}</p>

    <button @click="inc">{{ clicks }}</button>
  </div>
</template>

<script>
import counter from "../concerns/counter";

export default {
  name: "HelloWorld",
  setup() {
    return {
      ...counter(),
    };
  },
  data() {
    return {
      text: "",
    };
  },
};
</script>

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


<template>
  <div>
    <label
      >Type some stuff:
      <input v-model="text" @input="inc" />
    </label>
    <p>You typed: {{ text }}</p>
    <p>Text was changed {{ clicks }} times</p>
  </div>
</template>

<script>
import counter from "../concerns/counter";

export default {
  name: "CountChanges",
  setup() {
    return {
      ...counter(),
    };
  },
  data() {
    return {
      text: "",
    };
  },
};
</script>

מוזמנים לשחק עם קוד הפרויקט בקישור: https://codesandbox.io/s/focused-pasteur-i5864

או בתיבה הבאה:

3. דוגמה: תוספת מנגנון זמן לקומפוננטה

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

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

אוסף הפונקציות שמאפשרות לנו "לעשות דברים" כש"קורים דברים" ב Vue נקראות פונקציות מחזור חיים, ואפשר למצוא את הרשימה המלאה שלהן בקישור: https://v3.vuejs.org/guide/composition-api-lifecycle-hooks.html.

נוסיף לפרויקט קובץ בשם concerns/timer.js עם התוכן הבא:

import { onUnmounted, ref } from "vue";

export default function timer() {
  const ticks = ref(0);

  const timer = setTimeout(function () {
    ticks.value += 1;
  }, 1000);

  onUnmounted(function () {
    clearTimeout(timer);
  });

  return {
    ticks
  };
}

עכשיו אני יכול להשתמש בו בתוך קומפוננטה כדי לקבל את ההתנהגות של "עושה משהו כל שניה", לדוגמה הקומפוננטה הבאה פשוט מציגה את השעון המתקתק:

<template>
  <div>
    <p>Ticks: {{ ticks }}</p>
  </div>
</template>

<script>
import timer from "../concerns/timer";

export default {
  setup() {
    return {
      ...timer(),
    };
  },
};
</script>

והקומפוננטה הבאה מציגה משפטים שמתחלפים כל שתי שניות:

<template>
  <div>
    <p>{{ currentText }}</p>
  </div>
</template>

<script>
import timer from "../concerns/timer";
const texts = [
  "All work and no play",
  "Make jack a dull boy",
  "The brown fox jumped",
  "Over the yellow dog",
];

export default {
  setup() {
    return {
      ...timer(),
    };
  },
  data() {
    return { x: 10 };
  },

  computed: {
    currentText() {
      return texts[Math.floor(this.ticks / 2) % texts.length];
    },
  },
};
</script>

אפשר לראות את שתיהן בקישור: https://codesandbox.io/s/brave-kepler-5l3h9

או בתיבה הבאה:

4. דוגמה: משיכת מידע משרת חיצוני מתוך קומפוננטה

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

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

<template>
  <p>Fetching {{ id }}</p>

  <div v-if="isLoading">Loading ...</div>

  <div v-else-if="data">
    <p><b>ID: </b>{{ id }}</p>
    <p><b>Name: </b>{{ data.name }}</p>
    <p>
      <b>Abilities:</b
      >{{ data.abilities.map((a) => a.ability.name).join(", ") }}
    </p>
  </div>
  <div v-else-if="error">
    {{ error }}
  </div>
</template>

<script>
import { toRefs, watch } from "vue";
import { useSWR } from "vswr";

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,
    };
  },
};
</script>

בדיוק כמו עם הפונקציות שאני כתבתי, גם useSWR מחזירה משתנים ריאקטיביים אותם אני שותל בתוך האוביקט ש setup מחזירה וממשיך להשתמש בהם מתוך התבנית או הפונקציות האחרות של הקומפוננטה. במקרה של useSWR אני מעביר לפונקציה נתיב ומקבל חזרה את המשתנים data, error ואת הפונקציה clear (ועוד כמה משתנים שתוכלו למצוא בתיעוד הספריה). הספריה תעדכן במועד מאוחר יותר את המשתנים כדי לשקף את המידע שהגיע מהשרת או השגיאה שהתקבלה.

בואו נדבר על החיבור בין Props ל Setup, כי יש פה הרבה רעיונות חדשים. הפונקציה setup נקראת רק פעם אחת כשהקומפוננטה נוצרת, וזה אומר שהיא קוראת ל useSWR רק פעם אחת. אז איך ויו יודע להוציא בקשת רשת חדשה כש id משתנה?

התשובה נקראת ריאקטיביות והיא היופי וגם הסירבול בקוד הזה:

  1. המשתנה props עובר לפונקציה setup בתור פרמטר ראשון. משתנה זה הוא אוביקט ריאקטיבי, כלומר אפשר להעביר אותו הלאה לפונקציות נוספות והן יוכלו "להאזין" לשינויים בו.

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

  3. הפונקציה toRefs, שאנחנו נשתמש בה בכל התחלה של פונקציית setup שצריכה לעבוד עם props, הופכת כל אחד מהשדות ב props למשתנה ריאקטיבי נפרד. הכתיב:

const { id } = toRefs(props);

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

וממש שתי שורות אחרי זה אני באמת מעביר את id הלאה, ובגלל שיצרתי ממנו אוביקט ריאקטיבי אני צריך להסתכל על שדה value שלו:

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

דרך אגב - בגלל ש id הוא ריאקטיבי אני יכול להשתמש במנגנון של Vue שנקרא watch כדי לזהות כשהוא משתנה. הקטע הבא מאפס את data כל פעם שיש שינוי ב id:

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

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

מוזמנים לשחק עם הקוד המלא בקישור: https://codesandbox.io/s/nervous-pine-4hux3

או בתיבה:

5. עכשיו אתם

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

  2. כתבו פונקציה בשם useToggle שמוסיפה לקומפוננטה שלכם שדה בשם state ופונקציה בשם toggle. כל קריאה לפונקציה משנה את הערך של state מ"אמת" ל"שקר" ולהיפך.

  3. כתבו פונקציה בשם useLocalStorage שמקבלת מפתח ומידע וכותבת את המידע ל Local Storage. אם המידע ישתנה לאורך חיי התוכנית יש לכתוב אותו מחדש ל Local Storage עם אותו מפתח.