לדלג לתוכן

4.7 לולאת האירועים ותכנות פונקציונלי הרצאה

ג'אווהסקריפט היא שפה חד-תהליכונית - Single Threaded

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

  • אין ריבוי threads כמו בג'אווה או C++
  • אם פעולה כבדה חוסמת את ה-thread, כל הדף קופא - כולל ממשק המשתמש
  • אז איך fetch, setTimeout ו-event listeners עובדים בלי לחסום? התשובה: לולאת האירועים

מחסנית הקריאות - Call Stack

ה-Call Stack הוא מבנה נתונים שעוקב אחרי הפונקציות שרצות כרגע.

  • כשקוראים לפונקציה, היא נדחפת למחסנית
  • כשפונקציה מסתיימת, היא יוצאת מהמחסנית
  • JS מבצעת את מה שנמצא בראש המחסנית
function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);
}

function printSquare(n) {
    const result = square(n);
    console.log(result);
}

printSquare(4);

מה קורה במחסנית:

  • printSquare(4) נכנסת למחסנית
  • square(4) נכנסת למחסנית
  • multiply(4, 4) נכנסת למחסנית
  • multiply מחזירה 16 ויוצאת
  • square מחזירה 16 ויוצאת
  • console.log(16) נכנסת ויוצאת
  • printSquare מסתיימת ויוצאת
  • המחסנית ריקה

ממשקי הדפדפן - Web APIs

הדפדפן מספק APIs שרצים מחוץ ל-thread הראשי של JS:

  • setTimeout / setInterval - טיימרים
  • fetch - בקשות רשת
  • addEventListener - האזנה לאירועים
  • requestAnimationFrame - אנימציות
  • geolocation, IntersectionObserver ועוד

כשקוראים ל-setTimeout(fn, 1000), הדפדפן מתחיל טיימר ברקע. ה-JS ממשיכה הלאה. כשהטיימר מסתיים, הדפדפן מכניס את fn לתור.


תור המשימות - Macrotask Queue

כשה-Web API מסיים את העבודה שלו, הקולבק לא רץ מיד. הוא נכנס לתור המשימות (macrotask queue).

דוגמאות למשימות שנכנסות לתור:
- קולבקים של setTimeout / setInterval
- קולבקים של אירועי DOM (click, input...)

console.log("Start");

setTimeout(() => {
    console.log("Timeout");
}, 0);

console.log("End");

הפלט:

Start
End
Timeout

למרות שה-timeout הוא 0 מילישניות, הקולבק לא רץ מיד. הוא נכנס לתור ומחכה שהמחסנית תתרוקן.


תור המיקרו-משימות - Microtask Queue

יש תור נוסף עם עדיפות גבוהה יותר - תור המיקרו-משימות.

מה נכנס לתור המיקרו-משימות:
- קולבקים של Promises (then, catch, finally)
- queueMicrotask()
- MutationObserver

הכלל: לולאת האירועים מרוקנת את כל המיקרו-משימות לפני שהיא עוברת למשימה הבאה בתור הרגיל.

console.log("Start");

setTimeout(() => {
    console.log("Timeout");
}, 0);

Promise.resolve().then(() => {
    console.log("Promise");
});

console.log("End");

הפלט:

Start
End
Promise
Timeout

  • "Start" ו-"End" רצים מיד (קוד סינכרוני)
  • "Promise" רץ לפני "Timeout" כי מיקרו-משימות מתבצעות לפני מאקרו-משימות

מחזור לולאת האירועים - Event Loop Cycle

לולאת האירועים עובדת בלולאה אינסופית לפי הסדר הזה:

  1. הרצת כל הקוד הסינכרוני (עד שהמחסנית ריקה)
  2. ריקון כל תור המיקרו-משימות
  3. ביצוע משימה אחת מתור המאקרו-משימות
  4. חזרה לשלב 2

חשוב: אם מיקרו-משימה מוסיפה מיקרו-משימה נוספת, גם היא תרוץ לפני המאקרו-משימה הבאה.

console.log("1");

setTimeout(() => {
    console.log("2");
    Promise.resolve().then(() => console.log("3"));
}, 0);

setTimeout(() => {
    console.log("4");
}, 0);

Promise.resolve().then(() => {
    console.log("5");
    Promise.resolve().then(() => console.log("6"));
});

console.log("7");

הפלט:

1
7
5
6
2
3
4

הסבר:
- 1 ו-7 - קוד סינכרוני
- 5 - מיקרו-משימה מה-Promise הראשון
- 6 - מיקרו-משימה שנוצרה מתוך מיקרו-משימה (עדיין מתרוקנת לפני המאקרו)
- 2 - מאקרו-משימה ראשונה (setTimeout הראשון)
- 3 - מיקרו-משימה שנוצרה מתוך המאקרו-משימה
- 4 - מאקרו-משימה שנייה (setTimeout השני)


נחשו את סדר הפלט - תרגול

דוגמה 1

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve()
    .then(() => console.log("C"))
    .then(() => console.log("D"));

setTimeout(() => console.log("E"), 0);

console.log("F");

תשובה:

A
F
C
D
B
E

דוגמה 2

async function foo() {
    console.log("foo start");
    await Promise.resolve();
    console.log("foo end");
}

console.log("start");
foo();
console.log("end");

תשובה:

start
foo start
end
foo end

  • await גורם לשארית הפונקציה לרוץ כמיקרו-משימה
  • "foo start" רץ סינכרונית (לפני ה-await)
  • "foo end" רץ אחרי "end" כי הוא ממתין לסיום ה-await

דוגמה 3

setTimeout(() => console.log("1"), 0);

Promise.resolve().then(() => {
    console.log("2");
    setTimeout(() => console.log("3"), 0);
});

Promise.resolve().then(() => console.log("4"));

setTimeout(() => console.log("5"), 0);

תשובה:

2
4
1
5
3


setTimeout(fn, 0) - למה זה שימושי

setTimeout(fn, 0) לא אומר "הרץ עכשיו". זה אומר "הרץ ברגע שהמחסנית תתרוקן ותור המיקרו-משימות יתרוקן".

שימושים:
- לתת לדפדפן לעדכן את ה-DOM לפני שממשיכים
- לפצל עבודה כבדה לחלקים קטנים
- לדחות פעולה לאחרי כל ה-event handlers הנוכחיים

button.addEventListener("click", () => {
    // update the DOM
    output.textContent = "Processing...";

    // defer heavy work so the DOM update renders first
    setTimeout(() => {
        const result = heavyCalculation();
        output.textContent = result;
    }, 0);
});

בלי ה-setTimeout, הדפדפן לא יספיק לצייר את "Processing..." לפני שהחישוב הכבד מתחיל.


requestAnimationFrame

requestAnimationFrame (rAF) מבקש מהדפדפן להריץ פונקציה לפני הציור הבא (בדרך כלל 60 פעמים בשנייה).

function animate() {
    // update position, size, color, etc.
    element.style.left = position + "px";
    position += 2;

    if (position < 500) {
        requestAnimationFrame(animate); // schedule next frame
    }
}

requestAnimationFrame(animate); // start animation
  • עדיף על setInterval לאנימציות כי הוא מסונכרן עם קצב הרענון של המסך
  • אם הטאב לא גלוי, rAF מושהה (חוסך משאבים)
  • rAF רץ לפני שהדפדפן מצייר, אחרי המיקרו-משימות

סדר ביצוע מלא

  1. קוד סינכרוני (Call Stack)
  2. מיקרו-משימות (Promises)
  3. requestAnimationFrame
  4. ציור מסך (render/paint)
  5. מאקרו-משימות (setTimeout, events)

חסימה - Blocking

אם קוד סינכרוני רץ יותר מדי זמן, הוא חוסם את הכל - ה-UI, אירועים, אנימציות.

// BAD - blocks the main thread
button.addEventListener("click", () => {
    // simulate heavy computation
    const start = Date.now();
    while (Date.now() - start < 3000) {
        // busy loop for 3 seconds
    }
    console.log("Done");
    // during these 3 seconds, nothing works
});

פתרונות לחסימה:
- לפצל עבודה כבדה עם setTimeout או requestAnimationFrame
- להשתמש ב-Web Workers לחישובים כבדים (thread נפרד)
- להשתמש בפעולות אסינכרוניות (fetch, IndexedDB)

// GOOD - break heavy work into chunks
function processChunk(items, index, callback) {
    const chunkSize = 100;
    const end = Math.min(index + chunkSize, items.length);

    for (let i = index; i < end; i++) {
        // process item
    }

    if (end < items.length) {
        setTimeout(() => processChunk(items, end, callback), 0);
    } else {
        callback();
    }
}

תכנות פונקציונלי - Functional Programming

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

JS לא שפה פונקציונלית טהורה, אבל היא תומכת בהרבה מהעקרונות - ואנחנו כבר משתמשים בחלק מהם (map, filter, reduce).


פונקציות טהורות - Pure Functions

פונקציה טהורה:
- מחזירה תמיד את אותה תוצאה עבור אותם ארגומנטים
- אין לה תופעות לוואי (side effects) - לא משנה משתנים חיצוניים, לא כותבת לקובץ, לא משנה DOM

// PURE - same input always gives same output
function add(a, b) {
    return a + b;
}

// PURE
function formatName(first, last) {
    return `${first} ${last}`;
}

// NOT PURE - depends on external variable
let taxRate = 0.17;
function calculateTax(price) {
    return price * taxRate; // result changes if taxRate changes
}

// NOT PURE - side effect (modifies external state)
let total = 0;
function addToTotal(amount) {
    total += amount; // modifies external variable
    return total;
}

// NOT PURE - side effect (console.log)
function greet(name) {
    console.log(`Hello ${name}`); // side effect
}

למה פונקציות טהורות טובות:
- קל לבדוק אותן (לא צריך להכין סביבה)
- קל להבין אותן (לא צריך לעקוב אחרי מצב חיצוני)
- קל להריץ אותן במקביל (אין שינוי של מצב משותף)
- קל לשמור תוצאות ב-cache (מוכרנות - memoization)


אי-שינוי - Immutability

באי-שינוי, במקום לשנות אובייקט קיים, יוצרים אובייקט חדש:

// MUTABLE approach - BAD
const user = { name: "Alice", age: 25 };
user.age = 26; // mutating the original object

// IMMUTABLE approach - GOOD
const user2 = { name: "Alice", age: 25 };
const updatedUser = { ...user2, age: 26 }; // new object

// MUTABLE array - BAD
const numbers = [1, 2, 3];
numbers.push(4); // mutating the original array

// IMMUTABLE array - GOOD
const numbers2 = [1, 2, 3];
const newNumbers = [...numbers2, 4]; // new array

פעולות אי-שינוי נפוצות על מערכים

const items = [1, 2, 3, 4, 5];

// add item (instead of push)
const added = [...items, 6];

// remove item (instead of splice)
const removed = items.filter(item => item !== 3);

// update item (instead of direct assignment)
const updated = items.map(item => item === 3 ? 30 : item);

// sort without mutation (sort mutates!)
const sorted = [...items].sort((a, b) => b - a);

פעולות אי-שינוי על אובייקטים

const user = { name: "Alice", age: 25, address: { city: "Tel Aviv" } };

// update property
const updated = { ...user, age: 26 };

// remove property
const { age, ...withoutAge } = user;

// nested update (need to spread each level)
const movedUser = {
    ...user,
    address: { ...user.address, city: "Jerusalem" }
};

הרכבה - Composition

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

// small, focused functions
function trim(str) {
    return str.trim();
}

function toLowerCase(str) {
    return str.toLowerCase();
}

function replaceSpaces(str) {
    return str.replace(/\s+/g, "-");
}

// compose them manually
function createSlug(title) {
    return replaceSpaces(toLowerCase(trim(title)));
}

createSlug("  Hello World  "); // "hello-world"

פונקציית הרכבה כללית

function compose(...fns) {
    return function (value) {
        return fns.reduceRight((acc, fn) => fn(acc), value);
    };
}

function pipe(...fns) {
    return function (value) {
        return fns.reduce((acc, fn) => fn(acc), value);
    };
}

// compose reads right to left (like math: f(g(x)))
const createSlug = compose(replaceSpaces, toLowerCase, trim);

// pipe reads left to right (more intuitive)
const createSlug2 = pipe(trim, toLowerCase, replaceSpaces);

createSlug("  Hello World  ");  // "hello-world"
createSlug2("  Hello World  "); // "hello-world"
  • compose - מפעיל מימין לשמאל (כמו במתמטיקה)
  • pipe - מפעיל משמאל לימין (יותר אינטואיטיבי)

ייצור חלקי - Currying

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

// regular function
function add(a, b) {
    return a + b;
}

add(2, 3); // 5

// curried version
function curriedAdd(a) {
    return function (b) {
        return a + b;
    };
}

curriedAdd(2)(3); // 5

// with arrow functions - more concise
const curriedAdd2 = a => b => a + b;
curriedAdd2(2)(3); // 5

למה currying שימושי - יצירת פונקציות מותאמות:

// create specialized functions
const add5 = curriedAdd(5);
const add10 = curriedAdd(10);

add5(3);  // 8
add10(3); // 13

// practical example: filtering
const filterBy = property => value => array =>
    array.filter(item => item[property] === value);

const filterByStatus = filterBy("status");
const getActive = filterByStatus("active");
const getInactive = filterByStatus("inactive");

const users = [
    { name: "Alice", status: "active" },
    { name: "Bob", status: "inactive" },
    { name: "Charlie", status: "active" }
];

getActive(users);   // [Alice, Charlie]
getInactive(users); // [Bob]

קרינג עם pipe

const multiply = a => b => a * b;
const add = a => b => a + b;
const subtract = a => b => b - a;

const double = multiply(2);
const addTen = add(10);
const subtractThree = subtract(3);

const transform = pipe(double, addTen, subtractThree);

transform(5);  // ((5 * 2) + 10) - 3 = 17
transform(10); // ((10 * 2) + 10) - 3 = 27

דקלרטיבי מול אימפרטיבי - Declarative vs Imperative

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

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// IMPERATIVE - how to do it
const evenDoubled = [];
for (let i = 0; i < numbers.length; i++) {
    if (numbers[i] % 2 === 0) {
        evenDoubled.push(numbers[i] * 2);
    }
}

// DECLARATIVE - what we want
const evenDoubled2 = numbers
    .filter(n => n % 2 === 0)
    .map(n => n * 2);

דוגמה נוספת - חיפוש והמרה

const users = [
    { name: "Alice", age: 25, active: true },
    { name: "Bob", age: 30, active: false },
    { name: "Charlie", age: 35, active: true },
    { name: "Dana", age: 28, active: true }
];

// IMPERATIVE
let result = "";
for (let i = 0; i < users.length; i++) {
    if (users[i].active && users[i].age >= 28) {
        if (result !== "") {
            result += ", ";
        }
        result += users[i].name;
    }
}

// DECLARATIVE
const result2 = users
    .filter(user => user.active && user.age >= 28)
    .map(user => user.name)
    .join(", ");

הגישה הדקלרטיבית:
- קלה יותר לקריאה
- פחות מקום לבאגים (אין ניהול אינדקסים)
- כל שלב עושה דבר אחד
- זה בדיוק מה ש-React עושה - מתארים את ה-UI הרצוי במקום לתפעל DOM ידנית


סיכום

לולאת האירועים:
- JS היא חד-תהליכונית, אבל הדפדפן מספק Web APIs שרצים ברקע
- יש שני תורים: מיקרו-משימות (Promises) ומאקרו-משימות (setTimeout, events)
- מיקרו-משימות תמיד רצות לפני מאקרו-משימות
- setTimeout(fn, 0) לא מיידי - מחכה שהמחסנית ותור המיקרו-משימות יתרוקנו
- requestAnimationFrame מסונכרן עם ציור המסך
- קוד סינכרוני כבד חוסם את הכל

תכנות פונקציונלי:
- פונקציות טהורות - אין תופעות לוואי, תוצאה צפויה
- אי-שינוי - יוצרים אובייקטים חדשים במקום לשנות קיימים
- הרכבה - pipe ו-compose לבניית פונקציות מורכבות מפשוטות
- קרינג - currying - יצירת פונקציות מותאמות מפונקציות כלליות
- גישה דקלרטיבית - מתארים מה רוצים, לא איך לעשות