Развлекаемся с Proxy в ES6

Spread the love

Перевод статьи: Maciej CieślarHaving fun with ES6 proxies

Proxy является одной из самых игнорируемых концепций, представленных в JavaScript версии ES6.

Следует признать, что эта концепция не особенно полезна в повседневной жизни, но в какой-то момент в будущем она обязательно пригодится.

Основы

Объект Proxy используется для определения пользовательского поведения для основных операций, таких как получения свойств, назначение и вызов функций.

Самый простой пример proxy:

const obj = {
 a: 1,
 b: 2,
};

const proxiedObj = new Proxy(obj, {
 get: (target, propertyName) => {
   // берем значение из оригинального объекта
   const value = target[propertyName];

   if (!value && value !== 0) {
     console.warn('Trying to get non-existing property!');

     return 0;
   }

   // возвращаем инкременирование значение
   return value + 1;
 },
 set: (target, key, value) => {
   // уменьшаем на 1 каждое значение перед сохранением
   target[key] = value - 1;

   // возвращает true в случае успеха
   return true;
 },
});

proxiedObj.a = 5;

console.log(proxiedObj.a); // -> инкременированое obj.a (5)
console.log(obj.a); // -> 4

console.log(proxiedObj.c); // -> 0, вывод ошибки (the c property doesn't exist)

Мы перехватили поведение по умолчанию операций get и set, определив обработчики с их соответствующими именами в объекте, предоставленном конструктору прокси. Теперь каждая операция get будет возвращать увеличенное значение свойства, а set будет уменьшать значение перед сохранением в целевом объекте.

Что важно помнить с proxy, так это то, что после создания прокси он должен быть единственным способом взаимодействия с объектом.

Различные виды ловушек

Существует множество ловушек (обработчиков, которые перехватывают поведение объекта по умолчанию), кроме get и set, но мы не будем использовать ни одну из них в этой статье. Если вам интересно узнать больше о них, вот документация.

Веселье

Теперь, когда мы знаем, как работают прокси, давайте повеселимся с ними.

Наблюдение за состоянием объекта

Как было сказано ранее, с прокси очень легко перехватывать операции. Например, попробуем понаблюдать за состоянием объекта то есть будет выводим уведомление каждый раз, когда происходит операция присваивания.

const observe = (object, callback) => {
 return new Proxy(object, {
   set(target, propKey, value) {
     const oldValue = target[propKey];
   
     target[propKey] = value;

     callback({
       property: propKey,
       newValue: value,
       oldValue,
     });

     return true;
   }
 });
};

const a = observe({ b: 1 }, arg => {
 console.log(arg);
});

a.b = 5; // -> вывод сообщение в консоль: {property: "b", oldValue: 1, newValue: 5}

И это все, что нам нужно сделать – мы будем вызывать предоставленный обратный вызов callback каждый раз, когда запускается установленный обработчик (через set).

В качестве аргумента для обратного вызова callback мы предоставляем объект с тремя свойствами: имя измененного свойства, старое значение и новое значение.

Перед выполнением обратного вызова мы присваиваем новое значение в целевом объекте, чтобы присвоение действительно имело место. Мы так же возвращаем true, чтобы указать, что операция прошла успешно; в противном случае он выдаст ошибку TypeError.

Здесь живой пример.

Проверка свойств на set

Если подумать, прокси – хорошее место для реализации валидации – и они будут не тесно связаны с самими данными. Давайте реализуем простой proxy проверки.

Как и в предыдущем примере, мы должны перехватить операцию set. Допустим нам нужно получить следующий способ объявления данных:

const personWithValidation = withValidation(person, {
 firstName: [validators.string.isString(), validators.string.longerThan(3)],
 lastName: [validators.string.isString(), validators.string.longerThan(7)],
 age: [validators.number.isNumber(), validators.number.greaterThan(0)]
});

Для этого мы определяем функцию withValidation следующим образом:

const withValidation = (object, schema) => {
 return new Proxy(object, {
   set: (target, key, value) => {
     const validators = schema[key];

     if (!validators || !validators.length) {
       target[key] = value;

       return true;
     }

     const shouldSet = validators.every(validator => validator(value));

     if (!shouldSet) {
       // or get some custom error
       return false;
     }

     target[key] = value;
     return true;
   }
 });
};

Сначала мы проверяем, есть ли в предоставленной схеме validators для свойства, которое в настоящее время присваивается – если его нет, проверять нечего, и мы просто присваиваем значение.

Если для свойства действительно определены validators, мы предполагаем, что все они возвращают true перед присваиванием. Если один из validators возвращает false, вся операция set возвращает false, и в результате чего прокси выдает ошибку.

Последнее, что нужно сделать, это создать объект validators.

const validators = {
 number: {
   greaterThan: expectedValue => {
     return value => {
       return value > expectedValue;
     };
   },
   isNumber: () => {
     return value => {
       return Number(value) === value;
     };
   }
 },
 string: {
   longerThan: expectedLength => {
     return value => {
       return value.length > expectedLength;
     };
   },
   isString: () => {
     return value => {
       return String(value) === value;
     };
   }
 }
};

Объект validators содержит функции проверки, сгруппированные по типу, который они должны проверять. Каждый валидатор при вызове принимает необходимые аргументы, такие как validators.number.greaterThan(0), и возвращает функцию. Проверка происходит в возвращенной функции.

Мы могли бы расширить валидацию всеми видами функций, таких как виртуальные поля или выдача ошибок изнутри валидатора, чтобы указать, что пошло не так, но это сделает код менее читабельным и выходит за рамки этой статьи.

Здесь рабочий пример.

Делать код ленивым

В последнем, и, надеюсь, наиболее интересном примере, давайте создадим прокси, который сделает все операции ленивыми.

Вот очень простой класс Calculator, который содержит несколько основных арифметических операций.

class Calculator {
 add(a, b) {
   return a + b;
 }

 subtract(a, b) {
   return a - b;
 }

 multiply(a, b) {
   return a * b;
 }

 divide(a, b) {
   return a / b;
 }
}

Теперь, если мы запустили следующую строку:

new Calculator().add(1, 5) // -> 6

Результатом будет число 6.

Код выполнится сразу в момент запуска. Нам хотелось бы, чтобы код ожидал сигнала запуска, например запуска метода run. Таким образом, операция будет отложена до тех пор, пока она не понадобится – или не будет выполнена вообще, если в этом нет необходимости.

Таким образом, следующий код должен вместо 6 вернут экземпляр самого класса Calculator:

lazyCalculator.add(1, 5) // -> Calculator {}

Что даст нам еще одну приятную особенность: метод цепочки.

lazyCalculator.add(1, 5).divide(10, 10).run() // -> 1

Проблема с этим подходом заключается в том, что при деление мы не имеем ни малейшего представления о том, что является результатом добавления, что делает его бесполезным. Поскольку мы контролируем аргументы, мы можем легко предоставить способ сделать результат доступным через предварительно определенную переменную – например, $.

lazyCalculator.add(5, 10).subtract($, 5).multiply($, 10).run(); // -> 100

$ здесь просто постоянный Symbol. Во время выполнения мы динамически заменяем его на результат, возвращенный предыдущим методом.

const $ = Symbol('RESULT_ARGUMENT');

Теперь, когда у нас есть четкое понимание того, что мы хотим реализовать, давайте сделаем это.

Создадим функцию под названием lazify. Функция создает прокси, который перехватывает операцию get.

function lazify(instance) {
 const operations = [];

 const proxy = new Proxy(instance, {
   get(target, propKey) {
     const propertyOrMethod = target[propKey];

     if (!propertyOrMethod) {
       throw new Error('No property found.');
     }

     // is not a function
     if (typeof propertyOrMethod !== 'function') {
       return target[propKey];
     }

     return (...args) => {
       operations.push(internalResult => {
         return propertyOrMethod.apply(
           target,
           [...args].map(arg => (arg === $ ? internalResult : arg))
         );
       });

       return proxy;
     };
   }
 });

 return proxy;
}

Внутри ловушки get мы проверяем, существует ли запрошенное свойство; если это не так, мы выдаем ошибку. Если свойство не является функцией, мы возвращаем его, ничего не делая.

Прокси не могут перехватывать вызовы методов. Вместо этого они рассматривают их как две операции: операцию get и вызов функции. Наш обработчик get должен действовать соответственно.

Теперь, когда мы уверены, что свойство является функцией, мы возвращаем нашу собственную функцию, которая действует как оболочка. Когда функция-обертка выполняется, она добавляет еще одну новую функцию в массив операций. Функция-обертка должна возвращать прокси, чтобы сделать возможным цепочку методов.

Внутри функции, предоставленной массиву операций, мы выполняем метод с аргументами, предоставленными оболочке. Функция будет вызываться с аргументом результата, что позволит нам заменить все $ на результат, возвращенный предыдущим методом.

Таким образом, мы задерживаем выполнение до запроса.

Теперь, когда мы создали базовый механизм для хранения операций, нам нужно добавить способ запуска функций – метод .run().

Это довольно легко сделать. Все, что нам нужно сделать, это проверить, соответствует ли запрошенное имя свойства run. Если это так, мы возвращаем функцию-оболочку (поскольку run действует как метод). Внутри оболочки мы выполняем все функции из массива операций.

Окончательный код выглядит так:

const executeOperations = (operations, args) => {
 return operations.reduce((args, method) => {
   return [method(...args)];
 }, args);
};

const $ = Symbol('RESULT_ARGUMENT');

function lazify(instance) {
 const operations = [];

 const proxy = new Proxy(instance, {
   get(target, propKey) {
     const propertyOrMethod = target[propKey];

     if (propKey === 'run') {
       return (...args) => {
         return executeOperations(operations, args)[0];
       };
     }

     if (!propertyOrMethod) {
       throw new Error('No property found.');
     }

     // это не функция
     if (typeof propertyOrMethod !== 'function') {
       return target[propKey];
     }

     return (...args) => {
       operations.push(internalResult => {
         return propertyOrMethod.apply(
           target,
           [...args].map(arg => (arg === $ ? internalResult : arg))
         );
       });

       return proxy;
     };
   }
 });

 return proxy;
}

Функция executeOperations принимает массив функций и выполняет их одну за другой, передавая результат предыдущего вызову следующему.

А теперь последний пример:

const lazyCalculator = lazify(new Calculator());

const a = lazyCalculator
 .add(5, 10)
 .subtract($, 5)
 .multiply($, 10);

console.log(a.run()); // -> 100

Если вы заинтересованы в добавлении дополнительных функций, я добавил в функцию lazify еще несколько функций – асинхронное выполнение, имена пользовательских методов и возможность добавлять пользовательские функции с помощью метода .chain(). Обе версии функции lazify доступны в рабочем примере.

Заключение

Теперь, когда вы увидели прокси в действии, я надеюсь, что вы сможете найти для них хорошее применение в своей собственной кодовой базе.

Прокси имеют гораздо более интересные применения, чем описанные здесь, такие как реализация отрицательных индексов и перехват всех несуществующих свойств в объекте. Однако будьте осторожны: прокси – плохой выбор, когда производительность является важным фактором.

Была ли вам полезна эта статья?
[0 / 0]

Spread the love
Подписаться
Уведомление о
guest
1 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
dimden
dimden
3 лет назад

Красота, спасибо