Коли ви створюєте компоненти для свого проекту, все починається весело і легко.MyButton.vue
Додайте трохи стилю, і ось що.
<template>
<button class="my-fancy-style">
<slot></slot>
</button>
</template>
Тоді ви відразу ж усвідомлюєте, що вам потрібно десяток пристосувань, тому що ваша команда дизайнерів хоче, щоб він був різних кольорів і розмірів, з іконами зліва і праворуч, з лічильниками...
const props = withDefaults(defineProps<{
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg; // I’ve described how I cook icons in my previous article
rightIcon?: IconSvg;
counter?: number;
}>(), {
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined
});
Зрештою, ви не можете мати кнопки «Відмінити» і «Ок» одного кольору, і вам потрібно, щоб вони реагували на взаємодії користувачів.
const props = withDefaults(defineProps<{
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg;
rightIcon?: IconSvg;
counter?: number;
disabled?: boolean;
loading?: boolean;
}>(), {
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined
});
Ну, ви отримуєте ідею: буде щось дике, як проходженняwidth: 100%
або додаючи автофокус - ми всі знаємо, як це виглядає просто в Figma, поки реальне життя не вдарить важко.
Тепер уявіть кнопку посилання: вона виглядає однаково, але коли ви натискаєте її, ви повинні перейти до зовнішнього або внутрішнього посилання.<RouterLink>
або<a>
щоразу, але будь ласка, не. Ви також можете додатиto
таhref
Пропозиції до вашого початкового компонента, але ви будете відчувати задихання досить скоро:
<component
:is="to ? RouterLink : href ? 'a' : 'button'"
<!-- ugh! -->
Звичайно, вам знадобиться компонент "другого рівня", який обертає вашу кнопку (він також буде справлятися з умовними обрисами гіперпосилання та деякими іншими цікавими речами, але я пропустив їх заради простоти):
<template>
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton v-bind="$attrs">
<slot></slot>
</MyButton>
</component>
</template>
<script lang="ts" setup>
import MyButton from './MyButton.vue';
import { RouteLocationRaw, RouterLink } from 'vue-router';
const props = defineProps<{
to?: RouteLocationRaw;
href?: string;
}>();
</script>
І ось тут починається наша історія.
Square One
Площа 1Ну, в основному це буде працювати, я не буду брехати. ви все ще можете ввести<MyLinkButton :counter=“2">
Але не буде автокомплекту для похідних пропів, що не круто:
Ми можемо розповсюджувати прописки тихо, але IDE нічого про них не знає, і це ганьба.
Просте і очевидно рішення полягає в тому, щоб розповсюджувати їх прямо:
<template>
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton
:theme="props.theme"
:small="props.small"
:icon="props.icon"
:right-icon="props.rightIcon"
:counter="props.counter"
:disabled="props.disabled"
:loading="props.loading"
>
<slot></slot>
</MyButton>
</component>
</template>
<script lang="ts" setup>
// imports...
const props = withDefaults(
defineProps<{
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg;
rightIcon?: IconSvg;
counter?: number;
disabled?: boolean;
loading?: boolean;
to?: RouteLocationRaw;
href?: string;
}>(),
{
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined,
}
);
</script>
Це буде працювати. ІДЕ буде мати належний автокомплект. У нас буде багато болю і шкода підтримувати його.
Очевидно, що принцип «Не повторюйте себе» тут не був застосований, а це означає, що нам доведеться синхронізувати кожне оновлення. Одного дня вам доведеться додати ще один проп, і вам доведеться знайти кожен компонент, який обертає основний. Так, Кнопка і LinkButton, ймовірно, достатньо, але уявіть TextInput і десяток компонентів, які залежать від нього: PasswordInput, EmailInput, NumberInput, DateInput, HellKnowsWhatElseInput.
Адже це погано.І чим більше пристосувань у нас є, тим гірше воно стає.
Clean It Up
Очистити його вгоруДуже складно повторно використовувати анонімний тип, тому давайте назвемо його.
// MyButton.props.ts
export interface MyButtonProps {
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg;
rightIcon?: IconSvg;
counter?: number;
disabled?: boolean;
loading?: boolean;
}
Ми не можемо експортувати інтерфейс з.vue
Файли через внутрішнюscript setup
магії, тому ми повинні створити окрему.ts
На світлій стороні, дивіться, що ми маємо тут:
const props = withDefaults(defineProps<MyButtonProps>(), {
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined,
});
Чистіше, чи не так? і ось успадкований один:
interface MyLinkButtonProps {
to?: RouteLocationRaw;
href?: string;
}
const props = defineProps<MyButtonProps & MyLinkButtonProps>();
Однак, ось проблема: тепер, коли базові прописки розглядаються якMyLinkButton
«Перспективи, які не розповсюджуютьсяv-bind=”$attrs”
Більше того, ми повинні це зробити самі.
<!-- MyLinkButton.vue -->
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton v-bind="props"> <!-- there we go -->
<slot></slot>
</MyButton>
</component>
Все добре, але ми передаємо трохи більше, ніж хочемо:
Як бачите, тепер наша підземна кнопка також маєhref
Це не трагедія, просто трохи плутанини і додаткових байтів, хоча і не круто.
<template>
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton v-bind="propsToPass">
<slot></slot>
</MyButton>
</component>
</template>
<script lang="ts" setup>
// imports and definitions…
const props = defineProps<MyButtonProps & MyLinkButtonProps>();
const propsToPass = computed(() =>
Object.fromEntries(
Object.entries(props).filter(([key, _]) => !["to", "href"].includes(key))
)
);
</script>
Тепер ми тільки передаємо те, що має бути передано, але всі ці строкові літератури не виглядають чудово, чи не так?
Interfaces vs Abstract Interfaces
Інтерфейси проти абстрактних інтерфейсівЯкщо ви коли-небудь працювали з відповідними об'єктно-орієнтованими мовами, ви, напевно, знаєте про такі речі, як:рефлексіїНа жаль, в TypeScript інтерфейси є ефемеральними; вони не існують під час роботи, і ми не можемо легко з'ясувати, які поля належать доMyButtonProps
.
Це означає, що у нас є два варіанти.По-перше, ми можемо тримати речі такими, якими вони є: кожного разу, коли миMyLinkButton
Також необхідно виключити зpropsToPass
(І навіть якщо ми забудемо про це, це не велика справа).
Другий спосіб - використовувати об'єкти замість інтерфейсів. Це може звучати безглуздо, але дозвольте мені щось кодувати: це не буде жахливо; я обіцяю.ЦейСтрашним є
// MyButton.props.ts
export const defaultMyButtonProps: MyButtonProps = {
theme: ComponentTheme.BLUE,
small: false,
icon: undefined,
rightIcon: undefined,
counter: undefined,
disabled: false,
loading: false,
};
Немає сенсу створювати об'єкт тільки для створення об'єкта, але ми можемо використовувати його для за замовчуванням.MyButton.vue
Вони стають чистішими:
const props = withDefaults(defineProps<MyButtonProps>(), defaultMyButtonProps);
Потрібно лише оновитиpropsToPass
вMyLinkButton.vue
:
const propsToPass = computed(() =>
Object.fromEntries(
Object.entries(props).filter(([key, _]) =>
Object.hasOwn(defaultMyButtonProps, key)
)
)
);
Для цього необхідно чітко визначити всіundefined
таnull
Поля вdefaultMyButtonProps
В іншому випадку об’єкт не «власний».
Таким чином, коли ви додаєте прописку до базового компонента, вам також доведеться додати її до об'єкта з умовними значеннями. Так, так, це два місця знову, і, можливо, це не краще, ніж рішення з попередньої глави.
I’m Done
Я зробивЦе не шедевр, але це, мабуть, найкраще, що ми можемо зробити в межах обмежень TypeScript.
Також здається, що мати типи пропів всередині файлу SFC краще, але я не можу сказати, що переміщення їх в окремий файл зробило це набагато гірше.
Код з цієї статті можна знайти на GitHub.
GitHub