Наш досвід міграції на Vue 3. Частина друга

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Вітаю! Мене звати Данііл, і я є Front-end Team Lead в українській продуктовій компанії Webitel. У двох статтях я хочу поділитися нашим досвідом міграції з Vue 2 на Vue 3.

Попередню статтю можна прочитати тут, а у цій ми поговоримо про підводні камені та неочевидні проблеми, з якими ми стикнулись під час міграції.

Також, підкреслюю: ці всі проблеми та рефакторинги виникали на compatibility-білді! Якщо забрати compatibility-білд, то рефакторингів буде значно більше. Але, наразі, наша задача — просто зробити так, щоб проєкт, який працював на Vue 2, нормально працював на Vue 3.

Отже, не будемо затягувати, і одразу почнемо.

eventBus — все

У Vue 2 ми використовували eventBus для спрощення взаємодії між компонентами. А потім мігрували на Vue 3, і виявилось, що тут його вже немає! Ну, ця проблема вирішується просто: щоб багато не переписувати, ми у файлику, який містить в собі eventBus, міняємо його на якийсь еміттер з npm. Наприклад, нам чудово підійшов tiny-emitter.

Було:

// eventBus.js
import Vue from 'vue';
const eventBus = new Vue();
export default eventBus;

Стало:

// eventBus.js
import emitter from 'tiny-emitter/instance';
export default {
  $on: (...args) => emitter.on(...args),
  $once: (...args) => emitter.once(...args),
  $off: (...args) => emitter.off(...args),
  $emit: (...args) => emitter.emit(...args),
}

Proxy !== Proxy

Різниця між системами реактивності Vue 2 та Vue 3

Тут варто одразу зробити крок в сторону і пояснити, як у Vue 3 змінилась система реактивності. У Vue 2 використовувалась система Object.defineProperty, яка відстежувала зміни в об’єктах, в той час, як Vue 3 для цього обгортає об’єкти в Proxy.

Чому це важливо? А власне тому, що Proxy !== Proxy.

Кейс

Наприклад, ми маємо декілька елементів на вибір (скажімо, таби), і нам треба підсвічувати вибраний. У Vue 2 це реалізовувалося просто тим, що обраний елемент з масиву опцій ми зберігали в окрему змінну, і потім просто перевіряли через ===. У Vue 3 цього не вийде — треба перевіряти за певним полем, бо обʼєкт більше не дорівнює сам собі.

Було

// ComponentWithTabs.vue
<template>
  <div>
    <Tab
      v-for="(tab) in tabs"
      :key="tab.id"
      :selected="tab === selectedTab"
      @click="selectedTab = tab"
    >
      {{ tab.name }}
    </Tab>
  </div>
</template>
<script>
  export default {
    data: () => ({
      selectedTab: null,
      tabs: [
        { id: 1, name: 'Tab 1' },
        { id: 2, name: 'Tab 2' },
        { id: 3, name: 'Tab 3' },
      ],
    }),
  };
</script>

Стало

Маємо те саме, але тепер перевіряємо не за референсом на обʼєкт, а за якимось його полем.

// ComponentWithTabs.vue
<template>
  <div>
    <Tab
      ...
      :selected="tab.id === selectedTab.id"
      ...
    >
      ...
    </Tab>
  </div>
</template>

Оголошення defineEmit в компонентах

Vue 3 хоче оголошення кастомних емітів в компонентах. Це робиться за допомогою emits: [] (OptionsAPI), або defineEmit (Composition API).

Чомусь, у документації написано слово can, дослівно:

«A component can explicitly declare the events it will emit using the emits option».

Чому там слово can — я не розумію, тому що, якщо не оголошувати події, то навіть компоненти, зібрані з compatibility build, періодично криво емітають івенти.

То як ми з цим стикнулись

У нас була кастомна кнопка, яка емітила клік, чи щось подібне. Виглядала, якщо опускати деталі, дуже по примітивному, десь так:

// CustomButton.vue
<template>
  <button @click="$emit('click')">
    <slot />
  </button>
</template>

І було описане використання цієї кнопки в іншому компоненті:

// SomeComponent.vue
<template>
  <CustomButton
    @click="boolVar ? open : close"
  >
    Click me!
  </CustomButton>
</template>

Тобто, нічого нетривіального.

І от ця кнопка не працювала так, як мала: локально(!), у дев білді, все було добре, але у прод білді — на тестовому середовищі — кнопка клікалась один раз як треба, а потім вже викидала одразу 2 клік-події.

Чому? Не уявляю.

Спершу (тобто, через цілий день копання цієї баги, яка, здавалося б, виникала на пустому місці), я просто зробив дві кнопки, з v-if / v-else на ту boolVar. І це допомогло.

Лише потім, стикнувшись з подібною проблемою ще в декількох різних кейсах, я емпірично зʼясував, що проблема була в тому, щоб оголосити еміт цієї події через emits в компоненті.

// CustomButton.vue
// ...
export default {
  // ...
  emits: ['click'],
  // ...
}

Чому виникає ця проблема? Не знаю. Але перечіплявся я через неї кілька разів, і кожного разу вирішувалось все однаково. До речі, впевнений, що ця проблема виникає тільки на compatibility-білді.

Що робити, коли Proxy не може обгорнути обʼєкт

В одному з застосунків ми стикнулися з дуже цікавою проблемою. Ми використовували бібліотеку, в якій втрачалась реактивність. Просто от на Vue 2 працює, а на Vue 3 — вже ні. Одна і та сама бібліотека, з одним і тим самим сетапом.

Мучився з тим я довго: кілька підходів по кілька днів. В якийсь момент навіть пішов на GitHub створити у репозиторії Vue дискусію, де, мене наштовхнули на правильну відповідь: Vue 2 працює з тим самим обʼєктом, а Vue 3 — обгортає наявний і повертає новий — proxy, через що не вдавалось навісити реактивність на бібліотеку.

Обійшли ми це тим, що за допомогою методів Vue, зокрема reactive, перепризначили обʼєкти цієї бібліотеки, десь так:

import { reactive, markRaw } from 'vue';
const lib = new Lib();
lib.prop = reactive(lib.prop);

Втім, це допомогло не повністю. Почала відвалюватись бібліотека всередині бібліотеки: jssip. Ще два дні копання в їхньому репозиторії — і я зрозумів, що проблема в тому, що вони використовують Object.defineProperty({ writable: false }), щоб додати поле одному з ключових обʼєктів, через що proxy, яким Vue обгортає цей обʼєкт, просто не вʼяжеться.

Але, тут приходить на допомогу інша функція Vue — markRaw. Вона вказує Vue, що на цей обʼєкт не треба вішати реактивність. І далі почались махінації: необхідно було навісити реактивність на весь обʼєкт, крім того, дуже глибоко закладеного обʼєкта, який використовував Object.defineProperty з writable: false.

Виглядало це десь ось так (через спрощення може втрачатися правильність самого коду, але тут головне зрозуміти ідею):

import { reactive, markRaw } from 'vue';
const lib = new Lib();
lib.prop.some.nested.prop = markRaw(lib.prop.some.nested.prop);
lib.prop = reactive(lib.prop);

Інколи треба залишити обʼєкт raw

Знов таки, бібліотеки. Інколи бібліотека всередині себе проводить перевірки на референс, як описано в кейсі Proxy !== Proxy. Тобто, те саме, що було описано в тому кейсі, відбувається вже в середині бібліотеки, куди ми доступу не маємо, і не можемо це виправити.

Наприклад, я стикнувся з такою проблемою з monaco-editor, і, мені просто дуже пощастило, що колись раніше я побачив твіт Іллі Клімова, який писав саме про цю проблему.

В такому випадку, ми можемо просто відмітити обʼєкт як не реактивний, за допомогою того ж markRaw().

У випадку з monaco-editor, обійшлось ось так:

import { markRaw } from 'vue';
// ...
export default {
  data: () => ({
    editor: null,
  }),
  // ...
  mounted() {
    this.editor = markRaw(monaco.create({ ... }));
  },
};

Бонус-плюс: міграція з Vue Test Utils v1 на Vue Test Utils v2

Оновлюючи Vue до третьої версії, нам також необхідно оновити й Vue Test Utils.

З важливого можу виділити два моменти, з якими ми стикнулись:

propsData -> props

Тут все просто. В опціях маунта компонента, який ми тестуємо, раніше було потрібно писати propsData, а тепер — просто props.

// Vue Test Utils v1
const wrapper = mount(Component, {
  propsData: {
    prop1: 'value1',
    prop2: 'value2',
  },
});
// Vue Test Utils v2
const wrapper = mount(Component, {
  props: {
    prop1: 'value1',
    prop2: 'value2',
  },
});

default slot

Тут теж безхитрісно: у Vue Test Utils v1, використовуючи shallowMount, дефолтний слот компонента все одно відмальовувався.

У Vue Test Utils v2 — вже ні. Необхідно використовувати mount.

// Vue Test Utils v1
const wrapper = shallowMount(Component);
// Vue Test Utils v2
const wrapper = mount(Component);

Це саме те, з чим стикнулись ми. Насправді мені здається, що тести ще більш варіативні у написанні, і більш специфічні, тож у них ще більше нюансів і підводних каменів, які треба знаходити залежно саме від того, як тест написаний.

Висновок

Насамкінець скажу, що насправді, хоча деякі з цих задач були такими, що я за голову тримався — інколи по кілька днів, але міграція цього була варта. До того ж це було доволі корисно: коли ще б довелося покопатись у сорсах бібліотеки всередині бібліотеки, або детально розібратись у системі реактивності Vue 3? :)

Тож, до чого я! Кінець кінцем, я думаю, що це не тільки класний результат (сама міграція), а і класний досвід. А класного досвіду, звісно, я бажаю вам усім!

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному1
LinkedIn
Ctrl + Enter
Ctrl + Enter
Тут теж безхитрісно: у Vue Test Utils v1, використовуючи shallowMount, дефолтний слот компонента все одно відмальовувався.
// Vue Test Utils v1
const wrapper = shallowMount(Component);
// Vue Test Utils v2
const wrapper = mount(Component);

Що з це за рішення? Це два різні підходи в тестуванні компонентів і Ваша пропозиція просто замінити shallowMount на mount.
Можна зробити стаб цього компонента, наприклад
ClientOnly: {       template: '<div><slot /></div>' }

Щодо того що це два різні підходи — я згоден з вами. Наша задача полягала в тому щоб швидко реанімувати 80% проекту, не зариваючись в це надовго. Щодо окремого тесту це може бути неоптимальним рішенням, але коли таких тестів ціла купа, і крім них ще є 9 апплікейшенів, потребуючих міграції — то з тим щоб виправляти кожен тест окремо — є проблеми

Щодо вашого прикладу, чесно кажучи, не дуже його зрозумів, тому прокоментувати його мені важко

Згоден, що якщо швидко треба, то це підійде. Можна навіть і скіпнути тести. Доречі, заміна shallowMount на mount також може викликати проблеми, бо чогось може не вистачати для дочірніх компонентів.

Підписатись на коментарі