עברית

מדריך מקיף למפתחים לשליטה ב-JavaScript Proxy API. למדו ליירט ולהתאים אישית פעולות על אובייקטים עם דוגמאות מעשיות, מקרי שימוש וטיפים לביצועים.

JavaScript Proxy API: צלילה עמוקה לשינוי התנהגות של אובייקטים

בנוף המתפתח של JavaScript מודרני, מפתחים מחפשים כל הזמן דרכים חזקות ואלגנטיות יותר לנהל וליצור אינטראקציה עם נתונים. בעוד שתכונות כמו מחלקות, מודולים ו-async/await חוללו מהפכה באופן שבו אנו כותבים קוד, ישנה תכונת מטא-תכנות רבת עוצמה שהוצגה ב-ECMAScript 2015 (ES6) שלעיתים קרובות נותרת לא מנוצלת מספיק: ה-Proxy API.

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

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

הבנת מושגי הליבה: Target, Handler, ו-Traps

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

התחביר ליצירת פרוקסי הוא פשוט:

const proxy = new Proxy(target, handler);

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


// האובייקט המקורי
const target = {
  message: "Hello, World!"
};

// handler ריק. כל הפעולות יועברו ל-target.
const handler = {};

// אובייקט הפרוקסי
const proxy = new Proxy(target, handler);

// גישה למאפיין בפרוקסי
console.log(proxy.message); // Output: Hello, World!

// הפעולה הועברה ל-target
console.log(target.message); // Output: Hello, World!

// שינוי מאפיין דרך הפרוקסי
proxy.anotherMessage = "Hello, Proxy!";

console.log(proxy.anotherMessage); // Output: Hello, Proxy!
console.log(target.anotherMessage); // Output: Hello, Proxy!

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

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

אובייקט ה-handler יכול להכיל עד 13 מלכודות שונות, כל אחת מתאימה למתודה פנימית בסיסית של אובייקטי JavaScript. בואו נחקור את הנפוצות והשימושיות ביותר.

מלכודות גישה למאפיינים

1. `get(target, property, receiver)`

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

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


const user = {
  firstName: 'John',
  lastName: 'Doe',
  age: 30
};

const userHandler = {
  get(target, property) {
    // אם המאפיין קיים ב-target, החזר אותו.
    // אחרת, החזר הודעת ברירת מחדל.
    return property in target ? target[property] : `Property '${property}' does not exist.`;
  }
};

const userProxy = new Proxy(user, userHandler);

console.log(userProxy.firstName); // Output: John
console.log(userProxy.age);       // Output: 30
console.log(userProxy.country);   // Output: Property 'country' does not exist.

2. `set(target, property, value, receiver)`

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

דוגמה: אימות נתונים.


const person = {
  name: 'Jane Doe',
  age: 25
};

const validationHandler = {
  set(target, property, value) {
    if (property === 'age') {
      if (typeof value !== 'number' || !Number.isInteger(value)) {
        throw new TypeError('Age must be an integer.');
      }
      if (value <= 0) {
        throw new RangeError('Age must be a positive number.');
      }
    }

    // אם האימות עובר, קבע את הערך באובייקט ה-target.
    target[property] = value;

    // ציין הצלחה.
    return true;
  }
};

const personProxy = new Proxy(person, validationHandler);

personProxy.age = 30; // This is valid
console.log(personProxy.age); // Output: 30

try {
  personProxy.age = 'thirty'; // Throws TypeError
} catch (e) {
  console.error(e.message); // Output: Age must be an integer.
}

try {
  personProxy.age = -5; // Throws RangeError
} catch (e) {
  console.error(e.message); // Output: Age must be a positive number.
}

3. `has(target, property)`

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

דוגמה: הסתרת מאפיינים 'פרטיים'.

ב-JavaScript, מוסכמה נפוצה היא להתחיל מאפיינים פרטיים עם קו תחתון (_). אנו יכולים להשתמש במלכודת has כדי להסתיר אותם מהאופרטור in.


const secretData = {
  _apiKey: 'xyz123abc',
  publicKey: 'pub456def',
  id: 1
};

const hidingHandler = {
  has(target, property) {
    if (property.startsWith('_')) {
      return false; // העמד פנים שהוא לא קיים
    }
    return property in target;
  }
};

const dataProxy = new Proxy(secretData, hidingHandler);

console.log('publicKey' in dataProxy); // Output: true
console.log('_apiKey' in dataProxy);   // Output: false (למרות שהוא נמצא ב-target)
console.log('id' in dataProxy);        // Output: true

הערה: זה משפיע רק על האופרטור in. גישה ישירה כמו dataProxy._apiKey עדיין תעבוד אלא אם תממשו גם מלכודת get מתאימה.

4. `deleteProperty(target, property)`

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

המלכודת חייבת להחזיר true עבור מחיקה מוצלחת או false עבור כישלון.

דוגמה: מניעת מחיקת מאפיינים.


const immutableConfig = {
  databaseUrl: 'prod.db.server',
  port: 8080
};

const deletionGuardHandler = {
  deleteProperty(target, property) {
    if (property in target) {
      console.warn(`Attempted to delete protected property: '${property}'. Operation denied.`);
      return false;
    }
    return true; // Property didn't exist anyway
  }
};

const configProxy = new Proxy(immutableConfig, deletionGuardHandler);

delete configProxy.port;
// Console output: Attempted to delete protected property: 'port'. Operation denied.

console.log(configProxy.port); // Output: 8080 (הוא לא נמחק)

מלכודות ספירת מאפיינים ותיאור אובייקט

5. `ownKeys(target)`

מלכודת זו מופעלת על ידי פעולות שמקבלות את רשימת המאפיינים העצמיים של אובייקט, כגון Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertySymbols(), ו-Reflect.ownKeys().

דוגמה: סינון מפתחות.

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


const secretData = {
  _apiKey: 'xyz123abc',
  publicKey: 'pub456def',
  id: 1
};

const keyHidingHandler = {
  has(target, property) {
    return !property.startsWith('_') && property in target;
  },
  ownKeys(target) {
    return Reflect.ownKeys(target).filter(key => !key.startsWith('_'));
  },
  get(target, property, receiver) {
    // מניעת גישה ישירה גם כן
    if (property.startsWith('_')) {
      return undefined;
    }
    return Reflect.get(target, property, receiver);
  }
};

const fullProxy = new Proxy(secretData, keyHidingHandler);

console.log(Object.keys(fullProxy)); // Output: ['publicKey', 'id']
console.log('publicKey' in fullProxy); // Output: true
console.log('_apiKey' in fullProxy);   // Output: false
console.log(fullProxy._apiKey);      // Output: undefined

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

מלכודות פונקציה וקונסטרקטור

Proxies אינם מוגבלים לאובייקטים רגילים. כאשר ה-target הוא פונקציה, ניתן ליירט קריאות ויצירות מופעים.

6. `apply(target, thisArg, argumentsList)`

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

דוגמה: רישום קריאות לפונקציות והארגומנטים שלהן.


function sum(a, b) {
  return a + b;
}

const loggingHandler = {
  apply(target, thisArg, argumentsList) {
    console.log(`Calling function '${target.name}' with arguments: ${argumentsList}`);
    // בצע את הפונקציה המקורית עם הקונטקסט והארגומנטים הנכונים
    const result = Reflect.apply(target, thisArg, argumentsList);
    console.log(`Function '${target.name}' returned: ${result}`);
    return result;
  }
};

const proxiedSum = new Proxy(sum, loggingHandler);

proxiedSum(5, 10);
// Console output:
// Calling function 'sum' with arguments: 5,10
// Function 'sum' returned: 15

7. `construct(target, argumentsList, newTarget)`

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

דוגמה: מימוש תבנית Singleton.


class MyDatabaseConnection {
  constructor(url) {
    this.url = url;
    console.log(`Connecting to ${this.url}...`);
  }
}

let instance;

const singletonHandler = {
  construct(target, argumentsList) {
    if (!instance) {
      console.log('Creating new instance.');
      instance = Reflect.construct(target, argumentsList);
    }
    console.log('Returning existing instance.');
    return instance;
  }
};

const ProxiedConnection = new Proxy(MyDatabaseConnection, singletonHandler);

const conn1 = new ProxiedConnection('db://primary');
// Console output:
// Creating new instance.
// Connecting to db://primary...
// Returning existing instance.

const conn2 = new ProxiedConnection('db://secondary'); // ה-URL יתעלם ממנו
// Console output:
// Returning existing instance.

console.log(conn1 === conn2); // Output: true
console.log(conn1.url); // Output: db://primary
console.log(conn2.url); // Output: db://primary

מקרי שימוש מעשיים ודפוסים מתקדמים

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

1. הפשטת API והמרת נתונים

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


function snakeToCamel(s) {
  return s.replace(/(_\w)/g, (m) => m[1].toUpperCase());
}

// דמיינו שאלו הנתונים הגולמיים מ-API
const apiResponse = {
  user_id: 123,
  first_name: 'Alice',
  last_name: 'Wonderland',
  account_status: 'active'
};

const camelCaseHandler = {
  get(target, property) {
    const camelCaseProperty = snakeToCamel(property);
    // בדוק אם גרסת ה-camelCase קיימת ישירות
    if (camelCaseProperty in target) {
      return target[camelCaseProperty];
    }
    // חזור לשם המאפיין המקורי כברירת מחדל
    if (property in target) {
      return target[property];
    }
    return undefined;
  }
};

const userModel = new Proxy(apiResponse, camelCaseHandler);

// כעת אנו יכולים לגשת למאפיינים באמצעות camelCase, למרות שהם מאוחסנים כ-snake_case
console.log(userModel.userId);        // Output: 123
console.log(userModel.firstName);     // Output: Alice
console.log(userModel.accountStatus); // Output: active

2. Observables וקשירת נתונים (ליבת הפריימוורקים המודרניים)

Proxies הם המנוע מאחורי מערכות הריאקטיביות בפריימוורקים מודרניים כמו Vue 3. כאשר אתם משנים מאפיין באובייקט state שעטוף בפרוקסי, ניתן להשתמש במלכודת set כדי להפעיל עדכונים בממשק המשתמש או בחלקים אחרים של האפליקציה.

הנה דוגמה פשוטה מאוד:


function createObservable(target, callback) {
  const handler = {
    set(obj, prop, value) {
      const result = Reflect.set(obj, prop, value);
      callback(prop, value); // הפעל את ה-callback בעת שינוי
      return result;
    }
  };
  return new Proxy(target, handler);
}

const state = {
  count: 0,
  message: 'Hello'
};

function render(prop, value) {
  console.log(`CHANGE DETECTED: The property '${prop}' was set to '${value}'. Re-rendering UI...`);
}

const observableState = createObservable(state, render);

observableState.count = 1;
// Console output: CHANGE DETECTED: The property 'count' was set to '1'. Re-rendering UI...

observableState.message = 'Goodbye';
// Console output: CHANGE DETECTED: The property 'message' was set to 'Goodbye'. Re-rendering UI...

3. אינדקסים שליליים במערך

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


function createNegativeArrayProxy(arr) {
  const handler = {
    get(target, property) {
      const index = Number(property);
      if (!Number.isNaN(index) && index < 0) {
        // המר אינדקס שלילי לחיובי מהסוף
        property = String(target.length + index);
      }
      return Reflect.get(target, property);
    }
  };
  return new Proxy(arr, handler);
}

const originalArray = ['a', 'b', 'c', 'd', 'e'];
const proxiedArray = createNegativeArrayProxy(originalArray);

console.log(proxiedArray[0]);  // Output: a
console.log(proxiedArray[-1]); // Output: e
console.log(proxiedArray[-2]); // Output: d
console.log(proxiedArray.length); // Output: 5

שיקולי ביצועים ושיטות עבודה מומלצות

אף על פי ש-Proxies הם חזקים להפליא, הם אינם פתרון קסם. חיוני להבין את השלכותיהם.

תקורה בביצועים

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

אינווריאנטים של פרוקסי

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

לדוגמה, אינווריאנט עבור מלכודת deleteProperty הוא שהיא אינה יכולה להחזיר true (המעיד על הצלחה) אם המאפיין המתאים באובייקט ה-target אינו ניתן להגדרה (non-configurable). זה מונע מהפרוקסי לטעון שהוא מחק מאפיין שלא ניתן למחוק.


const target = {};
Object.defineProperty(target, 'unbreakable', { value: 10, configurable: false });

const handler = {
  deleteProperty(target, prop) {
    // זה יפר את האינווריאנט
    return true;
  }
};

const proxy = new Proxy(target, handler);

try {
  delete proxy.unbreakable; // זה יזרוק שגיאה
} catch (e) {
  console.error(e.message);
  // Output: 'deleteProperty' on proxy: returned true for non-configurable property 'unbreakable'
}

מתי להשתמש בפרוקסי (ומתי לא)

פרוקסי הניתן לביטול (Revocable Proxies)

לתרחישים שבהם ייתכן שתצטרכו 'לכבות' פרוקסי (למשל, מטעמי אבטחה או ניהול זיכרון), JavaScript מספק את Proxy.revocable(). הוא מחזיר אובייקט המכיל גם את הפרוקסי וגם פונקציית revoke.


const target = { data: 'sensitive' };
const handler = {};

const { proxy, revoke } = Proxy.revocable(target, handler);

console.log(proxy.data); // Output: sensitive

// כעת, אנו מבטלים את הגישה של הפרוקסי
revoke();

try {
  console.log(proxy.data); // זה יזרוק שגיאה
} catch (e) {
  console.error(e.message);
  // Output: Cannot perform 'get' on a proxy that has been revoked
}

פרוקסי לעומת טכניקות מטא-תכנות אחרות

לפני ה-Proxies, מפתחים השתמשו בשיטות אחרות כדי להשיג מטרות דומות. כדאי להבין כיצד Proxies משתווים אליהם.

`Object.defineProperty()`

Object.defineProperty() משנה אובייקט ישירות על ידי הגדרת getters ו-setters עבור מאפיינים ספציפיים. Proxies, לעומת זאת, אינם משנים כלל את האובייקט המקורי; הם עוטפים אותו.

סיכום: כוחה של הוירטואליזציה

ה-JavaScript Proxy API הוא יותר מסתם תכונה חכמה; הוא מהווה שינוי מהותי באופן שבו אנו יכולים לעצב וליצור אינטראקציה עם אובייקטים. על ידי כך שהם מאפשרים לנו ליירט ולהתאים אישית פעולות בסיסיות, Proxies פותחים את הדלת לעולם של דפוסים רבי עוצמה: מאימות והמרת נתונים שקופים ועד למערכות הריאקטיביות המניעות ממשקי משתמש מודרניים.

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