Под капотом у React. Пишем свою реализацию с нуля




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

image

Данная статья является переводом React Internals, Part One: basic rendering

На самом деле это первая статья из пяти


  1. Основы рендеринга < — мы здесь
  2. ComponentWillMount и componentDidMount
  3. Обновление
  4. setState
  5. Транзакции

Материал создавался, когда актуальным был React 15.3, в частности использование ReactDOM и stack reconciler. React 16 и выше имеет некоторые изменения. Тем не менее, этот материал остаётся актуальным, так как он даёт общее представление о том, что происходит «под капотом».

Часть 1. Основы рендеринга


Элементы и компоненты


В React есть три типа сущностей: нативный DOM элемент, виртуальный React элемент и компонент.

Нативные DOM элементы


Это и есть DOM элементы, которые браузер использует для создания веб-страницы, например, div, span, h1. React создаёт их, вызывая document.createElement(), и взаимодействует со страницей, используя методы браузерного DOM API, такие как element.insertBefore(), element.nodeValue и другие.

Виртуальный React элемент


Виртуальный React элемент (часто называется просто «элемент») — это javascript объект, который содержит нужные свойства для того, чтобы создать или обновить нативный DOM элемент или дерево таких элементов. На основе виртуального React элемента создаются нативные DOM элементы, такие как div, span, h1 и другие. Можно сказать, что виртуальный React элемент является экземпляром пользовательского составного компонента (user defined composite component), подробнее об этом ниже.

Компонент


Компонент — достаточно общий термин в React. Компонентами являются сущности, с которыми React делает различные манипуляции. Разные компоненты служат разным целям. Например, ReactDomComponent из библиотеки ReactDom отвечает за связывание между React элементами и соответствующим им нативным DOM элементам.

Пользовательские составные компоненты


Скорее всего вы уже сталкивались с этим видом компонентов. Когда вы вызываете React.createClass() или используетe ES6 классы через extend React.Component, вы создаёте пользовательский составной компонент. Такой компонент имеет методы жизненного цикла (lifecycle methods), такие как componentWillMount, shouldComponentUpdate и другие. Мы можем переопределять их, чтобы добавлять какую-то логику. Кроме того, создаются и другие методы, такие как mountComponent, receiveComponent. Эти методы используются только самим React для его внутренних целей, мы с ними никак не взаимодействуем.

ZanudaMode=on
На самом деле созданные пользователем компоненты изначально не полноценны. React оборачивает их в ReactCompositeComponentWrapper, который добавляет нашим компонентам все методы жизненного цикла, после чего React может ими управлять (вставлять, обновлять и др.).

React декларативный


Когда дело доходит до пользовательских компонентов, наша задача определить классы этих компонентов, но мы не создаём экземпляры этих классов. Их создаёт React, когда нужно.

Так же мы не создаём элементы явно, используя императивный стиль, вместо этого мы пишем в декларативном стиле, используя JSX:

class MyComponent extends React.Component {
    render() {
        return <div>hello</div>;
    }
}

Этот код с JSX разметкой траслируется компилятором в следующий:

class MyComponent extends React.Component {
    render() {
        return React.createElement('div', null, 'hello');
    }
}

То есть по сути превращается в императивную конструкцию создания элемента через явный вызов React.createElement(). Но эта конструкция находится внутри метода render(), который мы явно не вызываем, React сам вызовет этот метод, когда будет нужно. Поэтому воспринимать React стоит именно как декларативный: мы описываем, что хотим получить, а React определяет, как это сделать.

Напишем свой маленький React


Получив необходимый технический базис, начнем создавать собственную имплементацию React. Это будет очень упрощенная версия, назовем её Feact.

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

Feact.render(<h1>hello world</h1>, document.getElementById('root'));

Для начала сделаем отступление о JSX. Это именно «отступление», потому что парсинг JSX — это отдельная большая тема, которую мы опустим в рамках нашей имплементации Feact. Если бы мы имели дело с обработанным JSX, мы бы увидели следующий код:

Feact.render(
    Feact.createElement('h1', null, 'hello world'),
    document.getElementById('root')
);

То есть мы используем Feact.createElement вместо JSX. Вот и реализуем этот метод:

const Feact = {
    createElement(type, props, children) {
        const element = {
            type,
            props: props || {}
        };

        if (children) {
            element.props.children = children;
        }

        return element;
    }
};

Возвращаемый элемент — это простой объект, представляющий то, что мы хотим отрендерить.

Что делает Feact.render()?


Вызывая Feact.render(), мы передаём два параметра: что мы хотим отрендерить и где. Это начальная точка любого React приложения. Напишем реализацию метода render() для Feact:

const Feact = {
    createElement() { /* без изменений */ },

    render(element, container) {
        const componentInstance = new FeactDOMComponent(element);
        return componentInstance.mountComponent(container);
    }
};

По завершению работы render() мы получаем готовую веб-страницу. Созданием DOM элементов занимается FeactDOMComponent. Напишем его реализацию:

class FeactDOMComponent {
    constructor(element) {
        this._currentElement = element;
    }

    mountComponent(container) {
        const domElement =
            document.createElement(this._currentElement.type);
        const text = this._currentElement.props.children;
        const textNode = document.createTextNode(text);
        domElement.appendChild(textNode);

        container.appendChild(domElement);

        this._hostNode = domElement;
        return domElement;
    }
}

Метод mountComponent создаёт DOM элемент и сохраняет его в this._hostNode. Мы не будем это использовать сейчас, но это вернёмся к этому в следующих частях.

Текущую версию приложения можно посмотреть в fiddle.

Буквально 40 строк кода хватило, чтобы сделать примитивнейшую реализацию React. Созданный нами Feact вряд ли завоюет мир, но он неплохо отражает суть происходящего под капотом у React.

Добавление пользовательских компонентов


Наш Feact должен иметь возможность рендерить не только имеющиеся в HTML элементы (div, span и пр.), но и пользовательские компоненты (user defined composite component):
Описанный ранее метод Feact.createElement() на текущий момент нас устраивает, потому я не буду повторять его в листинге кода
const Feact = {
    createClass(spec) {
        function Constructor(props) {
            this.props = props;
        }

        Constructor.prototype.render = spec.render;

        return Constructor;
    }, 

    render(element, container) {
        // текущая реализация метода не может
        // обрабатывать пользовательские компоненты,
        // перепишем её позже
    }
};

const MyTitle = Feact.createClass({
    render() {
        return Feact.createElement('h1', null, this.props.message);
    }
};

Feact.render({
    Feact.createElement(MyTitle, { message: 'hey there Feact' }),
    document.getElementById('root')
);

Напомню, если бы JSX был доступен, вызов метода render() выглядел бы таким образом:

Feact.render(
    <MyTitle message="hey there Feact" />,
    document.getElementById('root')
);

Мы передали класс пользовательского компонента в createElement. Виртуальный React элемент может представлять или обычный DOM элемент, или пользовательский компонент. Будем их различать следующим образом: если передаём строковый тип, то это DOM элемент; если функция, то этот элемент представляет пользовательский компонент.

Улучшение Feact.render()


Если вы внимательно посмотрите на код на текущий момент, то вы увидите, что Feact.render() не может обрабатывать пользовательские компоненты. Исправим это:

Feact = {
    render(element, container) {
        const componentInstance =
            new FeactCompositeComponentWrapper(element);

        return componentInstance.mountComponent(container);
    }
}

class FeactCompositeComponentWrapper {
    constructor(element) {
        this._currentElement = element;
    }

    mountComponent(container) {
        const Component = this._currentElement.type;
        const componentInstance = new Component(this._currentElement.props);
        const element = componentInstance.render();

        const domComponentInstance = new FeactDOMComponent(element);
        return domComponentInstance.mountComponent(container);
    }
}

Мы создали обертку для передаваемого элемента. Внутри обёртки мы создаём экземпляр класса пользовательского компонента и вызываем его метод componentInstance.render(). Результат этого метода можно передать в FeactDOMComponent, где будут созданы соответствующие DOM элементы.

Теперь мы можем создавать и рендерить пользовательские компоненты. Feact будет создавать DOM узлы на основании пользовательских компонентов, и менять их в зависимости от свойств (props) наших пользовательских компонентов. Это значительное улучшение нашего Feact.
Обратите внимание, что FeactCompositeComponentWrapper напрямую создаёт FeactDOMComponent. Такая тесная связь — это плохо. Мы исправим это позже. Если бы в React существовала такая же тесная связь, то можно было бы создавать только web-приложения. Добавление дополнительного слоя ReactCompositeComponentWrapper позволяет разделить логику React по управлению виртуальными элементами и итоговое отображение нативных элементов, что позволяет использовать React не только при создании web-приложений, но и, например, React Native для мобильных.

Улучшение пользовательских компонентов


Созданные пользовательские компоненты могут возвращать только нативные DOM элементы, если попробовать возвратить другие пользовательские компоненты, мы получим ошибку. Исправим этот недостаток. Представим, что мы бы хотели выполнить следующий код без ошибок:

const MyMessage = Feact.createClass({
    render() {
        if (this.props.asTitle) {
            return Feact.createElement(MyTitle, {
                message: this.props.message
            });
        } else {
            return Feact.createElement('p', null, this.props.message);
        }
    }
}

Метод render() пользовательского компонента может вернуть или нативный DOM элемент, или другой пользовательский компонент. Если свойство asTitle истинно, то FeactCompositeComponentWrapper вернет пользовательский компонент для FeactDOMComponent, где произойдёт ошибка. Исправим FeactCompositeComponentWrapper:

class FeactCompositeComponentWrapper {
    constructor(element) {
        this._currentElement = element;
    }

    mountComponent(container) {
        const Component = this._currentElement.type;
        const componentInstance =
            new Component(this._currentElement.props);
        let element = componentInstance.render();

        while (typeof element.type === 'function') {
            element = (new element.type(element.props)).render();
        }

        const domComponentInstance = new FeactDOMComponent(element);
        domComponentInstance.mountComponent(container);
    }
}

По правде говоря, мы сейчас сделали костыль, чтобы удовлетворить текущие нужды. Вызов метода render будет возвращать дочерние компоненты до тех пор, не вернет нативный DOM элемент. Это плохо, потому что такие дочерние компоненты не будут принимать участия в жизненном цикле. Например, в таком случае мы не сможем реализовать вызов componentWillMount. Мы исправим это позже.

И снова исправляем Feact.render()


Первая версия Feact.render() могла обрабатывать только нативные DOM элементы. Сейчас корректно обрабатываются только пользовательские компоненты без поддержки нативных. Нужно же обрабатывать оба случая. Можно написать фабрику, которая будет создавать компонент в зависимости от типа переданного элемента, но в React выбрали другой способ: просто обернуть любой входящий компонент в другой компонент:

const TopLevelWrapper = function(props) {
    this.props = props;
};

TopLevelWrapper.prototype.render = function() {
    return this.props;
};

const Feact = {
    render(element, container) {
        const wrapperElement =
            this.createElement(TopLevelWrapper, element);

        const componentInstance =
            new FeactCompositeComponentWrapper(wrapperElement);

        // без изменений
    }
};

TopLevelWrapper это по сути пользовательский компонент. Он так же может быть определен через вызов Feact.createClass(). Его метод render просто возвращает переданный в него элемент. Теперь каждый элемент оборачивается TopLevelWrapper, и FeactCompositeComponentWrapper будет всегда получать на вход пользовательский компонент.

Заключение первой части


Мы реализовали Feact, который может рендерить компоненты. Созданный код показывает базовые концепции рендеринга. Настоящий рендеринг в React гораздо сложнее, и он охватывает события, фокус, скролл окна, производительность и т.д.

Итоговый jsfiddle первой части.
  • +20
  • 10,1k
  • 7
Поделиться публикацией

Комментарии 7

    +1
    Ну круто же. Отличная тема. Меньше магии, больше понимания!
      +1

      Виртуальный dom, все новое хорошо забытое старое. Был backbase с его bdom, был забытый уже нынче xul от мозиллы, потом шэдоу дом, теперь это. Тогда от него отказались потому что нахрен никому не нужен оказался, сейчас все пошло по второму кругу. Пора уже начинать учебники истории по веб разработке писать и заставлять их изучать всех тех кто в очередной раз пытается изобрести одно и то же по второму разу.

        0

        То, что раньше что-то было ненужно, совсем не означает, что это будет ненужно сегодня или в будущем.

          0

          Но анализ причин смерти технологии позволяет её не переизобретать, а улучшать и экономить тысячи человекочасов.

        0
        Несколько лет назад для своего доклада я написал свою реализацию реакта в 60 строк под названием Act. Там в примерах даже ToDo List примитивный есть.
          –1
          Разве Virtual DOM не устарел?

          Вот цитата из официальной документации

          Как показывают Svelte, Angular, Glimmer и другие технологии, компиляция компонентов перед их исполнением имеет огромный потенциал в будущем. Особенно, если шаблоны не накладывают ограничений. Недавно мы экспериментировали со свёртыванием компонентов с использованием Prepack и увидели первые многообещающие результаты.


            0
            Конечно устарел, зачем разбираться в том с чем работаешь каждый день если оно «устарело».

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое