Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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.

Коли не варто: якщо компонент завжди працює з одним типом - узагальнення лише ускладнюють читання.

Докладніше в документації: script setup: generics

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 - інакше попередження накопичуються й перестають щось означати.

Докладніше в документації: eslint-plugin-vue