React: слоты как у сына маминой подруги

    При композиции компонентов очень часто возникает задача точечной кастомизации содержимого какого-либо компонента. Например, у нас есть компонент DatePicker, и в разных частях веб-приложения нам нужно отображать разные кнопки "Применить".

    Для решения подобных задач в каждой популярной технологии сегодня применяется концепция "слотов". У Angular это ngContent, во Vue, Svelte и WebComponents это слоты. И только в популярной библиотеке React полноценной концепции слотов на сегодня нет.

    Для решения этой проблемы в React известны несколько подходов:

    1. Компонент может либо отрендерить всех своих детей целиком, либо "залезть" в них через React.Children API и точечно манипуляровать потомками

    2. Компонент может объявлять так называемые renderProps, и отрисовывать возвращаемый из них контент в нужных местах:

      <MyComponent renderFooter={data => (<h1>Bye, ${data.name}</h1>)}/>

    Подход с renderProps, в целом, широко известен и не имеет каких-то принципиальных изъянов. Разве что пользоваться им не слишком удобно, в сравнении с полноценными слотами. В NPM есть несколько библиотек, таких как react-view-slot, но мне не кажется, что они достаточно удобно и, главное, просто, решают задачу.

    Я решил попытаться исправить этот фатальный недостаток, и сейчас расскажу, как.

    Вижу цель – не вижу реализации

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

    const Component = props => {
      Component.NameSlot = useSlot(props.children);
    
      return (
        <div>
          <h1>
            <Component.NameSlot.Receiver>
              Default value
            </Component.NameSlot.Receiver>
          </h1>
    
          Hello {props.children}
        </div>
      );
    };
    
    function App() {
      return (
        <div>
          Hello!
          <Component>
            Foo
            <Component.NameSlot>
              Bobobo
            </Component.NameSlot>
          </Component>
        </div>
      );
    }

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

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

    import {createSlot} from 'react-slotify';
    
    export const MySlot = createSlot();
    
    export const Component = ({children}) => {
      return (
        <div>
          This component contains slot:
          
          <MySlot.Renderer childs={children}>
            This is default slot content
          </MySlot.Renderer>
          
          <div>It also renders children: {children}</div>
        </div>
      );
    };
    import {Component, MySlot} from './component';
    
    const App = () => {
      return (
        <div>
          <Component>
            <MySlot>Slotted content</MySlot>
            Other content
          </Component>
        </div>
      );
    };

    Под капотом

    Итак, если посмотреть на вышеописанное API, становится понятно, что наша задача – спрятать содержимое компонента MySlot, когда компонент рендерит своих потомков через {children}, но при этом отрисовать его содержимое в то место, в котором расположен MySlot.Renderer. Давайте посмотрим, сколько нужно написать JS-кода, чтобы это заработало:

    export function createSlot() {
      const Slot = ({ children, showChildren }) => {
        return showChildren ? children : null;
      }
    
      const Renderer = ({ childs, children }) => {
        const slotted = React.Children.toArray(childs).find(child => {
          return React.isValidElement(child) && child.type === Slot;
        });
    
        if (!slotted || !React.isValidElement(slotted)) {
          return children;
        }
        return React.cloneElement(slotted, { showChildren: true });
      };
    
      Slot.Renderer = Renderer;
    
      return Slot;
    }

    Да-да, всего 20 строчек. Но идея, реализованная в этом низкоуровневом React-специфичном коде, не лежит на поверхности. Давайте попробуем разобраться. Основная задача функции – создать и вернуть компонент Slot. Если удалить всё остальное, то получится тривиально:

    export function createSlot() {
      const Slot = ({ children, showChildren }) => {
        return showChildren ? children : null;
      }
      return Slot;
    }

    Всё, что умеет созданный компонент Slot – это прятать своих детей до тех пор, пока в него не будет передан проп showChildren={true}. Когда мы используем слот при использовании компонента, мы это не передаём, и поэтому Slot просто прячет свой контент.

    Тут же создаётся ещё один компонент – Renderer. Его задача – принять все дочерние компоненты своего компонента-пользователя, найти среди них нужный нам Slot-компонент, и отрисовать его, склонировав его и передав ему showChildren={true}:

      const Renderer = ({ childs, children }) => {
        const slotted = React.Children.toArray(childs).find(child => {
          return React.isValidElement(child) && child.type === Slot;
        });
    
        if (!slotted || !React.isValidElement(slotted)) {
          return children;
        }
        return React.cloneElement(slotted, { showChildren: true });
      };

    Обратите внимание, что Renderer так же принимает и своих собственных потомков, и рисует их в случае, если Slot не найден. Это обеспечивает отображение дефолтного контента слота.

    Ну и последнее – компонент Renderer записывается в статическое свойство только что созданного компонента Slot, чтобы его можно было использовать таким образом: <MySlot.Renderer/>.

    Заключение

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

    Готовую реализацию я опубликовал в виде библиотеки react-slotify на GitHub и в виде пакета в NPM. Уже на TypeScript и с поддержкой параметризации слотов. Буду рад конструктивной критике.

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 13

      +7

      в приведенных фрейворках слоты появились потому, что там для рендера везде используются шаблоны со своим неповторимым синтаксисом, а в реакте всё является функциями и render props — самый естественный подход

        –1

        Согласен, но в renderProps мне не хватает семантики, чтобы вёрстка говорила сама за себя. И тут, возможно, дело вкуса. Мне, чем сложнее компоненты отрисовываются в renderProps, тем сложнее читать:


        <Dialog>
          <CloseSlot>
            <button>close me</button>
          </CloseSlot>
          ...
        </Dialog>

        И


        <Dialog
          renderCloseButton={() => <button>close me</button>}
        >
          ...
        </Dialog>
          +1

          1) Так зачем передавать функцию?


          <Dialog close={<button>close</button>} />

          2) Как обеспечить обязательность слотов на уровне typescript?

            +1

            1) Согласен
            2) Под обязательностью вы имеете в виду как сделать использование слота обязательным? Чтобы пользователь компонента не мог игнорировать слот и был обязан что-то в него положить? К сожалению, насколько я знаю TypeScript и типизацию JSX, это нельзя выразить типами.


            Справедливые замечания, спасибо!

              0

              Я не часто пишу на react, но вроде же можно было описать props компонента при помощи интерфейса и ts даже проверял такие места. Почему бы и не указать в этом же интерфейсе функцию как prop?


              UPD. Действительно, можно.
              https://codesandbox.io/s/focused-field-3oq5w?file=/src/App.tsx

        0
        Исправьте название View на Vue.
          0

          Да, точно, спасибо!

          0
          с поддержкой параметризации слотов.

          Получить доступ к дефолтному содержимому слота тоже можно? Что-то типа:


          <MySlot>
            {params => (<div>Param is {params.myParam} and {params.defaultSlotContent}</div>)}
          </MySlot>

          P.S.: о том, какими должны быть слоты, неплохо рассказал Константин Лебедев в этой статье.

            0

            defaultSlotContent нету, но можно легко добавить. Пожалуй, так и сделаю!


            За ссылку на статью спасибо! Идеи похожи, и там есть преимущества (не надо руками передавать children) и недостатки (привязка по строковому имени ломает навигацию в IDE).


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

            +1

            Интересный подход)
            Ребята из БЭМа тоже решали ее проблему, можно тут почитать https://ru.bem.info/technologies/bem-react/ В основе — DI.

              0

              Там замена целых компонентов. Слоты же — для частей компонентов.

              +1

              Да писали уже про это тут https://habr.com/ru/post/475170/


              Всякий раз когда у меня появляется желание написать свою версию слотов для React я говорю себе — не делай этого!


              А если заменить "фатальный недостаток" на "фича" то и проблемы решатся сами собой.

                +1
                Я пишу что-то вроде такого:

                <Select items={[]} />
                
                <Select items={[]} slotItem={<CustomItem />} />
                

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

                Only users with full accounts can post comments. Log in, please.