composables-instead-of-mixins.md — vim

Composables вместо миксинов: как я раскладываю логику по фичам

Миксины нормально жили ровно до третьего миксина на компонент. Потом начинались вопросы, откуда взялся loading и почему mounted вызывается четыре раза.


Был у меня проект на Vue 2, где на одном компоненте висело три миксина. Работало. Пока коллега не поправил свой миксин и не сломал чужой поиск, которого не касался.

Полдня искали. Оказалось - две data-функции объявляли loading, и один тихо перетирал другой:

// mixins/pagination.js
export default {
  data: () => ({ page: 1, loading: false }),
  methods: { next() { this.page++ } },
};

// mixins/search.js
export default {
  data: () => ({ query: '', loading: false }), // тот же loading
  methods: { next() { /* и метод тоже */ } },
};

Ошибки нет, приложение работает, просто неправильно. Худший вид бага.

Это не мы такие криворукие - в документации Vue недостатки миксинов перечислены прямым текстом: непонятно, откуда что взялось, конфликты имён, неявные связи между самими миксинами.

Как то же самое выглядит сейчас

Composable - это обычная функция. Никакой магии:

// composables/usePagination.ts
export function usePagination(total: Ref<number>, perPage = 20) {
  const page = ref(1);
  const pages = computed(() => Math.ceil(total.value / perPage));
  const canNext = computed(() => page.value < pages.value);

  function next() {
    if (canNext.value) page.value++;
  }

  return { page, pages, canNext, next };
}
<script setup lang="ts">
const { page, canNext, next } = usePagination(total);
const { query, results, loading } = useSearch(page);
</script>

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

Четыре вещи, которые я гоняю на ревью

Имя начинается с use. Конвенция официальная, и по имени сразу понятно: внутри реактивность, вызывать надо в setup, а не где попало.

Возвращаем объект, а не массив. С массивом каждый переименовывает по-своему, и через месяц никто не помнит, что там на третьей позиции.

Аргументы принимаем через toValue(). Тогда функции всё равно, что ей передали - число, ref или геттер:

import { toValue, type MaybeRefOrGetter } from 'vue';

export function useUser(id: MaybeRefOrGetter<string>) {
  const user = ref<User | null>(null);

  watchEffect(async () => {
    user.value = await api.getUser(toValue(id));
  });

  return { user };
}

Мелочь, но избавляет от разговоров «а сюда ref передавать или значение».

Подписался - отпишись.

export function useOnline() {
  const online = ref(navigator.onLine);
  const update = () => (online.value = navigator.onLine);

  window.addEventListener('online', update);
  window.addEventListener('offline', update);

  onUnmounted(() => {
    window.removeEventListener('online', update);
    window.removeEventListener('offline', update);
  });

  return { online };
}

Тут есть подстава, на которой я сам обжёгся: код до первого await выполняется синхронно, а после - уже вне setup(). Если зарегистрировать onUnmounted после await, хук просто не привяжется к компоненту. Молча. Слушатель останется висеть.

Где composable уже не помогает

Composable - это логика одного экрана. Как только состояние нужно трём разным веткам роутера и должно переживать размонтирование - это стор, а не composable. Про эту границу у меня есть отдельная заметка про Pinia.

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

Проверка, которой я пользуюсь: если функцию не получается объяснить одним предложением без «и» - её надо резать. usePagination объясняется. useTableWithFiltersAndExport - нет.

Ссылки