Детали реализации
В этой главе собраны примеры из реализации согласователя Stack.
Они очень специфичны и требуют хорошего знания React API, а также ядра, рендереров и согласователя. Если вы не очень хорошо знакомы с архитектурой React, тогда изучите главу Архитектура кодовой базы, а затем вернитесь к этой.
Также предполагается, что вы понимаете разницу между компонентами, их экземплярами и элементами в React.
Согласователь Stack использовался в React 15 и более ранних версиях. Его код находится в каталоге src/renderers/shared/stack/reconciler.
Видео: сборка React с нуля
Paul O’Shannessy рассказал в своём докладе как собрать React с нуля, используя материал из этой главы.
В его докладе и этой главе описаны упрощённые реализации, поэтому, ознакомившись с ними, вы сможете лучше понять, как работает реальная реализация.
Введение
Согласователь не имеет открытого API. Рендереры, такие как React DOM и React Native, используются, чтобы эффективно обновлять пользовательские UI-компоненты.
Монтирование как рекурсивный процесс
Давайте рассмотрим, как компонент монтируется в первый раз.
const root = ReactDOM.createRoot(rootEl);
root.render(<App />);
root.render
передаст <App />
в согласователь. Запомните, что <App />
— это React-элемент, т. е. описание того, что нужно отрендерить. Вы можете представлять его как просто объект.
console.log(<App />);
// { type: App, props: {} }
Согласователь будет проверять, чем является App
: классом или функцией.
Если App
— функция, согласователь вызовет App(props)
, чтобы получить элемент, который нужно отрендерить.
Если App
— класс, согласователь создаст экземпляр App
с помощью new App(props)
, вызовет метод жизненного цикла componentWillMount()
, а затем вызовет render()
, чтобы получить элемент, который нужно отрендерить.
В любом случае, согласователь изучит элемент App
, чтобы узнать, что нужно отрендерить.
Этот процесс рекурсивен. App
может рендерить <Greeting />
, Greeting
— <Button />
, и т. д. Согласователь будет рекурсивно погружаться в пользовательские компоненты, пока не узнает, что каждый компонент должен рендерить.
Рассмотрим этот процесс с помощью псевдокода:
function isClass(type) {
// Подклассы React.Component имеют соответствующий флаг
return (
Boolean(type.prototype) &&
Boolean(type.prototype.isReactComponent)
);
}
// Функция получает React-элемент (например, <App />)
// и возвращает узел, являющуюся вершиной DOM- или Native-дерева элементов.
function mount(element) {
var type = element.type;
var props = element.props;
// Мы будем вычислять необходимый элемент
// либо выполняя type как функцию,
// либо с помощью создания экземпляра и вызова метода render().
var renderedElement;
if (isClass(type)) {
// Компонент является классом
var publicInstance = new type(props);
// Задать пропсы
publicInstance.props = props;
// Если необходимо, вызвать метод жизненного цикла
if (publicInstance.componentWillMount) {
publicInstance.componentWillMount();
}
// Получить необходимый элемент с помощью вызова render()
renderedElement = publicInstance.render();
} else {
// Компонент является функцией
renderedElement = type(props);
}
// Этот процесс может быть рекурсивным, потому что компонент может
// возвращать другой компонент.
return mount(renderedElement);
// Примечание: эта реализация не завершена и выполняется бесконечно!
// Обрабатывает такие элементы как <App /> и <Button />.
// Пока не обрабатывает такие элементы как <div /> и <p />.
}
var rootEl = document.getElementById('root');
var node = mount(<App />);
rootEl.appendChild(node);
Примечание:
Пример выше — псевдокод. Он не является реальной реализацией. А также этот код приводит к переполнению стека, потому что мы не описали, когда нужно остановить рекурсию.
Рассмотрим основные идеи этого кода:
- React-элементы — просто объекты, описывающие тип компонента (например,
App
) и его пропсы. - Пользовательские компоненты могут быть как классами, так и функциями, но оба «рендерят» элементы.
- «Монтирование» — рекурсивный процесс, который создаёт DOM- или Native-дерево заданного React-элемента верхнего уровня (например,
<App />
).
Монтирование базовых элементов
Процесс монтирования может стать бесполезным, если мы не отобразим результат на экран.
В дополнение к пользовательским («составным») компонентам, React-элементы также могут быть представлены платформо-специфическими («базовыми») компонентами. Например, Button
может вернуть <div />
из метода render().
Если свойство type
имеет тип string, значит мы имеем дело с базовым элементом:
console.log(<div />);
// { type: 'div', props: {} }
Для базового элемента не существует пользовательского кода.
Когда согласователь встречает базовый элемент, ответственность за монтирование возьмёт на себя рендерер. Например, React DOM может создать DOM-узел.
Если элемент имеет потомков, согласователь рекурсивно монтирует их, следуя алгоритму выше. Потомки могут быть базовыми (например, <div><hr /></div>
), составными (например, <div><Button /></div>
) или обоих типов.
DOM-узлы, созданные дочерними компонентами, будут добавлены к родительскому DOM-узлу, и рекурсивно будет собрана полная DOM-структура.
Примечание:
Согласователь не связан с DOM. Точный результат монтирования (иногда называемый «смонтированный образ») зависит от рендерера и может быть DOM-узлом (React DOM), строкой (React DOM Server) или числом (React Native).
Если мы изменим код, чтобы он обрабатывал базовые элементы, то результат будет выглядеть вот так:
function isClass(type) {
// Подклассы React.Component имеют соответствующий флаг
return (
Boolean(type.prototype) &&
Boolean(type.prototype.isReactComponent)
);
}
// Эта функция обрабатывает только составные элементы.
// Например, <App /> и <Button />, но не <div />
function mountComposite(element) {
var type = element.type;
var props = element.props;
var renderedElement;
if (isClass(type)) {
// Компонент является классом
var publicInstance = new type(props);
// Задать пропсы
publicInstance.props = props;
// Если необходимо, вызвать метод жизненного цикла
if (publicInstance.componentWillMount) {
publicInstance.componentWillMount();
}
renderedElement = publicInstance.render();
} else if (typeof type === 'function') {
// Компонент является функцией
renderedElement = type(props);
}
// Эта функция рекурсивна, но иногда достигает границ рекурсии, когда
// встречает базовый элемент (такой, как <div />), вместо составного (такого, как <App />):
return mount(renderedElement);
}
// Эта функция обрабатывает только базовые элементы.
// Например, <div /> и <p />, но не <App />.
function mountHost(element) {
var type = element.type;
var props = element.props;
var children = props.children || [];
if (!Array.isArray(children)) {
children = [children];
}
children = children.filter(Boolean);
// Этот блок кода не следует размещать в согласователе.
// Каждый рендерер может инициализировать узлы по-своему.
// Например, React Native может создать представление для iOS или Android.
var node = document.createElement(type);
Object.keys(props).forEach(propName => {
if (propName !== 'children') {
node.setAttribute(propName, props[propName]);
}
});
// Монтировать потомков
children.forEach(childElement => {
// Потомки могут быть как базовыми (<div />), так и составными (<Button />).
// Их мы тоже будем монтировать рекурсивно:
var childNode = mount(childElement);
// Эта строка кода может отличаться
// в зависимости от рендерера
node.appendChild(childNode);
});
// Вернуть DOM ноду в качестве результата.
// Здесь рекурсия заканчивается.
return node;
}
function mount(element) {
var type = element.type;
if (typeof type === 'function') {
// Пользовательский компонент
return mountComposite(element);
} else if (typeof type === 'string') {
// Платформо-специфический компонент
return mountHost(element);
}
}
var rootEl = document.getElementById('root');
var node = mount(<App />);
rootEl.appendChild(node);
Этот код работает, но всё ещё далёк от того, как согласователь реализован на самом деле. Отсутствует ключевая деталь — поддержка обновлений.
Введение во внутренние экземпляры
Ключевая особенность React — ререндеринг всего без пересоздания DOM или сброса состояния:
root.render(<App />);
// Использовать уже существующий DOM:
root.render(<App />);
Однако, наша реализация знает только как монтировать начальное дерево. Она не может обновлять его, потому что не содержит необходимой информации, например, экземпляры publicInstance
, или какой DOM-узел (node
) соответствует компоненту.
Согласователь Stack решает эту проблему, сделав функцию mount()
методом класса. В этом решении есть недостатки, поэтому мы решили переписать согласователь. Однако, опишем, как он сейчас работает.
Вместо разделения на функции mountHost
и mountComposite
, мы создадим два класса:
Оба класса имеют конструктор, принимающий element
, а также имеют метод mount()
, который возвращает необходимый узел. Заменим вызывающую функцию mount()
на фабрику, которая будет создавать нужный класс:
function instantiateComponent(element) {
var type = element.type;
if (typeof type === 'function') {
// Пользовательский компонент
return new CompositeComponent(element);
} else if (typeof type === 'string') {
// Платформо-специфический компонент
return new DOMComponent(element);
}
}
Для начала рассмотрим реализацию CompositeComponent
:
class CompositeComponent {
constructor(element) {
this.currentElement = element;
this.renderedComponent = null;
this.publicInstance = null;
}
getPublicInstance() {
// Для составных компонентов сделать экземпляр класса видимым.
return this.publicInstance;
}
mount() {
var element = this.currentElement;
var type = element.type;
var props = element.props;
var publicInstance;
var renderedElement;
if (isClass(type)) {
// Компонент является классом
publicInstance = new type(props);
// Задать пропсы
publicInstance.props = props;
// Если необходимо, вызвать метод жизненного цикла
if (publicInstance.componentWillMount) {
publicInstance.componentWillMount();
}
renderedElement = publicInstance.render();
} else if (typeof type === 'function') {
// Компонент является функцией
publicInstance = null;
renderedElement = type(props);
}
// Сохранить внешний экземпляр
this.publicInstance = publicInstance;
// Получить внутренний экземпляр в соответствии с элементом.
// Это может быть DOMComponent для <div /> или <p />,
// и CompositeComponent для <App /> или <Button />:
var renderedComponent = instantiateComponent(renderedElement);
this.renderedComponent = renderedComponent;
// Монтировать полученный результат
return renderedComponent.mount();
}
}
Это не сильно отличается от предыдущей реализации mountComposite()
, однако теперь мы можем сохранять некоторую информацию, такую как this.currentElement
, this.renderedComponent
и this.publicInstance
, чтобы использовать во время обновлений.
Заметьте, что экземпляр CompositeComponent
, это не то же самое, что экземпляр element.type
. CompositeComponent
— деталь реализации нашего согласователя, которая не может быть доступна пользователю. Пользовательский класс — это единственное, что мы читаем из element.type
, а CompositeComponent
создаёт его экземпляр.
Чтобы избежать путаницы, мы будем называть экземпляры CompositeComponent
и DOMComponent
«внутренними экземплярами». Они существуют для того, чтобы мы могли сохранять в них некоторые долгоживущие данные. Только рендереры и согласователь знают об их существовании.
В противоположность, мы будем называть экземпляры пользовательских классов «внешними экземплярами». Внешние экземпляры — это то, что вы видите как this
внутри render()
и других методов ваших компонентов.
Функция mountHost()
, переименованная в метод mount()
класса DOMComponent
, также будет выглядеть знакомо:
class DOMComponent {
constructor(element) {
this.currentElement = element;
this.renderedChildren = [];
this.node = null;
}
getPublicInstance() {
// Для DOM-компонентов сделать DOM-узел видимым.
return this.node;
}
mount() {
var element = this.currentElement;
var type = element.type;
var props = element.props;
var children = props.children || [];
if (!Array.isArray(children)) {
children = [children];
}
// Создать и сохранить узел
var node = document.createElement(type);
this.node = node;
// Задать атрибуты
Object.keys(props).forEach(propName => {
if (propName !== 'children') {
node.setAttribute(propName, props[propName]);
}
});
// Создать и сохранить потомков.
// Каждый из них может быть либо DOMComponent, либо CompositeComponent,
// в зависимости от типа свойства type (строка или функция).
var renderedChildren = children.map(instantiateComponent);
this.renderedChildren = renderedChildren;
// Собрать DOM-узлы, которые возвращает метод mount.
var childNodes = renderedChildren.map(child => child.mount());
childNodes.forEach(childNode => node.appendChild(childNode));
// Вернуть DOM-узел в качестве результата
return node;
}
}
Основное отличие от метода mountHost()
в том, что теперь мы храним this.node
и this.renderedChildren
внутри DOMComponent. Мы будем использовать их в дальнейшем, чтобы не поломать структуру во время обновлений.
В результате, каждый внутренний экземпляр (составной и базовый) имеет ссылку на свои внутренние экземпляры-потомки. Чтобы лучше представить себе этот процесс, создадим функциональный компонент <App>
, который рендерит класс-компонент <Button>
, который рендерит <div>
. Вот как может выглядеть дерево внутренних экземпляров:
[object CompositeComponent] {
currentElement: <App />,
publicInstance: null,
renderedComponent: [object CompositeComponent] {
currentElement: <Button />,
publicInstance: [object Button],
renderedComponent: [object DOMComponent] {
currentElement: <div />,
node: [object HTMLDivElement],
renderedChildren: []
}
}
}
Внутри DOM вы увидите только <div>
. Однако дерево внутренних экземпляров содержит как DOMComponent
, так и CompositeComponent
.
CompositeComponent
должен хранить:
- Текущий элемент.
- Внешний экземпляр, если свойство type в элементе является классом.
- Единственный внутренний экземпляр. Он может быть как
DOMComponent
, так иCompositeComponent
.
DOMComponent
должен хранить:
- Текущий элемент.
- DOM ноду.
- Все внутренние экземпляры-потомки. Каждый из них может быть как
DOMComponent
, так иCompositeComponent
.
Если в более сложном приложении вам тяжело представить, как выглядит дерево внутренних экземпляров, React DevTools поможет вам в этом, выделяя базовые экземпляры серым цветом, а составные фиолетовым:
В завершении, создадим функцию, которая монтирует полученное дерево в узел-контейнер и возвращает внешний экземпляр.
function mountTree(element, containerNode) {
// Создать верхнеуровневый внутренний экземпляр
var rootComponent = instantiateComponent(element);
// Монтировать верхнеуровневый компонент внутрь контейнера
var node = rootComponent.mount();
containerNode.appendChild(node);
// Вернуть внешний экземпляр
var publicInstance = rootComponent.getPublicInstance();
return publicInstance;
}
var rootEl = document.getElementById('root');
mountTree(<App />, rootEl);
Демонтирование
Теперь, когда у нас есть внутренние экземпляры, которые хранят своих потомков и DOM ноды, мы можем реализовать демонтирование. Для составного компонента демонтирование рекурсивно и вызывает метод жизненного цикла.
class CompositeComponent {
// ...
unmount() {
// Если необходимо, вызвать метод жизненного цикла
var publicInstance = this.publicInstance;
if (publicInstance) {
if (publicInstance.componentWillUnmount) {
publicInstance.componentWillUnmount();
}
}
// Демонтировать единственный компонент
var renderedComponent = this.renderedComponent;
renderedComponent.unmount();
}
}
Для DOMComponent
демонтировать каждого потомка:
class DOMComponent {
// ...
unmount() {
// Демонтировать всех потомков
var renderedChildren = this.renderedChildren;
renderedChildren.forEach(child => child.unmount());
}
}
В действительности демонтирование DOM-компонентов также удаляет слушателей событий и очищает кэш, но опустим эти детали.
Теперь добавим верхнеуровневую функцию unmountTree(containerNode)
, которая аналогична ReactDOM.unmountComponentAtNode()
:
function unmountTree(containerNode) {
// Получить внутренний экземпляр из DOM ноды:
// (Пока не работает, нам нужно изменить mountTree(), чтобы хранить переменную.)
var node = containerNode.firstChild;
var rootComponent = node._internalInstance;
// Демонтировать дерево и очистить контейнер
rootComponent.unmount();
containerNode.innerHTML = '';
}
Чтобы это работало, нам нужно получить корневой внутренний экземпляр из DOM ноды. Мы изменим mountTree()
, добавив свойство _internalInstance
в корневую ноду. Также научим mountTree()
уничтожать уже существующее дерево, чтобы можно было вызывать этот метод несколько раз.
function mountTree(element, containerNode) {
// Уничтожить уже существующее дерево
if (containerNode.firstChild) {
unmountTree(containerNode);
}
// Создать верхнеуровневый внутренний экземпляр
var rootComponent = instantiateComponent(element);
// Монтировать верхнеуровневый компонент внутрь контейнера
var node = rootComponent.mount();
containerNode.appendChild(node);
// Сохранить ссылку на внутренний экземпляр
node._internalInstance = rootComponent;
// Вернуть внешний экземпляр
var publicInstance = rootComponent.getPublicInstance();
return publicInstance;
}
Теперь вызов unmountTree()
, а также повторный вызов mountTree()
удалят старое дерево и запустят в компоненте метод жизненного цикла componentWillUnmount()
.
Обновления
В предыдущей части мы реализовали демонтирование. Однако React не был бы так полезен, если бы после каждого изменения пропсов демонтировалось и монтировалось заново всё дерево.
var rootEl = document.getElementById('root');
mountTree(<App />, rootEl);
// Использовать уже существующий DOM:
mountTree(<App />, rootEl);
Расширим наш внутренний экземпляр ещё одним методом. В дополнение к mount()
и unmount()
, в DOMComponent
и CompositeComponent
будет реализован новый метод receive(nextElement)
:
class CompositeComponent {
// ...
receive(nextElement) {
// ...
}
}
class DOMComponent {
// ...
receive(nextElement) {
// ...
}
}
Его задача — сделать всё необходимое, чтобы обновить компонент (со всеми потомками) в соответствии с nextElement
.
Несмотря на то, что мы рекурсивно обновляем экземпляры DOMComponent
и CompositeComponent
, эту часть часто называют «diff-алгоритмом для виртуального DOM».
Обновление составных компонентов
Когда составной компонент получает новый элемент, мы выполняем метод жизненного цикла componentWillUpdate()
.
Затем мы заново отрендерим компонент с новыми пропсами и получим новый React-элемент:
class CompositeComponent {
// ...
receive(nextElement) {
var prevProps = this.currentElement.props;
var publicInstance = this.publicInstance;
var prevRenderedComponent = this.renderedComponent;
var prevRenderedElement = prevRenderedComponent.currentElement;
// Обновить *свой* элемент
this.currentElement = nextElement;
var type = nextElement.type;
var nextProps = nextElement.props;
// Вычислить новый результат render()
var nextRenderedElement;
if (isClass(type)) {
// Компонент является классом
// Если необходимо, вызвать метод жизненного цикла
if (publicInstance.componentWillUpdate) {
publicInstance.componentWillUpdate(nextProps);
}
// Обновить пропсы
publicInstance.props = nextProps;
// Отрендерить снова
nextRenderedElement = publicInstance.render();
} else if (typeof type === 'function') {
// Компонент является функцией
nextRenderedElement = type(nextProps);
}
// ...
Далее, мы можем рассмотреть свойство type
. Если оно не изменилось с последнего рендеринга, то компонент ниже может быть обновлён.
Например, если в первый раз результат был <Button color="red" />
, а во второй — <Button color="blue" />
, значит мы можем просто вызвать receive()
(с новым элементом в качестве параметра) у соответствующего внутреннего экземпляра:
// ...
// Если свойство type не изменилось, то
// переиспользовать внутренний экземпляр и прекратить выполнение.
if (prevRenderedElement.type === nextRenderedElement.type) {
prevRenderedComponent.receive(nextRenderedElement);
return;
}
// ...
Однако, если свойство type
нового элемента отличается от предыдущего, то мы не можем обновить внутренний экземпляр. <button>
не может «превратиться» в <input>
.
Вместо этого мы демонтируем существующий внутренний экземпляр и монтируем новый, который будет рендерить соответствующий элемент. Например, вот что происходит, когда компонент, который ранее рендерил <button />
, рендерит <input />
:
// ...
// Если мы дошли до сюда, то нам нужно демонтировать предыдущий
// компонент, монтировать новый, и обменять их ноды.
// Найти старый узел, потому что его нужно заменить
var prevNode = prevRenderedComponent.getHostNode();
// Демонтировать старого потомка и монтировать нового
prevRenderedComponent.unmount();
var nextRenderedComponent = instantiateComponent(nextRenderedElement);
var nextNode = nextRenderedComponent.mount();
// Обновить потомка
this.renderedComponent = nextRenderedComponent;
// Заменить старого потомка на нового
// Заметка: этот код завязан на рендерере и,
// в идеале, должен находиться вне CompositeComponent:
prevNode.parentNode.replaceChild(nextNode, prevNode);
}
}
Подводя итог, когда составной компонент получает новый элемент, он может либо делегировать обновление внутреннему экземпляру, либо демонтировать старый компонент и монтировать новый.
Существует ещё одна ситуация, когда компонент будет заново монтировать потомка вместо вызова receive()
— key
элемента изменился. Мы не будем обсуждать обработку key
в этой главе, потому что это добавит ещё больше сложности к и без того уже сложному материалу.
Заметьте, что во внутренний экземпляр нам нужно добавить метод getHostNode()
, чтобы возможно было обнаружить платформо-специфичные ноды и заменить их во время обновления. Его реализация очевидна в обоих классах:
class CompositeComponent {
// ...
getHostNode() {
// Попросить внутренний экземпляр предоставить ноду.
// Этот вызов рекурсивно развернёт все компоненты.
return this.renderedComponent.getHostNode();
}
}
class DOMComponent {
// ...
getHostNode() {
return this.node;
}
}
Обновление базовых компонентов
Хостовые компоненты, такие как DOMComponent
, обновляются по-другому. Когда они получают элемент, то им необходимо обновить платформо-специфический view. В случае с React DOM, это означает обновить DOM-атрибуты:
class DOMComponent {
// ...
receive(nextElement) {
var node = this.node;
var prevElement = this.currentElement;
var prevProps = prevElement.props;
var nextProps = nextElement.props;
this.currentElement = nextElement;
// Удалить старые атрибуты.
Object.keys(prevProps).forEach(propName => {
if (propName !== 'children' && !nextProps.hasOwnProperty(propName)) {
node.removeAttribute(propName);
}
});
// Задать новые атрибуты.
Object.keys(nextProps).forEach(propName => {
if (propName !== 'children') {
node.setAttribute(propName, nextProps[propName]);
}
});
// ...
Затем хостовым компонентам необходимо обновить их потомков. В отличие от составных, они могут содержать более чем одного потомка.
В этом упрощённом примере, мы используем массив внутренних экземпляров и проходим по каждому из них, обновляя либо заменяя внутренние экземпляры, в зависимости от того, соответствует ли полученный type
предыдущему. В реальности reconciler также берёт key
элементов и, в добавок к вставкам и удалениям элементов, отслеживает их перемещение, но мы опустим эту деталь.
Соберём DOM-операции над потомками в список, чтобы мы смогли выполнить их обновление группой:
// ...
// Массивы React-элементов
var prevChildren = prevProps.children || [];
if (!Array.isArray(prevChildren)) {
prevChildren = [prevChildren];
}
var nextChildren = nextProps.children || [];
if (!Array.isArray(nextChildren)) {
nextChildren = [nextChildren];
}
// Массивы внутренних экземпляров
var prevRenderedChildren = this.renderedChildren;
var nextRenderedChildren = [];
// Проходя по потомкам, будем добавлять операции в массив.
var operationQueue = [];
// Заметка: блок кода ниже очень упрощён! Он не обрабатывает
// переупорядочивание, передачу компонентов как пропсов, и потомков со свойством `key`.
// Он нужен только чтобы показать основы, а не детали.
for (var i = 0; i < nextChildren.length; i++) {
// Попытаться получить внутренний экземпляр
var prevChild = prevRenderedChildren[i];
// Если не существует внутреннего экземпляра с этим индексом,
// то потомок будет добавлен в конец. Создать новый
// внутренний экземпляр, монтировать его и использовать его ноду.
if (!prevChild) {
var nextChild = instantiateComponent(nextChildren[i]);
var node = nextChild.mount();
// Записать, что нам нужно добавить ноду
operationQueue.push({type: 'ADD', node});
nextRenderedChildren.push(nextChild);
continue;
}
// Мы можем обновлять экземпляры только если их элементы совпадают.
// Например, <Button size="small" /> может быть обновлён на
// <Button size="large" />, но не на <App />.
var canUpdate = prevChildren[i].type === nextChildren[i].type;
// Если невозможно обновить существующий экземпляр, то мы должны
// демонтировать его и монтировать вместо него новый.
if (!canUpdate) {
var prevNode = prevChild.getHostNode();
prevChild.unmount();
var nextChild = instantiateComponent(nextChildren[i]);
var nextNode = nextChild.mount();
// Записать, что нам нужно поменять ноды
operationQueue.push({type: 'REPLACE', prevNode, nextNode});
nextRenderedChildren.push(nextChild);
continue;
}
// Если возможно обновить существующий экземпляр, то передать
// новый элемент в receive() и позволить ему обновиться самостоятельно.
prevChild.receive(nextChildren[i]);
nextRenderedChildren.push(prevChild);
}
// Наконец, демонтировать потомков, которых больше не существует:
for (var j = nextChildren.length; j < prevChildren.length; j++) {
var prevChild = prevRenderedChildren[j];
var node = prevChild.getHostNode();
prevChild.unmount();
// Записать, что нам нужно удалить ноды
operationQueue.push({type: 'REMOVE', node});
}
// Обновить список внутренних экземпляров
this.renderedChildren = nextRenderedChildren;
// ...
Последний шаг — выполним DOM-операции. Опять же, реальная реализация согласователя более сложная, потому что обрабатывает перемещения:
// ...
// Обработать очередь операций
while (operationQueue.length > 0) {
var operation = operationQueue.shift();
switch (operation.type) {
case 'ADD':
this.node.appendChild(operation.node);
break;
case 'REPLACE':
this.node.replaceChild(operation.nextNode, operation.prevNode);
break;
case 'REMOVE':
this.node.removeChild(operation.node);
break;
}
}
}
}
И всё это нужно для обновления хостовых компонентов.
Итоговая реализация обновлений
Сейчас, когда в CompositeComponent
и DOMComponent
реализован метод receive(nextElement)
, мы можем изменить вызывающую функцию mountTree()
, чтобы вызывать её, когда свойство type
в элементе не изменилось:
function mountTree(element, containerNode) {
// Проверить наличие дерева
if (containerNode.firstChild) {
var prevNode = containerNode.firstChild;
var prevRootComponent = prevNode._internalInstance;
var prevElement = prevRootComponent.currentElement;
// Переиспользовать существующий корневой компонент, если это возможно
if (prevElement.type === element.type) {
prevRootComponent.receive(element);
return;
}
// Иначе, демонтировать существующее дерево
unmountTree(containerNode);
}
// ...
}
Теперь вызов mountTree()
дважды с одним и тем же параметром ничего не разрушает.
var rootEl = document.getElementById('root');
mountTree(<App />, rootEl);
// Переиспользовать существующий DOM:
mountTree(<App />, rootEl);
Это и есть основа работы React изнутри.
Что не было рассмотрено
Эта реализация упрощена по сравнению с реальной. Существует много важных вещей на которые мы не обратили внимание:
- Компоненты могут рендерить
null
, а согласователь может обрабатывать массив из «пустых слотов». - Согласователь также берёт
key
элементов и использует их, чтобы установить связь между внутренними экземплярами и элементами в массиве. Основная сложность React связана именно с этой деталью. - В дополнение к составным и хостовым компонентам, также существуют внутренние экземпляры для «текстовых» и «пустых». Они представляют текстовые ноды и «пустые слоты», которые вы получаете, когда рендерите
null
. - Рендереры используют инъекции, чтобы прокинуть базовые компоненты в согласователь. Например, React Dom говорит reconciler-у, что нужно использовать
ReactDOMComponent
в качестве внутреннего экземпляра. - Логика обновления списка потомков вынесена в миксин
ReactMultiChild
, который используется хостовым компонентами в React DOM и React Native. - Согласователь также реализует поддержку
setState()
в составных компонентах. Множество обновлений внутри события объединяются в единое обновление. - Также согласователь берёт на себя ответственность за присоединение и отсоединение рефов для составных компонентов и хостовых нод.
- Методы жизненного цикла, которые вызываются по готовности DOM (такие, как
componentDidMount()
иcomponentDidUpdate()
), собираются в «очередь обратных вызовов» и выполняются единым вызовом. - React кладёт информацию о текущем обновлении в объект, называемый «транзакция». Транзакции полезны для отслеживания очереди из методов жизненного цикла, DOM предупреждений, и всего остального, что является «глобальным» для текущего обновления. Также транзакции гарантируют, что React «очищает всё» после обновлений. Например, класс транзакции, предоставляемый React DOM, после обновления восстановит выделение в инпуте.
Погружение в код
ReactMount
содержит методыmountTree()
иunmountTree()
из этой главы. Он заботится о монтировании и демонтировании компонентов.ReactNativeMount
— аналог из React Native.ReactDOMComponent
— эквивалентDOMComponent
из этой главы. Он реализует хостовый компонент для React DOM рендерера.ReactNativeBaseComponent
— аналог из React Native.ReactCompositeComponent
— эквивалентCompositeComponent
из этой главы. Он обрабатывает вызовы из пользовательских компонентов и хранит состояние.instantiateReactComponent
содержит switch для элемента, который выбирает необходимый внутренний экземпляр. Является эквивалентомinstantiateComponent()
из этой главы.ReactReconciler
— обёртка над методамиmountComponent()
,receiveComponent()
иunmountComponent()
. Вызывает соответствующую реализацию внутреннего экземпляра, а также включает некоторый общий код, который используют все реализации.ReactChildReconciler
реализует логику монтирования, обновления и демонтирования потомков, в соответствии со свойствомkey
.ReactMultiChild
обрабатывает очередь операций для вставки, удаления и перемещения потомков независимо от используемого рендерера.mount()
,receive()
, иunmount()
в репозитории React называютсяmountComponent()
,receiveComponent()
, иunmountComponent()
по историческим причинам, но, в качестве параметра, получают элемент.- Свойства во внутренних экземплярах начинаются с нижнего подчёркивания, например,
_currentElement
. Внутри репозитория являются открытыми и нигде не изменяются.
Будущие изменения
Согласователь Stack имеет ограничения, такие как синхронность и невозможность прерывать выполнение или разделять задачу на подзадачи. Сейчас идёт работа над new Fiber reconciler с совершенно другой архитектурой. В будущем, мы собираемся поменять согласователь Stack на Fiber, но в настоящий момент он не является полноценным аналогом.
Что дальше?
В следующей главе описаны принципы проектирования, которые мы используем в разработке React.