В идеале Vue-компоненты представляют собой самодостаточные части пользовательского интерфейса без каких-либо заметных побочных эффектов для объектов за пределами области действия данного элемента. Но, к сожалению, это не всегда возможно. Например, иногда нам нужно связать глобальных слушателей событий, использовать setInterval или инициализировать определенную стороннюю библиотеку внутри компонента.

// Vue 3
export default defineComponent({
  name: 'SomeComponent',
  setup() {
    // Global event listener
    document.body.addEventListener('click', () => {
      // do something expensive ...
    }, { capture: true });
    
    // Interval
    setInterval(() => {
      // do something expensive ...
    }, 2000);
    
    // Third-party library
    let flatpickrElement = ref(null);
    onMounted(() => {
      flatpickr(flatpickrElement.value);
    });
    
    // ...
  },
});

// Vue 2
export default {
  name: 'SomeComponent',
  created() {
    // Global event listener
    document.body.addEventListener('click', () => {
      // do something expensive ...
    }, { capture: true });
    
    // Interval
    setInterval(() => {
      // do something expensive ...
    }, 2000);
  },
  mounted() {
    // Third-party library
    flatpickr(this.$refs.flatpickrElement);
  },
};

В подобных случаях компоненты должны очищаться после их уничтожения. Если этого не сделать, могут произойти самые разнообразные неприятности — от сбоев в работе наших приложений до утечек памяти.

Удаление глобальных слушателей событий, очистка интервалов и сторонних библиотек

Давайте обновим наш демонстрационный пример, добавив хуки для очистки глобальных побочных эффектов, вызываемых нашим компонентом.

// Vue 3
export default defineComponent({
  name: 'SomeComponent',
  setup() {
    // Global event listener
    let options = { capture: true };
    let callback = () => {
      // do something expensive ...
    };
    document.body.addEventListener('click', callback, options);
    onUnmounted(() => document.body.removeEventListener('click', callback, options));
    
    // Interval
    let intervalId = setInterval(() => {
      // do something expensive ...
    }, 2000);
    onUnmounted(() => clearInterval(intervalId));
    
    // Third-party library
    let flatpickrElement = ref(null);
    let flatpickrInstance;
    onMounted(() => {
      flatpickrInstance = flatpickr(flatpickrElement.value);
    });
    onUnmounted(() => flatpickrInstance.destroy());
    
    // ...
  },
});

// Vue 2
export default {
  name: 'SomeComponent',
  created() {
    // Global event listener
    let options = { capture: true };
    let callback = () => {
      // do something expensive ...
    };
    document.body.addEventListener('click', callback, options);
    this.$once('hook:beforeDestroy', () => document.body.removeEventListener('click', callback, options));
    
    // Interval
    let intervalId = setInterval(() => {
      // do something expensive ...
    }, 2000);
    this.$once('hook:beforeDestroy', () => clearInterval(intervalId));
    
    // Third-party library
    let flatpickrInstance;
    this.$once('hook:mounted', () => {
      flatpickrInstance = flatpickr(this.$refs.flatpickrElement);
    });
    this.$once('hook:beforeDestroy', () => flatpickrInstance.destroy());
  },
};

Каждый раз, когда наш компонент уничтожается, вызываются соответствующие хуки, а глобальные побочные эффекты очищаются. Таким образом, нам не нужно беспокоиться об утечке памяти или о том, что глобальные слушатели событий будут накапливаться с каждым новым созданным нами инстансом компонента.

Хуки beforeDestroy и onUnmounted в тестах @vue/test-utils

Один из моих коллег обнаружил, что при тестировании компонентов с помощью замечательного пакета @vue/test-utils хуки beforeDestroy и onUnmounted не вызываются после теста! Так и задумано, хотя я не предполагал этого. В большинстве случаев это не проблема, но в иногда такое может привести к неожиданному поведению, когда тест-кейсы будут мешать друг другу из-за загрязненной глобальной области видимости.

test('It should make magic happen.', () => {
  const wrapper = mount(SomeComponent);

  // ...

  expect(magicHappened).toBe(true);
  // Vue 3.
  wrapper.unmount();
  // Vue 2.
  wrapper.destroy();
});

Использование столь простого решения в тех редких случаях, когда это фактор, обычно нормально. Но люди могут запросто забыть об этом, поэтому я предпочитаю более общее решение.

Я считаю лучшей практикой обертывание сторонних зависимостей, и @vue/test-utils не является исключением. Это позволяет нам установить параметры по умолчанию, которые целесообразно использовать для нашего приложения в глобальном масштабе.

// Vue 3
// test/utils.js
import { merge } from 'lodash';
import {
  mount as vueTestUtilsMount,
} from '@vue/test-utils';

let defaultOptions = {
  global: {
    mocks: {
      // Mocked plugins
      $t: input => input,
    },
  },
  // ...
};

export function mount(component, customOptions = {}) {
  let options = merge({}, defaultOptions, customOptions);
  return vueTestUtilsMount(component, options);
}

Более того, наличие кастомного модуля-обертки для @vue/test-utils дает нам идеальное место для настройки глобального поведения, подобного этому. К счастью, в @vue/test-utils для Vue 2 есть встроенная хелпер-функция, которая позволяет очень просто вызвать хук beforeDestroy для каждого компонента, инициализированного во время тестирования.

// Vue 2
// test/utils.js
import { merge } from 'lodash';
import {
  mount as vueTestUtilsMount,
  enableAutoDestroy,
} from '@vue/test-utils';

// See: https://vue-test-utils.vuejs.org/api/#enableautodestroy-hook
enableAutoDestroy(afterEach);

let defaultOptions = {
  mocks: {
    // Mocked plugins
    $t: input => input,
  },
  // ...
};

export function mount(component, customOptions = {}) {
  let options = merge({}, defaultOptions, customOptions);
  return vueTestUtilsMount(component, options);
}

К сожалению, этот хук, похоже, был удален в @vue/test-utils для Vue 3. Поэтому нам нужно имплементировать данную функциональность самостоятельно.

// Vue 3
// test/utils.js
import { merge } from 'lodash';
import {
  mount as vueTestUtilsMount,
} from '@vue/test-utils';

let defaultOptions = {
  global: {
    mocks: {
      // Mocked plugins
      $t: input => input,
    },
  },
  // ...
};

let wrappers = new Set();
afterEach(() => {
  wrappers.forEach(wrapper => wrapper.unmount());
  wrappers.clear();
});

export function mount(component, customOptions = {}) {
  let options = merge({}, defaultOptions, customOptions);
  let wrapper = vueTestUtilsMount(component, options);
  wrappers.add(wrapper);

  return wrapper;
}

Подведение итогов

То, что сейчас переживает человечество в глобальном масштабе, справедливо и для программирования. Если устроить бардак, не взяв на себя ответственность за уборку после себя, это приведет к плохим последствиям.


В любом приложении среднего размера разработчик сталкивается с задачей централизованного управления стейтом.В современном Vue 3 мы можем это делать и без Vuex, полагаясь только на hooks + provide/inject. Приглашаем на открытый урок «Сравнение стейт менеджеров — Redux vs Vuex vs новый — Pinua», на котором рассмотрим плюсы и минусы такого подхода в реальном приложении.Также в сообществе широко обсуждается упрощённый стейт-менеджер под названием Pinya. На занятии установим его и научимся пользоваться. Регистрация для всех желающих доступна по ссылке.

Комментарии (1)


  1. Aidar87
    10.03.2022 19:12

    Не чище ли выносить в beforeDestroy: function () {}