
После 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, можно использовать отдельно от всего фреймворка.
ссылка на оригинал статьи https://habr.com/ru/company/deliveryclub/blog/664506/
Добавить комментарий