Vue.js 3

Если вы используете 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 valueonMountedonUnmounted:

// ...
<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