Давным-давно, когда я начинал изучать 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
в onMounted
hook. Это всего лишь простой пример, в реальности логика внутри этих контейнеров может сильно отличаться, но главное, что ваш PetList.vue
остается тем же самым. Он принимает те же реквизиты, и пока другие контейнеры уважают этот интерфейс, он должен отображать красивый список изображений.
Вот и все на сегодня! Я надеюсь, что благодаря этой статье вы теперь лучше понимаете этот паттерн и можете воспользоваться этими знаниями, чтобы не попасть в те же подводные камни, что и я 🙂