Главная / Статьи / Как браузер отрисовывает содержимое и какова роль React в этом процессе

Как браузер отрисовывает содержимое и какова роль React в этом процессе

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

1798 слов

Сначала забудем о React: как на самом деле браузер отрисовывает страницу?

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

HTML → DOM tree
CSS  → CSSOM tree
DOM + CSSOM → Render Tree
Render Tree → Layout → Paint → Composite → Screen

Каждый этап выполняет определённую функцию, и одних только названий недостаточно, чтобы это понять:

  • Древо DOM — браузер парсит ваш HTML и преобразует его в древо узлов. Это чистая структура: какие элементы находятся внутри других.
  • Древо CSSOM — та же логика применяется к стилям. Каждое правило CSS, которое вы написали, преобразуется в древо, которое может использоваться браузером.
  • Дерево отрисовки — браузер объединяет два дерева: он просматривает DOM, присоединяет соответствующие правила CSSOM к каждому узлу и игнорирует всё то, что на самом деле не будет видно (элемент с display: none по-прежнему находится в DOM, но исключается из дерева отрисовки).
  • Разметка — это этап вычислений. Исходя из каждого элемента и его рассчитанных стилей, браузер определяет точное положение и размер каждого из них.
  • Отрисовка — теперь браузер заполняет визуальные элементы: текст, цвета фона, границы, тени и так далее.
  • Сложение — когда на странице есть несколько слоев (что часто происходит по причинам производительности), браузер располагает их в правильном порядке, и именно этот окончательный результат попадает на ваш экран.
  • Вместе эти шаги образуют то, что называется критическим путем отрисовки, или CRP.

    Эта последовательность выполняется одинаково независимо от того, используется ли React, Vue, обычный jQuery или вовсе никаких библиотек. Отрисовка пикселей — это обязанность браузера, а не функция какой-либо фреймворка интерфейса.

    Так какое же место занимает React во всем этом?

    Это та часть, которая требует некоторого времени на обработку клика. React не заменяет критический путь отрисовки — он работает до него.

    Без React обновление интерфейса означает необходимость вручную находить соответствующий узел DOM и изменять его самостоятельно:

    const counterEl = document.getElementById('counter');
    counterEl.textContent = newCount;
    

    Это удобно для одного счётчика. Но представьте панель управления, где сорок отдельных значений могут меняться независимо — вам придётся вручную отслеживать и обновлять каждое из них. Именно для решения этой проблемы и существует React.

    В React один и тот же процесс обновления выглядит так:

    function Counter({ count }) {
      return <p>{count}</p>;
    }
    

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

    State changes → React figures out what changed → applies a small patch to the real DOM
                                                                  ↓
                              browser does its normal thing: Layout → Paint → Composite → Screen
    

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

    Императивный vs декларативный подход: смена мышления

    Это противопоставление объясняет, почему React был спроектирован именно так.

    Императивный код описывает каждый отдельный шаг:

    list.innerHTML = '';
    for (const item of items) {
      const li = document.createElement('li');
      li.textContent = item;
      list.appendChild(li);
    }
    

    Решение принимаете вы: удаляйте это, создавайте тот элемент и привязывайте его сюда.

    Декларативный код, в свою очередь, описывает желаемый результат:

    <ul>
      {items.map(item => <li key={item}>{item}</li>)}
    </ul>
    

    Вместо того чтобы указывать браузеру «создай li и вставь его», вы формулируете «учитывая этот массив, таким должен быть итоговый интерфейс». Что-то ещё должно преобразовать это описание в конкретные операции DOM — и понимание того, что это за «что-то», приходит далее.

    Что на самом деле означает «согласование»

    Эта концепция кажется более сложной, чем она есть на самом деле, если посмотреть на неё напрямую.

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

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

    1. Тип элемента изменился (например, `

    ` превратился в ``) — React даже не смотрит на дочерние элементы; он полностью удаляет старый узел и создаёт новый.

    1. Тип элемента остался прежним (`

    стал

    `) — React сохраняет существующий узел реального DOM и просто исправляет те части, которые изменились.

    Одна из ловушек, которая затрагивает практически всех, связана с списками. По умолчанию React сопоставляет элементы списка по их индексам — элемент 0 с элементом 0, элемент 1 с элементом 1 и так далее. Если вы вставляете новый элемент в начало списка без ключа, React предполагает, что все элементы ниже него тоже изменились.

    Вот почему всегда следует присваивать стабильный key элементам списка:

    {items.map(item => <li key={item.id}>{item.name}</li>)}
    

    Как только у элементов появляется ключ, React может определить, что «этот конкретный элемент просто изменил положение», вместо того чтобы предполагать, что весь список был пересоздан.

    После всех этих сравнений React получает небольшой, целенаправленный набор инструкций — вроде «обновить этот текстовый узел» или «вставить узел здесь» — и именно эти операции фактически применяются к реальному DOM.

    Разве это не то же самое, что делает Fiber?

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

    Основная идея — согласование состояния, а Fiber — это просто инструмент для его реализации.

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

    Fiber, введённый в React 16, заменил прежний механизм. Сама концепция согласования не изменилась, но теперь работа делится на небольшие части, которые можно приостановить, отбросить или возобновить позже. Если появляется что-то более срочное — например, ввод текста пользователем — React может прервать любую работу низшего приоритета, обработать срочное обновление, а затем вернуться к прерванной задаче.

    Поэтому некорректно утверждать, что Fiber заменил согласование. Более точно будет сказать, что старый движок, выполнявший согласование, был заменён на более мощный.

    Что же делает планировщик?

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

    К его обязанностям относятся:

    • Обновление рейтинга в зависимости от срочности — например, обработка нажатия клавиши в поле ввода считается срочной, тогда как обновление далекого фона — нет.
    • Заполнение промежутков между фреймами браузера для постепенного выполнения задач низшего приоритета, а также возврат к предыдущему состоянию перед необходимостью отрисовки следующего фрейма.
    • Включение возможностей React 18, таких как startTransition, при котором пометка обновления как несрочного позволяет механизму планирования отложить его выполнение до более позднего времени.

    Простая концептуальная модель связывает эти три идеи воедино:

    Reconciliation  → the algorithm (what changed?)
    Fiber           → the engine that makes that algorithm interruptible
    Scheduler       → the traffic controller deciding when to pause/resume Fiber's work
    

    Этап отрисовки против этапа выполнения

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

    Этап рендеринга — это тот момент, когда происходит фактическое сравнение изменений. React вызывает функции компонента, формирует новую структуру и сравнивает её с предыдущей. Ничто из этого пока не влияет на реальную страницу, поэтому этот этап можно безопасно приостановить, пропустить или запустить заново.

    Этап коммита — это момент, когда React наконец записывает изменения в реальный DOM и применяет вычисленные правки. Этот этап нельзя прерывать — он выполняется полностью за один раз без перерывов, поскольку частичное обновление интерфейса приведёт к нарушению визуального состояния страницы. Сразу после обновления DOM, но до того, как браузер отрисует экран, синхронно запускается useLayoutEffect. В отличие от него, useEffect выполняется чуть позже, после того как браузер уже завершил отрисовку.

    Действительно ли вам нужен React?

    Честно говоря, не всегда. Множество производственных веб-сайтов работают исключительно на HTML, CSS и простом JavaScript, и у них всё отлично функционирует.

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

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

    Однако для простой лендинг-страницы или в основном статического сайта более подходящим выбором будет обычный JavaScript. Использование Fiber, Scheduler и полного механизма синхронизации означало бы необходимость платить за ресурсы, которые вам на самом деле не нужны.

    React по своей сути не лучше JavaScript. Это набор инструментов, созданный для решения одной конкретной проблемы: синхронизации пользовательского интерфейса со состоянием, которое постоянно меняется в крупных масштабах среди команды. При меньших объемах работ обычный HTML, CSS и JS справляются с задачей прекрасно.

    Связанные статьи

  • Как избежать скрытых ошибок состояния, возникающих при изменении ссылок в JavaScript — Узнайте, почему изменение объектов и массивов через ссылки нарушает процесс перерисовки в React, почему операция spread копирует данные только на поверхностном уровне, и как безопасно создавать глубокие клоны состояния.
  • Практическое сравнение шаблонов структуры папок в React — Рассматриваются структуры проектов React, основанные на функциях, слоях и доменной модели, а также даются рекомендации по выбору оптимального варианта по мере роста приложения.