Слоты

Слоты позволяют компоненту рассматривать дочерние JSX-элементы компонента как форму ввода и отображать эти элементы в DOM-дереве компонента.

Эта концепция имеет разные названия в различных фреймворках:

  • В Angular это называется Content Projection;
  • В React это children параметров;
  • В Web-компонентах это также <slot>.

Основным API для достижения этой цели является компонент <Slot>, экспортируемый в @builder.io/qwik:

import { Slot, component$ } from '@builder.io/qwik';
 
const Button = component$(() => {
  return (
    <button>
      Content: <Slot />
    </button>
  );
});
 
export default component$(() => {
  return (
    <Button>
      This goes inside {'<Button>'} component marked by{`<Slot>`}
    </Button>
  );
});

Компонент <Slot> является заполнителем для дочерних элементов компонента. Компонент <Slot> будет заменён дочерними элементами во время рендера приложения.

Примечание: Слоты в Qwik являются декларативными, что позволяет Qwik рендерить родительские и дочерние слоты по отдельности. Поскольку слоты являются декларативными, дочерние элементы НЕ могут быть прочитаны или преобразованы компонентами.

Почему? Потому что Qwik должен иметь возможность рендера родительских/дочерних компонентов независимо друг от друга. При императивном (children) подходе дочерний компонент может изменять children бесчисленными способами. Если бы дочерний компонент полагался на children, он был бы вынужден ререндериться всякий раз, когда родительский компонент ререндерился бы для повторного применения императивного преобразования к children. Дополнительный рендер явно противоречит целям рендера в изоляции компонентов Qwik.

Именованные слоты

Компонент Slot может использоваться несколько раз в одном и том же компоненте, если он имеет другое свойство name:

import { Slot, component$, useStylesScoped$ } from '@builder.io/qwik';
import CSS from './index.css?inline';
 
const Tab = component$(() => {
  useStylesScoped$(CSS);
  return (
    <section>
      <h2>
        <Slot name="title" />
      </h2>
      <div>
        <Slot /> {/* слот по умолчанию */}
        <div>
          <Slot name="footer" />
        </div>
      </div>
    </section>
  );
});
 
export default component$(() => {
  return (
    <Tab>
      <div q:slot="title">Qwik</div>
      <div>A resumable framework for building instant web applications</div>
      <span q:slot="footer">made with ❤️ by </span>
      <a q:slot="footer" href="https://builder.io">
        builder.io
      </a>
    </Tab>
  );
});

Теперь, при использовании компонента <Tab>, мы можем передавать дочерние элементы и указывать, в какой слот они должны быть помещены, используя атрибут q:slot:

Помните что:

  • Если q:slot не указан или это пустая строка, содержимое будет проецироваться в <Slot> по умолчанию, т.е. <Slot> без свойства name.
  • Несколько атрибутов q:slot ="footer" объединяют элементы вместе при рендере содержимого.

Неотображаемое содержимое

Qwik хранит весь контент, даже если он не отображается. Это потому, что контент может быть отображён позднее. Если проецируемое содержимое не соответствует ни одному компоненту <Slot>, содержимое перемещается в инертный элемент <q:template>.

import { Slot, component$, useSignal } from '@builder.io/qwik';
 
const Accordion = component$(() => {
  const isOpen = useSignal(false);
  return (
    <div>
      <h1 onClick$={() => (isOpen.value = !isOpen.value)}>
        {isOpen.value ? '▼' : '▶︎'}
      </h1>
      {isOpen.value && <Slot />}
    </div>
  );
});
 
export default component$(() => {
  return (
    <Accordion>
      Я предварительно рендерюсь на сервере и скрыт до тех пор, пока не понадоблюсь.
    </Accordion>
  );
});

Результат:

<div>
  <h1>▶︎</h1>
</div>
<q:template q:slot hidden aria-hidden="true">
  Я предварительно рендерюсь на сервере и скрыт до тех пор, пока не понадоблюсь.
</q:template>

Обратите внимание, что неотображаемое содержимое перемещено в инертный <q:template>. Это делается на тот случай, если компонент Accordion ререндерится и вставит <Slot>. В этом случае мы избегаем повторного рендера родительского компонента для создания проецируемого содержимого. Благодаря сохранению неотображаемого содержимого при первоначальном рендере родителя, рендер двух компонентов может оставаться независимым.

Некорректная проекция

Атрибут q:slot должен быть прямым потомком компонента.

import { component$ } from '@builder.io/qwik';
 
export const Project = component$(() => { ... })
 
export const MyApp = component$(() => {
  return (
    <Project>
      <span q:slot="title">Хорошо, прямой наследник.</span>
      <div>
        <span q:slot="title">Ошибка, не является прямым наследником.</span>
      </div>
    </Project>
  );
});

Расширенный пример

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

import { Slot, component$, useSignal } from '@builder.io/qwik';
 
export const Collapsible = component$(() => {
  const isOpen = useSignal(true);
 
  return (
    <div>
      <h1 onClick$={() => (isOpen.value = !isOpen.value)}>
        {isOpen.value ? '▼' : '▶︎'}
        <Slot name="title" />
      </h1>
      {isOpen.value && <Slot />}
    </div>
  );
});
 
export default component$(() => {
  const title = useSignal('Qwik');
  const description = useSignal(
    'A resumable framework for building instant web applications'
  );
  return (
    <>
      <label>Заголовок</label>
      <input bind:value={title} type="text" />
      <label>Описание</label>
      <textarea bind:value={description} cols={50} />
      <hr />
      <Collapsible>
        <span q:slot="title">{title}</span>
        {description}
      </Collapsible>
    </>
  );
});

Компонент Collapsible всегда будет отображать заголовок, но основной текст будет отображаться только в том случае, если store.isOpen имеет значение true.

  • Родительский компонент должен иметь возможность изменять содержимое, не заставляя компонент Collapsible ререндериться.
  • Дочерний компонент должен изменить то, что отображается, без повторного рендера родительского компонента. В нашем случае Collapsible должен иметь возможность показывать/скрывать стандартный q:slot без загрузки и повторного рендера родительского компонента.

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

Проекция vs наследник

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

  • проекция: Проекция - это декларативный способ описания того, как содержимое попадает из родительского шаблона туда, куда его нужно спроецировать.
  • children: children относится к подходам vDOM, которые рассматривают содержимое как еще один вход.

Эти два подхода можно описать как декларативный и императивный. Оба они имеют свои достоинства и недостатки.

Qwik использует подход декларативной проекции. Причина этого в том, что Qwik должен иметь возможность рендера родительских/дочерних компонентов независимо друг от друга. При императивном (children) подходе дочерний компонент может изменять children бесчисленным количеством способов. Если бы дочерний компонент полагался на children, он был бы вынужден ререндериться всякий раз, когда рендерится родительский компонент, для повторного применения императивного преобразования к children. Дополнительный рендер явно противоречит целям рендера в изоляции компонентов Qwik.

Примечание: Поскольку в Qwik слоты являются декларативными, проекция со <Slot> будет работать только в том случае, если родительский компонент обёрнут в component$(). Если родительский компонент не обёрнут в component$(), то он считается встроенным компонентом и <Slot> не будет работать.

Дополнительно: Слоты и контекст

Компоненты в слотах имеют доступ к контексту своего родительского компонента, даже если они не проецируются. Более того, если родитель проецирует <Slot /> внутрь другого компонента, то компоненты со слотами также будут иметь доступ к контекстам этого более глубокого компонента.

Однако если компонент ещё не был отображён, поскольку <Slot /> отображается по условию, то узнать о более глубоком Context-е невозможно, и тогда компонент со слотом будет видеть только контекст непосредственного родителя.

Поэтому лучше всего избегать таких ситуаций; если вы предоставляете контекст, не делайте условный рендер вашего <Slot />.

Участники

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

  • RATIU5
  • manucorporat
  • forresst
  • adamdbradley
  • cunzaizhuyi
  • zanettin
  • lbensaad
  • gabrielgrant
  • mhevery
  • jakovljevic-mladen
  • mrhoodz