Vue.js 3: перспективы развития
В статье описано как новое API внедренное в Vue.js 3 на основе функций будет решать проблему повторного использования логики.
Оригинальная статья: Taras Batenkov Vue.js 3: Future-Oriented Programming
Если вы работаете с Vue.js, то вы, вероятно, уже слышали о 3-й версии этого фреймворка, которая будет выпущена в ближайшее время (если вы читаете эту статью в будущем, я надеюсь, что она по-прежнему актуальна 😉). Новая версия в настоящее время активно разрабатывается, но все возможные функции можно найти в отдельном репозитории RFC: https://github.com/vuejs/rfcs. Одна из них, function-api, может кардинально изменить стиль разработки приложений Vue.
Эта статья предназначена для людей, которые хоть немного знакомы с JavaScript и Vue.
Что не так с текущим API? 👀
Лучший способ — показать все на примере. Итак, давайте представим, что нам нужно реализовать компонент, который должен извлекать некоторые пользовательские данные, отображать состояние загрузки и верхнюю панель в зависимости от смещения прокрутки. Вот пример окончательного результата:
Живой пример вы можете посмотреть здесь.
Хорошей практикой является выделение некоторой логики для повторного использования в нескольких компонентах. В текущем API Vue 2.x существует ряд общих шаблонов, наиболее известными из которых являются:
- Миксины (используя
mixin
) 🍹 - Компоненты высшего порядка (HOCs — Higher-order components) 🎢
Итак, давайте переместим логику отслеживания прокрутки в миксин и извлечем логику в компонент более высокого порядка. Ниже будет типичная реализация с Vue этой логики.
Скрол миксин:
const scrollMixin = { data() { return { pageOffset: 0 } }, mounted() { window.addEventListener('scroll', this.update) }, destroyed() { window.removeEventListener('scroll', this.update) }, methods: { update() { this.pageOffset = window.pageYOffset } } }
Здесь мы добавляем слушителя события прокрутки addEventListener, таким образом отслеживаем смещение страницы и сохраняем его в свойстве pageOffset.
Компонент высшего порядка будет выглядеть так:
import { fetchUserPosts } from '@/api' const withPostsHOC = WrappedComponent => ({ props: WrappedComponent.props, data() { return { postsIsLoading: false, fetchedPosts: [] } }, watch: { id: { handler: 'fetchPosts', immediate: true } }, methods: { async fetchPosts() { this.postsIsLoading = true this.fetchedPosts = await fetchUserPosts(this.id) this.postsIsLoading = false } }, computed: { postsCount() { return this.fetchedPosts.length } }, render(h) { return h(WrappedComponent, { props: { ...this.$props, isLoading: this.postsIsLoading, posts: this.fetchedPosts, count: this.postsCount } }) } })
Здесь isLoading, posts инициализированы для отслеживания загрузки состояния и данных сообщений соответственно. Метод fetchPosts будет вызываться после создания экземпляра и каждый раз, когда изменяется props.id, для получения данных для нового id.
Это не полная реализация HOC, но для примера этого будет достаточно. Здесь мы просто обертываем целевой компонент и передаем оригинальный props вместе с props, связанными с fetch.
Конечный компонент выглядит так:
// ... <script> export default { name: 'PostsPage', mixins: [scrollMixin], props: { id: Number, isLoading: Boolean, posts: Array, count: Number } } </script> // ...
Чтобы получить указанные props, их следует обернуть в созданный HOC:
const PostsPage = withPostsHOC(PostsPage)
Полный компонент с шаблоном и стилями можно найти здесь.
Супер! 🥳 Мы только что реализовали нашу задачу, используя mixin и HOC, теперь их можно использовать и в других компонентах. Но не все так радужно, есть несколько проблем с этими подходами.
1. Столкновение пространства имен ⚔️
Представьте, что нам нужно добавить метод update в наш компонент:
// ... <script> export default { name: 'PostsPage', mixins: [scrollMixin], props: { id: Number, isLoading: Boolean, posts: Array, count: Number }, methods: { update() { console.log('some update logic here') } } } </script> // ...
Если вы снова откроете страницу и прокрутите ее, верхняя панель больше не будет отображаться. Это связано с перезаписью метода update в mixin. То же самое работает для HOCs. Если вы измените поле данных fetchedPosts на posts:
const withPostsHOC = WrappedComponent => ({ props: WrappedComponent.props, // ['posts', ...] data() { return { postsIsLoading: false, posts: [] // fetchedPosts -> posts } }, // ...
… вы получите такие ошибки:
Причина этого заключается в том, что в обернутом компоненте уже указано свойство с именем posts.
2. Неясные источники 📦
Что если через некоторое время вы решили использовать другой миксин в своем компоненте:
// ... <script> export default { name: 'PostsPage', mixins: [scrollMixin, mouseMixin], // ...
Сможете ли вы сказать, из какого именно миксина было введено свойство pageOffset? Или в другом сценарии оба миксина могут иметь, например, yOffset, поэтому последний миксин будет переопределять свойство из предыдущего. Это не хорошо и может вызвать много неожиданных ошибок. 😕
3. Производительность ⏱
Другая проблема с HOC состоит в том, что отдельные экземпляры компонентов, созданные просто для целей повторного использования логики, приводят к снижению производительности.
Vue.js 3 🏗
Давайте посмотрим, какую альтернативу может предложить следующая версия Vue.js и как мы можем решить эту проблему, используя API на основе функций.
Поскольку Vue 3 еще не выпущен, был создан вспомогательный плагин — vue-function-api. Он предоставляет функцию api из Vue3.x для Vue2.x для разработки приложений Vue следующего поколения.
Во-первых, вам нужно установить его:
$ npm install vue-function-api
и явно подключить через Vue.use():
import Vue from 'vue' import { plugin } from 'vue-function-api' Vue.use(plugin)
Основным дополнением, основанным на API-функциях, является новая опция компонента — setup(). Как следует из названия, это место, где мы используем функции нового API для настройки логики нашего компонента. Итак, давайте реализуем функцию отображения верхней панели в зависимости от смещения прокрутки. Пример базового компонента:
// ... <script> export default { setup(props) { const pageOffset = 0 return { pageOffset } } } </script> // ...
Обратите внимание, что функция setup получает объект props в качестве первого аргумента, и этот объект props является реактивным. Мы также возвращаем объект, содержащий свойство pageOffset, чтобы он был доступным для контекста рендеринга шаблона. Это свойство также становится реактивным, но только в контексте визуализации. Мы можем использовать его в шаблоне как обычно:
<div class="topbar" :class="{ open: pageOffset > 120 }">...</div>
Но это свойство должно быть изменено при каждом событии прокрутки. Чтобы реализовать это, нам нужно добавить слушитель события прокрутки (scroll event listener), когда компонент будет mounted, и удалить слушителя — когда unmounted. Для этого были созданы функции API value, onMounting, onUnmounting :
// ... <script> import { value, onMounted, onUnmounted } from 'vue-function-api' export default { setup(props) { const pageOffset = value(0) const update = () => { pageOffset.value = window.pageYOffset } onMounted(() => window.addEventListener('scroll', update)) onUnmounted(() => window.removeEventListener('scroll', update)) return { pageOffset } } } </script> // ...
Обратите внимание, что все хуки жизненного цикла в версии Vue 2.x имеют эквивалентную функцию onXXX, которую можно использовать внутри setup().
Вы, вероятно, также заметили, что переменная pageOffset содержит единственное реактивное свойство: .value. Нам нужно использовать это упакованное свойство, потому что примитивные значения в JavaScript, такие как числа и строки, не передаются по ссылке. Оболочки значений обеспечивают способ передачи изменчивых и реактивных ссылок для произвольных типов значений.
Вот как выглядит объект pageOffset:
Следующим шагом является реализация выборки данных пользователя. Как и при использовании API на основе параметров, вы можете объявлять вычисленные значения (computed values) и watchers, используя API на основе функций:
// ... <script> import { value, watch, computed, onMounted, onUnmounted } from 'vue-function-api' import { fetchUserPosts } from '@/api' export default { setup(props) { const pageOffset = value(0) const isLoading = value(false) const posts = value([]) const count = computed(() => posts.value.length) const update = () => { pageOffset.value = window.pageYOffset } onMounted(() => window.addEventListener('scroll', update)) onUnmounted(() => window.removeEventListener('scroll', update)) watch( () => props.id, async id => { isLoading.value = true posts.value = await fetchUserPosts(id) isLoading.value = false } ) return { isLoading, pageOffset, posts, count } } } </script> // ...
Вычисленное значение ведет себя так же, как вычисляемое свойство 2.x: оно отслеживает свои зависимости и вычисляет заново только при изменении зависимостей. Первый аргумент, передаваемый для watch, называется «source», который может быть одним из следующих:
- геттер (getter) функция
- обертка значения
- массив, содержащий два вышеуказанных типа
Второй аргумент — это обратный вызов, который вызывается только тогда, когда значение, возвращаемое из метода getter или оболочки значения, изменилось.
Мы только что реализовали целевой компонент, используя API на основе функций. 🎉 Следующий шаг — сделать всю эту логику многократно используемой.
Декомпозиция 🎻 ✂️
Это самая интересная часть: для повторного использования кода, связанного с фрагментом логики, мы просто можем извлечь его в так называемую «композиционную функцию» («composition function») и вернуть реактивное состояние:
// ... <script> import { value, watch, computed, onMounted, onUnmounted } from 'vue-function-api' import { fetchUserPosts } from '@/api' function useScroll() { const pageOffset = value(0) const update = () => { pageOffset.value = window.pageYOffset } onMounted(() => window.addEventListener('scroll', update)) onUnmounted(() => window.removeEventListener('scroll', update)) return { pageOffset } } function useFetchPosts(props) { const isLoading = value(false) const posts = value([]) watch( () => props.id, async id => { isLoading.value = true posts.value = await fetchUserPosts(id) isLoading.value = false } ) return { isLoading, posts } } export default { props: { id: Number }, setup(props) { const { isLoading, posts } = useFetchPosts(props) const count = computed(() => posts.value.length) return { ...useScroll(), isLoading, posts, count } } } </script> // ...
Обратите внимание, как мы использовали функции useFetchPosts и useScroll для возврата реактивных свойств. Эти функции могут храниться в отдельных файлах и использоваться в любом другом компоненте. По сравнению с option-based решением:
- Свойства, предоставляемые шаблону, имеют понятные источники (sources), поскольку они являются значениями, возвращаемыми из функций композиции;
- Возвращенные значения из композиционных функций имеют произвольные имена, поэтому нет столкновений пространства имен;
- Не существует ненужных экземпляров компонентов, созданных только для повторного использования логики.
Существует множество других преимуществ, которые можно найти на официальной странице RFC.
Все примеры кода, используемые в этой статье, вы можете найти здесь.
Живой пример компонента вы можете посмотреть здесь.
Заключение
Как вы можете видеть, API на основе функций Vue представляет собой простой и гибкий способ создания логики внутри и между компонентами без каких-либо недостатков API на основе параметров. Только представьте, какими мощными могут быть функции композиции для любого типа проекта — от маленьких до больших, сложных веб-приложений. 🚀
Я надеюсь, что этот пост был вам полезен 🎓. Если у вас есть какие-либо мысли или вопросы, пожалуйста, не стесняйтесь отвечать и комментировать ниже (в оригинальной статье автора)! Я буду рад ответить 🙂. Благодарю.