Если вы используете Vue.js, то знаете, что версия 3.X будет выпущена в ближайшее время. На данный момент новая версия находится в активной разработке, но все возможные функции можно найти в отдельном репозитории RFC (request for comments): https://github.com/vuejs/rfcs. Одна из них, function-api, может кардинально изменить стиль разработки приложений Vue.
Эта статья предназначена для тех, у кого есть опыт работы с JavaScript и Vue.
Что не так с текущим API? ?
Рассмотрим на примере. Предположим, что нужно реализовать компонент, который извлекает пользовательские данные, отображает состояние загрузки и верхнюю панель в зависимости от смещения прокрутки. Вот конечный результат:
Живой пример можно просмотреть здесь.
Хорошей практикой является извлечение логики для повторного использования в нескольких компонентах. В текущем API Vue 2.x есть ряд общих шаблонов, наиболее известными из которых являются:
- Миксины (через опцию
mixins
) ? - Компоненты высшего порядка (HOC) ?
Попробуем переместить логику отслеживания прокрутки в Mixin и логику извлечения в компонент высшего порядка. Типичную реализацию с Vue можно увидеть ниже.
Mixin прокрутки:
const scrollMixin = {
data() {
return {
pageOffset: 0
}
},
mounted() {
window.addEventListener('scroll', this.update)
},
destroyed() {
window.removeEventListener('scroll', this.update)
},
methods: {
update() {
this.pageOffset = window.pageYOffset
}
}
}
Здесь мы добавляем прослушиватель события scroll
, отслеживаем смещение страницы и сохраняем его в свойстве 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, связанными с извлечением.
Целевой компонент выглядит следующим образом:
// ...
<script>
export default {
name: 'PostsPage',
mixins: [scrollMixin],
props: {
id: Number,
isLoading: Boolean,
posts: Array,
count: Number
}
}
</script>
// .
Для получения указанных props он должен быть обернут в созданный HOC:
const PostsPage = withPostsHOC(PostsPage)
Полный компонент с шаблоном и стилями можно найти здесь.
Отлично! Задача была реализована с помощью примеси и 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
. То же самое происходит с HOC. При изменении поля данных 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 заключается в необходимости отдельных экземпляров компонентов, созданных для повторного использования логики, что приводит к снижению производительности.
Setup
Теперь посмотрим, какую альтернативу предлагает следующий релиз Vue.js и как можно решить эту проблему с помощью API на основе функций.
Поскольку Vue 3 еще не выпущен, был создан вспомогательный плагин vue-function-api. Он предоставляет функцию api из Vue3.x
в Vue2.x
для разработки приложений Vue следующего поколения.
Для начала нужно установить:
$ npm install vue-function-api
и выполнить установку для Vue через 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. Для этого нужно добавить прослушиватель события scroll, когда компонент будет монтирован, и удалить прослушиватель при размонтировании. Для этих целей существуют функции API value
, onMounted
, onUnmounted
:
// ...
<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 на основе опций, вычисляемые значения и наблюдателей можно объявлять с помощью 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
, называется «источником» и может являться одним из следующих элементов:
- геттером;
- оберткой значения;
- массивом, содержащим два вышеуказанных типа.
Второй аргумент — это обратный вызов, который выполняется только при возвращении значения из геттера или изменении обертки.
Мы реализовали целевой компонент с помощью API на основе функций. ? Теперь нужно сделать эту логику переиспользуемой.
Декомпозиция ? ✂️
Это самая интересная часть: для повторного использования кода, связанного с фрагментом логики, его можно извлечь в так называемую «композицию функций» и вернуть реактивное состояние:
// ...
<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
для возврата реактивных свойств. Эти функции могут храниться в отдельных файлах и использоваться в любом компоненте. По сравнению с опционным решением:
- Свойства, предоставляемые шаблону, обладают четкими источниками, поскольку они являются значениями, возвращаемыми из композиционных функций.
- Возвращенные значения из композиционных функций называются произвольно, чтобы избежать конфликта пространства имен.
- Отсутствуют ненужные экземпляры компонентов, созданные только для повторного использования логики.
Множество других преимуществ можно найти на официальной странице RFC.
Все примеры кода, используемые в этой статье, можно найти здесь.
Живой пример компонента находится здесь.
Заключение
API на основе функций от Vue предоставляет чистый и гибкий способ создания логики внутри и между компонентами, восполняя недостатки API на основе опций. ?
Читайте также:
Перевод статьи Taras Batenkov: Vue.js 3: Future-Oriented Programming