angular
April 2

Работа рендеринга в Angular

Кратко рассмотрим взаимодействие между различными слоями в приложении Angular и задачи, которые они выполняют. Начнем с общего обзора, а затем углубимся в детали.

Состояние приложения - это данные, которые управляют обновлениями на экране. Эти данные используются для обновления DOM, который затем браузер использует для отображения различных визуальных элементов, таких как текст, изображения, кнопки и т. д.

    import { Component } from '@angular/core';
 
    @Component({
    selector: 'app-root',
    template: `
    <div class="container">
      <div>
        <button (click)="fetchData()">Fetch data</button>
        {{status}}
        <div>title: {{title}}</div>
      </div>
    </div>
 
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {}


Обновление состояния вызывается асинхронными событиями, инициированными действиями пользователей.

Для того чтобы Angular понимал, когда может измениться состояние приложения, ему необходимо знать, когда происходят эти события. В этом помогает библиотека zone.js. Эта библиотека дополняет API платформы браузера, чтобы все асинхронные события в браузере можно было перехватывать. Angular привязывается к хукам, предоставляемым zone.js, и использует уведомления о событиях DOM, задержках, AJAX/XHR, Promise и т. д. как сигнал для запуска обнаружения изменений.

Предполагается, что большинство событий вызывают изменение состояния приложения, которое должно быть отражено в DOM и, соответственно, на экране, поэтому обнаружение изменений автоматически запускается после каждого асинхронного события. Angular не взаимодействует напрямую с zone.js, а использует NgZone, который является своего рода оберткой вокруг zone.js для ограничения области событий, о которых Angular должен получать уведомления (подробнее об этом в главе о zone.js).

Когда возникает связанное с зоной Angular (NgZone) событие, запускается обработчик, прикрепленный к нему. Наиболее часто это метод приложения, выставленный на экземпляре компонента. Бизнес-логика может обновить любые данные - общую модель/состояние приложения и/или состояние view компонента. Затем, когда Angular получает уведомление от NgZone о том, что нет ожидающих микрозадач, запускается алгоритм обнаружения изменений в Angular.

По умолчанию (т.е. если вы не используете стратегию обнаружения изменений onPush на каком-либо из ваших компонентов), каждый компонент в дереве проверяется один раз сверху вниз, в порядке обхода в глубину. Если вы находитесь в режиме разработки, обнаружение изменений запускается дважды из-за дополнительной проверки, которую Angular выполняет для обеспечения стабильного состояния. Оно выполняет проверку "грязных" данных для всех ваших привязок, используя объекты обнаружения изменений. Хуки жизненного цикла, запросы и привязки обрабатываются как часть обнаружения изменений.

В Angular каждый компонент представлен структурой данных, называемой LView. Здесь фреймворк отслеживает состояние (последнее значение) для всех шаблонных привязок, таких как {{service.a}}. Эти значения используются во время обнаружения изменений для "грязной" проверки, чтобы определить, нужно ли выполнить эффект обновление. Между экземпляром компонента и его соответствующим LView существует однозначное соответствие.

Angular инкапсулирует LView с помощью сервиса ChangeDetectorRef. Вы можете получить доступ к этому объекту, внедрив его в конструктор компонента. Для каждого компонента существует такой сервис обнаружения изменений, поэтому Angular поддерживает дерево сервисов обнаружения изменений, которое отображает дерево компонентов. Граф обнаружения изменений является направленным графом (однонаправленным потоком данных) и не может содержать циклов.

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

Процесс обновления экрана включает хорошо известные этапы рендеринга и отрисовки, разбитые на подэтапы:

1. [Рендеринг] Вычисление стилей. Это процесс определения, к каким элементам применяются CSS-правила на основе сопоставления селекторов, например, .headline или .nav > .nav__item. После того, как правила известны, они применяются, и окончательные стили для каждого элемента рассчитываются.

2. [Рендеринг] Разметка. Когда браузер узнает, к каким элементам применяются правила, он начинает вычислять, сколько места занимает каждый элемент и где он находится на экране. Модель разметки веба означает, что один элемент может влиять на другие, например, ширина элемента <body> обычно влияет на ширину его дочерних элементов и так далее вверх и вниз по дереву, поэтому процесс может быть довольно сложным для браузера.

3. [Отрисовка] Отрисовка. Отрисовка - это процесс заполнения пикселей. Он включает рисование текста, цветов, изображений, границ и теней, фактически все визуальные части элементов. Браузер рисует страницу по слоям.

4. [Отрисовка] Компоновка. Поскольку части страницы были нарисованы на нескольких слоях, их необходимо нарисовать на экране в правильном порядке, чтобы страница отображалась корректно. Это особенно важно для элементов, которые перекрывают друг друга, поскольку ошибка может привести к тому, что один элемент будет неправильно отображаться поверх другого.

Браузер не обязательно выполняет каждый подэтап конвейера на каждом кадре. Некоторые изменения могут затрагивать только «только для отрисовки» свойства, которые не влияют на разметку страницы, например, фоновое изображение, цвет текста или тени, в этом случае браузер пропускает разметку, но все равно выполняет отрисовку. Самый быстрый и желательный конвейер - это тот, который пропускает все части, кроме компоновки, что обычно бывает в случае анимации или прокрутки.

Итак, мы определили что рендеринг разбит на 4 составляющие:

  • Браузер: рендеринг (стиль, компоновка), рисование (отрисовка, компоновка)
  • zone.js: патч API браузера, управление жизненным циклом задач и уведомления
  • Angular: обнаружение изменений, обновление DOM
  • Приложение: бизнес-логика, обновление состояния

Теперь рассмотрим теорию на конкретном примере. Допустим, у нас есть такой пример:

import { Component } from '@angular/core';
 
@Component({
  selector: 'app-root',
  template: `
    <div class="container">
      <div>
        <button (click)="fetchData()">Fetch data</button>
        {{status}}
        <div>title: {{title}}</div>
      </div>
    </div>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'learn-angular';
  status = null;
 
  fetchData() {
    this.status = 'Loading...';
    const req = new XMLHttpRequest();
 
    req.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);
 
    req.onreadystatechange = () => {
      if (req.readyState === XMLHttpRequest.DONE && req.status === 200) {
        const todo = JSON.parse(req.responseText);
        this.title = todo.title;
        this.status = null;
      }
    };
 
    req.send(null);
  }
}

Работающий пример есть на stackblitz

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

Итак, вот подробный обзор того, что происходит:

  1. Браузер обнаруживает клик и добавляет обработчик события в очередь задач
  2. Zone.js запускает zoneAwareCallback, который в свою очередь вызывает invokeTask внутри Angular
  3. Angular выполняет обёртку вокруг обратного вызова из шаблона компонента; обёртка помечает представление и все его предков как изменённые
  4. Angular выполняет метод fetchData через обработчик события, зарегистрированный в функции шаблона компонента
  5. Логика приложения внутри метода fetchData запускает сетевой запрос
  6. Запрос перехватывается zone.js, который планирует макрозадачу в браузере
  7. Событие обрабатывается, zone.js вызывает onMicrotaskEmpty через NgZone
  8. Angular реагирует, запуская проверку изменений по всему приложению через ApplicationRef.tick
  9. changeDetection обновляет DOM и запускает другие побочные эффекты
  10. Браузер рендерит обновления на экране и переходит к выполнению макрозадачи, связанной с сетевым запросом. Когда приходит сетевой запрос, жизненный цикл повторяется, запуская слушатель onreadystatechange вместо события click. Фаза выполнения JavaScript заканчивается запуском changeDetection. После этого браузер снова проходит через конвейер рендеринга.

Представим это на диаграмме:

Описанное выше можно увидеть так же при помощи DevTools профайлера:

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

Если раскрыть дерево событий, то получим:

На изображении выше ответственность zone, angular и браузера выделана цветами:

  • zone.js - желтый
  • angular - фиолетовый
  • браузер - зеленый

Как только Angular завершает цикл changeDetection, браузер проходит через свой конвейер - стили, компоновку, рисование и, в некоторых случаях, компоновку:

Рассмотрим особенный случай, связанный с использованием setTimeout для завершения обнаружения изменений:

setTimeout и changeDetection

Часто можно встретить использование setTimeout в логике приложения. Предположим, требуется показать элемент управления вводом и немедленно установить на него фокус при нажатии пользователем на кнопку.

Вот как мы могли бы это сделать:

import { Component } from '@angular/core';
 
@Component({
  selector: 'b-cmp',
  template: `
    Add a new todo:
    <button (click)="showSearchInput(ctrl)">Add</button>
    <div [hidden]="searchInputHidden">
      <input #ctrl />
    </div>
  `,
})
export class B {
  searchInputHidden = true;
 
  showSearchInput(ctrl) {
    this.searchInputHidden = false;
 
    setTimeout(function () {
      ctrl.focus();
    });
  }
}

Сам код довольно простой. Приложение реагирует на ввод пользователя, запускает обнаружение изменений и обновляет состояние компонента. Angular запускает обнаружение изменений и обновляет DOM.

Но при чём здесь setTimeOut?

Таймаут нужен, потому что вы не можете сфокусироваться на элементе, который все еще скрыт. Пока Angular не запустит changeDetection (что произойдет после завершения выполнения метода showSearchInput()), свойство hidden в DOM не будет обновлено, даже если вы установили searchInputHidden в false в своем методе.

Используя setTimeout() с значением 0 (или без значения, что по умолчанию составляет около 4 мс), мы планируем макрозадачу, которая будет выполняться после того, как Angular получит возможность запустить changeDetection и обновить значение свойства hidden.

Обратите внимание, что после завершения выполнения setTimeout() снова будет запущено обнаружение изменений (потому что Angular слушает все вызовы setTimeout(), которые выполняются в зоне Angular). Поскольку единственное, что мы изменяем в нашей асинхронной функции обратного вызова, это фокус, приложение можно оптимизировать и запускать нашу функцию обратного вызова вне зоны Angular, чтобы избежать дополнительного цикла changeDetection:

import { Component } from '@angular/core';
 
@Component({
  selector: 'b-cmp',
  template: `
    Add a new todo:
    <button (click)="showSearchInput(ctrl)">Add</button>
    <div [hidden]="searchInputHidden">
      <input #ctrl />
    </div>
  `,
})
export class B {
  constructor(private _ngZone: NgZone) {}

  searchInputHidden = true;
 
  private showSearchInput(ctrl) {
    this.searchInputHidden = false;
 
    this._ngZone.runOutsideAngular(() => {
      setTimeout(() => ctrl.focus());
    });
  }
}

Это вольный перевод материала