После jQuery я попробовал AngularJS и был очарован его возможностями. Несколько строк в AngularJS заменяли кучу спагетти-кода в jQuery. Это было похоже на магию. Сейчас все современные Frontend-фреймворки так или иначе обеспечивают реактивность, и это уже никого не удивляет. Тем не менее далеко не все разработчики понимают, как это работает.
Сейчас я работаю с Vue, поэтому и разбираться с тем, как устроены реактивные функции, будем на его примере. Я расскажу, как сделать из простого объекта реактивный, а также немного о том, какие современные возможности JS для этого используются.
Описание проблемы
Допустим, у нас есть объект, в котором хранится информация о количестве товаров на разных складах. Мы хотим знать, сколько их суммарно:
const items = {
store1: 3,
store2: 4,
};
let totalCount = 0;
const effect = () => (totalCount = items.store1 + items.store2);
effect();
console.log(totalCount); // 7
items.store2 = 23;
console.log(totalCount); // 7 - итоговая сумма не поменялась
Такое поведение очевидно для тех, кто работает с JS. Но как быть, если не хочется после каждого изменения объекта вызывать функцию пересчёта? Во Vue есть функция reactive
, и выглядит она примерно так:
import { reactive } from 'vue'
// реактивное состояние
const items = reactive({
store1: 3,
store2: 4,
})
setTimeout(() => {
items.store1 = 10;
}, 3000)
return { items }
Теперь где-нибудь в шаблоне выведем сумму товаров на всех складах. После срабатывания setTimeout
сумма в шаблоне поменяется:
<template>
<pre>{{items.store1 + items.store2 }}</pre>
</template>
Реализуем аналог функции reactive
Я предлагаю написать свою реализацию функции reactive
, чтобы понять, как она работает «под капотом». Структура данных будет такой:
здесь:
targetMap
— корневое хранилище наших реактивных объектов; ключом будет объект, который мы хотим сделать реактивным, а значением —depsMap
;depsMap
— словарь со всеми зависимостями конкретного поля, поэтому в качестве ключа у неё поле объекта, который мы хотим сделать реактивным, а в качестве значения — сами зависимости;deps
—Set
со всем зависимостями конкретного поля.
Попробуем реализовать это в коде. Допустим, у нас есть объект items
, который хранит в себе информацию о том, сколько объектов лежит на каждом из складов.
const items = {
store1: 3,
store2: 4,
};
let totalCount = 0;
const effect = () => (totalCount = items.store1 + items.store2);
// Создаем корневое хранилище для реактивного объекта
const targetMap = new WeakMap();
// Функция track будет начинать отслеживание изменений в конкретном поле объекта
function track(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(effect);
}
// Начинаем отслеживать изменения в обоих полях объекта items
track(items, 'store1');
track(items, 'store2');
// Функция trigger будет запускаться каждый раз, когда происходят какие-то изменения в поле объекта
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
if (!deps) return;
deps.forEach((_effect) => _effect());
}
// Запускаем нашу функцию, чтобы обновить значение переменной totalCount
effect();
console.log(totalCount); // 7
// Обновляем количество айтемов на одном из складов
items.store2 = 23;
// Оповещаем об этом при помощи функции.
trigger(items, 'store2');
// Проверяем, что totalCount пересчиталась
console.log(totalCount); // 26
По-моему, прекрасно: целая страница кода ради функциональности, которую можно описать тремя строчками кода :) Но это только первая итерация, давайте улучшать. Проблемы этого решения:
чтобы отслеживать изменения, необходимо вручную добавлять каждое поле каждого объекта;
для применения изменений необходимо вручную запускать функцию-триггер.
Для решения этих проблем воспользуемся современными инструментами, которые нам предоставляет JS, а именно Proxy
и Reflect
.
Reflect
Предположим, у нас есть объект user
:
const user = {
name: 'Alex',
age: 32
}
Существует три способа обратиться к полю age
:
console.log(user.age);
console.log(user['age']);
сonsole.log(Reflect.get(user, 'age'));
Все три вернут значение, но у Reflect.get
есть дополнительное преимущество в виде третьего аргумента receiver
, который мы будем использовать совместно с Proxy
. Если коротко, то receiver
— это прокси или объект, унаследованный от прокси.
Больше информации о Reflect API можно найти в этой статье, но если коротко, Reflect API — это такая обёртка для манипуляций с объектом.
Proxy
Proxy
— это объект, который оборачивается вокруг другого объекта и позволяет перехватывать запросы к нему (target
), модифицируя их.
Для примера опять воспользуемся объектом user
, а также создадим объект handler
, который будет иметь два метода — get
и set
, и передадим его в новый экземпляр Proxy
.
const user = {
name: 'Alex',
age: 32
}
const handler = {
get(target, key, receiver) {
console.log('Was called Get method with key: ' + key);
return Reflect.get(target, key, receiver)
},
set(target, key, val, receiver) {
console.log('Was called Set method with key: ' + key + ' and velue: ' + val);
return Reflect.set(target, key, val, receiver);
}
}
user = new Proxy(user, handler);
console.log(user.name); // Was called Get method with key: name
// Alex
console.log((user.age = 33)); // Was called Set method with key: age and value 33
// 33
Пара уточнений по коду: я специально записал proxy
-обёртку над объектом user
в ту же переменную, поскольку в таком случае можно быть уверенным, что нигде не используется оригинальный объект. В данном случае receiver
сохраняет контекст this
для тех объектов, которые имеют унаследованные от других объектов области или функции.
Для более глубокого понимания вот пара статей на тему JS Proxy
:
Используем проксирование запросов к исходному объекту
Теперь понимая, как работает Proxy
и Reflect
, применим их для создания реактивного объекта.
Давайте наконец напишем функцию reactive
, а внутри неё создадим handler
, аналогичный тому, который мы писали в примере с Proxy
. Сам handler
я предлагаю немного модифицировать и добавить Getter-функцию track
, а в Setter — проверку на факт отличия нового значения от предыдущего, и если это действительно так, то запускать trigger()
.
const targetMap = new WeakMap();
function track(target, key) {
let depsMap = targetMap.get(target);
if(!depsMap){
targetMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if(!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(effect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
if (!deps) return;
deps.forEach((_effect) => _effect());
}
const reactive = (target) => {
const handler = {
get: (target, key, receiver) => {
const d = Reflect.get(target, key, receiver)
track(target, key);
return d;
},
set: (target, key, value, receiver) => {
const oldVal = target[key];
const newVal = Reflect.set(target, key, val, receiver);
oldVal !== newVal && trigger(target, key);
target[key] = value;
return newVal;
},
};
return new Proxy(target, handler);
};
let totalCount = 0;
const effect = () => (totalCount = items.store1 + items.store2);
let items = reactive({
store1: 3,
store2: 4,
});
effect(); // нужно запустить effect для того, чтобы прочитать поля из объекта и запустить отслеживание
items.store1 = 44; // устанавливаем новое значение
console.log(totalCount); // 48
items.store2 = 24; // устанавливаем новое значение для второго поля
console.log(totalCount); //68
Отлично, этот пример уже больше похож на реактивную функцию, но мне по-прежнему не нравится, что перед использованием необходимо вручную запускать функцию effect()
. Исправим это дополнительной переменной activeEffect
:
let activeEffect = null;
function effect(eff) {
activeEffect = eff; // устанавливаем новое значение activeEffect
activeEffect(); // запускаем новое значение
activeEffect = null; // отменяем установку
}
Теперь давайте перепишем объявление функции effect
:
// Напомню она выглядела вот так
const effect = () => (totalCount = items.store1 + items.store2);
// Теперь она будет выглядеть так
effect(() => {
totalCount = items.store1 + items.store2
})
И теперь отдельный вызов effect()
можно удалить, но при этом нам необходимо модифицировать функцию track
:
function track(target, key) {
if(!activeEffect) return // добавляем проверку
let depsMap = targetMap.get(target);
!depsMap && targetMap.set(target, (depsMap = new Map()));
let deps = depsMap.get(key);
!deps && depsMap.set(key, (deps = new Set()));
deps.add(activeEffect); // добавляем ее в хранилище
}
Итоговый код получился такой:
const targetMap = new WeakMap();
let activeEffect = null;
function effect(eff) {
activeEffect = eff;
activeEffect();
activeEffect = null;
}
function track(target, key) {
if (activeEffect) {
let depsMap = targetMap.get(target);
!depsMap && targetMap.set(target, (depsMap = new Map()));
let deps = depsMap.get(key);
!deps && depsMap.set(key, (deps = new Set()));
deps.add(activeEffect);
}
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
if (!deps) return;
deps.forEach((_effect) => _effect());
}
const reactive = (target) => {
const handler = {
get: (target, key, receiver) => {
const d = Reflect.get(target, key, receiver);
track(target, key);
return d;
},
set: (target, key, value, receiver) => {
const oldVal = target[key];
oldVal !== value && trigger(target, key);
return Reflect.set(target, key, value, receiver);
},
};
return new Proxy(target, handler);
};
let totalCount = 0;
let items = reactive({
store1: 3,
store2: 4,
});
effect(() => {
totalCount = items.store1 + items.store2;
});
items.store1 = 44; // устанавливаем новое значение
console.log(totalCount); // 48
items.store2 = 24; // устанавливаем новое значение для второго поля
console.log(totalCount); //68
Заключение
Эта реализация функции Reactive
очень похожа на реализацию в Vue 3. Если вы работаете с Vue, то очень советую познакомиться с тем, как функция написана в библиотеке, а эта статья поможет вам разобраться.
P.S. Кстати, реактивные функции, используемые во Vue, можно использовать отдельно от всего фреймворка.