История одного архитектурного круга: реактивность Vue

от автора

Vue снова меняет способ обновления интерфейса. Во фреймворке появляется Vapor — режим рендеринга, который позволяет точечно менять страницу без виртуального DOM. Для разработчика привычный код при этом почти не меняется. Возникает логичный вопрос: если прежняя модель работала, зачем Vue понадобилось снова переделывать одну из ключевых частей фреймворка?

Возможно, ответ в том, что Vue сделал круг в своём развитии. Его первые версии обновляли конкретные части страницы напрямую. Затем фреймворк перешёл на виртуальный DOM, а теперь снова движется к точечным обновлениям — только уже с более зрелой реактивной системой, современным компилятором и с учётом опыта, накопленного за десять лет.

Этот путь помогает понять, почему во Vue появились ref и .value, чем его реактивность похожа на популярные сегодня сигналы и какую задачу на самом деле решает Vapor. А ещё — почему переход с Vue 2 на Vue 3 оказался таким масштабным, но следующий архитектурный скачок уже не требует переписывать приложение.

В статье проследим развитие реактивности от первых версий Vue до Vapor и посмотрим, как менялась связь между состоянием и интерфейсом. Но сначала отделим эту идею от конкретного фреймворка и разберём, из каких частей состоит простейшая реактивная система.

Минимальная модель реактивности

Начнём с задачи, которую должна решать любая реактивная система.

Допустим, у нас есть исходные значения и производное значение:

let price = 100let quantity = 2let total = price * quantity

После изменения price переменная total автоматически не пересчитывается:

price = 120console.log(total) // всё ещё 200

Чтобы получить базовую модель реактивности, системе необходимо:

  1. перехватить чтение значения;

  2. запомнить выполняющийся в этот момент эффект;

  3. повторно запустить эффект после изменения значения.

Минимальная реализация может выглядеть так:

const targetMap = new WeakMap()let activeEffect = nullfunction track(target, key) {  if (!activeEffect)    return  let depsMap = targetMap.get(target)  if (!depsMap) {    depsMap = new Map()    targetMap.set(target, depsMap)  }  let effects = depsMap.get(key)  if (!effects) {    effects = new Set()    depsMap.set(key, effects)  }  effects.add(activeEffect)}function trigger(target, key) {  const depsMap = targetMap.get(target)  const effects = depsMap?.get(key)  effects?.forEach(effect => effect())}function reactive(target) {  return new Proxy(target, {    get(target, key, receiver) {      track(target, key)      return Reflect.get(target, key, receiver)    },    set(target, key, value, receiver) {      const oldValue = target[key]      const result = Reflect.set(target, key, value, receiver)      if (!Object.is(oldValue, value))        trigger(target, key)      return result    },  })}function effect(fn) {  const reactiveEffect = () => {    activeEffect = reactiveEffect    try {      fn()    }    finally {      activeEffect = null    }  }  reactiveEffect()}

Теперь можно связать данные и побочный эффект:

const state = reactive({  price: 100,  quantity: 2,})effect(() => {  console.log(state.price * state.quantity)})// 200state.price = 120// 240

Во время первого выполнения effect система читает price и quantity. Прокси вызывает track, поэтому эффект подписывается на оба свойства. При изменении price вызывается trigger, который повторно запускает эффект.

Реальный Vue же устроен намного сложнее. Ему приходится учитывать:

  • вложенные эффекты;

  • условные и устаревшие зависимости;

  • вычисляемые значения;

  • приоритет и порядок обновлений;

  • асинхронную очередь;

  • защиту от рекурсивных вызовов;

  • массивы, Map, Set и итераторы;

  • остановку эффектов и очистку ресурсов;

  • отладочные хуки.

Тем не менее фундаментальная модель остаётся той же:

чтение создаёт зависимость, запись уведомляет подписчиков.

В нашем примере за перехват операций отвечает Proxy, но для первых версий Vue этот вариант был недоступен: стандарт только появлялся и не поддерживался нужными браузерами. При этом задача оставалась той же — заметить чтение свойства, запомнить зависимый код и отреагировать на изменение. Vue пришлось решить её средствами ES5, связав геттеры и сеттеры напрямую с обновлениями страницы. Так появилась первая реализация реактивности фреймворка — ещё без виртуального DOM.

Vue 1: реактивность без виртуального DOM

Первые публичные версии Vue появились в 2014 году. Уже в ранней ветке 0.x, а затем и во Vue 1, основой реактивности был Object.defineProperty.

Во время инициализации Vue обходил переданный объект и заменял его свойства геттерами и сеттерами:

function reactive(obj) {    const observed = {};    for (const key of Object.keys(obj)) {        let value = obj[key];        Object.defineProperty(observed, key, {            get() {                // Запоминаем, кто читает                track(observed, key);                return value;            },            set(newValue) {                if (newValue === value) return;                value = newValue;                // Уведомляем всех                trigger(observed, key);            }        });    }    return observed;}

Когда происходило чтение свойства, Vue связывал его с наблюдателем. Когда сеттер срабатывал, вызывался trigger и уведомлял подписчиков об изменениях.

Важная особенность Vue 1

Vue 1 ещё не использовал виртуальный DOM. Компилятор шаблонов создавал директивы, связанные с конкретными DOM-узлами. Условно, выражение:

<span>{{ message }}</span>

превращалось в подписку, которая знала, какой текстовый узел нужно изменить при обновлении message.

В определённом смысле это был достаточно гранулярный рендеринг: изменение одного значения могло приводить непосредственно к обновлению связанного DOM-узла.

Ограничения раннего подхода

Главный недостаток заключался в том, что Vue мог сделать реактивными только свойства, существовавшие во время обхода объекта:

const data = {  user: {    name: 'Ada',  },}// Свойства age не было во время инициализацииdata.user.age = 37

Новое свойство не получало автоматически созданного сеттера. Поэтому появились специальные методы вроде $add, $set и $delete.

У подхода были и другие издержки:

  • перед запуском приложения нужно было рекурсивно обойти объект;

  • массивы требовали отдельной инструментации;

  • добавление и удаление свойств нельзя было нормально перехватить;

  • поведение зависело от того, существовало ли свойство заранее;

  • Object.defineProperty нельзя было полноценно полифиллить для старых браузеров.

Тем не менее для своего времени это был удачный компромисс. Vue позволял работать почти с обычными JavaScript-объектами и автоматически синхронизировал их с DOM — без ручного рендеринга.

Vue 2: та же реактивность, но другая модель рендеринга

Vue 2 вышел в 2016 году. Реактивность по-прежнему строилась на Object.defineProperty, однако архитектура рендеринга заметно изменилась: в Vue появился виртуальный DOM.

Шаблон стал компилироваться в render-функцию:

function render() {  return h('div', this.message)}

Для каждого компонента создавался render watcher. Во время выполнения render-функции он собирал все прочитанные реактивные свойства. Когда одно из них изменялось, watcher ставил компонент в очередь на повторный рендеринг.

После этого Vue создавал новое дерево виртуальных узлов, сравнивал его с предыдущим, применял минимально необходимые изменения к реальному DOM.

Схематично процесс выглядел так:

Для каждого компонента создавался render watcher. Во время выполнения render-функции он собирал все прочитанные реактивные свойства. Когда одно из них изменялось, watcher ставил компонент в очередь на повторный рендеринг.

После этого Vue создавал новое дерево виртуальных узлов, сравнивал его с предыдущим, применял минимально необходимые изменения к реальному DOM.

Схематично процесс выглядел так:

Что дал виртуальный DOM

Переход был нужен не только ради модного на тот момент подхода, но и дал Vue несколько важных возможностей:

  • декларативные render-функции и JSX;

  • более предсказуемое обновление целых компонентов;

  • компонентные абстракции;

  • возможность создавать разные renderer-реализации;

  • упрощённую работу со сложными динамическими деревьями;

  • более чёткое разделение реактивности и отображения.

Vue 2 сохранил автоматический сбор зависимостей, но сделал единицей обновления прежде всего компонент, а не отдельную привязку к DOM.

Асинхронная очередь

Vue 2 не обновлял компонент немедленно при каждой записи. Watcher помещался в очередь, причём повторные добавления дедуплицировались:

this.count++this.count++this.count++

Это не означало три отдельных перерисовки. Vue собирал изменения и применял их в следующем цикле обновления. Отсюда знакомый API:

await Vue.nextTick()

Плюсы подхода Vue 2

  • зрелая и понятная модель компонентов;

  • сравнительно простая ментальная модель;

  • автоматический сбор зависимостей;

  • эффективное объединение нескольких изменений;

  • хорошая производительность для большинства приложений;

  • удобная интеграция с шаблонами, JSX и SSR.

Минусы подхода Vue 2

Фундаментальные ограничения Object.defineProperty никуда не исчезли.

Vue не мог автоматически обнаруживать добавление нового свойства:

this.user.age = 37

Приходилось использовать:

this.$set(this.user, 'age', 37)

С массивами возникали похожие проблемы:

this.items[1] = newItem// this.items.length = 0

Для гарантированного обновления использовались Vue.set или splice.

Кроме того:

  • наблюдение требовало предварительного глубокого обхода данных;

  • Map, Set, WeakMap и WeakSet нельзя было полноценно сделать реактивными;

  • изменение одной зависимости могло запустить повторный рендеринг всего компонента;

  • виртуальный DOM добавлял стоимость создания и сравнения промежуточных объектов;

  • внутренняя кодовая база Vue 2 становилась всё сложнее расширяемой.

Vue 2 оказался очень успешным, но его реактивная модель достигла предела, заданного возможностями ES5.

Vue 3: почему пришлось менять почти всё

Vue 3 вышел в 2020 году и оказался не обычным обновлением, а практически новым фреймворком под знакомым API. Команда переписала реактивность, рендерер, компилятор и внутреннее устройство компонентов.

Это дало Vue пространство для развития, но сделало миграцию дорогой. Последствия ощущаются до сих пор: даже спустя шесть лет часть проектов остаётся на Vue 2, официальная поддержка которого завершилась 31 декабря 2023 года. Перейти нужно было не только на новую версию фреймворка, но и на совместимые версии роутера, хранилища, UI-библиотек, плагинов и сборщика.

При этом столь радикальное обновление было необходимо: ограничения архитектуры Vue 2 уже нельзя было устранить локальными исправлениями.

От Object.defineProperty к Proxy

Основой реактивности Vue 3 стал Proxy. Вместо преобразования каждого свойства Vue теперь перехватывает операции над объектом целиком:

function reactive(target) {  return new Proxy(target, {    get(target, key, receiver) {      track(target, key)      return Reflect.get(target, key, receiver)    },    set(target, key, value, receiver) {      const result = Reflect.set(target, key, value, receiver)      trigger(target, key)      return result    },  })}

Это позволило отслеживать добавление и удаление свойств, изменения массивов, перебор ключей и операции с Map и Set. Специальные методы вроде Vue.set больше не требовались:

const state = reactive({  user: {    name: 'Ada',  },})state.user.age = 37delete state.user.name

Новую систему нельзя было просто встроить в Vue 2. Реактивность связана с рендерингом, жизненным циклом компонентов, watcher’s, computed и планировщиком обновлений. Кроме того, Proxy невозможно полноценно полифиллить для Internet Explorer.

Поэтому команда использовала переход для более глубокой переработки: ядро переписали на TypeScript, разделили на самостоятельные модули, а реактивность отделили от экземпляра компонента.

Реактивность за пределами компонента

Во Vue 3 реактивность стала отдельным слоем, который можно использовать независимо от Options API:

const price = ref(100)const quantity = ref(2)const total = computed(() => {  return price.value * quantity.value})

На этом построена Composition API. Логику теперь можно группировать по смыслу, выносить в composables и использовать в разных компонентах. При этом Options API не исчез — в Vue 3 он работает поверх того же реактивного ядра.

ref понадобился для отдельных значений, которые нельзя обернуть в Proxy. JavaScript не позволяет перехватить чтение или изменение обычной локальной переменной:

let count = 0

Поэтому Vue помещает значение в объект-контейнер:

const count = ref(0)count.value++

Свойство .value — это точка, в которой Vue может зарегистрировать чтение и обнаружить запись. В шаблонах компилятор раскрывает refs автоматически, поэтому там .value не требуется:

<button @click="count++">  {{ count }}</button>

Так во Vue 3 сложилась единая система: reactive работает с объектами, ref — с отдельными значениями, computed создаёт производные значения, а эффекты подписываются на их изменения. Этот фундамент и стал основой для последующих оптимизаций реактивности и будущего гранулярного рендеринга.

Vue 3.4 и 3.5: точнее граф, меньше лишней работы

После выхода Vue 3 внешняя модель реактивности стала достаточно стабильной. Дальнейшая эволюция переместилась внутрь системы: команда начала оптимизировать структуру графа зависимостей и правила распространения изменений.

Vue 3.4: стабильность computed

До Vue 3.4 зависимость от computed могла повторно запустить эффект, даже если итоговое значение не изменилось:

const count = ref(0)const isEven = computed(() => count.value % 2 === 0)watchEffect(() => {  console.log(isEven.value)})count.value = 2

count изменился, но isEven остался равен true. Раньше зависимый эффект всё равно мог выполниться повторно, потому что computed считался потенциально устаревшим.

Начиная с Vue 3.4 подписчики уведомляются только тогда, когда вычисленный результат действительно изменился.

Это особенно важно для цепочек:

state → computed A → computed B → component

Чем длиннее цепочка, тем дороже ложное распространение изменений. Оптимизация позволяет остановить обновление раньше.

Vue 3.4 также сократил повторные срабатывания синхронных эффектов при нескольких изменениях зависимостей и при вызовах некоторых методов массивов.

Vue 3.5: новая внутренняя переработка

В Vue 3.5 реактивная система прошла ещё один крупный рефакторинг — уже без изменения публичного поведения.

По данным команды Vue, рефакторинг дал:

  • примерно на 56% меньше потребления памяти реактивной системой;

  • снижение потребления памяти реактивной системой примерно на 56%

  • устранение проблем с устаревшими computed;

  • исправление зависших вычислений при SSR;

  • ускорение некоторых операций над большими глубоко реактивными массивами до десяти раз.

Vue 3.5 также стабилизировал реактивную деструктуризацию props:

<script setup lang="ts">const {  count = 0,  message = 'Hello',} = defineProps<{  count?: number  message?: string}>()</script>

В обычном JavaScript деструктуризация разрывает связь с прокси:

const state = reactive({ count: 0 })const { count } = state// count — обычное число

Но в <script setup> компилятор может преобразовать обращение к count обратно в обращение к props.count. Это важный пример того, как Vue постепенно сочетает runtime-реактивность с compile-time-анализом.

К Vue 3.5 реактивная система уже довольно точно определяла, что изменилось и какие эффекты от этого зависят. Но обновление компонента всё ещё проходило через render-функцию и Virtual DOM. Возникал следующий вопрос: можно ли сохранить точность графа зависимостей до самого DOM и обновлять только конкретный узел? Примерно в это же время фронтенд-индустрия начала активно обсуждать сигналы — подход, обещавший именно такую гранулярность.

Оглядываясь на сигналы

В последние годы сигналы стали одной из самых обсуждаемых идей во фронтенде. Их популяризировал Solid, затем собственные реализации или похожие API появились в Preact, Angular и других инструментах. На фоне усталости от лишних перерисовок и усложнения виртуального DOM сигналы начали восприниматься как следующий большой этап развития реактивных интерфейсов.

Базовая идея довольно проста: сигнал хранит значение и одновременно знает, какие вычисления от него зависят. Чтение сигнала регистрирует зависимость, а запись уведомляет подписчиков:

const [count, setCount] = createSignal(0)createEffect(() => {  console.log(count())})setCount(1)

В отличие от модели, в которой изменение состояния приводит к повторному рендерингу компонента, сигналы позволяют связать значение с конкретными вычислениями или операциями над DOM. Если изменился текст внутри кнопки, система может обновить только этот текст, не создавая заново виртуальное представление всего компонента.

На первый взгляд может показаться, что Vue предстоит перейти на новую модель. Но концептуально сигналы для него не новы. ref, computed и реактивные эффекты уже много лет образуют почти такую же систему:

const count = ref(0)const doubled = computed(() => count.value * 2)watchEffect(() => {  console.log(doubled.value)})

Здесь ref выступает изменяемым сигналом, computed — производным, а watchEffect — подписанным эффектом. Когда эффект читает count.value, Vue регистрирует зависимость. Когда значение меняется, зависимые вычисления получают уведомление.

Отличается в основном форма API:

// Vuecount.valuecount.value = 1// Сигнал в функциональном стилеcount()setCount(1)// Сигнал в объектном стилеcount.get()count.set(1)

Поэтому было бы неточно говорить, что Vue вслед за другими фреймворками решил перейти на модель близкую к сигналам. Скорее индустрия стала активнее обсуждать модель, близкую к той, которая давно лежит в основе реактивности Vue.

Однако тут надо понять, что сигналы и гранулярный рендеринг — не одно и то же.

Система реактивности Vue достаточно точно знает, какие значения прочитал компонент. Но в обычном Vue 3 подписчиком часто является render-эффект всего компонента:

Даже если изменился только один текстовый узел, Vue обычно должен повторно выполнить render-функцию компонента и сравнить виртуальные деревья. Компилятор Vue умеет отмечать динамические узлы и существенно сокращать объём такой работы, но виртуальный DOM всё равно остаётся посредником между реактивностью и браузером.

Фреймворки, построенные вокруг гранулярных сигналов, идут дальше. Они стараются сделать подписчиком не компонент целиком, а конкретное вычисление или DOM-операцию:

Получается, что у Vue уже давно есть подходящий реактивный фундамент, но его возможности не до конца используются существующей моделью рендеринга. ref умеет уведомить точных подписчиков, однако одним из таких подписчиков остаётся функция, повторно строящая виртуальное дерево компонента.

Именно это противоречие определяет следующий этап эволюции Vue. Фреймворку не обязательно изобретать новую реактивность или копировать чужой API сигналов. Ему нужно теснее связать уже существующий граф зависимостей с конкретными операциями над интерфейсом.

Так появляется Vapor Mode — попытка сохранить привычные ref, computed, компоненты и шаблоны Vue, но убрать виртуальный DOM из пути обновления. Вместо повторного рендеринга компонента компилятор может заранее создать гранулярные эффекты, которые будут изменять конкретные DOM-узлы.

Vapor: реактивность без виртуального DOM

Vapor Mode — экспериментальное направление Vue, в котором шаблон компилируется в непосредственные операции над DOM без создания виртуального DOM при каждом обновлении.

Модель Vapor упрощённо выглядит вот так:

Например, шаблон:

<template>  <button @click="count++">    Count: {{ count }}  </button></template>

Компилятор может концептуально разделить процесс на 3 стадии:

  1. однократное создание кнопки;

  2. однократную регистрацию обработчика;

  3. отдельный реактивный эффект для текстового узла.

Когда меняется count, не нужно заново создавать виртуальное описание кнопки и сравнивать его с предыдущим. Достаточно обновить конкретный текст.

Подробнее про Vapor можно почитать в другой моей статье.

Почему Vue возвращается к гранулярности

В некотором смысле Vapor замыкает круг. Vue 1 тоже связывал реактивные выражения с конкретными DOM-операциями. Но современный Vapor строится на другом фундаменте:

  • полноценной системе ref, reactive и computed;

  • современном компиляторе SFC;

  • статическом анализе шаблонов;

  • TypeScript;

  • компонентной модели Vue 3;

  • возможности взаимодействия с существующим VDOM-рендерером.

Цель состоит не в том, чтобы создать второй Solid или Svelte, а в том, чтобы сохранить привычный Vue API и получить более дешёвый рендеринг там, где компилятор способен заранее определить необходимые обновления.

Что дальше

Скорее всего, следующая глава Vue будет не про очередную замену API реактивности. ref, reactive, computed и эффекты уже образуют зрелую модель. Основные изменения будут происходить в том, как компилятор связывает эту модель с интерфейсом.

Можно выделить несколько вероятных направлений.

1. Сосуществование VDOM и Vapor

Виртуальный DOM не обязательно должен исчезнуть. Он остаётся удобным для:

  • динамических render-функций;

  • JSX;

  • библиотек с непредсказуемой структурой;

  • сценариев, которые трудно статически проанализировать.

Vapor лучше подходит для обычных SFC-шаблонов, где большая часть структуры известна во время сборки. Поэтому реалистичен гибридный Vue: разные компоненты смогут использовать разные стратегии рендеринга.

2. Компилятор будет выполнять больше работы

Компилятор уже:

  • оптимизирует статические части шаблона;

  • определяет динамические узлы;

  • раскрывает refs в шаблонах;

  • сохраняет реактивность деструктурированных props;

  • генерирует код для SSR и гидратации.

Vapor развивает ту же идею: если зависимость можно обнаружить при сборке, не обязательно искать её и сравнивать результат во время выполнения.

3. Граф зависимостей станет важнее дерева компонентов

Традиционный UI-фреймворк думает деревом:

изменилось состояние → обновился компонент → проверились его дочерние узлы

Гранулярная система думает графом:

изменился сигнал → уведомились конкретные подписчики → выполнились конкретные DOM-операции

Vue, вероятно, продолжит двигаться ко второй модели, сохраняя при этом компоненты как средство организации приложения.

Заключение

Vue 3 уже заложил основу для дальнейшего развития фреймворка, поэтому Vapor не выглядит как ещё одна миграция масштаба Vue 2 → Vue 3. Что это означает? 

Если ваш проект всё ещё на Vue 2, переход на Vue 3 лучше не откладывать. Миграция может потребовать обновления зависимостей и части кода, но именно на архитектуре Vue 3 строятся дальнейшие изменения фреймворка.

Если вы уже на Vue 3, специально готовиться к Vapor не нужно. ref, reactive, computed и SFC никуда не делись — меняется в первую способ доставки изменений до DOM.

Поэтому сейчас достаточно использовать публичные API и не завязываться на внутренности рендерера. Тогда будущие оптимизации Vue в значительной степени сможет взять на себя.


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

Положение об акции

ссылка на оригинал статьи https://habr.com/ru/articles/1069048/