0
Обложка: Как создать приложение вокруг Composition API во Vue 3 

Как создать приложение вокруг Composition API во Vue 3 

Валерий
Валерий
Noveo VueJS Developer

Vue 3 и Composition API сегодня

Прошло уже больше года, как Vue 3 был выпущен с его главной особенностью: Composition API. Примерно с осени 2021 года синтаксис script setup стал рекомендуемым способом создания нового проекта на Vue, так что, надеюсь, будет всё больше и больше серьезных приложений, построенных на третьей версии Vue.

Я также создавал с нуля приложения на этом стеке, и большинство информации здесь почерпнуто из этого опыта. Эта статья призвана показать интересные фишки Composition API и то, как структурировать приложение вокруг него. Речь будет идти о дизайне кода и паттернах, поэтому рекомендую повторить, как работает Vue 3, если ещё чувствуете себя в нём неуверенно

Функции Composable и переиспользование кода

Новый Composition API создает много удобных способов переиспользования кода в компонентах. Вспомним, что во Vue 2 логика разделялась по опциям: data, methods, created, и так далее:

// Стиль Options API, как во Vue 2
data: () => ({
    refA: 1,
    refB: 2,
  }),
// Здесь часто можно встретить 500 строк кода..
computed: {
  computedA() {
    return this.refA + 10;
  },
  computedB() {
    return this.refA + 10;
  },
},

С Composition API мы не ограничены этой структурой и можем разделять код по фичам, а не по опциям:

setup() {
    const refA = ref(1);
		const computedA = computed(() => refA.value + 10);
		/* 
			Здесь тоже может быть 500 строк, но логика
			фич может оставаться рядом друг с другом
		*/
    const computedB = computed(() => refA.value + 10);
		const refB = ref(2);

    return {
      refA,
      refB,
      computedA,
      computedB,
    };
  },

Vue 3.2 ввел новый синтаксис <script setup>, который является сахаром функции setup(), просто делая код более кратким. С этого момента я буду использовать этот синтаксис, так как он наиболее актуален.

<script setup>
import { ref, computed } from 'vue'

const refA = ref(1);
const computedA = computed(() => refA.value + 10);

const refB = ref(2);
const computedB = computed(() => refA.value + 10);
</script>

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

// Component.vue
<script setup>
import useFeatureA from "./featureA";
import useFeatureB from "./featureB";

const { refA, computedA } = useFeatureA();
const { refB, computedB } = useFeatureB();
</script>

// featureA.js 
import { ref, computed } from "vue";

export default function () {
  const refA = ref(1);
  const computedA = computed(() => refA.value + 10);
  return {
    refA,
    computedA,
  };
}

// featureB.js 
import { ref, computed } from "vue";

export default function () {
  const refB = ref(2);
  const computedB = computed(() => refB.value + 10);
  return {
    refB,
    computedB,
  };
}

Обратите внимание, что featureA.js и featureB.js экспортируют типы Ref и ComputedRef, поэтому все эти данные являются реактивными!

Этот сниппет может показаться излишним, но:

  • Представьте, что компонент состоит из 500+ строк кода, а не из 10. Благодаря разделению логики на файлы use__.js код становится более читабельным.
  • Мы можем свободно переиспользовать функции сomposable внутри .js-файлов в нескольких компонентах! Больше нет ограничений на renderless-компоненты со scoped-слотами или конфликов неймспейса в миксинах. Поскольку сomposable-функции используют ref и computed прямо из Vue, этот код будет работать с любым компонентом .vue в проекте.

Подводный камень 1: хуки жизненного цикла в setup

Если хуки жизненного цикла (onMounted, onUpdated и так далее) можно использовать внутри setup, то их так же можно использовать и внутри нашей сomposable-функции. Можно даже написать что-то вроде такого:

// Component.vue
<script setup>
import { useStore } from 'vuex';

const store = useStore();
store.dispatch('myAction');
</script>


// store/actions.js
import { onMounted } from 'vue'
// ...
actions: {
  myAction() {
    onMounted(() => {
			console.log('Вы не поверите, но этот хук зарегистрируется!')
		})
  }
}
// ...

И это будет работать даже внутри vuex! Вопрос только в том, надо ли так делать 🙂

При такой гибкости использования важно понимать, как и когда регистрируются эти хуки. Посмотрим на приведенный ниже фрагмент: какие onUpdated будут зарегистрированы?

```jsx
<script setup lang="ts">
import { ref, onUpdated } from "vue";

/*
	Этот хук отработает. Он используется внутри setup, 
	все стандартно.
*/
onUpdated(() => {
  console.log('✅')
});

class Foo {
  constructor() {
    this.registerOnMounted();
  }

  registerOnMounted() {
		/*
			Этот хук тоже зарегистрируется! Он внутри метода класса, 
			но функция будет выполнена синхронно внутри setup
		*/
    onUpdated(() => { 	
      console.log('✅')
    });
  }
}
new Foo();

// IIFE будет работать
(function () {
  onUpdated(() => {
    state.value += "✅";
  });
})();

const onClick = () => {
	/* 
		Этот хук не будет заригестрирован, так как он находится
		внутри другой функции. Vue не может достать содержание метода при 
		инициализации. Самое плохое, что Vue не пришлет warning об этом, 
		пока функция не отработает. Так что важно за этим следить. 
	*/ 
  onUpdated(() => {
    console.log('❌')
  });
};

// Асинхронный IIFE тоже не отработает
(async function () {
  await Promise.resolve();
  onUpdated(() => {
    state.value += "❌";
  });
})();
</script>
```

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

Подводный камень 2: Асинхронные функции в setup

В логике компонента часто необходимо использовать async/await. Наивным подходом будет попробовать что-то такое:

<script setup lang="ts">
import { myAsyncFunction } from './myAsyncFunction.js
const data = await myAsyncFunction();
</script>

<template>
  Асинхронные данные: {{ data }}
</template>

Однако если запустить этот код, компонент вообще не будет отрендерен. Почему? Потому что промисы не могут обновить состояние. Мы присваиваем промис к переменной data, но Vue не может реактивно её обновить. К счастью, есть несколько обходных путей:

Решение 1: ref с синтаксисом .then

Для асинхронного стейта можно использовать синтаксис .then:

<script setup>
import { ref } from "vue";
import { myAsyncFunction } from './myAsyncFunction.js

const data = ref(null);
myAsyncFunction().then((res) =>
  data.value = fetchedData
);
</script>

<template>
  Асинхронные данные: {{ data }}
</template>
  1. В начале мы создаём реактивный ref, который имеет значение null.
  2. Вызывается асинхронная функция myAsyncFunction(). Setup всё ещё выполняется синхронно, компонент становится отрисованным.
  3. Когда промис myAsyncFunction() разрешается, его результат присваивается к реактивному рефу data, и после этого его результат рендерится в DOM.

Плюсы: просто работает.

Минусы: синтаксис чувствуется немного старым и может стать громоздким, если чейнить много .then и .catch одновременно.

Решение 2: IIFE

Мы можем сохранить синтаксис async/await, если обернём эту логику в асинхронный IIFE:

<script setup>
import { ref } from "vue";
import { myAsyncFunction } from './myAsyncFunction.js

const data = ref(null);
(async function () {
    data.value = await myAsyncFunction()
})();
</script>

<template>
  Асинхронные данные: {{ data }}
</template>

Плюсы: синтаксис async / await.

Минусы: на мой взгляд, выглядит немного более грязно. Всё ещё нужен дополнительный ref.

Решение 3. Компонент Suspense (экспериментальная фича)

Если обернуть асинхронный компонент в <Suspense> в родительском компоненте, мы сможем использовать async/await, как в самом первом примере!

// Parent.vue
<script setup lang="ts">
import { Child } from './Child.vue
</script>

<template>
  <Suspense>
		<Child />
	</Suspense>
</template>


// Child.vue
<script setup lang="ts">
import { myAsyncFunction } from './myAsyncFunction.js
const data = await myAsyncFunction();
</script>

<template>
  Асинхронные данные: {{ data }}
</template>

Плюсы: Самый лаконичный вариант.

Минусы: по состоянию на декабрь 2021 это всё ещё экспериментальная фича, она, вероятно, будет меняться.

Компонент <Suspense> встроен во Vue 3, и он имеет гораздо больше возможностей, чем просто асинхронность в дочернем компоненте. В нём можно также указать состояния загрузки или ошибки. Nuxt 3 уже вовсю использует этот компонент, и, думаю, он будет более широко использоваться в стоковом Vue в будущем. Для меня этот метод, возможно, станет предпочтительным, когда эта фича выйдет из experimental.

Решение 4. Сторонние библиотеки для таких ситуаций

(см. Следующий раздел)

Плюсы: Больше гибкости. Не надо писать самому, это зависимость в package.json.

Минусы: Это зависимость в package.json.

Библиотека VueUse

Библиотека VueUse тоже опирается на композиционный подход к построению компонентов и дает много helper-функций. Так же, как мы писали useFeatureA и useFeatureB в самом начале, эта библиотека дает уже готовые хелперы, написанные в композиционном стиле. Вот пример использования:

<script setup lang="ts">
import {
  useStorage,
	useDark
} from "@vueuse/core";
import { ref } from "vue";

/* 
	Пример имплементации localStorage.
	Эта функция просто возвращает Ref, поэтому ее можно сразу 
	использовать внутри setup или в template. Нет необходиимости 
	вызывать различные getItem/setItem методы.
*/
const localStorageData = useStorage("foo", undefined);

/* 
	Хелпер темной/светлой темы браузера. 
	Возвращаемое значение - это просто Ref,
	поэтому его можно сразу менять.
*/
const isDark = useDark()
</script>

Я крайне рекомендую это библиотеку; на мой взгляд, это must have для каждого нового приложения на Vue 3:

  • Эти хелперы могут сэкономить вам много строк кода и вашего времени. Не надо писать все с нуля или копипастить из проекта в проект.
  • Не влияет на размер бандла.
  • Простой и понятный исходный код. Если возможностей библиотеки будет недостаточно, можно просто расширить функцию и дописать её самому, как надо. Это означает, что вы не сильно рискуете, решив подключать эту библиотеку.

Вот как эта библиотека решает предыдущую проблему асинхронности в setup через функцию useAsyncState:

<script setup>
import { useAsyncState } from "@vueuse/core";
import { myAsyncFunction } from './myAsyncFunction.js';

const { state, isReady } = useAsyncState(
	// Aсинхронная функция, которую нужно выполнить
  myAsyncFunction,

  // Дефолтное состояние state:
  "Loading...",

  // Опции useAsyncState:
  {
    onError: (e) => {
      console.error("Ошибка!", e);
      state.value = "fallback";
    },
  }
);
</script>

<template>
  useAsyncState: {{ state }}
  Данные готовы: {{ isReady }}
</template>

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

Больше информации: документация useAsyncState.

Если вы используете Typescript

Новый синтаксис defineProps и defineEmits

script setup даёт более краткий метод декларирования пропов и эмитов:

<script setup lang="ts">
import { PropType } from "vue";

interface CustomPropType {
  bar: string;
  baz: number;
}

// Перегрузки defineProps:
// 1. Синтаксис как в Options API
defineProps({
  foo: {
    type: Object as PropType<CustomPropType>,
    required: false,
    default: () => ({
      bar: "",
      baz: 0,
    }),
  },
});

// 2. Через дженерик. PropType уже не нужен!
defineProps<{ foo: CustomPropType }>();

// 3. Дефолтное состояние можно задать вот так: 
withDefaults(
  defineProps<{
    foo: CustomPropType;
  }>(),
  {
    foo: () => ({
      bar: "",
      baz: 0,
    }),
  }
);

// Эмиты тоже можно задекларировать более кратко:
defineEmits<{ (foo: "foo"): string }>();
</script>

Лично я всегда предпочитаю типизировать через дженерик, так как это убирает лишний импорт PropType и выглядит более выразительно с типами null и undefined, чем { required: false } в синтаксисе Options API.

Заметьте, что импортировать defineProps и defineEmits не нужно. Это специальные макросы, которые использует Vue. Они обрабатываются во время компиляции в «обычный» синтаксис Options API. Скорее всего, мы будем видеть всё больше и больше подобных макросов в будущих релизах.

Типизация функций composable

Так как TS просил типизировать return каждой функции, в прошлом я писал composables подобным образом:

import { ref, Ref, SetupContext, watch } from "vue";

export default function ({
  emit,
}: SetupContext<("change-component" | "close")[]>): 
// А так ли нужен этот код ниже?:
{
  onCloseStructureDetails: () => void;
  showTimeSlots: Ref<boolean>;
  showStructureDetails: Ref<boolean>;
  onSelectSlot: (arg1: onSelectSlotArgs) => void;
  onBackButtonClick: () => void;
  showMobileStepsLayout: Ref<boolean>;
  authStepsComponent: Ref<string>;
  isMobile: Ref<boolean>;
  selectedTimeSlot: Ref<null | TimeSlot>;
  showQuestionarireLink: Ref<boolean>;
} {
  const isMobile = useBreakpoints().smaller("md");
  const store = useStore();
	// и так далее
	// ... 
}

Сейчас мне это кажется ошибкой. Не обязательно типизировать return каждой composable, так как возвращаемый объект практически всегда будет целиком типизирован имплицитно, когда вы пишете composable. Это сохранит много строк кода.

import { ref, Ref, SetupContext, watch } from "vue";

export default function ({
  emit,
}: SetupContext<("change-component" | "close")[]>) {
  const isMobile = useBreakpoints().smaller("md");
  const store = useStore();
	// return может быть типизирован имплицитно в composable
	return {
		// ...
	}
}

Если EsLint подчеркивает это как ошибку, напишите ‘@typescript-eslint/explicit-module-boundary-types’: ‘error’ в конфиг (.eslintrc)

Расширение Volar

Volar пришел на смену Vetur в качестве IDE-расширения для VsCode и WebStorm. Теперь он официально рекомендован для использования во Vue 3. Главное, в чём он хорош, — это типизация пропов и эмитов из коробки. Это отлично помогает, если использовать Typescript.

На данный момент я бы всегда использовал Volar в проектах с Vue 3. Для Vue 2 по-прежнему лучше работает Vetur; по моему опыту, для его работы требуется меньше настроек.

Полезная ссылка: как зарегистрировать глобальные компоненты в Volar.

Архитектура приложения и Composition API

Вынесение логики из файла .vue

Ранее были показаны примеры, где вся логика выполнялась внутри script setup, а были такие, где компоненты использовали функции composable, которые импортировались из других файлов.

Большой архитектурный вопрос заключается в следующем: следует ли нам выносить всю логику из .vue-файла? Есть свои плюсы и минусы.

Какой личный выбор я сделал для себя:

  • Использовать гибридный подход в небольших/средних проектах. В обычных ситауциях писать логику внутри setup. Выносить её в отдельные js/ts файлы, когда компонент слишком разрастается или когда становится ясно, что этот код будет переиспользоваться.
  • Для больших проектов просто писать все в composable-функции. Использовать setup исключительно для создания неймспейса в template.

Использование сomposables в open source

Краткий обзор, как composables используются в популярных проектах open source:

Интересно, что composables разбиты на виды private и public. Приватные функции предназначены только для внутреннего использования в Quasar, а публичные могут быть вызваны пользователями библиотеки.

Vue Storefront были одними из самых первых, кто начали использовать композиционный подход, реализовав его ещё во Vue 2 через vue/composition-api. Интересно, что они оставили эти composables в виде фабрик, на основе которых конкретные CMS-имплементации уже могут их реализовывать.

На данный момент, все composables приватные (используются только изнутри). Хотя проект сейчас в ранней стадии разработки, я предполагаю, что эта папка разрастется в будущем.

  • Папка hooks в Element plus.

Element plus тоже внутри использует composables. Здесь они, как правило, привязанны к конкретным UI-компонентам.

Ссылки/Что ещё можно прочитать на эту тему (на английском)