angular
March 22

Оптимизация изображений в Angular

Команды Angular и Chrome Aurora проработали директиву для изображений, которая поможет разработчикам оптимизировать контент.

Инициатива Chrome Aurora и Angular по созданию Директивы Изображения в Angular направлена на улучшение загрузки изображений в вебе. Об этом подробно написано в блоге Chrome Aurora. Загрузка изображений значительно влияет на основные показатели сайта, которые используются для оценки производительности загрузки страницы. Директива NgOptimizedImage направлена на улучшение этих показателей, обеспечивая эффективную загрузку изображений.

NgOptimizedImage — это директива для Angular, которая начинает работать автоматически после того, как вы импортировали ее и заменили атрибут src в теге <img> на ngSrc, вот так:

import { NgOptimizedImage } from '@angular/common';
import { Component } from '@angular/core';
 
@Component({
  selector: 'ng-conf-image',
  template: `
    <!-- Will not use the NgOptimizedImageDirective -->
    <img src="angie.jpg" />
    <!-- Will use the NgOptimizedImageDirective -->
    <img ngSrc="angie.jpg" />
  `,
  imports: [NgOptimizedImage],
})
class NgConfImage {}

Даже если весь функционал директивы избыточен, все равно стоит её попробовать. В режиме разработки в консоль будут выводиться предупреждения с предложениями, которые легко добавить шаблон.

Web Vitals

Основные показатели качества веб-страниц (Core Web Vitals, сокр. CWV), о которых мы будем говорить в контексте функциональности директивы изображений, включают: LCP, FID, CLS, TTFB, FCP, TBT и TTI. Каждый из них измеряет определенный аспект производительности загрузки веб-приложения. Некоторые из этих метрик, возможно, вам уже знакомы, например, Largest Contentful Paint (LCP), а некоторые могут быть более узкоспециализированными и измерять что-то, о чем обычно не говорят, как Time to First Byte (TTFB).

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

  1. Largest Contentful Paint (LCP) - Измеряет время, необходимое для отображения наибольшего изображения или текстового блока, видимого в области просмотра относительно момента начала загрузки страницы. Таким образом, если на сайте большое изображение расположено прямо в верхней части страницы или выше складки, и оно настроено некорректно, это может существенно негативно повлиять на показатель LCP.
  2. First Input Delay (FID) - Измеряет, насколько быстро браузер реагирует на действия пользователя. На FID могут влиять: сложный JS, активность основного потока (JS однопоточен, поэтому избыточная активность основного потока будет задерживать действия пользователя; изображения загружаются асинхронно, и современные браузеры стараются оптимизировать отрисовку изображений для минимизации фризов для пользователя) и задержки в сети. Изображения обычно не оказывают прямого воздействия на FID.
  3. Cumulative Layout Shift (CLS) - Измеряет, насколько элементы на странице сдвигают макет во время их отрисовки. Размер изображения (в байтах) не влияет напрямую на CLS, но если они загружены без указания размеров или заменены изображениями другого размера (в случае динамических плейсхолдеров), это может негативно сказаться на показателе CLS.
  4. Time to First Byte (TTFB) - Время, необходимое браузеру для получения первого байта веб-страницы. Обычно это не связано с изображениями, поскольку первый байт, получаемый браузером пользователя, обычно является index.html, который предоставляется веб-сервером или CDN (до начала любой отрисовки).
  5. First Contentful Paint (FCP) - Изображения могут напрямую влиять на FCP сайта. FCP измеряет время, необходимое для отрисовки первого элемента содержимого. Если это большое изображение, то это приведет к нежелательному показателю FCP.
  6. Total Blocking Time (TBT) - Изображения могут косвенно влиять на TBT в некоторых случаях, как обсуждалось выше в пункте FID и при отрисовке изображений после их загрузки.
  7. Time to Interactive (TTI) - Подобно TBT, TTI может быть косвенно затронут размером изображений, когда браузер их отрисовывает.

Мы сосредоточимся на следующих ключевых показателях веб-страницы: LCP, CLS и FCP, так как они больше всего зависят от изображений. Мы рассмотрим, как директива изображения Angular помогает с этими метриками и какие внутренние механизмы она использует для их достижения.

Чтобы понять, как директива изображения влияет на каждый из ключевых показателей веб-страницы и какие инструменты и настройки мы имеем для настройки загрузки изображений, мы рассмотрим каждое из свойств, поддерживаемых директивой изображения. Некоторые из этих свойств являются встроенными для тега <img> и встроены в браузер, а некоторые добавляются как @Inputs при импорте NgOptimizedImage. Каждое свойство контролирует различные аспекты процесса загрузки изображения, от сетевых соединений до размеров. Они группируются по секциям Priority и Image Sizing, каждая из которых имеет свои подразделы, более детально рассматривающие конкретные атрибуты.

Priority

Приоритет загрузки и получения данных

Первым на очереди priority. Атрибут priorityявляется первой линией обороны для защиты LCP (и, возможно, FCP). Давайте возьмем URL изображения https://images.unsplash.com/photo-1417325384643-aac51acc9e5d в качестве примера. По умолчанию, если мы рендерим шаблон, например, следующим образом:

<img
  ngSrc="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
  fill
/>


Результирующий тег <img> в DOM будет выглядеть примерно так (для краткости опущены дополнительные свойства):

<img
  loading="lazy"
  fetchpriority="auto"
  src="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
/>

Обратите внимание, что изображение имеет установленные атрибуты loading="lazy" и fetchpriority="auto". Если это изображение LCP, мы не хотим, чтобы браузер загружал его лениво (поскольку мы знаем, что оно будет необходимо для загрузки страницы сразу же). fetchpriority также установлен по умолчанию в auto, так как это браузерное значение по умолчанию, если альтернатива не указана. Это сообщает браузеру "загрузи его максимально оптимизированно", но если мы знаем, что нам нужно загрузить его немедленно, мы должны явно указать это. Можно так же установить атрибут priority, и тогда директива автоматически установит оптимальные значения, чтобы изображение было загружено как можно быстрее.


Укажем браузеру, что изображение должно иметь приоритет при загрузке:

<img
  ngSrc="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
  fill
  priority
/>

в DOM дереве это будет выглядеть следующим образом:

<img
  priority=""
  loading="eager"
  fetchpriority="high"
  src="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
/>

Теперь мы видим, что loading и fetchpriority установлены как eager и high соответственно. Это говорит браузеру: «загружай это сразу же, независимо от того, где это находится на странице, и загружай это перед ресурсами lowприоритета». Это гарантирует, что изображение не конкурирует с ненужными ресурсами (или теми, которые менее важны), что может негативно сказаться на LCP.

Предзагрузка

По умолчанию, при использовании серверного рендеринга (SSR) Angular автоматически добавит то, что называется preload в index.html. Это тег <link>, добавленный в элемент <head> собранного приложения, который сообщает браузеру, что это изображение должно быть загружено раньше, поскольку оно будет нужно в начале жизненного цикла приложения.

Несмотря на название, он ничего не загружает; он просто планирует загрузку ресурса, указанного в href, с более высоким приоритетом для кэширования.

Тег preload будет выглядеть примерно так, как показано ниже, и, как уже упоминалось, будет расположен в конце тега <head>:

<link
  as="image"
  href="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
  rel="preload"
  fetchpriority="high"
  imagesizes="100vw"
/>

Обратите внимание на свойство as="image". Это дополнительный контекст, который Angular добавляет, чтобы помочь браузеру понять, какой ресурс загружается, и, таким образом, позволить браузеру оптимизировать процесс загрузки.

Еще одно улучшение производительности, которое Angular предложит внести (но не сделает автоматически), - это добавитьpreconnect в заголовок, чтобы браузер мог заранее установить соединение с сервером, где хранится изображение. Это позволяет браузеру сэкономить время на установлении соединения.

Так выглядят советы от Angular по оптимизации изображений

Таким образом, добавив preconnect атрибут мы гарантируем, что:

1. Браузер заранее установит соединение с сервером, где хранится изображение.

2. Изображение будет загружено и кэшировано заранее, еще до того, как оно понадобится приложению.

3. Изображение будет загружено раньше других менее важных элементов;

В некоторых случаях может понадобиться игнорировать предупреждения о отсутствии ссылок preconnect для определенных источников. Это можно сделать, предоставив значение для DI токена PRECONNECT_CHECK_BLOCKLIST. Значение этого токена имеет формат строки или массива строк. Например:
{
provide: PRECONNECT_CHECK_BLOCKLIST,
useValue: https://images.unsplash.com'
}

В теории все понятно, но что с точки зрения практики?

Так выглядит загрузка после оптимизации
Без оптимизации мы бы потеряли порядка 100мс

Получилось сэкономить чуть больше 100мс. Конечно, стоит отметить, что это простое приложение, запущенное на настольном компьютере. Эта разница станет более заметной, когда приложение будет загружать множество других устройств, и возможно, будет работать на более медленной сети, например, 2G/3G.

Размеры изображений

Ширина, высота и заполнение

Для предотвращения сдвигов макета (CLS) директива обязывает вас либо предоставить widthи height, либо использовать атрибут fill, чтобы позволить ему заполнить родительский контейнер.

Использование непосредственно ширины и высоты сообщит браузеру, сколько места будет занимать изображение, что позволит браузеру отрисовать страницу и предотвратить сдвиг макета, вызывающий отрицательные значения CLS.

<img
  ngSrc="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
  width="100"
  height="100"
/>

Настройка ширины и высоты различна, если мы работаем с фиксированным и адаптивным изображением. Адаптивные изображения - это те, которые вы хотите масштабировать относительно размера окна. При настройке изображения на адаптивность важно установить атрибут sizes изображения. Директива с этим тоже поможет.

В качестве альтернативы, при использовании fill, изображение полностью заполняет родительский контейнер. В этом примере мы задаем размер div таким образом, чтобы он был 100 пикселей на 100 пикселей.

<div style="width: 100px; height: 100px;">
  <img
    ngSrc="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
    fill
  />
</div>

fill атрибут особенно удобен, когда нужно отображать фоновые изображения. Им можно управлять с помощью свойства CSS object-fit на теге <img>. Из документации Angular:

Вы можете использовать свойство CSS object-fit для изменения способа заполнения контейнера. Если вы стилизуете изображение с помощью object-fit: "contain", изображение сохранит свое соотношение сторон и будет "вписано в рамку" для соответствия элементу. Если вы установите object-fit: "cover", элемент сохранит свое соотношение сторон, полностью заполнит элемент, и некоторый контент может быть "обрезан".
Object-fit не применен. Изображение просто растягивается по контейнеру
Object-fit: contain. Изображение вписывается в контейнер, не обрезая ничего по оси X или Y.
Object-fit: cover. Изображение обрезается (либо по оси X, либо по оси Y, и подгоняется под контейнер.

Все вышеперечисленное изменяет только размер, с которым отрисовывается изображение, а не размер загружаемого изображения. Возможно, вы задаетесь вопросом: "Что если я храню высококачественные копии всех своих изображений на CDN, но мне нужны только их меньшие версии в веб-приложении? Нужно ли мне все равно загружать все 4 МБ изображения, чтобы отобразить его в элементе 100 x 100 пикселей?". Для этого тоже есть решение.

Размеры и атрибут srcset

Выбор правильного размера изображений может оказать наибольшее влияние на скорость их загрузки. Предоставляя атрибут sizes в сочетании с атрибутом ngSrcset в теге <img>, можно предупредить Angular, какой размер изображения использовать в каких случаях.

Браузеры имеют атрибут srcset. Это расширение атрибута src и созданное специально для объявления различных размеров изображений для одного тега <img>, из которых браузер выберет наиболее подходящее с точки зрения эффективности загрузки изображение. Объявляя значение атрибута sizes, директива автоматически сгенерирует srcset , а так же установит его на <img>, так что при рендеринге изображения в DOM браузер может выбрать и показать оптимизированный размер изображения.

<img
  src="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d"
  srcset="
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?w=1080 1080w,
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?w=400   400w,
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?w=200   200w
  "
  sizes="(min-width: 50vw) 1080px,
           ((min-width: 20vw) and (max-width: 50vw)) 400px,
           (max-width: 20vw) 200px"
  alt="A treed park"
/>

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

В этом примере довольно легко определить, какой размер соответствует какому srcset, так как значение px соответствует свойству w (ширина) каждого изображения. Однако так может быть не всегда. Должно быть строгое соответствие между размерами и URL-адресами изображений в srcset.

Angular предоставляет набор стандартных адаптивных брекпоинтов [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840], которые можно переопределить, предоставив IMAGE_CONFIG в корне приложения с массивом чисел[]. Подробнее в документации

Самый эффективный способ использования sizes и srcset - это использование загрузчика изображений. Это отвечает на вопрос "Как я могу подать правильный размер изображения с CDN?".

"Загрузчик изображений" - это функция, предоставляемая Angular, которая форматирует URL-адреса изображений в определенном формате, чтобы они могли быть обработаны максимально эффективно. Используя загрузчик изображений и атрибут srcset, Angular автоматически создаст URL-адреса изображений для разных адаптивных размеров. К счастью, Angular предоставляет множество предварительно созданных загрузчиков изображений для популярных CDN изображений, таких как: Cloudflare Image Resizing, Cloudinary, ImageKit и Imgix. Для этой демонстрации мы использовали изображения Unsplash, которые используют Imgix в качестве сервиса для их динамического изменения размера.

Ранее, когда мы загружали изображение из Unsplash, его размер составлял около 3,7 МБ, что слишком много для загрузки для каждого пользователя. Для оптимизации можно использовать встроенный загрузчик изображений Imgix для уменьшения размера изображения в зависимости от размера экрана.

Первый шаг - добавить провайдер provideImgixLoader в массив провайдеров в AppModule или appConfig

import { ApplicationConfig } from '@angular/core';
import { appRoutes } from './app.routes';
import { provideImgixLoader } from '@angular/common';
 
export const appConfig: ApplicationConfig = {
  providers: [
    // ...other providers
    // We are using Unsplash here but if you have your own Imgix distribution you should put your own origin here.
    provideImgixLoader('https://images.unsplash.com'),
  ],
};

Таким образом мы зарегистрируем загрузчик Imgix в приложении, так что каждый раз, когда Angular обнаружит использование ngSrc, он будет обрабатывать изображение через загрузчик изображений (со всеми другими настроенными свойствами, такими как sizes и ngSrcset), чтобы сформировать URL-адреса, которые браузер обнаружит в теге <img>.

Далее остается только использовать директиву:

import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
 
@Component({
  selector: 'ngconf-src-v-ngsrc',
  template: `
    <div style="height: 100%; width: 100%;">
      <!-- Depending on the image loader you are using the value of ngSrc may differ but usually it is the "name" of the image you want to load from your CDN. -->
      <img
        [ngSrc]="'photo-1417325384643-aac51acc9e5d'"
        style="object-fit: cover;"
        fill
        priority
        ngSrcset="300w, 800w, 1500w"
      />
    </div>
  `,
  imports: [NgOptimizedImage],
  standalone: true,
})
export class PriorityNgSrcComponent {}

Теперь Angular будет генерировать srcset на основе размера окна. В небольших экранах будет запрашиваться изображение с шириной 300, на средних — 800, а на больших — 1500 от CDN Imgix (Unsplash)

Ниже мы можем увидеть результат рендеринга в HTML.

<img
  fill=""
  priority=""
  style="object-fit: cover;
  position: absolute; width: 100%; height: 100%; inset: 0px;"
  loading="eager"
  fetchpriority="high"
  src="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?auto=format"
  srcset="
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?auto=format&amp;w=300   300w,
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?auto=format&amp;w=800   800w,
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?auto=format&amp;w=1500 1500w
  "
  sizes="100vw"
/>

Изменяя настройки окна в инструментах разработчика Chrome, мы можем увидеть различные URL-адреса, а также сгенерированные srcset и теги изображений:

Для небольшого окна картинка получилась всего 73Кб
Для окна побольше размер увеличился до 508Кб
И для самых больших экранов размер не превышает 1600кб. В оригинале картинка занимает 3,4мб.

Свой загрузчик

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

Создадим свой загрузчик для Unsplash, который также позволит нам настраивать качество сжатия вместе с шириной и высотой изображения. Для начала создадим простую функцию, принимающую некоторые конфигурации и возвращающую строку:

import { ImageLoader, ImageLoaderConfig } from '@angular/common';
 
// The origin of the CDN we are going to use to pull images from.
const base = 'https://images.unsplash.com';
 
export const myCustomLoader: ImageLoader = (config: ImageLoaderConfig) => {
  // Join the value that the user put in the `ngSrc` attribute and the CDN base into a single URL.
  const url = new URL(config.src, base);
 
  if (config.width) {
    url.searchParams.set('w', config.width.toString());
  }
 
  if (config.loaderParams?.['compression']) {
    url.searchParams.set('q', config.loaderParams['compression']);
  }
 
  return url.toString();
};

Разберем, что происходит здесь:

  • Объединяем src изображения (обычно это ключ, ссылка на изображение, без домена) и домен CDN.
  • Если для этого изображения настроена ширина, установим параметр запроса w (w - это параметр запроса для ширины в Imgix).
  • Если требуется изменить уровень сжатия через параметры загрузчика (это дополнительные свойства, которые можно передать в директиву и которые передаются в пользовательские загрузчики изображений), установим параметр запроса сжатия q Imgix.
  • Результат преобразуем в строку и вернем обратно.

Чтобы зарегистрировать этот загрузчик в приложении, вместо объявления provideImgixLoader() в корне, переопределим провайдер на уровне приложения:

import { ApplicationConfig } from '@angular/core';
import { appRoutes } from './app.routes';
import { IMAGE_LOADER } from '@angular/common';
 
export const appConfig: ApplicationConfig = {
  providers: [
    // ...other providers
    {
      provide: IMAGE_LOADER,
      useValue: myCustomLoader,
    },
  ],
};
<img
  [ngSrc]="'photo-1417325384643-aac51acc9e5d'"
  [loaderParams]="{ compression: 50 }"
  ngSrcset="300w, 800w, 1500w"
/>

В результате к каждому URL изображения в srcset в теге <img> после рендеринга будет добавлен параметр запроса q (сжатие):

<img
  fill=""
  priority=""
  style="object-fit: cover; position: absolute; width: 100%; height: 100%; inset: 0px;"
  loading="eager"
  fetchpriority="high"
  src="https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?q=50"
  srcset="
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?w=300&amp;q=50   300w,
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?w=800&amp;q=50   800w,
    https://images.unsplash.com/photo-1417325384643-aac51acc9e5d?w=1500&amp;q=50 1500w
  "
  sizes="100vw"
/>

Что в итоге?

Теперь, когда настроено окружение и приложение посмотрим на результаты. Слева изображение, использующее загрузчик, ngSrcset, preconnnect и все остальные хорошие вещи, которыми автоматически управляет директива Image. Справа - то же самое изображение, но без использования средств оптимизации.

И немного в цифрах:

| | Raw | Optimized | Change | | ----------— | ----— | -------— | ----— | | Size | 3.7MB | 1.0MB | -73% | | Loading Time | 1300ms | 452ms | -65% |

Исходный код всего компонента:

import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NgOptimizedImage } from '@angular/common';
 
@Component({
  standalone: true,
  imports: [RouterModule, NgOptimizedImage],
  selector: 'ngconf-image-directive-article-root',
  template: `
    <div class="container">
      <!-- Image on the left, all the optimizations -->
      <div class="image">
        <img
          [ngSrc]="'photo-1417325384643-aac51acc9e5d'"
          [loaderParams]="{ compression: 50 }"
          fill
          priority
          ngSrcset="300w, 800w, 1500w"
        />
      </div>
      <!-- Image on the right, no optimizations -->
      <div class="image">
        <img
          style="height: 100vh; width: 50vw"
          [src]="'https://images.unsplash.com/photo-1417325384643-aac51acc9e5d'"
        />
      </div>
    </div>
  `,
  styles: [
    `
      .container {
        display: flex;
        height: 100%;
        width: 100%;
      }
 
      .image {
        flex: 1;
        height: 100vh;
        width: 50vw;
        position: relative;
        object-fit: cover;
      }
    `,
  ],
})
export class AppComponent {}

Подытожим, что именно делает директива NgOptimizedImage

1. Добавляет preconnect ссылок в <head>: подготавливает браузер к быстрой загрузке изображений.
2. Добавляет тег preload для изображений помеченных как приоритетные, чтобы загрузка началась сразу после получения браузером index.html приложения Angular (до того, как Angular даже начнет работу).
3. Заполняет изображения контейнером-родителем, чтобы не приходилось беспокоиться о установке ширины и высоты.
4. Создает srcset, чтобы браузер мог выбрать наиболее оптимальное изображение.
5. Управляет атрибутом priority, чтобы гарантировать, что браузер загружает приоритетные изображения в нужное время (loading и fetchpriority).

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