Наш досвід міграції на Vue 3. Частина друга
Вітаю! Мене звати Данііл, і я є 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? :)
Тож, до чого я! Кінець кінцем, я думаю, що це не тільки класний результат (сама міграція), а і класний досвід. А класного досвіду, звісно, я бажаю вам усім!
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівЩо з це за рішення? Це два різні підходи в тестуванні компонентів і Ваша пропозиція просто замінити shallowMount на mount.
Можна зробити стаб цього компонента, наприклад
ClientOnly: { template: '<div><slot /></div>' }Щодо того що це два різні підходи — я згоден з вами. Наша задача полягала в тому щоб швидко реанімувати 80% проекту, не зариваючись в це надовго. Щодо окремого тесту це може бути неоптимальним рішенням, але коли таких тестів ціла купа, і крім них ще є 9 апплікейшенів, потребуючих міграції — то з тим щоб виправляти кожен тест окремо — є проблеми
Щодо вашого прикладу, чесно кажучи, не дуже його зрозумів, тому прокоментувати його мені важко
Згоден, що якщо швидко треба, то це підійде. Можна навіть і скіпнути тести. Доречі, заміна shallowMount на mount також може викликати проблеми, бо чогось може не вистачати для дочірніх компонентів.