Обновлять

все шаблоны, описанные в этом посте, были объединены в модуль reactive-props, который можно настроить для любого варианта использования.

Некоторые шаблоны могут быть удобными, не требуя тяжелых библиотек или фреймворков!

Этот пост представляет собой прогулку по реактивным подходам и всем деталям, которые следует учитывать при принятии того или иного.

Свойство Accessor

Первое, что нам нужно понять, это то, как работает реактивная парадигма, которую можно резюмировать как «некоторые данные изменены, требуется некоторое обновление».

const data = {key: 'some value'};
// how can we intercept the following?
data.key = 'some other value';

Способ перехватить любое изменение свойства внутри почти любого универсального объекта - описать «средство доступа».

{
  get(){},       // optional
  set(value){},  // also optional
  enumerable,    // false by default
  configurable   // false by default
}

Для этого используется метод Object.defineProperty(obj, key, descriptor) или Object.defineProperties(obj, descriptors).

В отличие от общих дескрипторов, конфигурация writable не может использоваться для аксессоров, и наоборот: доступные для записи свойства не могут быть аксессорами.

Чтобы быстро показать, где может располагаться наш код, чтобы отреагировать на изменение, все, что нам нужно сделать, это использовать один из двух Object методов и перехватить установщик:

let value = void 0;
const data = Object.defineProperty({}, 'key', {
  get() { return value; },
  set(newValue) {
    value = newValue;
    console.log('react to changes!');
  }
});

После этого data.key = 'any value' запустит этот установщик, обновит старое значение и, наконец, запишет «реагировать на изменения!».

Отлично, у нас уже есть все необходимое, чтобы реагировать на изменение данных!

Базовый аксессуар

Примечание: с модулем reactive-props этот шаблон покрывается:

const basicHandler = reactiveProps({all: true});

Как бы просто это ни выглядело, basicAccessor уже решает множество вариантов использования, в первую очередь тех, которые должны реагировать / обновлять что-либо при каждом изменении свойства.

const basicAccessor = (value, update) => ({
  get: () => value,
  set: _ => {
    value = _;
    update();
  }
});

Однако, чтобы создать объект, который вызывает несколько update() для каждого свойства, нам понадобится дополнительная утилита, подобная этой:

const {defineProperties, keys} = Object;
const basicHandler = (props, update) => {
  const desc = {};
  for (const key of keys(props))
    desc[key] = basicAccessor(props[key], update);
  return defineProperties({}, desc);
};

basicHandler принимает некоторый общий литерал объекта для настройки всех средств доступа и возвращает специальную копию, которая будет реагировать на любое изменение:

const state = basicHandler(
  {test: ''},
  () => console.log('updated')
);
state.test = 'OK';  // updated
state.test = 'OK';  // updated
state.test = 'OK';  // updated

Вот и все, теперь у нас есть несколько строк кода для создания состояний, которые будут вызывать общий обратный вызов, как только некоторая часть такого состояния была установлена ​​, даже если свойство, которое изменилось получили точно такое же значение ... но можем ли мы сделать что-нибудь лучше?

Лучший аксессуар

Примечание: с модулем reactive-props этот шаблон покрывается:

const reactOnChanges = reactiveProps();

Есть масса случаев, когда мы не хотим или не нуждаемся в реакции на изменения, особенно если функция update запускает перекомпоновку, перерисовку или вычисление всего макета для чего-то, что ранее было идентичным.

Для этих очень распространенных случаев, по крайней мере, на стороне пользовательского интерфейса, мы могли бы написать следующее:

const betterAccessor = (value, update, all) => ({
  get: () => value,
  set: _ => {
    if (all || value !== _) {
      value = _;
      update();
    }
  }
});

Основное отличие состоит в том, что теперь у нас есть флаг «уведомлять все» или «уведомлять только об изменениях» в уравнении, но как мы можем передать такой флаг, если он есть операция изменения состояния - это просто «установить значение свойства»?

state.change = value;

Чтобы различать состояния, которые должны запускать обновления, независимо от того, является ли значение одинаковым, нам нужно создать разные обработчики состояний:

const {defineProperties, keys} = Object;
const betterHandler = ({all = false} = {}) =>
  (props, update) => {
    const desc = {};
    for (const key of keys(props))
      desc[key] = betterAccessor(props[key], update, all);
    return defineProperties({}, desc);
};

Этот betterHandler теперь возвращает функцию, которая будет содержать логический параметр all, так что состояния, созданные одним обработчиком, могут реагировать на все, а другие обработчики могут реагировать только на фактические изменения.

const reactOnChanges = betterHandler();
// all option is false by default
const state = reactOnChanges(
  {test: ''},
  () => console.log('updated')
);
state.test = 'OK';  // updated
state.test = 'OK';
state.test = 'OK';
state.test = 'new'; // updated
state.test = 'new';

Замечательно: без раздувания кода у нас есть способ создать два типа «реакторов данных»… но почему это важно?

Различные мелкие варианты использования

Добавление средств доступа к любому полю / свойству означает, что вместо простого извлечения или перезаписи некоторого значения в процесс вовлечены обратные вызовы, а именно get и set (value) один плюс возможные затраты на update ().

Поскольку мы делаем это только на верхнем уровне объекта, в основном для того, чтобы это было быстро и просто, мы не знаем, представляют ли передаваемые данные на самом деле те же данные, что и раньше.

Вот пример:

const a = {data: [1, 2, 3]};
const b = {data: [1, 2, 3]};
state.data = a;
state.data = b;

В частности, когда дело доходит до сетевых запросов и синтаксического анализа JSON, объект данных каждый раз отличается, но если у нас есть данные, хранящиеся где-то в другом месте, и только одна часть его содержимого изменилась, у нас будут проблемы. не вызывая никаких update() при таком изменении данных, даже если это та же ссылка:

const same = {data: [1, 2, 3]};
state.data = same;
// later on ...
same.data.push(4);
state.data = same;
// no update triggered!!!

Соответственно, когда общие данные предназначены для передачи, рекомендуется сделать наш дескриптор менее строгим:

const betterAccessor = (value, update, all) => ({
  get: () => value,
  set: _ => {
    if (
      all ||
      (typeof value === 'object' &&
       value !== null) ||
      value !== _
    ) {
      value = _;
      update();
    }
  }
});

На этом этапе мы могли бы также отказаться от флага all и сделать его еще более простым, поскольку защита от «точно таких же данных» и «возможно разных данных» уже есть встроен в дескриптор, который у нас есть для каждого свойства.

Я позволю вам решить, нужен ли all флаг или нет, когда simpleDescriptor реализует только что описанный мною охранник, но, безусловно, разумно рассмотреть все эти случаи и быть готовым избежать сюрпризов, таких как пропущенные или слишком большие обновления. .

С модулем reactive-props передайте {shallow: false}, если переданные данные неизменяемы.

Подцепленный аксессуар

Примечание: с модулем reactive-props этот шаблон покрывается:

const useStateHandler = reactiveProps({useState});

Хотя «хуки» не являются строго обязательными для наличия реактивных изменений, когда они есть, самым простым в использовании примитивом, вероятно, будет примитив useState, предоставляемый практически каждой библиотекой на основе хуков. там.

Пометка all может быть хорошей идеей, но это всего лишь один из вариантов, который нам может понадобиться для предварительной настройки наших «реактивных состояний».

const useStateAccessor = (value, update, all) => ({
  get: () => value,
  set: _ => {
    if (all || value !== _)
      update(value = _);
      // note the value is passed
    }
});

Основное отличие этого метода доступа заключается в том, что value передается как update(value) аргумент, так что следующий, общий шаблон может работать:

const [counter, update] = useState(0);
update(counter + 1);

Танец, необходимый для использования крючков, все еще проходящий через state, теперь следующий:

const {defineProperties, keys} = Object;
const useStateHandler = ({all = false, useState} = {}) =>
  props => {
    const desc = {};
    for (const key of keys(props)) {
      const [value, update] = useState(props[key]);
      desc[key] = useStateAccessor(value, update, all);
    }
    return defineProperties({}, desc);
  };

value будет по-прежнему передаваться через вызов get (), в то время как часть update(newValue) будет делегирована вызову set (value), при этом value будет синхронизироваться внутри собственное закрытие.

На этом этапе, чтобы создать функцию «реактивная», все, что нам нужно сделать, это передать утилиту useState, обычно предоставляемую множеством библиотек:

// not a real useState utility
const useState = oldValue => [
  oldValue,
  newValue => {
    console.log(`the value is now: ${newValue}`);
  }
];
const reactive = useStateHandler({useState});
const state = reactive({test: ''});
state.test = 'OK';  // the value is now: OK
state.test = 'OK';
state.test = 'OK';
state.test = 'new'; // the value is now: new
state.test = 'new';

Отлично, с 20 строками JS мы теперь подключены ко всему 🎉

Но как это на самом деле работает?

Причина, по которой это работает, заключается в том, что обычно свойства, на которые нужно реагировать, хорошо известны и статичны, но, поскольку useState, используется условно, это как бы запрещено в большинстве «подключенных» решений, следующий шаблон выглядит очень плохой идеей:

// foreign object might have a different number
// of properties each time, and this is an issue!
const state = reactive({...foreign, test: ''});

Если вы хотите лучше понять, почему это может сломаться, не стесняйтесь читать этот добрый мой пост о хуках 😉

О ДОМ

Теперь, когда мы знаем почти все, что нужно знать об обработке состояния данных, пора перейти к немного более сложной теме.

Есть два солнца

Возможно, наиболее неправильно понимаемая часть спецификации HTML заключается в том, что HTML не имеет собственной концепции свойств, она основана только на атрибутах, которые либо есть, имеют значение или не существуют.

<p attr="value">OK</p>
<p attr>also OK</p>
<p>still OK</p>
  • первый p имеет атрибут attr со значением value, который также всегда является строкой.
  • второй p имеет атрибут attr, но его значение является пустой строкой, даже если его значение является логическим (т.е. <button disabled>)
  • у третьего p вообще нет attr

Однако, если мы прикрепим напрямую свойство вместо атрибута к любому из этих элементов p, ни одно из их значений не будет отражено как атрибут, если только их прототип не имеет некоторых специальное значение для этого свойства, каким-то образом сопоставленное с обновлением представления DOM (иначе: DOM также реагирует на изменения свойств)

const p = document.createElement('p');
p.some = 'thing';
p.outerHTML; // "<p></p>"
p.hidden = true;
p.outerHTML; // "<p hidden=\"\"></p>"

Чрезвычайно важно провести различие между прямым доступом к свойствам и атрибутами, поскольку применимы следующие последствия:

  • изменение атрибута легко приводит к потенциальному изменению CSS, если мы ошибочно ошибемся свойства с атрибутами, браузер может в конечном итоге сделать гораздо больше, чем мы надеялись на
  • хорошо известное свойство может вызывать визуальные реакции после того, как свойство отражается в DOM, и существует риск того, что наши собственные методы доступа могут затенять собственное поведение DOM, к лучшему или худшему
  • в мире пользовательских элементов наблюдение за атрибутами требует, чтобы разработчики использовали element.setAttribute(key, value), что также всегда приводит к value и attributeChangedCallback receive, строка, так что взаимодействие становится менее естественным с обычным JS мир, где свойства обычно могут обращаться к любому типу значения, а атрибуты могут обрабатывать только строки
  • установка атрибута или использование его метода доступа DOM не часто дает ожидаемый результат
const input = document.createElement('input');
// as attribute
input.setAttribute('value', 'before');
input.outerHTML;
// <input value="before">
// as accessor
input.value = 'after';
input.outerHTML;
// still <input value="before">
// but ...
input.value;
// "after"

Соответственно, всякий раз, когда мы видим <el attr="value">, мы видим атрибут, установленный как строку, которая видна в пользовательском интерфейсе / CSS / DOM, а el.attr = 'value' не будет отражена, если только атрибут не является специальным, который также влияет на представление, как большинство логические атрибуты есть, но также косвенно style.

Почему это важно?

Это важно, потому что, если выбранная вами библиотека не имеет такого различия между тем, что устанавливается как атрибут, и тем, что вместо него является свойством, такая библиотека, вероятно, вызовет больше головной боли, и проблем с производительностью, связанных с пользовательским интерфейсом, чем вы могли ожидать. Например:

  • библиотека, отражающая изменения свойств в виде атрибутов, может вызвать ненужные операции отрисовки, поскольку в любом селекторе CSS может быть div[data] { do: something; }, так что каждое изменение атрибута может мешать такому селектору, что приводит к большему количеству операций браузеров
  • библиотека, которая рассматривает атрибуты как свойства, всегда будет ограничена строковыми значениями, так как ничто другое не может быть представлено на стороне HTML
  • библиотека, которая не делает этого различия явно, просто неоднозначна

Хотя SSR…

Так как же нам выбрать какое-то свойство, которое может быть просто строкой или мы уверены, что оно представляет какое-то значение JSON, которое можно проанализировать?

Поскольку рендеринг на стороне сервера может создавать только HTML, мы должны принимать во внимание возможные значения, так же, как это происходит при естественном взаимодействии JS / DOM… так что , вернемся к примеру ввода:

const input = document.createElement('input');
input.setAttribute('value', 'before');
input.value; // "before"

Только когда мы напрямую устанавливаем свойство input, оно будет отсоединено от его HTML-представления, но поскольку любой сервер или статическая страница может отображать <input value="before">, существует упрощение средства доступа, так что, если оно не установлено напрямую, возвращаемое значение является атрибутом, а не свойством.

Аксессор элемента

Примечание: с модулем reactive-props этот шаблон покрывается:

const elementHandler = reactiveProps({dom: true});

Хорошо, теперь мы знаем все, что нужно знать между свойствами и атрибутами, так что следующий код не должен вызывать особого удивления:

const {defineProperties, keys} = Object;
const elementHandler = ({all = false} = {}) =>
  (element, props, update) => {
    const desc = {};
    for (const key of keys(props)) {
      let value = props[key];
      if (element.hasOwnProperty(key)) {
        value = element[key];
        delete element[key];
      }
      else if (element.hasAttribute(key))
        value = element.getAttribute(key);
      desc[key] = betterAccessor(value, update, all);
    }
    return defineProperties(element, desc);
  };

Вот несколько ключевых моментов:

  • если element.property = value был ранее установлен, начальное значение средства доступа должно использовать его, поскольку оно более значимо, чем базовое свойство по умолчанию, переданное как props
  • то же самое относится к элементу, полученному из HTML с атрибутом, и тем же именем свойства, уже определенным, как и в предыдущем <input value="initial"> примере, помните? В таком случае можно использовать это значение атрибута в качестве начального значения средства доступа, потому что, вероятно, если оно уже было представлено как HTML, это означает, что значение, вероятно, в любом случае является строкой, но имейте в виду, что числа и логическим значениям может потребоваться JSON.parse(attribute), если ожидаемое свойство имеет тип, отличный от string, чтобы остальная часть кода работала должным образом
  • в любом другом случае начальное значение средства доступа - это просто props[key], предназначенное как значение по умолчанию или резерв

Стоит упомянуть, вероятно, немного позже в этом абзаце, что betterAccessor здесь переработано из верхней части этого сообщения, но это также могут быть утилиты любых других упомянутых аксессоров, так как здесь мы фокусируемся на состоянии логика обработчика ... вот окончательный результат:

const reactiveElement = elementHandler();
const body = reactiveElement(
  document.body,
  {test: ''},
  () => {
    console.log('body updated');
  }
);
body.test = 'OK';  // body updated
body.test = 'OK';
body.test = 'OK';
body.test = 'new'; // body updated
body.test = 'new';

И вот оно, реактивное состояние, прикрепленное непосредственно к любому элементу DOM, так что пользовательские элементы, как и любой другой обычный элемент, могут иметь собственную обработку состояния, представленную свойства, не атрибуты!

Иметь в виду

Даже если пользовательские элементы были созданы для обработки любых изменений атрибутов, перечисленных через статический массив observedAttribute, который принимает любое имя атрибута, всякий раз, когда мы используем свойства, в отличие от атрибутов , мы должны быть осторожны, чтобы не рисковать будущими проблемами совместимости.

Верно, что веб-стандарты - это не самая быстрая движущаяся часть в наши дни, когда дело доходит до новых предложений и их принятия, но если мы добавим свойства, названные с уже доступными атрибутами в других узлах, или те, которые имеют особое значение , мы можем поставить под угрозу Интернет будущего (поэтому, пожалуйста, не называйте свой аксессуар доступа как el[aria-case])

Несколько слов о библиотеках

В случае React JSX, который является (своего рода) HTML фасадом, часто можно увидеть то, что явно похоже на атрибуты, но используется как свойства:

function Comp() {
  return (
    <Sub key={value} />
  );
}

Но даже если key выглядит как допустимый атрибут, компонент Sub получит его не как атрибут, а как объект props, так что props.key будет иметь value, а не узел это представляет.

Однако в React не все свойства отражаются как атрибуты, это выбор разработчика, поэтому различие здесь важно, поскольку JSX может ввести в заблуждение, если его интерпретировать как простой / необработанный HTML.

В других библиотеках на основе шаблонных литералов также важно понимать, как они работают.

Например, µhtml проводит четкое различие между тем, что подразумевается как атрибут, и тем, что подразумевается как средство доступа к свойству JS, посредством явного, а не стандартного HTML префикса ., поэтому что:

html`<element attr=${str} .prop=${value} />`

означает, что элемент element будет иметь атрибут arr со строковым содержимым, но также element.prop, что будет нести какое бы то ни было значение, а не только строки, с аксессуарами или без них.

Почему это важно?

Когда дело доходит до настраиваемых элементов в сочетании с реактивными шаблонами, чрезвычайно важно знать, что атрибут может вызвать attributeChangedCallback вместо обновления с помощью хуков или любой другой техники, показанной в этом посте, просто потому, что изменение атрибута не пройдет. через установщик он будет передан как изменение атрибута и строка, и если не будет наблюдателя, который передаст этот атрибут через обработчик state, ничего не будет работать должным образом.

class MyCE extends HTMLElement {
  static observedAttributes = ['prop'];
  constructor() {
    super();
    const update = this.update.bind(this);
    reactiveElement(this, ['prop'], update);
  }
  attributeChangedCallback(key, prev, value) {
    this[key] = value;
  }
  update() {
    this.textContent = this.prop;
  }
}

Вышеупомянутый класс Custom Element является примером компонента, который может принимать как <el prop=${value}>, так и <el .prop=${value}>, но все же лучше сделать это различие менее неоднозначным на уровне библиотеки просто потому, что предыдущая версия <el prop=...> всегда будет передавать строку, в то время как свойство может быть любого другого типа, так что если остальная часть кода ожидает, что el.prop вернет логическое значение, число или некоторые сложные данные, он не сможет.

Почему бы не использовать прокси?

Так как меня недавно спросили об этом, я думаю, что здесь тоже стоит ответить. Есть несколько причин не использовать прокси:

  • совместимость: прокси-сервер не будет работать в IE11 и старых версиях браузеров. Текущий модуль reactive-props работает до IE9 или IE8 в случае обработчика dom
  • производительность: прокси-сервер может работать с любым ключом, а не только с предварительно определенным, но, как объяснялось ранее, если есть useState шаблон, он должен знать все свойства заранее и он должен защититься от доступа к нежелательным свойствам. Итог: прокси как оболочка и дополнительные проверки, необходимые для каждого доступного свойства.
  • не совместим с DOM: прокси-сервер обертывает ссылку своим проксированием, что может быть нормально для обычных состояний, но не будет производить желаемый эффект с элементами DOM, потому что только создатель прокси может отправлять или передавать вокруг прокси, но сам элемент не будет обновлен, поэтому его состояние не будет отражено в остальной части стека.

В итоге, поскольку чтение и установка свойств состояния находится в критическом пути и поскольку элементы должны обновляться, а не просто упаковываться, прокси-сервер не является решением этой проблемы, или лучше, он не подходит по производительности и переносимости, и он не будет работать с useState и элементами.

Если нас не интересуют какие-либо из этих ограничений и мы просто хотим реагировать на любые изменения, это тоже может быть вариантом:

const proxyHandler = (props, update) => new Proxy(props, {
  set: (props, key, value) => {
    props[key] = value;
    update(key, value);
    return true;
  }
});
const state = proxyHandler({test: ''}, console.log);
state.test = 'OK';
// test OK
state.any = 'still OK';

Чтобы защититься от неизвестных свойств, обработчик будет выглядеть так:

const proxyHandler = (props, update) => new Proxy(props, {
  set: (props, key, value) => {
    const result = props.hasOwnProperty(key);
    if (result) {
      // <optional>
      // if (props[key] !== value) {
      props[key] = value;
      update(key, value);
      // }
    }
    return result;
  }
});

Некоторые живые примеры

Эта демонстрация wickedElements, вместе с этой основанной на µce, использует эти шаблоны, без использования хуков, для обновления всего при изменении состояния, так что можно увидеть и поиграть по-настоящему с этими крошечными помощниками, которые мы обсуждали. .

Заключение

Я считаю, что есть много подтем, редко затрагиваемых в технических сообщениях, связанных с Интернетом, и я надеюсь, что есть пища для размышлений о шаблонах обработки состояния, их потенциале, а также о том факте, что нам на самом деле не нужно React, чтобы иметь такие хуки, как функциональность, и тот факт, что нужно больше говорить об атрибутах VS свойствах, когда дело доходит до DOM элементы.

И последнее, но не менее важное, приношу свои извинения за более длинный, чем обычно, пост, но я думаю, что это хороший справочник, который стоит иметь под рукой, и я надеюсь, что здесь тоже есть чему поучиться. 👋