הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

ומה אם שתי קומפוננטות מקשיבות לאותו SSE ?

04/05/2024

כשהייתי צריך לכתוב קומפוננטת ריאקט שמקשיבה ל Server Sent Events ומעדכנת את התצוגה לפי האירוע שמגיע האינטואיציה הראשונה שלי היתה להוסיף אפקט:

useEffect(() => {
  const evtSource = new EventSource("/notifications");
  const handler = (e: MessageEvent) => {
    setPosts(p => [...p, JSON.parse(e.data)]);
  };
  evtSource.addEventListener('message', handler);

  return () => {
    evtSource.close();
  }
}, []);

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

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

דרך אחת לבנות State גלובאלי לאפליקציה היא לשלב פריימוורק כמו רידאקס (או Jotai או preact-signals או zustand או אפילו מובאקס). פריימוורק לניהול סטייט מכריח אותי לכתוב את הקוד שמתחבר ל SSE Endpoint ולהחליט איפה יישמר המידע ומתי מפסיקים להקשיב לאירועים.

דרך שנייה לבנות State גלובאלי היא לשים את המידע ב Context ולמשוך אותו דרך Hook. הרבה פעמים נראה את הפיתרון הזה ב Hooks מוכנים, למשל use-event-source. אני מדביק כאן את הקוד שלו כי זה מעניין:

import { createContext, useContext, useEffect, useState } from "react";

export interface EventSourceOptions {
    init?: EventSourceInit;
    event?: string;
}

export type EventSourceMap = Map<
    string,
    { count: number; source: EventSource }
>;

const context = createContext<EventSourceMap>(
    new Map<string, { count: number; source: EventSource }>(),
);

export const EventSourceProvider = context.Provider;

/**
 * Subscribe to an event source and return the latest event.
 * @param url The URL of the event source to connect to
 * @param options The options to pass to the EventSource constructor
 * @returns The last event received from the server
 */
export function useEventSource(
    url: string | URL,
    { event = "message", init }: EventSourceOptions = {},
) {
    let map = useContext(context);
    let [data, setData] = useState<string | null>(null);

    useEffect(() => {
        let key = [url.toString(), init?.withCredentials].join("::");

        let value = map.get(key) ?? {
            count: 0,
            source: new EventSource(url, init),
        };

        ++value.count;

        map.set(key, value);

        value.source.addEventListener(event, handler);

        // rest data if dependencies change
        setData(null);

        function handler(event: MessageEvent) {
            setData(event.data || "UNKNOWN_EVENT_DATA");
        }

        return () => {
            value.source.removeEventListener(event, handler);
            --value.count;
            if (value.count <= 0) {
                value.source.close();
                map.delete(key);
            }
        };
    }, [url, event, init, map]);

    return data;
}

ה Hook יוצר מפה גלובאלית של Event Listeners שמקשיבים לאירועים לפי ה URL. כל פעם שקומפוננטה צריכה להתחבר ל SSE Endpoint הנתיב מצטרף למפה ואם הוא כבר קיים שם אז ערך ה count עולה ב-1. כשאף קומפוננטה לא מקשיבה לאירועים ה SSE נסגר ונמחק מהמפה.

השימוש ב Hook נראה כך (מתוך הדוגמה שלהם ב Readme):

// app/components/counter.ts
import { useEventSource } from "remix-utils/sse/react";

function Counter() {
    // Here `/sse/time` is the resource route returning an eventStream response
    let time = useEventSource("/sse/time", { event: "time" });

    if (!time) return null;

    return (
        <time dateTime={time}>
            {new Date(time).toLocaleTimeString("en", {
                minute: "2-digit",
                second: "2-digit",
                hour: "2-digit",
            })}
        </time>
    );
}

עכשיו בחזרה להתלבטות שלנו - האם לנהל את המידע שנשמר בצד לקוח בצורה מובלעת בתוך ה Hooks או בפריימוורק לניהול סטייט צד לקוח מסודר? כמו תמיד בשאלות האלה אין תשובה חד משמעית. הנטייה שלי היא לבחור בפריימוורק צד לקוח כדי לקבל שליטה טובה יותר בתזמונים. בדוגמה של אירועי Server Sent Events קל לדמיין שאני ארצה להמשיך להקשיב לאירועים גם בלי קומפוננטה על המסך. למשל אם קומפוננטה מציגה רשימת עדכונים ומשתמש עובר למסך אחר, אז כשאני חוזר למסך רשימת העדכונים אני אראה שם את העדכונים שנשלחו גם בזמן שהייתי במסך האחר. יחד עם זאת אין ספק שבמקרים פשוטים ה Hook במיוחד כזה שפשוט מדביקים מהאינטרנט חוסך כתיבת קוד ויכול לקצר זמני פיתוח.

מה דעתכם? איזה גישה עבדה לכם טוב יותר?

איך בוחרים בין הגיוני למועיל?

02/05/2024

הטיפוס object ב TypeScript נכנס לשפה בגירסה 2.2 של טייפסקריפט כדי לפתור בעיה. באותו זמן דפדפנים כללו את הפונקציה Object.create שיוצרת אובייקט חדש שהפרוטוטייפ שלו הוא הפרמטר שקיבלה, אבל הפרמטר ש create יכלה לקבל היה חייב להיות משהו שאינו "פרימיטיב" כלומר משהו שאפשר להוסיף לו מפתחות, לדוגמה מערך, פונקציה או אוביקט.

אז עכשיו אנחנו יכולים להגדיר את הטיפוס:

create(o: object | null): any;

ורגע אם כבר הגדרנו את create אפשר להמשיך לרוץ עם אותו רעיון ולהגדיר גם את:

setPrototypeOf(o: any, proto: object | null): any;

ויש עוד כמה הגדרות שאפשר למצוא ב es5.d.ts שהמשותף לכולן הוא ה object | null. בעצם יש רק מקום אחד ש object מופיע בלי החיבור ל null וזה הטיפוס של הפונקציה Object.keys:

keys(o: object): string[];

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

בחזרה לאתגר שלנו - למה null הוא לא חלק מ object? למה להגדיר טיפוס חדש כשכל פעם שמשתמשים בו זה כחלק מהחיבור object | null?

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

typeof null === 'object'

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

היום למדתי: nth-child וקלאס ב CSS

01/05/2024

נניח שיצרתם טבלה עם CSS Grid ועכשיו אתם רוצים לצבוע רק שורה מסוימת - או יותר טוב, להדגיש את הגבול מסביב לשורה מסוימת. בעולם הישן של table היה מספיק למצוא את ה tr שמתאים לשורה ולהגדיר לו גבול, אבל בגריד הטבלה לא כוללת אלמנט tr. כל ה HTML שלה הוא בסך הכל:

<div class='container'>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <div>5</div>
  <div>6</div>
  <div>7</div>
  <div>8</div>
  <div>6</div>
  <div>7</div>
  <div>8</div>
</div>

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

<div class='container'>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div class='selected-row'>3</div>
  <div class='selected-row'>4</div>
  <div class='selected-row'>5</div>
  <div>6</div>
  <div>7</div>
  <div>8</div>
  <div>6</div>
  <div>7</div>
  <div>8</div>
</div>

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

.container .selected-row{
  border-top-color: red;
  border-bottom-color: red;
}

ושני התאים בקצה? אפשר היה לדמיין להשתמש פה ב nth-child(1):

.container .selected-row:nth-child(1) {
  border-left-color: red;
}

אבל זה לא עובד. ה div הראשון עם הקלאס selected-row הוא לא הילד הראשון ולכן כלום לא נצבע. מה עושים? מסתבר שיש ל nth-child טריק כדי לתפוס את הילד ה n-י שמתאים לקלאס מסוים וזה נראה ככה:

.container :nth-child(1 of .selected-row) {
  border-left-color: red;
}

.container :nth-child(3 of .selected-row) {
  border-right-color: red;
}

הדוגמה כאן למי שרוצה לשחק עם זה: https://codepen.io/ynonp/pen/zYXQqmO

מה בעצם אנחנו פוחדים לאבד?

29/04/2024

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

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

דרך אחרת להסתכל על הבעיה היא לשנות את השאלה - במקום לשאול איך אני רוצה לשפר את המצב אני אשאל "מה בעצם אני פוחד לאבד פה?", מה ה Best Case שיקרה אם אשאר?

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

מטלות והזדמנויות

28/04/2024

הנה רשימת הדברים שאתה צריך לעשות בשביל להצליח.

מול-

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

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

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

אם ערך חוזר ואף אחד לא מסתכל עליו, הוא עדיין משמיע רעש?

27/04/2024

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

def twice(x): x * 2

ברובי זה לא היה קורה. הפונקציה הזאת ברובי מחזירה בדיוק את מה שרציתי - כלומר את המספר שקיבלה כפול 2:

def twice(x) = x * 2

אבל אז מגיעה הדוגמה הבאה ששוב מזכירה שקיצורי דרך בתכנות זה נחמד אבל תמיד עדיף להבין בדיוק מה קורה מתחת:

def fib_rb(n)
  a = 0
  b = 1
  n.times { a, b = b, a + b }
  a
end

בדוגמה הזאת הבלוק (הקוד שרץ n פעמים מוקף בסוגריים מסולסלים) בעצם עושה את זה:

def fib_rb(n)
  a = 0
  b = 1
  n.times {
    a, b = b, a + b
    return [b, a + b]
  }
  a
end

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

def fib_rb(n)
  a = 0
  b = 1
  n.times { a, b = b, a + b; nil }
  a
end

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

טיפ ריאקט: איך לתפוס לחיצה מחוץ לקומפוננטה

26/04/2024

חבר שואל - יש לי קומפוננטה שפותחת תפריט קופץ. איך אני מעלים את התפריט כשמשתמש לוחץ על המסך מחוץ לקומפוננטה? יש משהו כמו onClick אבל על כל שאר הדברים?

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

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

סך הכל הקוד נראה כך:

import React, { useEffect, useState } from 'react';

const initialText = "Click outside the component to change this text"

export function App(props) {
  const [text, setText] = useState(initialText);

  useEffect(() => {
    const changeText = () => setText('Yay!');
    window.addEventListener('click', changeText);

    return () => {
      window.removeEventListener(changeText);
    }
  }, []);

  return (
    <div
      className='App'
      style={{background: 'red'}}
      onClick={(e) => e.stopPropagation()}
    >
      <h2>{text}</h2>
    </div>
  );
}

מוזמנים לשחק עם הקוד לייב בקודפליי בקישור: https://playcode.io/1848762

טרנזאקציות

25/04/2024

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

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

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

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

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

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

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

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