Реактивность

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

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

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

Прокси

Реактивность требует, чтобы фреймворк отслеживал взаимосвязь между состоянием приложений и компонентов. Для построения графа реактивности фреймворк должен хотя бы один раз отрисовать всё приложение. Построение этого графа реактивности первоначально происходит на сервере и сериализуется в HTML, чтобы браузер мог использовать информацию без необходимости совершать проход по всем компонентам для восстановления графа (Qwik не нуждается в гидратации для регистрации событий или построения графа реактивности).

Реактивность может быть достигнута несколькими способами:

  1. Используя явную регистрацию слушателей с помощью .subscribe() (как в RxJS);
  2. Используя неявную регистрации с помощью компилятора (как в Svelte);
  3. Используя неявную регистрации с помощью прокси.

Qwik использует прокси по нескольким причинам:

  1. Использование явной регистрации, такой как .subscribe(), потребует от системы сериализации всех подписанных слушателей, чтобы избежать гидратации. Сериализация подписанных замыканий невозможна, поскольку все функции подписки должны быть лениво загружены и асинхронны (слишком дорого).
  2. Использование компилятора для неявного создания графа будет работать, но только для компонентов. Внутрикомпонентные коммуникации по-прежнему будут требовать методов .subscribe() и, следовательно, страдать от проблем, описанных выше.

Из-за вышеуказанных ограничений Qwik использует прокси для отслеживания графа реактивности.

  • используйте useStore() для создания прокси хранилища состояния;
  • прокси перехватывает операции чтения и создаёт подписки, которые сериализуемы;
  • прокси перехватывает запись и использует информацию о подписке для инвалидации соответствующих компонентов.

Пример счётчика

export const Counter = component$(() => {
  const store = useStore({ count: 0 });
 
  return <button onClick$={() => store.count++}>{store.count}</button>;
});
  1. Сервер выполняет начальный рендер компонента. Рендер на сервере включает в себя создание прокси, представленного переменной store.
  2. Начальный рендер вызывает метод OnRender, который содержит ссылку на прокси store. Рендер переводит прокси в режим "обучения". Во время создания JSX прокси отслеживает операцию чтения свойства count. Поскольку прокси находится в режиме "обучения", он записывает, что текстовый узел внутри Counter имеет подписку на store.count.
  3. Сервер сериализует состояние приложения в HTML. Сюда входит store, а также информация о подписке, которая говорит, что текстовый узел внутри Counter подписан на store.count.
  4. В браузере пользователь нажимает на кнопку. Поскольку обработчик события клика замыкается на store, Qwik восстанавливает прокси хранилища. Прокси содержит состояние приложения (счётчик) и подписку, которая связывает текстовый узел внутри Counter со свойством state.count.
  5. Обработчик клика увеличивает значение store.count. Поскольку store является прокси, он замечает запись и использует информацию для создания сигнальной операции по обновлению текстового узла внутри Counter.
  6. После requestAnimationFrame значение сигнала отражается в DOM путём обновления значения текстового узла до значения сигнала.

Пример отписки

export const ComplexCounter = component$(() => {
  const store = useStore({ count: 0, visible: true });
 
  return (
    <>
      <button onClick$={() => (store.visible = !store.visible)}>
        {store.visible ? 'скрыть' : 'показать'}
      </button>
      <button onClick$={() => store.count++}>увеличить</button>
      {store.visible ? <p>{store.count}</p> : null}
    </>
  );
});

Этот пример представляет собой более сложный счётчик.

  • Он содержит кнопку увеличить, которая увеличивает store.count.
  • Он содержит кнопку показать/ скрыть, которая определяет, будет ли показан счётчик.
  1. При первоначальном рендере счётчик виден. Поэтому сервер создаёт подписку, которая записывает, что ComplexCounter должен быть перерисован, если изменится либо store.count, либо store.visible.
  2. Если пользователь нажимает кнопку скрыть, ComplexCounter рендерится заново. Повторный рендер очищает все подписки и записывает новые. На этот раз JSX не читает store.count. Поэтому в список подписок добавляется только store.visible.
  3. Нажатие пользователя на кнопку увеличить приведёт к обновлению store.count, но это не вызовет повторного рендера компонента. Это правильно, потому что счётчик не виден, поэтому ререндер будет бесполезен.
  4. Если пользователь нажмет кнопку показать, то компонент будет повторно рендериться, и на этот раз JSX отметит чтение как store.visible, так и store.count. Список подписок вновь будет обновлен.
  5. Теперь нажатие на увеличить обновляет store.count. Поскольку счётчик виден, ComplexCounter подписывается на store.count.

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

Вложенные объекты

До сих пор примеры показывали, что состояние (useStore()) было простым объектом с примитивными значениями.

export const MyComp = component$(() => {
  const store = useStore({
    person: { first: null, last: null },
    location: null
  });
 
  store.location = {street: 'main st'};
 
  return (
    <section>
      <p>{store.person.last}, {store.person.first}</p>
      <p>{store.location.street}</p>
    </section>
  );
})

В приведенных выше примерах Qwik автоматически обернёт дочерние объекты person и location в прокси и правильно создаст подписки на все вложенные свойства.

Описанное выше поведение обёртки имеет один удивительный побочный эффект. Запись и чтение из прокси автоматически оборачивает объект, что означает, что идентичность объекта меняется. Обычно это не бывает проблемой, но разработчику следует помнить об этом.

export const MyComp = component$(() => {
  const store = useStore({ person: null });
  const person = { first: 'John', last: 'Smith' };
  store.person = person; // store.person автоматически оборачивает объект в прокси.
 
  if (store.person !== person) {
    // Следствием этого является изменение идентичности объекта.
    console.log('store auto-wrapped person into a proxy');
  }
});

Внеочередной рендер

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

Когда происходит рендер компонентов, они должны иметь доступ к своим параметрам. Параметры задают родительские компоненты. Параметры должны быть сериализуемыми, чтобы компонент рендерился независимо от родительского (смотри раздел сериализация для более подробной инфорамции о том, что сериализуется).

Инвалидация дочерних компонентов

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

export const Child = component$((props: { count: number }) => {
  return <span>{props.count}</span>;
});
 
export const MyApp = component$(() => {
  const store = useStore({ a: 0, b: 0, c: 0 });
 
  return (
    <>
      <button onClick$={() => store.a++}>a++</button>
      <button onClick$={() => store.b++}>b++</button>
      <button onClick$={() => store.c++}>c++</button>
      {JSON.stringify(store)}
 
      <Child count={store.a} />
      <Child count={store.b} />
    </>
  );
});

В приведённом выше примере есть два компонента <Child/>.

  • При каждом нажатии на кнопку один из трёх счетчиков увеличивается. Изменение состояния счётчика приведёт к тому, что компонент MyApp будет перерисовываться при каждом клике.
  • Если store.c был увеличен, ни один из дочерних компонентов не будет рендериться (и, следовательно, их код не будет лениво загружен).
  • Если store.a был увеличен, то рендериться будет только <Child count={store.a}/>.
  • Если store.b был увеличен, то рендериться будет только <Child count={store.b}/>.

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

Участники

Спасибо всем участникам, которые помогли сделать эту документацию лучше!

  • wmertens
  • bado22
  • RATIU5
  • manucorporat
  • adamdbradley
  • fleish80
  • saikatdas0790
  • dario-piotrowicz
  • the-r3aper7
  • AnthonyPAlicea
  • mhevery
  • wtlin1228
  • mrhoodz
  • thejackshelton
  • aivarsliepa
  • zanettin