Senior: питання на співбесіді з теми «TypeScript та інструменти»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Компоненти на кшталт таблиці, списку з вибором чи автодоповнення працюють з будь-яким типом елементів. Без узагальнень доводиться писати items: any[], і батько втрачає перевірку типів у слотах і подіях.
Атрибут generic на <script setup>:
<!-- DataTable.vue -->
<script setup lang="ts" generic="T extends { id: number | string }">
defineProps<{
rows: T[]
columns: Array<{ key: keyof T; label: string }>
}>()
const emit = defineEmits<{
select: [row: T]
}>()
</script>
<template>
<table>
<tr v-for="row in rows" :key="row.id" @click="emit('select', row)">
<td v-for="column in columns" :key="String(column.key)">
<slot :name="String(column.key)" :row="row">{{ row[column.key] }}</slot>
</td>
</tr>
</table>
</template>
Що отримує батько:
<DataTable
:rows="orders"
:columns="[{ key: 'total', label: 'Сума' }]"
@select="(order) => open(order.number)"
/>
T виводиться з переданого rows як Order: key: 'totla' - помилка, у @select параметр має тип Order, а в слоті row - теж Order.
Можливості:
- кілька параметрів і обмеження:
generic="T extends Item, K extends keyof T"; - імпортовані типи в обмеженнях;
- типізовані слоти через
defineSlots<{ default(props: { item: T }): any }>().
Обмеження й нюанси:
- runtime не знає про
T- узагальнення існують лише для TypeScript. Vue перевіряє props за базовим типом (Array), а не заT; - виведення типу йде з props. Якщо
Tне можна вивести з жодного prop, він стає обмеженням (unknownчи тип зextends); - перевірку в шаблоні батька дає лише
vue-tsc/ Vue - Official; без них узагальнення - просто документація; - ref на узагальнений компонент типізується складніше - звичайний
InstanceType<typeof DataTable>не працює, бо компонент - функція з параметром типу. Для таких випадків використовуютьComponentExposedз пакетаvue-component-type-helpers.
Коли не варто: якщо компонент завжди працює з одним типом - узагальнення лише ускладнюють читання.
provide/inject передає значення від предка до будь-якого нащадка без прокидання через props. З рядковим ключем TypeScript не знає, що там лежить:
provide('theme', theme)
const theme = inject('theme') // unknown - і помилка в назві ключа ніде не підсвітиться
InjectionKey<T> - символ, що несе тип значення:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
// провайдер
provide(ThemeKey, { mode, toggle }) // TypeScript перевірить форму значення
// споживач
const theme = inject(ThemeKey) // ThemeContext | undefined
Чому undefined у типі: компонент може опинитися поза деревом провайдера - тоді inject поверне undefined (з попередженням у режимі розробки). Варіанти:
const theme = inject(ThemeKey, defaultTheme) // значення за замовчуванням
function useTheme(): ThemeContext {
const theme = inject(ThemeKey)
if (!theme) throw new Error('useTheme() потрібно викликати всередині ThemeProvider')
return theme
}
Обгортка-composable з явною помилкою - найпрактичніший варіант: зрозуміле повідомлення замість Cannot read properties of undefined десь глибоко.
Чому символ кращий за рядок:
- немає зіткнень: дві бібліотеки з ключем
'theme'перезапишуть одна одну; символи завжди унікальні; - тип прив'язаний до ключа - не треба дублювати тип у кожному
inject; - рефакторинг і пошук працюють за імпортом ключа, а не за рядком.
Що надавати:
- реактивні значення (
ref,computed) - інакше нащадки не побачать змін; - функції для зміни замість відкритого змінюваного стану - провайдер контролює, як змінюється дані. Для захисту від прямої зміни -
readonly(state).
Коли не provide/inject: глобальний стан застосунку (кошик, користувач) - Pinia з DevTools і плагінами. Provide/inject - для контексту піддерева: форма й її поля, таблиця й колонки, тема окремого віджета.
Докладніше в документації: TypeScript: типізація provide / inject
Плагіни й app.config.globalProperties додають властивості, доступні в кожному компоненті ($t для перекладів, route() від Ziggy в Laravel-проєктах). Глобально зареєстровані компоненти (app.component('Icon', Icon)) доступні в усіх шаблонах без імпорту. TypeScript про них нічого не знає, поки їх не оголосити.
Глобальні властивості - розширення модуля vue:
// types/vue.d.ts
import type { route as routeFn } from 'ziggy-js'
export {}
declare module 'vue' {
interface ComponentCustomProperties {
route: typeof routeFn
$t: (key: string, params?: Record<string, unknown>) => string
}
}
Тепер у шаблоні {{ route('posts.show', post.id) }} і $t('cart.empty') мають типи, а друкарська помилка в назві - помилка vue-tsc.
Глобальні компоненти:
declare module 'vue' {
interface GlobalComponents {
Icon: typeof import('./components/Icon.vue')['default']
Link: typeof import('@inertiajs/vue3')['Link']
}
}
Розширення Vue - Official і vue-tsc перевіряють props таких компонентів у шаблонах так само, як імпортованих.
Важливі деталі:
- файл оголошень має бути модулем (
export {}чи будь-який імпорт/експорт), інакшеdeclare module 'vue'замінить типи Vue замість розширення; - файл має потрапити в
includeуtsconfig; - бібліотеки (Vue Router, Pinia, Inertia) самі постачають такі розширення -
$route,$routerтипізовані без ваших зусиль.
Краще уникати глобальних властивостей у Composition API. У <script setup> доступ до них через getCurrentInstance() - незручний і крихкий. Явний імпорт чи composable (const { t } = useI18n()) зрозуміліший, краще працює з tree shaking і тестами: у тесті достатньо замокати модуль, а не налаштовувати глобальні властивості.
Глобальні компоненти теж мають ціну: збирач не знає, чи вони використовуються, тож не може їх викинути чи винести в окрему частину. Реєструвати глобально варто лише справді повсюдні (іконка, посилання), решту - імпортувати там, де потрібні.
Докладніше в документації: TypeScript: розширення глобальних властивостей
eslint-plugin-vue - офіційний плагін ESLint, що розбирає .vue-файли, включно з шаблонами. Для TypeScript у Vue-файлах поруч ставлять @vue/eslint-config-typescript (обгортка над typescript-eslint, що знає про SFC). create-vue налаштовує все це сам.
Плоска конфігурація (ESLint 9+):
// eslint.config.js
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
export default defineConfigWithVueTs(
pluginVue.configs['flat/recommended'],
vueTsConfigs.recommended,
{
rules: {
'vue/multi-word-component-names': 'off',
},
},
)
Рівні наборів правил: flat/essential (помилки), flat/strongly-recommended (+ читабельність), flat/recommended (+ узгодженість стилю).
Правила, що ловлять справжні баги:
vue/no-mutating-props- зміна props у дочірньому компоненті;vue/require-v-for-keyіvue/valid-v-for-v-forбез ключа;vue/no-use-v-if-with-v-for-v-ifіv-forна одному елементі (пріоритетv-ifвищий, і змінна циклу в ньому недоступна);vue/no-side-effects-in-computed-properties- зміна стану вcomputed;vue/no-ref-as-operand-count + 1замістьcount.value + 1;vue/no-setup-props-reactivity-loss- деструктуризація props зі втратою реактивності;vue/no-v-html- потенційний XSS;vue/require-explicit-emits- неоголошені події.
З TypeScript-правилами на основі типів (vueTsConfigs.recommendedTypeChecked) додаються, наприклад, @typescript-eslint/no-floating-promises - необроблені Promise в обробниках. Вони повільніші, бо будують програму TypeScript.
Розділення відповідальності:
- форматування - Prettier, а правила стилю ESLint, що з ним конфліктують, вимикаються (
@vue/eslint-config-prettier); - типи -
vue-tsc, а не ESLint; - ESLint - помилки логіки й небезпечні шаблони.
У CI лінтер запускають з --max-warnings 0 - інакше попередження накопичуються й перестають щось означати.