Функция Reactive во Vue: как это работает

от автора

После 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];       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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *