После 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];
const newVal = Reflect.set(target, key, value, receiver);
oldVal !== newVal && trigger(target, key);
target[key] = value;
return newVal;
},
};
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, можно использовать отдельно от всего фреймворка.
rudinandrey
Кстати, реактивные функции, используемые во Vue, можно использовать отдельно от всего фреймворка.
Тогда почему этот код возвращает 7 и 7 ?
nin-jin
Потому что у вас там опечатка в коде, давайте я поправлю: