Composition API RFC
Перевод RFC в котором предложено и описывается Composition API. Оригинал: Composition API RFC
- Дата создания: 2019-07-10
- Целевая основная версия: 2.x / 3.x
- Ссылка на Issues: #42
- Реализация PR:
Обзор
Представляем Composition API: набор аддитивных API на основе функций, которые позволяют гибко комбинировать логику компонентов.
Посмотрите Vue Mastery’s Vue 3 Essentials Course. Скачать Vue 3 шпаргалку.
Базовый пример
<template> <button @click="increment"> Count is: {{ state.count }}, double is: {{ state.double }} </button> </template> <script> import { reactive, computed } from 'vue' export default { setup() { const state = reactive({ count: 0, double: computed(() => state.count * 2) }) function increment() { state.count++ } return { state, increment } } } </script>
Мотивация
Логическое повторное использование кода (Reuse) & Организация кода
Мы все любим, как с помощью Vue можно очень легко создавать небольшие и средние приложения на одном дыхании. Но сегодня, по мере роста популярности Vue, многие пользователи также используют Vue для создания крупномасштабных проектов — проектов, которые выполняются и поддерживаются в течение длительного периода времени командой из нескольких разработчиков. За прошедшие годы мы стали свидетелями того, как некоторые из этих проектов испытывают проблемы в рамках модели программирования, используемой текущим Vue API. Проблемы могут быть разделены на две категории:
- Код сложных компонентов становится все труднее поддерживать, так как размер и количество функций со временем неизбежно растут. Особенно это происходит, когда разработчикам необходимо читают код, созданный другими программистами. Основная причина заключается в том, что существующие Vue API вынуждает организацию кода с помощью опций (options), хотя в некоторых случаях имеет смысл организовывать код по логическим соображениям.
- Отсутствие простого механизма извлечения и повторного использования логики между несколькими компонентами. (Подробнее в разделе «Извлечение и повторное использование логики»)
API-интерфейсы, предложенные в этом RFC, предоставляют пользователям больше гибкости при организации кода компонента. Вместо того, чтобы заставлять всегда организовывать код по заранее определенным опциям, код теперь может быть организован в функциях, каждая из которых будет реализовывать определенный функционал. API также упрощают извлечение и повторное использование логики между компонентами или даже внешними компонентами. Мы покажем, как эти цели достигаются в разделе «Детальный дизайн».
Улучшенное представление типов
Другой распространенный запрос от разработчиков, работающих над большими проектами, — лучшая поддержка TypeScript. Нынешний Vue API создает некоторые проблемы, когда речь заходит об интеграции с TypeScript, в основном из-за того, что Vue полагается на контекст this для предоставления свойств, и что использование this в компоненте Vue немного более магически (сложнее), чем в простом JavaScript (например, this внутри функций, вложенных в methods, указывает на экземпляр компонента, а не на объект methods). Другими словами, существующий Vue API просто не был разработан с учетом логического вывода типов, и это создает большую сложность при попытке адаптировать его для работы с TypeScript.
Большинство пользователей, которые сегодня используют Vue с TypeScript, используют библиотеку vue-class-component, которая позволяет создавать компоненты как классы TypeScript (с помощью декораторов). При разработке 3.0 мы пытались предоставить встроенный Class API для лучшего решения проблем типизации в предыдущем (удаленном) RFC. Однако, как мы заметили, что для того, чтобы Class API мог разрешать проблемы с типизацией, он должен полагаться на декораторы — что является очень нестабильным второго этапа предложения (proposal) с большой неопределенностью в отношении деталей его реализации. Это делает его довольно рискованным для реализации. (Подробнее о проблемах типов Class API здесь)
Для сравнения, API, предложенные в этом RFC, используют в основном простые переменные и функции, которые естественно дружественны к типу. Код, написанный с использованием предложенных API-интерфейсов, может иметь полный вывод типа без необходимости подсказок типа вручную. Это также означает, что код, написанный с использованием предложенных API-интерфейсов, будет выглядеть практически одинаково в TypeScript и обычном JavaScript, поэтому даже пользователи, не использующие TypeScript, потенциально смогут извлечь выгоду из типов для лучшей поддержки IDE.
Детальный дизайн
Введение в API
Вместо того, чтобы вводить новые концепции, предлагаемое API больше направлено на то, чтобы представить основные возможности Vue, такие как создание и наблюдение за реактивным состоянием, в качестве отдельных функций. Здесь мы представим ряд наиболее фундаментальных API и способы их использования вместо опций версии 2.x для выражения внутрикомпонентной логики. Обратите внимание, что этот раздел посвящен ознакомлению с основными идеями, поэтому в нем подробно не рассматривается каждое новое API. Полные спецификации API можно найти в разделе API Reference.
Реактивное состояние и побочные эффекты
Начнем с простой задачи: объявление какой-то реактивной переменной.
import { reactive } from 'vue' // reactive state const state = reactive({ count: 0 })
reactive является эквивалентом текущего API-интерфейса Vue.observable() в 2.x, переименованного во избежание путаницы с наблюдаемыми (observables) объектами RxJS. Здесь возвращаемая переменная state является реактивным объектом, с которым все пользователи Vue должны быть знакомы.
Основным вариантом использования реактивной переменной в Vue является то, что мы можем использовать ее во время рендеринга. Благодаря отслеживанию зависимостей представление автоматически обновляется при изменении реактивной переменной. Рендеринг чего-либо в DOM считается «побочным эффектом»: наша программа изменяет переменную, внешнею по отношению к самой программе (DOM). Чтобы применить и автоматически повторно применить побочный эффект в зависимости от реактивной переменной, мы можем использовать watch API:
import { reactive, watch } from 'vue' const state = reactive({ count: 0 }) watch(() => { document.body.innerHTML = `count is ${state.count}` })
watch ожидают функцию, которая применяет желаемый побочный эффект (в нашем случае, определение innerHTML). Она немедленно выполняет функцию и отслеживает все свойства реактивной переменной, которую она использовала во время выполнения, в качестве зависимостей. Здесь, state.count будет отслеживаться как зависимость для этого наблюдателя после первоначального выполнения. Когда в будущем будет изменена state.count, внутренняя функция снова будет выполнена.
Это сама суть системы реактивности Vue. Когда вы возвращаете объект из data() в компоненте, он внутренне становится реактивным с помощью reactive(). Шаблон компилируется в функцию рендеринга (воспринимается как более эффективный innerHTML), которая использует эти реактивные свойства.
Продолжая приведенный выше пример, мы обработаем пользовательский ввод:
function increment() { state.count++ } document.body.addEventListener('click', increment)
Но с системой шаблонов Vue нам не нужно связываться с innerHTML или вручную подключать прослушиватели событий (event listeners). Давайте упростим пример с помощью гипотетического метода renderTemplate, чтобы мы могли сосредоточиться на реактивности:
import { reactive, watch } from 'vue' const state = reactive({ count: 0 }) function increment() { state.count++ } const renderContext = { state, increment } watch(() => { // hypothetical internal code, NOT actual API renderTemplate( `<button @click="increment">{{ state.count }}</button>`, renderContext ) })
Вычисляемое состояние и Refs
Иногда нам нужно состояние, которое зависит от другого состояния — в Vue это обрабатывается с помощью вычисляемых свойств (computed). Чтобы напрямую создать вычисляемое значение, мы можем использовать computed API:
import { reactive, computed } from 'vue' const state = reactive({ count: 0 }) const double = computed(() => state.count * 2)
Что вернет computed? Предположим, как computed реализован внутри, это будет что-то вроде такого:
// упрощенный pseudo код function computed(getter) { let value watch(() => { value = getter() }) return value }
Но мы знаем, что это не сработает: если значение value является примитивным типом (например число), то его связь с логикой обновления внутри computed будет потеряна после его возврата (оператор return). Это потому, что примитивные типы JavaScript передаются по значению, а не по ссылке:
Та же проблема возникает, когда объекту присваивается значение как свойство. Реактивное значение не будет очень полезным, если оно не сможет сохранить свою реактивность при назначении в качестве свойства или при возврате из функций. Чтобы убедиться, что мы всегда можем прочитать последнее значение вычисления, нам нужно обернуть фактическое значение в объекте и вместо этого вернуть этот объект:
// упрощенный pseudo код function computed(getter) { const ref = { value: null } watch(() => { ref.value = getter() }) return ref }
Кроме того, нам также необходимо перехватывать операции чтения/записи в свойстве объекта .value для отслеживания зависимостей и уведомления об изменениях (здесь для простоты код опущен). Теперь мы можем передавать вычисленное значение по ссылке, не беспокоясь о потере реактивности. Компромисс в том, что для получения последнего значения нам теперь нужно получить к нему доступ через .value:
const double = computed(() => state.count * 2) watch(() => { console.log(double.value) }) // -> 0 state.count++ // -> 2
Здесь double — это объект, который мы называем «ref», поскольку он служит реактивной ссылкой на внутреннее значение, которое он содержит.
Возможно, вы знаете, что в Vue уже есть понятие «refs», но только для ссылки на элементы DOM или экземпляры компонентов в template («refs шаблона»). Прочтите это, чтобы увидеть, как новая система ссылок refs может использоваться как для логического состояния, так и для шаблона ссылок refs.
В дополнение к вычисляемым ссылкам, мы также можем напрямую создавать простые изменяемые ссылки, используя ref API:
const count = ref(0) console.log(count.value) // 0 count.value++ console.log(count.value) // 1
Использование Ref
Мы можем выставить ref как свойство в контексте визуализации. Внутри Vue будет выполнять специальную обработку для refs, так что, когда refs встречается в контексте рендеринга, контекст напрямую раскрывает свое внутреннее значение. Это означает, что в шаблоне мы можем напрямую написать {{ count }} вместо {{count.value}}.
Вот версия того же примера счетчика, использующего ref вместо reactive:
import { ref, watch } from 'vue' const count = ref(0) function increment() { count.value++ } const renderContext = { count, increment } watch(() => { renderTemplate( `<button @click="increment">{{ count }}</button>`, renderContext ) })
Кроме того, когда ref вкладывается как свойство в реактивный объект, она также автоматически разворачивается при доступе:
const state = reactive({ count: 0, double: computed(() => state.count * 2) }) // no need to use `state.double.value` console.log(state.double)
Использование в Компонентах
Наш код на данный момент уже предоставляет рабочий UI, который может обновляться на основе пользовательского ввода, но код выполняется только один раз и не может использоваться повторно. Если мы хотим повторно использовать логику, следующим разумным шагом будет рефакторинг его в функцию:
import { reactive, computed, watch } from 'vue' function setup() { const state = reactive({ count: 0, double: computed(() => state.count * 2) }) function increment() { state.count++ } return { state, increment } } const renderContext = setup() watch(() => { renderTemplate( `<button @click="increment"> Count is: {{ state.count }}, double is: {{ state.double }} </button>`, renderContext ) })
Обратите внимание, что приведенный выше код не зависит от наличия экземпляра компонента. Действительно, API, представленные до сих пор, могут использоваться вне контекста компонентов, что позволяет нам использовать систему реактивности Vue в более широком диапазоне сценариев.
Теперь, если мы оставим задачи вызова setup(), создания watcher и рендеринга template в фреймворке, мы можем определить компонент только с помощью функции setup() и template:
<template> <button @click="increment"> Count is: {{ state.count }}, double is: {{ state.double }} </button> </template> <script> import { reactive, computed } from 'vue' export default { setup() { const state = reactive({ count: 0, double: computed(() => state.count * 2) }) function increment() { state.count++ } return { state, increment } } } </script>
Это знакомый нам формат однофайлового компонента, только с логической частью (<script>) выраженной в другом формате. Синтаксис шаблона остается точно таким же. <style> пропущен, но будет работать точно так же.
Хуки жизненного цикла
До сих пор мы рассмотрели аспект чистого состояния компонента: реактивное состояние, вычисленное состояние и состояние изменения при вводе пользователем. Но компонент также может выполнять другие действия — например, вывод в консоль, отправление запроса ajax или подключение слушителя событий в window. Эти дополнительные действия обычно выполняются в следующие временные промежутки:
- Когда меняется состояние;
- Когда компонент смонтирован (mounted), обновлен или unmounted (то есть в хуках жизненного цикла).
Мы знаем, что мы можем использовать watch API для применения дополнительных действий на основе изменений состояния. Что касается выполнения этих действий в различных хуках жизненного цикла, мы можем использовать выделенные API по имени начинающееся с onXXX (которые напрямую отражают существующие параметры жизненного цикла):
import { onMounted } from 'vue' export default { setup() { onMounted(() => { console.log('component is mounted!') }) } }
Эти методы регистрации жизненного цикла могут использоваться только во время вызова setup хуков. Они автоматически определяет текущий экземпляр, вызывающий setup хук, используя внутреннее глобальное состояние. Они специально разработаны таким образом, чтобы уменьшить проблемы при извлечении логики во внешние функции.
Более подробную информацию об этом API можно найти в API Reference. Тем не менее, мы рекомендуем закончить чтение следующих разделов, прежде чем углубляться в детали дизайна.
Организация кода
На этом этапе мы воспроизвели API компонента с импортированными функциями, но зачем? Определение компонентов с опциями кажется гораздо более организованным, чем смешивание всего вместе в большую функцию!
Это понятное первое впечатление. Но, как уже упоминалось в разделе «Мотивации», мы считаем, что Composition API на самом деле приводит к более организованному коду, особенно в сложных компонентах. Здесь мы попытаемся объяснить, почему.
Что такое «Организация кода»?
Давайте сделаем шаг назад и рассмотрим, что мы на самом деле имеем в виду, когда говорим об «организованном коде». Конечная цель сохранения кода должна состоять в том, чтобы сделать код более легким для чтения и понимания. И что мы подразумеваем под «пониманием» кода? Можем ли мы действительно утверждать, что «понимаем» компонент только потому, что знаем, какие параметры он содержит? Вы когда-нибудь сталкивались с большим компонентом, автором которого является другой разработчик (например, этот), и как легко вам было его понять?
Подумайте о том, как бы вы бы помогли коллеге-разработчику пройти через большой компонент, подобному указанному выше. Скорее всего, вы начнете с «этот компонент имеет дело с X, Y и Z» вместо того что бы сказать «у этого компонента есть эти свойства данных, эти вычисляемые свойства и эти методы». Когда дело доходит до понимания компонента, мы больше заботимся о том, «что пытается сделать компонент» (какие были намерения, стоящих за кодом), а не о том, «какие опции использует компонент». Хотя код, написанный с использованием API, основанного на опциях, естественным образом отвечает последнему, он довольно плохо справляется с выражением первого утверждения.
Логические проблемы против опций
Давайте определим «X, Y и Z», с которыми имеет дело компонент, как логические проблемы. Проблема читабельности, как правило, отсутствует в небольших одноцелевых компонентах, поскольку весь компонент имеет дело с одной логической проблемой. Тем не менее, проблема становится гораздо более заметной в сложных случаях использования. Возьмите в качестве примера файловый менеджер Vue CLI. Компонент имеет дело со многими различными логическими задачами:
- Отслеживание текущего состояния папки и отображение ее содержимого
- Обработка навигации по папкам (открытие, закрытие, обновление …)
- Обработка создания новой папки
- Переключение показа только избранных папок
- Переключение показа скрытых папок
- Обработка текущих изменений рабочего каталога
Можете ли вы мгновенно распознать и найтие эти логические проблемы, прочитав код, основанный на опциях? Это будет сложно. Вы заметите, что код, связанный с определенной логической проблемой, часто фрагментирован и разбросан повсюду. Например, функция «Создать новую папку» использует два свойства data, одно вычисляемое свойство и метод, причем этот метод определен на расстоянии более ста строк от свойств данных.
Если мы раскрасим каждую из этих логических задач в цвет, мы заметим, насколько они фрагментированы, когда выражены с помощью опций компонента:
Такая фрагментация — именно то, что затрудняет понимание и поддержание сложного компонента. Принудительное разделение с помощью опций скрывает основные логические проблемы. Кроме того, работая над одной логической проблемой, мы должны постоянно «перепрыгивать» вокруг блоков опций, чтобы найти части, связанные с этой проблемой.
Примечание: исходный код, вероятно, может быть улучшен в нескольких местах, но мы показываем его в последнем коммите (на момент написания статьи) без изменений, чтобы предоставить пример фактического рабочего кода, который мы написали сами.
Было бы намного лучше, если бы мы могли разместить код, основываясь на логической задачей который он решает. И это именно то, что для чего мы создали Composition API. Функцию «Создать новую папку» можно теперь записать так:
function useCreateFolder (openFolder) { // originally data properties const showNewFolder = ref(false) const newFolderName = ref('') // originally computed property const newFolderValid = computed(() => isValidMultiName(newFolderName.value)) // originally a method async function createFolder () { if (!newFolderValid.value) return const result = await mutate({ mutation: FOLDER_CREATE, variables: { name: newFolderName.value } }) openFolder(result.data.folderCreate.path) newFolderName.value = '' showNewFolder.value = false } return { showNewFolder, newFolderName, newFolderValid, createFolder } }
Обратите внимание на то, как вся логика, связанная с функцией создания новой папки, теперь расположена и помещена в одну функцию. Функция также несколько самодокументируется из-за своего описательного имени. Это то, что мы называем композиционной функцией (composition function). Рекомендуется начинать имя функции с use, чтобы указать, что это составная функция. Этот шаблон может быть применен ко всем другим логическим проблемам в компоненте, что приводит к ряду красиво отделенных функций:
Это сравнение исключает операторы импорта и функцию setup(). Полный компонент, повторно реализованный с использованием Composition API, можно найти здесь.
Код для каждой логической задачи теперь объединен в композиционной функции. Это значительно снижает необходимость в постоянных «скачках» при работе с большим компонентом. Компоновочные функции также могут быть свернуты в редакторе, чтобы сделать сканирование компонента намного проще:
export default { setup() { // ... } } function useCurrentFolderData(networkState) { // ... } function useFolderNavigation({ networkState, currentFolderData }) { // ... } function useFavoriteFolder(currentFolderData) { // ... } function useHiddenFolders() { // ... } function useCreateFolder(openFolder) { // ... }
Функция setup() теперь в основном служит точкой входа, где вызываются все функции композиции:
export default { setup () { // Network const { networkState } = useNetworkState() // Folder const { folders, currentFolderData } = useCurrentFolderData(networkState) const folderNavigation = useFolderNavigation({ networkState, currentFolderData }) const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData) const { showHiddenFolders } = useHiddenFolders() const createFolder = useCreateFolder(folderNavigation.openFolder) // Current working directory resetCwdOnLeave() const { updateOnCwdChanged } = useCwdUtils() // Utils const { slicePath } = usePathUtils() return { networkState, folders, currentFolderData, folderNavigation, favoriteFolders, toggleFavorite, showHiddenFolders, createFolder, updateOnCwdChanged, slicePath } } }
Конечно, это код, который нам не нужно было писать при использовании API с опциями. И обратите внимание, что функция setup почти читается как словесное описание того, что пытается сделать компонент — это информация, так же полностью отсутствовала в версии, основанной на опциях. Вы можете четко видеть поток зависимостей между композиционными функциями на основе передаваемых аргументов. Наконец, оператор return служит единственным местом для проверки того, что доступно шаблону.
При одинаковой функциональности компонент, определенный с помощью опций, и компонент, определенный с помощью композиционных функций, проявляют два разных способа выражения одной и той же базовой логики. API на основе параметров вынуждает нас организовывать код на основе типов опций (option types), в то время как API-интерфейс Composition позволяет нам организовывать код на основе логических соображений (logical concerns).
Извлечение и повторное использование логики
Composition API чрезвычайно гибок, когда дело доходит до извлечения и повторного использования логики между компонентами. Вместо того, чтобы полагаться на магический контекст this, функция композиции опирается только на свои аргументы и глобально импортированные API-интерфейсы Vue. Вы можете повторно использовать любую часть вашей компонентной логики, просто экспортировав ее как функцию. Вы даже можете получить эквивалент extends, экспортируя всю функцию setup.
Давайте посмотрим на пример: отслеживание положения мыши.
import { ref, onMounted, onUnmounted } from 'vue' export function useMousePosition() { const x = ref(0) const y = ref(0) function update(e) { x.value = e.pageX y.value = e.pageY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y } }
Вот как компонент может использовать эту функцию:
import { useMousePosition } from './mouse' export default { setup() { const { x, y } = useMousePosition() // other logic... return { x, y } } }
В версии Composition API примера файлового эксплорера мы перенесли некоторый служебный код (например, usePathUtils и useCwdUtils) во внешние файлы, потому что мы нашли их полезными для других компонентов.
Аналогичное повторное использование логики также может быть достигнуто с использованием существующих шаблонов, таких как миксины, компоненты высшего порядка или компоненты без рендеринга (через scoped слоты). В интернете достаточно информации, объясняющей эти паттерны, поэтому мы не будем их здесь подробно описывать. Более общая идея заключается в том, что каждый из этих шаблонов имеет соответствующие недостатки по сравнению с функциями композиции:
- Неясные источники свойств, отображаемых в контексте визуализации. Например, при чтении шаблона компонента с использованием нескольких миксинов может быть трудно определить, из какого миксина было введено определенное свойство.
- Столкновение пространства имен. Миксины могут потенциально конфликтовать с именами свойств и методов, в то время как HOC могут конфликтовать с ожидаемыми именами prop.
- Производительность. Для объектов HOC и компонентов без рендеринга требуются дополнительные экземпляры компонентов с сохранением состояния, что приводит к снижению производительности.
По сравнению с Composition API:
- Свойства, предоставляемые шаблону, имеют четкие источники, поскольку они являются значениями, возвращаемыми из композиционных функций.
- Возвращаемые значения из композиционных функций могут быть произвольно названы, поэтому не будет конфликта пространства имен.
- Не существует ненужных экземпляров компонентов, созданных только для повторного использования логики.
Использование вместе с существующим API
Composition API может использоваться вместе с существующим API на основе опций.
- Composition API обрабатывается до обработки опций 2.x (data, computed и methods) и не будет иметь доступа к свойствам, определенным этими параметрами.
- Свойства, возвращаемые из setup(), будут представлены в this и будут доступны в опциях 2.x.
Разработка плагинов
Многие плагины Vue сегодня внедряют свойства в this. Например, Vue Router внедряет this.$route и this.$router, а Vuex внедряет this.$store. Это усложняет получение типов, так как каждый плагин требует, чтобы пользователь увеличивал типизацию Vue для введенных свойств.
При использовании Composition API this не используется. Вместо этого плагины будут использовать provide
и inject
внутри и предоставят функцию композиции. Ниже приведен гипотетический код для плагина:
const StoreSymbol = Symbol() export function provideStore(store) { provide(StoreSymbol, store) } export function useStore() { const store = inject(StoreSymbol) if (!store) { // throw error, no store provided } return store }
И код использования:
// provide store at component root // const App = { setup() { provideStore(store) } } const Child = { setup() { const store = useStore() // use the store } }
Обратите внимание, что store также может быть предоставлено с помощью provide уровня приложения, предложенного в Global API change RFC, но API useStore в компоненте где он используется будет таким же.
Недостатки
Накладные расходы по представлению Refs
Ref технически является единственной «новой» концепцией, представленной в этом RFC. Оно введено для того, чтобы создавать реактивные переменные, не полагаясь на доступ к this. Недостатки:
- При использовании Composition API нам нужно будет постоянно отличать refs от простых значений и объектов, увеличивая «умственную нагрузку» при работе с API. «Умственная нагрузка» может быть значительно уменьшена с помощью соглашения об именовании (например, добавление суффикса ко всем переменным ref, например как xxxRef) или с помощью системы типов. С другой стороны, из-за повышенной гибкости в организации кода логика компонентов будет чаще изолироваться на небольшие функции, где будет простой локальный контекст, а издержки на refs легко управляемы.
- Чтение и изменение refs более многословны, чем работа с простыми значениями, из-за необходимости обращения к .value. Некоторые предложили использовать синтаксический сахар во время компиляции (аналогично Svelte 3), чтобы решить эту проблему. Хотя это технически осуществимо, но мы не считаем, что это будет иметь смысл в качестве значения по умолчанию для Vue (это будет обсуждаться в разделе «Сравнение с Svelte«). Тем не менее, это технически возможно с использованием плагина Babel.
Мы обсудили, возможно ли полностью избежать концепции Ref и использовать только реактивные объекты (reactive), однако:
- Вычисляемые геттеры могут возвращать примитивные типы, поэтому появление функции, похожий на Ref, неизбежно.
- Функции композиции, ожидающие или возвращающие только примитивные типы, также должны обернуть значение в объекте только ради реактивности. Весьма вероятно, что пользователи в конечном итоге придумают свои собственные шаблоны, подобные Ref (что будут вызывать фрагментацию экосистемы), если стандартная реализация не будет предоставлена платформой.
Ref против Reactive
Понятно, что пользователи могут запутаться в отношении того, что использовать ref или reactive. Первое, что нужно знать, это то, что вам нужно будет понять и то, и другое, чтобы эффективно использовать Composition API. Использование одного из них, скорее всего, приведет к эзотерическим обходным путям или к новым колесам.
Разницу между использованием ref и reactive можно несколько сравнить с тем, как вы будете писать стандартную логику JavaScript:
// style 1: несколько переменных let x = 0 let y = 0 function updatePosition(e) { x = e.pageX y = e.pageY } // --- в сравнение с --- // style 2: одиночный объект const pos = { x: 0, y: 0 } function updatePosition(e) { pos.x = e.pageX pos.y = e.pageY }
- При использовании ref мы в значительной степени переводим style (1) в более подробный эквивалент, используя refs (чтобы сделать примитивные значения реактивными).
- Использование reactive практически идентично стилю (2). Нам нужно только создать объект с reactive и все.
Однако проблема с использованием только reactive заключается в том, что потребитель композиционной функции должен постоянно сохранять ссылку на возвращаемый объект, чтобы сохранить реактивность. Объект не может быть разрушен или распространен:
// композиционная функция function useMousePosition() { const pos = reactive({ x: 0, y: 0 }) // ... return pos } // используемый ее компонент export default { setup() { // реактивность потеряна! const { x, y } = useMousePosition() return { x, y } // реактивность потеряна! return { ...useMousePosition() } // это единственный способ сохранить реактивность. // вы должны return `pos` как есть и ссылаться на x и y как `pos.x` и `pos.y` // in the template. return { pos: useMousePosition() } } }
API toRefs
предназначен для решения этого ограничения — он преобразует каждое свойство реактивного объекта в соответствующий ref:
function useMousePosition() { const pos = reactive({ x: 0, y: 0 }) // ... return toRefs(pos) } // x и y сейчас refs! const { x, y } = useMousePosition()
Подводя итог, можно выделить два жизнеспособных стиля:
- Используйте ref и reactive так же, как вы бы объявляете переменные примитивного типа и объектные переменные в обычном JavaScript. При использовании этого стиля рекомендуется использовать систему типов с поддержкой IDE.
- Всегда используйте реактивный режим и не забывайте использовать toRefs при возврате реактивных объектов из функций композиции. Это уменьшает «умственную нагрузку» при использование refs, но не устраняет необходимости быть знакомым с концепцией.
На данном этапе мы полагаем, что еще слишком рано называть лучшей практикой использование ref, а не reactive. Мы рекомендуем вам придерживаться стиля, который лучше соответствует вашей ментальной модели, из двух вариантов выше. Мы будем собирать отзывы пользователей в реальном мире и в конечном итоге предоставим более четкие рекомендации по этой теме.
Детально о return в setup()
Некоторые пользователи выражают беспокойство по поводу того, что выражение return в setup() является многословным и ощущается как бесполезный шаблон.
Мы считаем, что явное использование return полезно для удобства поддержки кода. Это дает нам возможность явно контролировать то, что раскрывается в шаблоне, и служит отправной точкой при трассировке, где свойство шаблона определено в компоненте.
Были предложения автоматически выставлять переменные, объявленные в setup(), делая оператор return необязательным. Опять же, мы не думаем, что он должен быть по умолчанию, так как это противоречит интуиции стандартного JavaScript. Однако, есть возможные способы сделать его использование менее рутинным в пользовательском пространстве:
- Использование расширения IDE, которое автоматически генерирует оператор return на основе переменных, объявленных в setup()
- Плагин Babel, который неявно генерирует и вставляет оператор return.
Больше гибкости требует больше дисциплины
Многие пользователи отмечают, что, хотя интерфейс Composition API обеспечивает большую гибкость в организации кода, он также требует большей дисциплины от разработчика, чтобы «все сделать правильно». Некоторые опасаются, что API приведет к созданию спагетти-кода в неопытных руках. Другими словами, хотя интерфейс Composition API повышает верхнюю границу качества кода, он также понижает нижнюю границу.
Мы согласны с этим в определенной степени. Тем не менее, мы считаем, что:
- Усиление в верхней границе значительно перевешивает потерю в нижней границе.
- Мы можем эффективно решить проблему организации кода с надлежащей документацией и руководством сообщества.
Некоторые пользователи использовали контроллеры Angular 1 в качестве примеров того, как проект может привести к плохо написанному коду. Самое большое различие между Composition API и контроллерами Angular 1 заключается в том, что Composition API не зависит от общего контекста scope. Это значительно упрощает разделение логики на отдельные функции, что является основным механизмом организации кода JavaScript.
Любая JavaScript-программа начинается с входного файла (воспринимается как setup() для программы). Мы должны организовывать программу, разбивая ее на функции и модули, исходя из логических соображений. Composition API позволяет нам делать то же самое для кода компонента Vue. Другими словами, навыки написания хорошо организованного кода JavaScript напрямую влияют на навыки написания хорошо организованного кода Vue при использовании Composition API.
Стратегия последовательного перехода
Composition API является исключительно аддитивным и не влияет на существующие API-интерфейсы 2.x. Он доступен как плагин 2.x через библиотеку @vue/composition
. Основная цель библиотеки — предоставить возможность экспериментировать с API и собирать отзывы. Текущая реализация соответствует данному RFC, но может содержать незначительные несоответствия из-за технических ограничений, связанных с подключением. Кроме того, могут появиться изменения по мере обновления этого RFC, поэтому мы не рекомендуем использовать его в производстве на данном этапе.
Мы намерены выпустить API как встроенное в 3.0. Он будет использоваться вместе с существующими опциями 2.x.
Для пользователей, которые предпочитают использовать исключительно Composition API в приложении, можно будет указать флаг во время компиляции, чтобы отбросить код, используемый только для параметров 2.x, и уменьшать размер библиотеки. Однако это совершенно необязательно.
API будет позиционироваться как расширенная функция, поскольку проблемы, на решение которых он нацелен, появляются главным образом в крупномасштабных приложениях. Мы не намерены пересматривать документацию, чтобы использовать ее по умолчанию. Вместо этого он будет иметь свой собственный выделенный раздел в документации.
Приложение
Проблемы типов с Class API
Основная цель введения Class API состояла в том, чтобы предоставить альтернативный API, который поставляется с лучшей поддержкой вывода TypeScript. Однако тот факт, что компоненты Vue должны объединять свойства, объявленные из нескольких источников, в один контекст this, создает некоторую проблему даже с API на основе классов.
Одним из примеров является типы в props. Чтобы объединить props с this, мы должны либо использовать обобщенные аргументы для класса компонента, либо использовать декоратор.
Вот пример использования обобщенных аргументов:
interface Props { message: string } class App extends Component<Props> { static props = { message: String } }
Поскольку интерфейс, передаваемый универсальному аргументу, находится только в области типов, пользователю все равно необходимо предоставить объявление props во время выполнения для соединения с this. Это двойное объявление является излишним и неудобным.
Мы рассмотрели использование декораторов в качестве альтернативы:
class App extends Component<Props> { @prop message: string }
Использование декораторов создает зависимость от спецификации этапа 2 с большим количеством неопределенностей, особенно когда текущая реализация TypeScript полностью не синхронизирована с предложением TC39. Кроме того, в this.$props нет способа выставить типы props, объявленные с помощью декораторов, что нарушает поддержку TSX. Пользователи могут также предположить, что они могут объявить значение по умолчанию для @prop message: string = ‘foo’, когда технически это просто не может работать должным образом.
Кроме того, в настоящее время нет способа использовать контекстную типизацию для аргументов методов класса — это означает, что аргументы, передаваемые в функцию рендеринга класса render, не могут иметь предполагаемые типы, основанные на других свойствах класса.
Сравнение с React Hooks
API на основе функций обеспечивает тот же уровень возможностей логической композиции, что и React Hooks, но с некоторыми важными отличиями. В отличие от React Hooks, функция setup() вызывается только один раз. Это означает, что код, использующий Composition API:
- В целом, больше согласуется с интуицией идиоматического кода JavaScript;
- Не чувствителен к порядку вызовов и может быть условным;
- Не вызывается повторно при каждом рендере и создает меньшее GC давление;
- Не подвержен проблеме, когда useCallback почти всегда необходим для предотвращения встроенных обработчиков, вызывающих пере-рендеринг дочерних компонентов;
- Не подвержен проблеме, когда useEffect и useMemo могут перехватывать устаревшие переменные, если пользователь забывает передать правильный массив зависимостей. Автоматическое отслеживание зависимостей Vue гарантирует, что значения watchers и computed всегда будут корректно аннулированы.
Мы признаем креативность React Hooks, и это является основным источником вдохновения для этого RFC. Тем не менее, проблемы, упомянутые выше, существуют в его дизайне, и мы заметили, что модель реактивности Vue обеспечивает обходной путь.
Сравнение с Svelte
Несмотря на совершенно разные подходы, Composition API и подход на основе компиляторов Svelte 3 фактически концептуально имеют много общего. Вот пример:
Vue
<script> import { ref, watch, onMounted } from 'vue' export default { setup() { const count = ref(0) function increment() { count.value++ } watch(() => console.log(count.value)) onMounted(() => console.log('mounted!')) return { count, increment } } } </script>
Svelte
<script> import { onMount } from 'svelte' let count = 0 function increment() { count++ } $: console.log(count) onMount(() => console.log('mounted!')) </script>
Код Svelte выглядит более лаконичным, потому что во время компиляции он делает следующее:
- Неявно оборачивает весь <script> блок (кроме операторов import) в функцию, которая вызывается для каждого экземпляра компонента (вместо того, чтобы выполняться только один раз)
- Неявно регистрирует реактивность на изменяемые переменные
- Неявно предоставляет все переменные в области видимости для контекста рендеринга
- Компилирует операторы $ в повторно выполненный код
Технически, мы можем сделать то же самое в Vue (и это возможно через пользовательские плагины Babel). Основной причиной, по которой мы этого не делаем, является соответствие стандартному JavaScript. Если мы извлекаете код из блока <script> файла Vue, мы хотим, чтобы он работал точно так же, как стандартный модуль ES. Код внутри блока Svelte <script>, с другой стороны, технически больше не является стандартным JavaScript. Есть ряд проблем, которые мы видим с этим подходом на основе компилятора:
- Код работает по-разному с/без компиляции. В качестве прогрессивной фреймворка многие пользователи Vue могут пожелать/нуждаются использовать ее без настройки сборки, поэтому скомпилированная версия не может быть по умолчанию. Svelte, с другой стороны, позиционирует себя как компилятор и может использоваться только с шагом сборки. Это компромисс, который обе системы делают сознательно.
- Код работает по-разному внутри/снаружи компонентов. При попытке извлечь логику из компонента Svelte в стандартные файлы JavaScript, мы потеряем магический лаконичный синтаксис и вынуждены использовать более подробный низкоуровневый API.
- Компиляция реактивности Svelte работает только для переменных верхнего уровня — она не касается переменных, объявленных внутри функций, поэтому мы не можем инкапсулировать реактивное состояние в функции, объявленной внутри компонента. Это накладывает нетривиальные ограничения на организацию кода с функциями — что, как мы продемонстрировали в этом RFC, важно для обеспечения поддержки больших компонентов в удобном для обслуживания.
- Нестандартная семантика затрудняет интеграцию с TypeScript.
Это ни в коем случае не говорит о том, что Svelte 3 — плохая идея — на самом деле, это очень инновационный подход, и мы высоко ценим работу Рича. Но исходя из конструктивных ограничений и целей Vue, мы должны делать различные компромиссы.