Что отличает модель компонентов React, разницу между функциональными и классовыми компонентами, а также обзор того, как все это работает.

В первом посте этой серии мы начали строить ментальную модель кодовой базы Preact, исследовали некоторый периферийный код для утилит и глобальных опций и увидели, как JSX становится виртуальным доменным деревом. Во втором посте мы рассмотрим (P) компонентную модель реакции: что это такое, как работают функциональные и основанные на классах компоненты и как структурирована реализация.

То, о чем (P) реагирует на самом деле

Давайте начнем с того, что отбросим главное заблуждение о модели пользовательского интерфейса React (и ее частях, реализованных в Preact). Я до сих пор часто читаю, что главное преимущество React - это повышение производительности за счет использования виртуального DOM diff для рендеринга HTML. Разница в виртуальном DOM - это изящно, но это всего лишь функция, позволяющая реализовать основную идею React, а именно его компонентную модель.

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

  • Составные компоненты можно повторно использовать и объединять способами, специально не предусмотренными автором компонента. Пользователи могут даже передавать один компонент другому, чтобы настроить то, что он отображает. Например, мы должны иметь возможность написать общий компонент List, который очень мало знает о своих дочерних элементах, просто принимая другой компонент в качестве аргумента и отображая его копию для каждого фрагмента данных.
  • Компоненты с отслеживанием состояния знают свою историю и жизненный цикл. При разработке компонента часто бывает полезно иметь возможность сравнивать текущие данные с предыдущими, сохранять локальное состояние, скрытое от родительского компонента, и запускать код при первом создании или окончательном уничтожении экземпляра.
  • Декларативные компоненты описывают свое содержимое с помощью простых структур данных, а не экземпляров с отслеживанием состояния. Когда данные декларативного компонента обновляются, он возвращает совершенно новую спецификацию того, что должно быть отображено. Ему не нужно запоминать предыдущие дочерние компоненты, старые версии их данных или уничтожать их, когда они больше не используются.

Обратите внимание: если нам нужна модель составного компонента, существует противоречие между ее сохранением состояния и декларативностью. Компромисс заключается между полномочиями, предоставленными автору компонента, и сложностью, требуемой от его пользователей. Как пользователь компонента с отслеживанием состояния, мы должны создать его экземпляр в нужное время, управлять его событиями и обновлениями и не забыть уничтожить его. С другой стороны, если мы хотим использовать дочерний компонент декларативно, избегая всей этой бухгалтерии с отслеживанием состояния, у него, похоже, нет никакого способа хранить состояние или управлять своим жизненным циклом.

Модель компонентов React обходит этот очевидный компромисс и дает нам все возможности для создания компонентов с сохранением состояния и все удобство их декларативного использования.

Как такое возможно? Помните, что метод рендеринга компонента не создает узлы DOM или экземпляры компонентов, а только возвращает vnode, описывающий, как они могут быть созданы. Среда выполнения React берет это описание и ранее отрисованные экземпляры компонентов и определяет, какие виртуальные узлы представляют новые компоненты для создания экземпляров, какие сопоставлены с существующими компонентами, которые должны быть обновлены, а какие существующие компоненты не сопоставляются ни с чем в виртуальном узле и должны быть уничтожен. Среда выполнения управляет этим процессом с отслеживанием состояния в общем и тщательно протестированном виде, поэтому нашему коду не нужно беспокоиться об этом. В результате автор компонента может писать его с сохранением состояния, но пользователи могут использовать его декларативно.

Это большая идея React: составляемые компоненты с реализациями с отслеживанием состояния, но декларативным использованием. И весь этот сложный бухгалтерский код с отслеживанием состояния, который нашим приложениям не нужен? Все это перемещено в среду выполнения, которая по совпадению является кодовой базой, которую мы пытаемся понять. Будет весело.

Типы компонентов: компоненты на основе классов и функциональные компоненты

Модель компонентов React имеет два типа компонентов: объекты, наследуемые от базового Component класса, и простые функции. На данный момент функциональные компоненты всегда не имеют состояния, они просто получают реквизиты и контекст в качестве аргументов и возвращают виртуальный узел DOM:

let Person = (props, _context) => 
  <div>{props.name}</div>

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

class Person extends Component {
  render(props, _state, _context) {
    // In React, props/state/context are not passed as arguments,
    // but accessed on `this`. Preact adds them as arguments too.
    return <div>{props.name}</div>
  }
}

Мы хотим, чтобы эти два компонента имели (в основном) одинаковое поведение, но их нужно вызывать по-разному. Как исполняемый код узнает, какие компоненты какие?

Помните, что каждый vnode имеет nodeName, который может быть либо строкой, представляющей элемент HTML (например, «div»), либо функцией, обозначающей компонент. Если это функция, нам нужно знать, является ли эта функция конструктором для экземпляра класса Component или просто старым чистым функциональным компонентом. Чтобы выяснить это, src/vdom/functional-component.js определяет isFunctionalComponent, который ищет метод render в прототипе функции. Для компонента на основе классов этот метод будет существовать. Для функциональных компонентов этого не произойдет.

Базовый класс Component

Компонент выше не имел состояния, он просто возвращал дерево vdom для своих свойств. Но мы также можем использовать компоненты на основе классов для добавления локального состояния и обработки обратных вызовов жизненного цикла. (Лучшее руководство по полному Component API - это документация по React.) В этом разделе мы прочитаем объявление класса Preact Component, который могут быть расширены вашими компонентами с отслеживанием состояния.

База Component определена как конструктор в стиле ES5 в src/component.js. Этот файл содержит интерфейс и несколько обязательных методов, но не включает многие дополнительные методы жизненного цикла.

  • Существуют закомментированные версии shouldComponentUpdate и пустая версия render, обе из которых могут быть переопределены в компонентах, которые вы пишете.
  • setState объединяет новые значения состояния с существующим состоянием компонента, а затем ставит компонент в очередь для асинхронной визуализации. (Изменения локального состояния, в отличие от изменений в свойствах, переданных от родителя, всегда отображаются асинхронно.)
  • forceUpdate вызывает синхронный рендеринг компонента.

И это все, что касается класса Component, который вы можете расширить. Большая часть интересных реализаций компонентов находится в отдельном файле src/vdom/component.js, который отвечает за управление экземплярами компонентов и вызов их методов жизненного цикла в нужное время.

Реализация жизненного цикла компонента

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

В этом обзоре реализации мы будем иметь дело с узлами DOM, виртуальными узлами и фактическими экземплярами компонентов, часто представляющими одну и ту же абстрактную вещь. Чтобы избежать путаницы, я добавил цветовую кодировку, чтобы помочь нам сохранить четкость: синий для узлов DOM, зеленый для vnodes и оранжевый для экземпляров компонентов.

Процесс начинается, когда у нас есть дерево vnode, которое мы хотим сравнить с узлом dom и поместить внутрь родительского узла.

diff берет на себя некоторую бухгалтерию, а затем передает управление вспомогательной функции idiff, которая фактически выполняет различие. По завершении idiff diff будет следить за тем, чтобы обновленный узел попал в правильный родительский узел.

Поведение idiff зависит от типа предоставленного vnode. Если vnode описывает обычный элемент HTML, то idiff обновляет DOM, чтобы он имел правильный элемент и атрибуты. Как только текущий уровень DOM совпадает с текущим уровнем vnode, idiff вызывает innerDiffNode для сравнения дочерних узлов DOM с потомками vnode.

Для каждого дочернего виртуального узла innerDiffNode находит или создает соответствующий дочерний узел текущего узла DOM. Процесс сопоставления немного сложен, и именно здесь использование key в дочерних компонентах может значительно ускорить работу. После того, как дочерние объекты DOM и vnode объединены в пары, функция вызывает idiff для них, чтобы изменить дочерний DOM в соответствии с описанием.

Рекурсивные вызовы между idiff и innerDiffNode могут поэтому изменить все дерево узлов DOM, чтобы оно соответствовало целому дереву vnodes, описывающих элементы HTML. Но что происходит, когда idiff достигает vnode, который представляет компонент? Ему нужно знать, какой контент хочет отобразить компонент. Для этого он вызывает buildComponentFromVNode.

buildComponentFromVNode ищет существующий экземпляр компонента, который соответствует vnode, или создает новый. Когда у него есть экземпляр компонента, ему необходимо установить атрибуты vnode в качестве новых свойств для этого экземпляра, вызвав setComponentProps.

setComponentProps выполняет некоторые операции по обновлению свойств экземпляра, сохраняя при этом старые. Теперь, когда у экземпляра компонента есть новые данные, он может захотеть обновить то, что он отображает, поэтому он вызывает renderComponent для фактического управления обратными вызовами жизненного цикла и визуализацией.

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

Теперь у него есть новый vnode контента для рендеринга в DOM. Как это сделать? К счастью, у нас есть функция именно для этой цели: diff.

И все. Круг Preact замкнут!

Похоже, это может продолжаться вечно. Но в конечном итоге все ваши компоненты исчерпают себя при рендеринге обычных элементов html, так что в конечном итоге idiff достигнет предела при внесении изменений в DOM, вместо того, чтобы получать содержимое компонента.

Я надеюсь, что эта карта даст вам четкое общее представление о том, что происходит внутри Preact. Мы будем регулярно возвращаться к нему по мере погружения в код, чтобы увидеть, как он работает на более глубоком уровне.

Между тем, у вас, вероятно, все еще есть много вопросов, потому что я упустил много деталей. Например:

  • Функциональные компоненты и компоненты более высокого порядка
  • Сопряжение дочерних элементов DOM с дочерними элементами vnode и как key работает
  • Сумасшедшие особые случаи DOM, такие как привязка событий и пространства имен XML
  • Утилизация неиспользуемых компонентов и узлов DOM для повышения производительности
  • Гидратация, обратные вызовы монтирования и размонтирования и ссылки

Как Preact справляется с этими сложностями, мы поговорим в следующей статье.

Между тем, если вам понравилось это пошаговое руководство по компонентной модели React и вы хотите узнать больше о различных моделях программирования пользовательского интерфейса, наслаждайтесь приложением со ссылками на множество других материалов для чтения:

Приложение: дополнительные материалы по программированию пользовательского интерфейса

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

  • Немного теории: в 2005 году в комментарии к Lambda the Ultimate профессор Питер Ван Рой описал теоретическую систему веб-интерфейса, которая в ретроспективе выглядит очень похожей на React. Он объясняет, что система будет использовать декларативные деревья элементов, такие как HTML, но с реальными данными в их атрибутах, а не просто строками, и со средой выполнения, чтобы интерпретировать их как объекты с отслеживанием состояния. Иногда немного теории может заглянуть далеко в будущее. Я очень рекомендую его книгу о различных моделях программирования. В нем есть целая глава, посвященная декларативному программированию пользовательского интерфейса с отслеживанием состояния, особенно при наличии параллелизма.
  • Передовые технологии. В первой публикации этой серии мы увидели базовую поддержку Preact для асинхронного рендеринга, которая в настоящее время является основной областью исследований. Исходная модель React визуализирует сразу все дерево DOM. Если бы мы вместо этого могли разбить и расставить приоритеты в работе, мы могли бы быстро отображать наиболее важный контент или обновлять короткие анимации, вместо того, чтобы позволять замедлять их менее важным контентом. React Fiber - это повторная реализация React с моделью параллелизма, которая позволяет выполнять фрагментированный, приоритетный и спекулятивный рендеринг. Начните с просмотра потрясающего выступления Лин Кларк Мультяшное введение в Fiber. Ожидайте, что в следующем году вы услышите гораздо больше о Fiber.
  • Совершенно иная модель программирования: Elm - это чисто функциональный язык со статической типизацией, предназначенный для создания веб-интерфейсов. Существует подробное и хорошо написанное руководство по архитектуре пользовательского интерфейса Elm, в котором приводятся веские аргументы в пользу типов и чистоты разработки пользовательского интерфейса. Отдельные компоненты Elm не могут инкапсулировать локальное состояние или поведение. Это дает возможность использовать некоторые удивительные инструменты, такие как отладчик, путешествующий во времени, который всегда работает. Но это также создает барьер для определенных видов инкапсуляции и повторного использования компонентов. Если вы знаком с Haskell и хотите увидеть, как типизированные функциональные компоненты могут иметь локальное состояние за счет некоторых интересных сигнатур типов, вы можете изучить PureScript’s Halogen.

Предпочитаете другую компонентную модель, которую я забыл упомянуть? Хотите поделиться другой теорией программирования пользовательского интерфейса, о которой мне следует знать? Оставьте комментарий!