Как этот паттерн помог мне написать многократно используемые компоненты Vue без особых усилий

Давным-давно, когда я начинал изучать Vue.js, я обращался к людям, имеющим опыт работы с этим фреймворком, чтобы спросить совета и получить указания о том, что изучать и как структурировать код. Один совет, который запомнился мне, гласил: “Всегда отделяйте логику от презентационного компонента”. Я подумал: “Ну, это кажется разумным, я попробую”. Но, честно говоря, в то время я не понимал, что конкретно они имеют в виду. Так что со временем я писал все больше и больше кода Vue, писал большие SPA, пока наконец не понял, почему люди говорят мне отделить логику от презентационного слоя, и, самое главное, я нашел отличный паттерн, который решает эту проблему.

Допустим, у вас есть Vue SFC (однофайловый компонент), который отправляет запрос на сервер, хранит элементы в состоянии (как VueX или Pinia), а затем отображает список элементов. Более опытные разработчики, возможно, уже видят проблему, просто прочитав это предложение, но мне потребовалось некоторое время. У меня начались проблемы с написанием подобных компонентов, потому что при создании модульных тестов для этих компонентов вам приходится моделировать очень много вещей: запрос к серверу, ответ, состояние… то же самое происходит, если вы хотите добавить компонент в библиотеку компонентов, например Storybook. Это сплошная боль. Теперь, если компонент только принимает список элементов для рендеринга, рендерит их и испускает события, ваша жизнь становится намного проще, потому что вы можете тестировать только интерфейс компонента, т.е. если он рендерит x, когда вы передаете x в качестве реквизита, и испускает событие y, когда вы каким-то образом взаимодействуете с компонентом.

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

Я вызвал у вас интерес? Отлично! Давайте посмотрим на код. Я создал репозиторий с начальным проектом, чтобы вы могли следовать за мной, вы можете клонировать репозиторий здесь.

Чтобы достичь точки этого стартового проекта, вы можете запустить npm init vue@latest и, когда вас спросят “Добавить Pinia для управления состоянием”, выберите “Да”. После завершения работы перейдите в каталог проекта и установите зависимости (npm install), а затем вы можете запустить проект (npm run dev). Хотя это пример Vue 3, я уже применял этот же шаблон в Vue 2, и он работает так же (и, вероятно, будет работать так же в любом другом фронтенд-фреймворке).

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

// src/components/PetList.vue
<script setup>
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue';
import { usePets } from '../stores/pets'

const store = usePets();
const { pets } = storeToRefs(store)

onMounted(() => {
    store.fetchCats();
})

const emit = defineEmits(['click']);

function handleClick(index) {
    emit('click', index);
}

defineExpose({
    pets,
    handleClick
})
</script>

<template>
  <ul>
      <li v-for="(pet, i) in pets">
          <button @click="handleClick(i)">
              <img :src="pet" />
          </button>
      </li>
  </ul>
</template>

Вход в полноэкранный режим Выход из полноэкранного режима

Во-первых, не беспокойтесь о стиле шаблона или чем-то подобном. Давайте сначала сосредоточимся на теге скрипта:

Файл App.vue отображает только компонент PetList, и этот компонент Vue в основном отображает список изображений, но в части сценария он отправляет запрос к API через Pinia, когда компонент смонтирован, который возвращает список изображений кошек и использует состояние, чтобы получить этот список и, наконец, выставить их шаблону для отображения.

Это и есть магазин:

// src/stores/pets.js
import { defineStore } from 'pinia';

export const usePets = defineStore('pets', {
    state: () => {
        return {
            pets: [],
        };
    },

    actions: {
        async fetchCats() {
            try {
                const cats = await fetch(
                    '<https://api.thecatapi.com/v1/images/search?limit=10&order=DESC>'
                ).then(res => res.json());
                this.pets = cats.map(cat => cat.url);
            } catch (e) {
                //
            }
        },
        }
})

Вход в полноэкранный режим Выход из полноэкранного режима

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

Ну, один из вариантов – скопировать весь компонент, заменить логику получения и рендеринга собак и на этом закончить. У вас все равно возникнут проблемы с тестированием компонента, и теперь, если что-то изменится в шаблоне, вам придется вносить изменения в оба компонента. Вы ведь не забудете об этом, верно?

Другим вариантом может быть передача компоненту параметра, который работает как флаг: catOrDog и на основе этого реквизита вы могли бы использовать if/else везде, чтобы отображать изображения кошек или собак. Вы храните шаблон в одном месте, но, честно говоря, сценарий может стать очень запутанным и полным путей “это против того”.

Вводим шаблон “Контейнер/Презентация” 🎉.

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

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

// src/components/PetList.vue
<script setup>
const props = defineProps({
  pets: {
    type: Array,
    default: () => [],
  }
});

const emit = defineEmits(['click']);

function handleClick(index) {
    emit('click', index);
}

defineExpose({
    pets: props.pets,
    handleClick
})
</script>

<template>
  <ul class="pet-list">
      <li v-for="(pet, i) in pets">
          <button @click="handleClick(i)" class="pet-list__button">
              <img :src="pet" class="pet-list__img" />
          </button>
      </li>
  </ul>
</template>

Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что часть шаблона почти не изменилась. С другой стороны, скриптовая часть намного проще: вы определяете реквизиты и события, которые компонент может испускать, и все. Если вы передаете изображения кошек, то будут отображаться изображения кошек, если вы передаете изображения собак, то будут отображаться изображения собак. Если в будущем бизнес решит вывести список для целого зоопарка, вы будете защищены. Юнит-тесты компонентов становятся простым делом, и вы можете легко добавить компонент в Storybook или другие библиотеки компонентов.

Теперь давайте создадим контейнер для CatList:

// src/components/CatList.container.js
import { storeToRefs } from 'pinia';
import { onMounted, h } from 'vue';
import { usePets } from '../stores/pets';
import PetList from './PetList.vue';

export default {
    setup() {
        const store = usePets();
        const { cats } = storeToRefs(store);

        onMounted(() => {
            store.fetchCats();
        });

        function handleOnClick(index) {
            console.log('Clicked on cat picture #' + index);
        }

        return () =>
            h(PetList, {
                pets: cats.value,
                onClick: handleOnClick,
            });
    },
};

Вход в полноэкранный режим Выход из полноэкранного режима

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

Мне нравится использовать соглашение для именования контейнеров: [Имя компонента].container.js, чтобы я знал, чего ожидать при работе с этими файлами.

Теперь, в вашем App.vue, вместо импорта PetList.vue вам нужно будет импортировать CatList.container.js и отобразить его так же, как вы отображаете обычный компонент Vue. Для любых целей это обычный компонент Vue, но вместо шаблона он возвращает функцию h, которая фактически рендерит компонент.

Теперь давайте создадим контейнер DogList:

// src/components/DogList.container.js
import { storeToRefs } from 'pinia';
import { onMounted, h } from 'vue';
import { usePets } from '../stores/pets';
import PetList from './PetList.vue';

export default {
    setup() {
        const store = usePets();
        const { dogs } = storeToRefs(store);

        onMounted(() => {
            store.fetchDogs();
        });

        function handleOnClick(index) {
            console.log('Clicked on dog picture #' + index);
        }

        return () =>
            h(PetList, {
                pets: dogs.value,
                onClick: handleOnClick,
            });
    },
};

Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что это почти идентично CatList.container, но вместо fetchCats он вызывает fetchDogs в onMountedhook. Это всего лишь простой пример, в реальности логика внутри этих контейнеров может сильно отличаться, но главное, что ваш PetList.vue остается тем же самым. Он принимает те же реквизиты, и пока другие контейнеры уважают этот интерфейс, он должен отображать красивый список изображений.

Вот и все на сегодня! Я надеюсь, что благодаря этой статье вы теперь лучше понимаете этот паттерн и можете воспользоваться этими знаниями, чтобы не попасть в те же подводные камни, что и я 🙂

Оцените статью
Procodings.ru
Добавить комментарий