Головна / Статті / Розуміння JavaScript Proxy: пастки, Reflect та реактивні патерни

Розуміння JavaScript Proxy: пастки, Reflect та реактивні патерни

Дізнайтеся, як об’єкти Proxy та Reflect у JavaScript перехоплюють доступ до властивостей для забезпечення перевірки даних, віртуальних властивостей та реактивних фреймворків.

1096 слів

Кожного разу, коли ви пишете obj.property або присвоюєте йому значення за допомогою obj.property = value, ви користуєтесь операціями, які JavaScript виконує безшумно, без будь-якого вбудованого способу спостерігати чи змінювати те, що відбувається під капотом. Об’єкт Proxy повністю скасовує це припущення. Він дозволяє обгорнути цільовий об’єкт та перехоплювати ці базові операції — читання, запис та видалення — ще до того, як вони набудуть чинності. Цей механізм перехоплення виявляється справжнім двигуном багатьох ефектів, які здаються „чарівними“ у сучасних бібліотеках та інструментах JavaScript.

Обгортка, яка може перехоплювати все

Proxy знаходиться між вашим кодом та об’єктом, який він обгортає, а набір функцій-пасток визначає, що насправді відбувається для кожного типу операцій, які виконуються над цим об’єктом.

const config = { retries: 3, timeout: 5000 };

const guardedConfig = new Proxy(config, {
  set(target, key, value) {
    if (key === "retries" && (typeof value !== "number" || value < 0)) {
      throw new TypeError("retries must be a non-negative number");
    }
    target[key] = value;
    return true;
  },
});

guardedConfig.retries = 5;   // works fine
guardedConfig.retries = -1;  // throws TypeError, caught before it ever reaches the object

Ніщо у запису guardedConfig.retries = 5 не відрізняється від звичайного присвоєння властивості. Саме в цьому полягає сенс такого підходу: перехоплення залишається непомітним у місці виклику. Ви можете додати перевірки, логування чи інші побічні ефекти до того, що виглядає як звичайний доступ до властивостей, не змушуючи змінювати код, який фактично використовує об’єкт.

Механізм, що лежить в основі реактивних фреймворків

function reactive(target, onChange) {
  return new Proxy(target, {
    get(obj, key) {
      return obj[key];
    },
    set(obj, key, value) {
      const changed = obj[key] !== value;
      obj[key] = value;
      if (changed) onChange(key, value);
      return true;
    },
  });
}

const state = reactive({ count: 0 }, (key, value) => {
  console.log(`${key} changed to ${value}, re-rendering...`);
});

state.count = 1; // "count changed to 1, re-rendering..." — no explicit call needed

Написання state.count = 1 виглядає як звичайне присвоєння, адже синтаксично це саме так. Те, що надає йому сенсу, — це «пастка set», яка перетворює цей звичайний рядок на подію, на яку може відреагувати решта системи. Саме на цьому ґрунтується реактивна архітектура. Не існує жодного спеціального типу даних «observable», який потрібно було б використовувати скрізь; це звичайні об’єкти, загорнуті в Proxy, який тихо фіксує кожне змінення, внесене до них.

Віртуальні властивості: значення, які існують лише тоді, коли ви їх запитуєте

«Пастка get» може вільно повертати щось, що насправді ніколи не зберігалося у базовому об’єкті, що дозволяє використовувати обчислені чи похідні значення так, ніби це звичайні властивості.

const product = { name: "Desk Lamp", priceCents: 3499 };

const productView = new Proxy(product, {
  get(target, key) {
    if (key === "priceDollars") {
      return (target.priceCents / 100).toFixed(2);
    }
    return target[key];
  },
});

console.log(productView.priceDollars); // "34.99" — computed on read, not stored anywhere

У об’єкті product немає жодного поля priceDollars. Воно створюється динамічно щоразу, коли до нього здійснюється доступ, у межах функції get. Це суттєво відрізняється від геттера, який був би написаний за допомогою get усередині класу чи об’єкта, оскільки механізм Proxy може переривати доступ до ключів, які взагалі не були оголошені заздалегідь. Це корисно, наприклад, тоді, коли потрібно оточити відповідь API додатковими обчисленими полями, не торкаючись оригінального даних.

Чому Reflect зазвичай використовується разом із Proxy

Є певна тонкість, яка спантеличує людей під час першого написання такої функції, яка має переходити до стандартної поведінки. Робити це примітивним способом легко призвести до непомітних помилок.

const handler = {
  get(target, key) {
    console.log(`reading ${key}`);
    return target[key]; // works, but loses some edge-case correctness
  },
};

Для простих об’єктів цей підхід здебільшого є ефективним, але безпечнішим та правильнішим способом передачі операції до її стандартної поведінки є використання Reflect. Він надає функцію для кожної ситуації, яка точно відтворює операцію, зберігаючи правильне прив’язування this та належним чином обробляючи складніші ситуації, такі як успадковані геттери.

const handler = {
  get(target, key, receiver) {
    console.log(`reading ${key}`);
    return Reflect.get(target, key, receiver);
  },
};

Виклик Reflect.get(target, key, receiver) відтворює точно те саме, що б зробив звичайний доступ до властивостей, включаючи крайні випадки з прототипами та геттерами, які залежать від this, ситуації, коли target[key] може тихо повернути неправильний результат. У практичному коді з Proxy правило майже завжди полягає у виклику відповідного методу Reflect зсередини кожної «пастки», якщо тільки ви не намагаєтесь навмисно змінити стандартну поведінку, адже саме ця версія гарантовано відповідає тому, що зазвичай робить звичайний доступ до властивостей.

«Пастки», які обмежують, а не розширюють

Трапи не лише корисні для додавання поведінки, вони так само легко можуть її прибрати. Трап has керує тим, що повертає оператор in, а трап deleteProperty може повністю заборонити видалення.

const secureRecord = new Proxy(
  { id: 1, ssn: "123-45-6789" },
  {
    get(target, key) {
      if (key === "ssn") throw new Error("Direct access to ssn is not allowed");
      return Reflect.get(target, key);
    },
    deleteProperty() {
      throw new Error("Deleting fields is not allowed on this record");
    },
  }
);

console.log(secureRecord.id); // 1
console.log(secureRecord.ssn); // throws
delete secureRecord.id; // throws

Цей тип захисту суттєво відрізняється від приватного поля класу: його можна застосувати до будь-якого об’єкта, без необхідності того, щоб цей об’єкт спочатку був описаний як клас, і він дозволяє визначати вузькі правила для кожного ключа, які просто неможливо сформулювати за допомогою загального обмеження #private.

Одна ідея, багато трапів

Кожна „пастка“ насправді відповідає на одне й те саме питання: що має статися у момент, коли код намагається виконати цю повсякденну операцію над цим об’єктом? Читання значення, запис його, видалення, перевірка наявності ключа – усе це зазвичай є невидимими, автоматичними діями, а Proxy перетворює кожну з них на момент, який можна спостерігати чи перенаправити. Системи реактивного стану, обгортки для валідації, шари контролю доступу, віртуальні обчислювані поля – жоден з цих елементів не є окремим трюком, запозиченим з інших джерел. Усі вони побудовані на однаковому основному механізмі, просто спрямовані на різну „пастку“.

Пов’язана література