Уроки, полученные от написания следующей основной версии Vue.js
Автор: Эван Ю (Evan You)
В течение прошлого года команда Vue работала над следующей основной (major) версией Vue.js, которую мы надеемся выпустить в первой половине 2020 года (эта работа продолжается на момент написания данной статьи). Идея новой основной версии Vue сформировалась в конце 2018 года, когда кодовой базе Vue 2 было около двух с половиной лет. Это может показаться не таким уж долгим периодом в жизни программного обеспечения, но идеи фронт-энда сильно изменились за этот период.
Два ключевых соображения привели нас к написанию новой (и переписанию старой) основной версии Vue: во-первых, доступность новых функций языка JavaScript в распространенных браузерах. Во-вторых, проблемы дизайна и архитектуры в текущей кодовой базе, выявленные с течением времени.
Зачем переписывать
Использование новых языковых функций
Со стандартизацией ES2015, язык JavaScript — формально известный как ECMAScript, сокращенно ES — получил значительные улучшения, и основные браузеры наконец начали оказывать достойную поддержку этим новым его дополнениям. Некоторые из этих улучшений языка, в частности, предоставили нам возможности значительно улучшить возможности Vue.
Наиболее примечательным среди них является Proxy, который позволяет фреймворку перехватывать совершаемые над объектами операции. Основная особенность Vue — это возможность следить за изменениями, сделанными в определяемом пользователем объекте состояния (state), и оперативно обновлять DOM. Vue 2 реализует эту реактивность, заменяя свойства объекта состояния геттерами и сеттерами. Переход на Proxy позволит нам устранить существующие ограничения Vue, такие как невозможность обнаружения добавления новых свойств к объекту, и обеспечить более высокую производительность.
Однако Proxy — это нативная функция, которая не может быть полностью заполифилена в старых браузерах. Чтобы использовать ее нам придется изменить диапазон поддерживаемых браузеров — большое изменение, которое может быть добавлено только в новой основной версии фреймворка.
Решение архитектурных проблем
В ходе поддержки Vue 2 мы накопили ряд проблем, которые трудно решить из-за ограничений существующей архитектуры. Например, компилятор шаблонов (templates) был написан таким образом, что делает правильную поддержку sourcemap очень сложной. Кроме того, хотя Vue 2 технически позволяет создавать высокоуровневые средства рендеринга, предназначенные для платформ, отличных от DOM, нам пришлось продублировать кодовую базу и большую часть кода, чтобы сделать это возможным. Для исправления этих проблем в текущей кодовой базе потребуются огромные рискованные рефакторинги, которые почти эквивалентны переписыванию.
В то же время у нас накопились технические недоработки в форме неявных связей между внутренними компонентами различных модулей и кодом, который, так кажется, ничему не принадлежит. Это затрудняло понимание части кодовой базы, и мы заметили, что разработчики редко чувствовали себя уверенно, внося в нее нетривиальные изменения. Переписывание даст нам возможность переосмыслить организацию кода с учетом этих вещей .
Начальная фаза прототипирования
Мы начали прототипирование Vue 3 в конце 2018 года с предварительной целью проверки возможности решения этих проблем. На этом этапе мы в основном сосредоточились на создании прочной основы для дальнейшего развития.
Переключение на Typescript
Vue 2 был первоначально написан на простом ES. Вскоре после этапа создания прототипа мы поняли, что система типизации будет очень полезна для проекта такого масштаба. Проверка типов значительно снижает вероятность появления непреднамеренных ошибок при рефакторинге и помогает участникам более уверенно вносить нетривиальные изменения. Мы использовали продукт Facebook Flow type checker, потому что он может быть постепенно добавлен к существующему проекту на ES. Flow помог в некоторой степени, но мы не получили от него столько пользы, сколько ожидали; в частности, постоянные серьезные изменения в коде делали развитие проекта сложным. Поддержка интегрированных сред разработки также не была такой хорошей по сравнению, например, с глубокой интеграцией TypeScript с Visual Studio Code.
Мы также заметили, что пользователи все чаще используют Vue и TypeScript вместе. Чтобы поддержать эти варианты использования, мы должны были создавать и поддерживать объявления TypeScript отдельно от исходного кода, который использовал другую систему типов. Переход на TypeScript позволил бы нам автоматически генерировать файлы объявлений, облегчая бремя обслуживания.
Разделение (decoupling) внутренних пакетов
Мы также приняли философию monorepo, в которой фреймворк состоит из внутренних пакетов, каждый из которых имеет свои собственные API, определения типов и тесты. Мы хотели сделать зависимости между этими модулями более явными, чтобы разработчикам было проще читать, понимать и вносить изменения в них. Это было ключевым фактором к нашим усилиям по снижению проблем, с которыми сталкивались разработчики, желающие участвовать в проекте, и улучшению его долгосрочной поддержки.
Настройка процесса RFC
К концу 2018 года у нас был рабочий прототип с новой системой реактивности и виртуальным рендерингом DOM. Мы испытали внутренние архитектурные улучшения, которые мы хотели сделать, но имели только черновики общих изменений API. Пришло время превратить их в конкретные конструкции.
Мы знали, что должны были сделать это как можно раньше и тщательно. Широкое использование Vue означает, что серьезные изменения могут привести к огромным затратам на миграцию для пользователей и потенциальной фрагментации экосистемы. Чтобы пользователи могли предоставлять отзывы о критических изменениях, мы задействовали процесс RFC (Request for Comments) в начале 2019 года. Каждый RFC следует шаблону, разделы которого посвящены мотивации, деталям дизайна, компромиссам и принятию стратегии. Поскольку процесс проводится в репозитории GitHub с предложениями, представленными в виде пул реквестов, дискуссии разворачиваются в комментариях естественным образом.
Процесс RFC оказался чрезвычайно полезным, выступая в качестве основы для размышлений, которая заставляет нас полностью учитывать все аспекты потенциальных изменений, и позволяет нашему сообществу участвовать в процессе проектирования и отправлять хорошо продуманные feature requests.
Быстрее и меньше
Производительность имеет важное значение для фронт-энд фреймворков. Хотя Vue 2 может похвастаться конкурентоспособностью на этом поле, переписывание дает возможность пойти еще дальше, экспериментируя с новыми стратегиями рендеринга .
Преодоление узкого места виртуального DOM
У Vue довольно уникальная стратегия рендеринга: он предоставляет синтаксис HTML- подобного шаблона, но компилирует шаблоны в функции рендеринга, которые возвращают виртуальные DOM-деревья. Фреймворк вычисляет, какие части фактического DOM обновлять, путем рекурсивного обхода двух виртуальных деревьев DOM и сравнения каждого свойства на каждом узле. Этот довольно грубый алгоритм, как правило, довольно быстр, благодаря продвинутым оптимизациям, выполняемым современными механизмами JavaScript, но обновления по-прежнему занимают много процессорного времени. Неэффективность особенно очевидна на шаблоне с, в основном, статическим содержимым и только несколькими динамическими привязками (bindings) — всё дерево DOM все еще должно быть рекурсивно пройдено, чтобы найти изменения.
К счастью, этап компиляции шаблона дает нам возможность выполнить статический анализ шаблона и извлечь информацию о динамических деталях. Vue 2 делает это в некоторой степени, пропуская статические поддеревья, но более сложную оптимизацию было сложно реализовать из-за чрезмерно упрощенной архитектуры компилятора. В Vue 3 мы переписали компилятор с правильным AST transform pipeline, который позволяет нам делать оптимизации во время компиляции в форме преобразующих (transform) плагинов.
С новой архитектурой мы хотели найти такую стратегию рендеринга, которая позволила бы устранить как можно больше накладных расходов. Один из вариантов состоял в том, чтобы отказаться от виртуального DOM и напрямую генерировать императивные операции над DOM, но это исключило бы возможность напрямую создавать функции рендеринга виртуального DOM, что, как мы обнаружили, очень ценно для опытных пользователей и авторов библиотек. Плюс, это было бы огромным изменением.
Следующая полезная вещь состояла в том, чтобы избавиться от ненужных виртуальных обходов дерева DOM и сравнений свойств, что, как правило, дает наибольшую нагрузку при обновлении. Чтобы достичь этого, компилятор и среда выполнения (runtime) должны работать вместе: компилятор анализирует шаблон и генерирует код с советами по оптимизации, в то время как среда выполнения использует эти подсказки для выбора быстрых путей где возможно. Здесь работают три основные оптимизации:
Во-первых, на уровне дерева мы заметили, что структуры узлов остаются полностью статичными в отсутствие шаблонных директив, которые динамически изменяют структуру узлов (например, v-if и v-for). Если мы разделим шаблон (template) на вложенные «блоки», разделенные этими структурными директивами, структуры узлов внутри каждого блока снова становятся полностью статичными. Когда мы обновляем узлы в блоке, нам больше не нужно рекурсивно обходить дерево — динамические привязки внутри блока можно отслеживать в плоском массиве. Эта оптимизация позволяет избежать большей части накладных расходов виртуального DOM, уменьшив объем обхода дерева, который мы должны выполнить, на порядок.
Во-вторых, компилятор активно обнаруживает статические узлы, поддеревья и даже объекты данных в шаблоне и выводит их за пределы функции рендеринга в сгенерированном коде. Это позволяет избежать повторного создания этих объектов при каждом рендеринге, значительно улучшая использование памяти и снижая частоту сбора мусора.
В-третьих, на уровне DOM элементов компилятор также генерирует флаг оптимизации для каждого элемента с динамическими привязками на основе типа обновлений, которые он должен выполнить. Например, элемент с динамической привязкой класса и рядом статических атрибутов получит флаг, который указывает, что требуется только проверка класса. Среда выполнения соберет эти подсказки и выберет быстрые пути.
В совокупности эти методы значительно улучшили наши тесты ре-рендеринга: время исполнения Vue 3 иногда занимало менее одной десятой процессорного времени Vue 2.
Минимизация размера пакета
Размер фреймворка также влияет на его производительность. Это уникальная проблема для веб-приложений, поскольку ресурсы нужно загружать на лету, и приложение не будет интерактивным, пока браузер не проанализирует необходимый JavaScript код. Это особенно верно для одностраничных приложений. Хотя Vue всегда был относительно легким — размер среды выполнения Vue 2 составляет около 23 КБ в сжатом виде — мы заметили две проблемы:
Во-первых, не все используют все возможности фреймворка. Например, приложение, которое никогда не использует функции переходов (transitions), по-прежнему загружает и анализирует код, связанный с ними.
Во-вторых, фреймворк продолжает расти, так как мы добавляем новые функции. Это дает непропорциональный размера пакета, когда мы рассматриваем возможность добавления новой функции. В результате мы склонны включать только те функции, которые будут использоваться большинством наших пользователей.
В идеале пользователь должен иметь возможность отбрасывать код для неиспользуемых функций фреймворка во время сборки — алгоритм, также известный как tree-shaking — и платить только за то, что используется. Это также позволило бы нам добавлять функции, которые часть наших пользователей сочла бы полезными, без добавления ненужного бремени для остальных.
В Vue 3 мы достигли этого, переместив большинство глобальных API и внутренних хелперов в экспортируемые ES модули. Это позволяет современным упаковщикам статически анализировать зависимости модуля и удалять код, связанный с неиспользованным экспортом. Компилятор шаблона также генерирует tree-shaking дружественный код, который импортирует хелперы для функции, только если эта функция фактически используется в шаблоне.
Некоторые части фреймворка никогда не могут быть "стрясены", потому что они необходимы для любого приложения. Мы называем меру этих обязательных частей базовым размером. Базовый размер Vue 3 составляет около 10 КБ в сжатом виде — это менее половины размера Vue 2, несмотря на добавление многочисленных новых функций.
Решение проблемы масштабирования
Мы также хотели улучшить способность Vue обрабатывать крупномасштабные приложения. Наш первоначальный дизайн Vue был ориентирован на низкий барьер для входа и плавное обучение. Но поскольку Vue стал более широко распространенным, мы узнали больше о потребностях проектов, которые содержат сотни модулей и поддерживаются десятками разработчиков в течении длительного времени. Для проектов такого типа критически важна система типов, такая как TypeScript, и возможность чистой организации многократно используемого кода, и возможности Vue 2 в этих областях была далеко не идеальной.
На ранних этапах планирования архитектуры Vue 3 мы пытались улучшить интеграцию TypeScript, предлагая встроенную поддержку для разработки компонентов с использованием классов (Class API). Трудность состояла в том, что многие из возможностей языка, которые мы хотели задействовать, чтобы сделать классы пригодными к использованию, например, атрибуты классов и декораторы, не были еще стандартизированы и могли изменяться, прежде чем официально стать частью JavaScript. Сложность и неопределенность заставили нас усомниться в том, действительно ли добавление Class API было оправдано, поскольку оно не предлагало ничего, кроме немного лучшей интеграции TypeScript.
Мы решили исследовать другие способы решения проблемы масштабирования. Вдохновленные React Hooks, мы подумали об использовании низкоуровневых API-интерфейсов реактивности и жизненного цикла компонентов, чтобы обеспечить более легковесный способ создания логики компонентов, называемый Composition API. Вместо определения компонента путем указания длинного списка параметров, Composition API позволяет пользователю также свободно создавать, составлять и повторно использовать логику компонентов с состоянием (statefull components), как при написании простой функции, обеспечивая при этом превосходную поддержку TypeScript.
Нам очень понравилась эта идея. Хотя Composition API был разработан для решения конкретной категории проблем, технически возможно использовать его только при разработке компонентов. При обсуждении Vue 3 мы стали рассуждать, что Composition API могло бы заменить Options API в будущем релизе. Это привело к массовому не очень положительному отклику со стороны членов сообщества, который послужил нам ценным уроком о необходимости четкого определения долгосрочных планов и намерений, а также понимания потребностей пользователей. Выслушав отзывы нашего сообщества, мы полностью переработали предложение, пояснив, что Composition API будет дополнять Options API. Это понравилось сообществу больше, и мы получили много конструктивных предложений.
В поисках баланса
Среди пользователей Vue, насчитывающих более миллиона разработчиков, есть новички с базовыми знаниями HTML/CSS, профессионалы, переходящие с jQuery, ветераны, переходящие с других фреймоворков, бэкенд-инженеры, ищущие фронтэнд решение, и архитекторы программного обеспечения. Разнообразие типов разработчиков соответствует разнообразию вариантов использования: некоторые разработчики могут захотеть добавить интерактивность в уже существующие приложения, в то время как другие могут работать над проектами с быстрым временем разработки, но не очень легкими в обслуживании; архитекторам, возможно, придется иметь дело с крупномасштабными, многолетними проектами и меняющейся командой разработчиков на протяжении всего жизненного цикла проекта.
Архитектура Vue постоянно формировалась учитывая эти потребности, поскольку мы стремимся найти баланс между различными компромиссами. Лозунг Vue «прогрессивный фреймворк», заключает в себе многоуровневую структуру API, которая является результатом этого процесса. Новички могут наслаждаться плавным обучением с использованием одного CDN скрипта, шаблонов на основе HTML и интуитивно понятного Options API, в то время как эксперты могут заниматься сложными проектами с полнофункциональным интерфейсом командной строки (CLI), функциями рендеринга и Composition API.
Нам еще предстоит проделать большую работу, чтобы реализовать наше видение — самое главное, обновить вспомогательные библиотеки, документацию и инструменты для обеспечения плавного перехода на новую версию. Мы будем усердно работать в ближайшие месяцы, и мы ждем — не дождемся посмотреть, что сообщество создаст с помощью Vue 3.
Автор: gmtd