После 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 — словарь со всеми зависимостями конкретного поля, поэтому в качестве ключа у неё поле объекта, который мы хотим сделать реактивным, а в качестве значения — сами зависимости;

  • depsSet со всем зависимостями конкретного поля.

Попробуем реализовать это в коде. Допустим, у нас есть объект 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, можно использовать отдельно от всего фреймворка.