Предыстория этой статьи простая. На одном из моих проектов я заметил, что мы с двумя коллегами частенько пишем очень похожие функции open/close/toggle для модалок, табов и других подобных элементов. В относительно среднем по количеству страниц/компонентов/коду проекте я нашел примерно 25 реализаций этих функций. Конечно, в некоторых случаях мы не просто что-то открываем, но и выполняем какие-либо сайд эффекты, например, отправляем события. Само по себе это боли не доставляет, а к особым поборникам DRY мы явно не относимся. Однако мне стало интересно, что может предложить Composition API, чтобы не писать каждый раз даже лишние пару-тройку строк кода.

Наследие Vue 2

Одним из самых распространенных паттернов решение моей задачи является создание компонента-обертки.

<Wrapper @closeWrapper="is_open = false" v-if="is_open">
  ...
</Wrapper>
<button @click="is_open = true">Open</button>

В родительском компоненте обертка скрыта v-if="is_open", который на момент маунта false. С помощью этого @click="is_open = true" мы показываем обертку и ее содержимое. Реакция на событие @closeWrapperскрывает обертку.

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

<Wrapper @closeWrapper="fn_close" v-if="is_open">
  ...
</Wrapper>
<button @click="fn_open">Open</button>

<script setup>
  import { ref } from 'vue';
  const is_open = ref(false);
  const fn_open = () => {
    is_open.value = true;
    // side effect
    ...
  }
  const fn_close = () => {
    is_open.value = false;
    // side effect
    ...
  }
</script>

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

watch(is_open, val => {
  if(val === false) ...
  else ...
})

Composition API

Я начал смотреть в сторону Composition API, который позволяет нам переиспользовать логику в разных компонентах и избавляет нас от лишнего дублирования. Первая попытка вынести функционал в отдельный файл не увенчалась успехом по вполне понятным причинам. Рассмотрим на примере open.

// composables
export const useToggle = () => {
  const open = (proxy) => proxy.value = true; 
  return { open }
}

// component
<button @click="open(is_open)">open</button>
<div v-if="is_open">...</div>

<script setup>
  // все необходимые импорты
 
  const { open } = useToggle();

  const is_open = ref(false);
</script>

Все дело в том, что @click="open(is_open)"передает не Proxy, а его значение, которое в данном случае будет false. Можно ли передать внутри <template> в функцию не значение, а Proxy мне не понятно, решения я не нашел.

Поэтому второй вариант был связан с передачей Proxy в функцию useToggle. И этот вариант уже сработал.

//component 
<button @click="open">open</button>
<div v-if="is_open">...</div>

<script setup>
  // все необходимые импорты
  const is_open = ref(false);
  const { open } = useToggle(is_open);
</script>

Но здесь есть ограничение: так мы можем применить функцию open в связке только с одной переменной, которую передаем в useToggle. А что делать, если в компоненте надо открыть несколько подобных элементов независимо друг от друга? Я решил поиграться с объектом.

//component 
<button @click="open('first')">open</button>
<button @click="open('second')">open</button>

<div v-if="is_open_first">...</div>
<div v-if="is_open_second">...</div>

<script setup>
  // все необходимые импорты
  const is_open_first = ref(false);
  const is_open_second = ref(false);

  const { open } = useToggle({ first: is_open_first, second: is_open_second });
</script>

Но в самом useToggle пришлось доработать функционал.

// composables
export const useToggle = (proxy) => {

  const open = (key = null) => {
    if(key === null){
      if(proxy.value === true) return;
      if(proxy.value === undefined) throw Error('Some error');
      proxy.value = true;
    } else {
      if(proxy[key].value === true) return;
      proxy[key].value = true;
    }
  }
  
  return { open }
}

open будет работать корректно и когда proxy только один, и когда мы передали в useToggle объект. А ошибка throw Error('Some error') будет вызываться тогда, когда мы передали объект, но забыли в open указать ключ.

Остается последнее - вызывать на open функцию с сайд эффектом. Самое простое решение заключается в том, чтобы передавать в useToggle второй аргумент и немного доработать open. В объект с options я также передавал функции для вызова на close/toggle, поэтому это объект, а не функция.

//component 
<script setup>
  // все необходимые импорты
  const fn = () => console.log('fn');
  const is_open = ref(false);

  const { open } = useToggle(is_open, { open: fn });
</script>

// composables
export const useToggle = (proxy, options) => {

  const open = (key = null) => {
    let fn;

    if(key === null){
      if(proxy.value === true) return;
      if(proxy.value === undefined) throw Error('Some error')
      proxy.value = true;

      fn = options?.open;
    } else {
      if(proxy[key].value === true) return;
      proxy[key].value = true;

      fn = options[key]?.open;
    }

    if(fn === undefined) return;
    fn();
  }
  
  return { open }
}

Теперь наша функция open умеет не только менять состояние у proxy, но и вызывать передаваемую в нее функцию. А еще она работает тогда, когда нескольким элементам в компоненте необходимо ее использовать.

const { open } = 
useToggle({ first: is_open_1, second: is_open_2 }, { first: { open: fn }});

Ограничения

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

Всем добра и Нового года!

Комментарии (9)


  1. Kulikoff_A
    30.12.2022 01:08

    посмотрите в сторону такого подхода https://vue-final-modal.org/


    1. yudeek Автор
      30.12.2022 10:36

      Подход интересный, большое спасибо!


  1. Vadiok
    30.12.2022 09:17
    +1

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

    const useToggle = (proxy, callback) => {
      const setValue = (value) => {
        proxy.value = value;
        if (typeof callback === 'function') {
          callback(value);
        }
      };
      const open = () => setValue(true);
      const close = () => setValue(false);
      return { setValue, open, close };
    };
    
    const isOpenFirst = ref(false);
    const isOpenSecond = ref(false);
    
    const {
      open: openFirst,
    } = useToggle(isOpenFirst);
    const {
      open: openSecond,
    } = useToggle(isOpenSecond);
    


    1. yudeek Автор
      30.12.2022 10:33

      Интересное решение, но тут два раза вызывается функция useToggle в одном компоненте. Как минимум, никакие сайд эффекты (onMount, watch и т.д.) в нее уже не поместить без костылей. Также не совсем понятно, почему должна работать вот такая деструктуризация const { open: openFirst } = useToggle(isOpenFirst);


      1. Vadiok
        30.12.2022 10:42

        тут два раза вызывается функция useToggle в одном компоненте

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

        не совсем понятно, почему должна работать вот такая деструктуризация

        Встречный вопрос - почему не должна? Вместо 2 open мы получаем методы openFirst и openSecond соответственно.

        никакие сайд эффекты (onMount, watch и т.д.) в нее уже не поместить без костылей

        watch: isOpenFirst / isOpenSecond у вас в области видимости, смотрите за ними. Насчет onMount - совершенно не понимаю, каким образом может понадобиться связывать это событие и открытие/закрытие модалки, может приведете пример.


        1. yudeek Автор
          30.12.2022 11:12

          Встречный вопрос - почему не должна?

          Уточню, вот такое тоже не будет вызывать ошибок const { open } = useToggle(isOpenFirst). Чисто на мой взгляд, исходная деструктуризация неявная, также есть риск ошибки. Но идея в целом мне нравится.

          может приведете пример

          Вам надо на закрытие всех модалок отправлять статистику и вы используете watch внутри useToggle.
           


          1. Vadiok
            30.12.2022 11:20
            +1

            Уточню, вот такое тоже не будет вызывать ошибок const { open } = useToggle(isOpenFirst).

            Может я не совсем понимаю смысл вашего комментария. Когда у нас одна модалка в компоненте, пользуемся const { open } = useToggle(isOpen); и пользуемся open(); когда несколько - const { open: openFirst } = useToggle(isOpenFirst); и пользуемся openFirst().

            Вам надо на закрытие всех модалок отправлять статистику и вы используете watch внутри useToggle.

            Тут вообще watch не требуется:

            useToggle(isOpen, (value) => {
              if (!value) {
                log('...');
              }
            });
            


  1. mSnus
    30.12.2022 11:23

    По коду - типичный Реакт. Интересно, в какой момент они станут вообще взаимозаменяемы?


    1. yudeek Автор
      30.12.2022 15:19

      Сейчас синтаксис почти у всех React-подобный (Solid, Qwik, Vue composition API). Но движки то фреймворков разные, наверное, без костылей не взаимозаменить.