Vue.jsMiddleExperience

Расскажите о случае, когда вы улучшали performance, accessibility, testing или maintainability в проекте на Vue.js.

В проекте на Vue.js оптимизировал производительность через lazy-loading маршрутов и виртуализацию списков, улучшил доступность добавив ARIA-атрибуты и фокус-менеджмент, покрыл компоненты тестами на Vitest и разбил монолитный компонент на составные части.

Улучшение производительности, доступности, тестирования и поддерживаемости в Vue.js-проекте

На одном из проектов — маркетплейс с большим каталогом товаров — я последовательно улучшил четыре аспекта качества кода и пользовательского опыта. Ниже описываю конкретные изменения по каждому направлению.

Производительность (Performance)

Главной проблемой было медленное первоначальное время загрузки: бандл весил 1.8 МБ. Я применил несколько техник:

  • Lazy-loading маршрутов через динамический import() в Vue Router — каждая страница превратилась в отдельный чанк.
  • Виртуализация списков с помощью библиотеки vue-virtual-scroller для каталога из 10 000+ товаров.
  • defineAsyncComponent для тяжёлых модальных окон, которые подгружаются только при открытии.
  • Замена v-if на v-show для часто переключаемых блоков и наоборот — для редко видимых.
// router/index.js — lazy-loading страниц
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/catalog',
    component: () => import('@/pages/CatalogPage.vue'),
  },
  {
    path: '/product/:id',
    component: () => import('@/pages/ProductPage.vue'),
  },
]

export default createRouter({
  history: createWebHistory(),
  routes,
})
// components/HeavyModal.vue — defineAsyncComponent
import { defineAsyncComponent } from 'vue'

const HeavyModal = defineAsyncComponent(() =>
  import('@/components/HeavyModal.vue')
)

Размер начального бандла снизился до 380 КБ, LCP улучшился с 4.2 с до 1.8 с.

Доступность (Accessibility)

Аудит с помощью axe DevTools выявил 23 нарушения WCAG 2.1. Основные правки:

  • Добавил aria-label и role к иконочным кнопкам без видимого текста.
  • Реализовал фокус-ловушку в модальных окнах: при открытии фокус переходит на первый интерактивный элемент, при закрытии — возвращается на триггер.
  • Применил директиву v-focus и хук onMounted для управления фокусом программно.
  • Добавил aria-live="polite" для уведомлений об изменениях содержимого (добавление в корзину).
// composables/useFocusTrap.js
import { onMounted, onUnmounted, ref } from 'vue'

export function useFocusTrap(containerRef) {
  const previousFocus = ref(null)

  onMounted(() => {
    previousFocus.value = document.activeElement
    const focusable = containerRef.value.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    if (focusable.length) focusable[0].focus()
  })

  onUnmounted(() => {
    previousFocus.value?.focus()
  })
}

Тестирование (Testing)

Покрытие юнит-тестами было около 12%. Я настроил Vitest + @vue/test-utils и написал тесты для критических компонентов и composables:

// tests/useCart.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '@/stores/cart'

describe('useCartStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('добавляет товар в корзину', () => {
    const cart = useCartStore()
    cart.addItem({ id: 1, name: 'Widget', price: 100 })
    expect(cart.items).toHaveLength(1)
    expect(cart.total).toBe(100)
  })

  it('не дублирует товар, а увеличивает количество', () => {
    const cart = useCartStore()
    cart.addItem({ id: 1, name: 'Widget', price: 100 })
    cart.addItem({ id: 1, name: 'Widget', price: 100 })
    expect(cart.items).toHaveLength(1)
    expect(cart.items[0].quantity).toBe(2)
  })
})

Покрытие выросло до 68%, что позволило отловить три регрессии при рефакторинге.

Поддерживаемость (Maintainability)

Компонент ProductCard.vue вырос до 600 строк. Я разбил его по принципу Single Responsibility:

  • ProductCardImage.vue — изображение с lazy-load и fallback.
  • ProductCardBadges.vue — ярлыки «Хит», «Скидка».
  • ProductCardActions.vue — кнопки «В корзину» и «В избранное».
  • Логику работы с корзиной вынес в composables/useCartActions.js.

После декомпозиции каждый файл стал <150 строк, PR-ревью ускорились в 2 раза.

Подводные камни

  • Чрезмерный lazy-loading: если дробить на слишком маленькие чанки, браузер делает десятки мелких запросов — хуже, чем один большой бандл. Группируйте логически связанные маршруты через webpackChunkName или rollupOptions.output.manualChunks.
  • Виртуализация и динамическая высота строк: vue-virtual-scroller плохо работает с элементами переменной высоты без явного указания min-item-size — список прыгает при скролле.
  • Фокус-ловушка и порталы: если модальное окно рендерится через Teleport, containerRef может указывать не туда — нужно явно передавать ссылку на телепортируемый DOM-узел.
  • ARIA и динамический контент: aria-live не работает, если элемент добавляется в DOM вместе с текстом — регион должен существовать в DOM до изменения его содержимого.
  • Vitest и глобальные моки: vi.mock() поднимается в начало файла автоматически, что ломает порядок импортов при написании интеграционных тестов с реальными Pinia-сторами.
  • Декомпозиция ради декомпозиции: чрезмерное дробление создаёт «prop drilling» через 3–4 уровня. Используйте provide/inject или Pinia, когда данные нужны глубоко вложенным компонентам.
  • Метрики без базовой линии: улучшения производительности бессмысленны без замеров до и после. Всегда фиксируйте Lighthouse-отчёт или Web Vitals перед началом оптимизации.

What hurts your answer

  • Выдумывать опыт или говорить слишком общими фразами
  • Не объяснять свою личную роль в работе с Vue.js
  • Не показывать результат, метрики или извлечённые уроки

What they're listening for

  • Может подготовить честный пример использования Vue.js
  • Показывает свою роль, решения и результат
  • Умеет рефлексировать над trade-offs и уроками

Related topics