Привет, друзья!


В этой статье я хочу поделиться с вами результатами небольшого эксперимента, связанного с ускорением загрузки изображений с помощью Imgproxy, Cache API (далее — кеш) и Service Worker API (далее — СВ).


Мы с вами разработаем простое приложение на React, в котором используется несколько изображений, и добьемся того, что загружаемые изображения будут более чем в 10 раз легче (меньше по размеру) оригиналов (imgproxy), а также практически мгновенной загрузки (доставки) изображений (СВ и кеш).


Обратите внимание: в части, касающейся imgproxy, особых препятствий на пути использования рассматриваемого в статье подхода к загрузке изображений в продакшне нет, но в части, касающейся СВ, следует проявлять крайнюю осторожность, поскольку данная технология является экспериментальной — это означает, что поведение СВ во многом определяется конкретной реализацией (браузером), что в ряде случаев делает его довольно непредсказуемым. Возможно, для кеширования изображений лучше предпочесть старые-добрые HTTP-заголовки Cache-Control и Etag. Но эксперимент на то и эксперимент, чтобы, в том числе, искать новые ответы на старые вопросы.


Для тех, кого интересует только результат эксперимента, вот репозиторий с исходным кодом проекта.


Остальных прошу под кат.


Обратите внимание: для успешного прохождения туториала на вашей машине должны быть установлены Node.js и Docker.


Создаем директорию, переходим в нее, и создаем шаблон React-приложения с помощью Vite:


mkdir imgproxy-cache
cd imgproxy-cache

# client - название директории клиента
# --template react - название используемого шаблона
yarn create vite client --template react
# or
npm create vite ...

Создаем директорию для медиафайлов (media) и внутри нее директорию для изображений (images):


mkdir media media/images

Помещаем в директорию images 3 изображения:











Характеристики этих изображений следующие:


  • 1.jpeg: размер — 1880 x 1256, вес — 594 КБ, формат — JPEG;
  • 2.jpeg: размер — 1880 x 1255, вес — 283 КБ, формат — JPEG;
  • fallback.jpeg: размер — 1880 x 1253, вес — 143 КБ, формат — JPEG.

Создаем файл docker-compose.yml с настройками сервиса imgproxy:


# версия docker
version: '3.9'
services:
  # название сервиса
  imgproxy:
    # образ
    image: darthsim/imgproxy:v3.3.0
    # файл с переменными среды окружения
    env_file:
      - .env
    # название контейнера
    container_name: ${APP_NAME}_imgproxy
    # портом по умолчанию, на котором запускается `imgproxy`, является 8080
    ports:
      - 8080:8080
    # том
    volumes:
      - ./media:/media
    # политика перезапуска контейнера
    restart: on-failure

Создаем файл .env следующего содержания:


# название приложения
APP_NAME=my-app
# это пригодится нам при "контейнеризации" клиента
NODE_VERSION=16.13.1

# imgproxy
IMGPROXY_PATH_PREFIX=/imgproxy
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
IMGPROXY_ALLOWED_SOURCES=local://
IMGPROXY_LOCAL_FILESYSTEM_ROOT=/media
IMGPROXY_FALLBACK_IMAGE_PATH=/media/images/fallback.jpeg
IMGPROXY_ENFORCE_WEBP=true

Рассмотрим переменные для imgproxy:


  • IMGPROXY_PATH_PREFIX — префикс пути, по которому будет доступен imgproxy: http://localhost:8080/imgproxy;
  • IMGPROXY_ALLOW_ORIGIN — разрешенный источник (протокол, домен и порт): только этот источник будет иметь доступ к imgproxy (это позволяет в какой-то мере обеспечить секьюрность);
  • IMGPROXY_ALLOWED_SOURCES — разрешенные источники изображений: в данном случае разрешена загрузка только изображений, находящихся в локальной файловой системе;
  • IMGPROXY_LOCAL_FILESYSTEM_ROOT — директория в локальной файловой системе, из которой загружаются изображения (/media);
  • IMGPROXY_FALLBACK_IMAGE_PATH — путь к резервному изображению: данное изображение возвращается при запросе отсутствующего файла;
  • IMGPROXY_ENFORCE_WEBP — если браузер пользователя поддерживает WebP, возвращается изображение в этом формате.

С полным списком переменных среды окружения для imgproxy можно ознакомиться здесь.


Поднимаем сервис с помощью команды docker compose up -d:








Видим, что сервис imgproxy-and-cache-api с контейнером my-app_imgproxy успешно запущен.


К слову, для остановки сервиса используется команда docker compose stop, а для удаления сервиса — docker compose rm.


Теперь займемся клиентом.


Структура директории client/src будет следующей:


src
  utils
    image.utils.js
  App.css
  App.jsx
  main.jsx

Реализуем утилиту для формирования валидного с точки зрения imgproxy пути к изображению (utils/image.utils.js):


const BASE_IMAGE_URL = 'http://localhost:8080/imgproxy/insecure'

// rt - это тип масштабирования изображения (resize type)
export const getImageUrl = ({ rt = 'fill', width = 480, height = 320, src = 'fallback.jpeg' }) =>
  `${BASE_IMAGE_URL}/rs:${rt}:${width}:${height}/plain/local:///images/${src}`

В пути мы определяем тип масштабирования (заполнение контейнера), ширину и высоту изображения. В остальном мы полагаемся на дефолтные настройки imgproxy.


Обратите внимание: мы можем опустить /media, поскольку определили IMGPROXY_LOCAL_FILESYSTEM_ROOT.


Если мы передадим данной утилите src: '1.jpeg', то на выходе получим http://localhost:8080/imgproxy/insecure/rs:fill:480:320/plain/local:///images/1.jpeg.


Подробнее о генерации пути к изображению для imgproxy можно почитать здесь.


Обратите внимание: мы используем небезопасный способ получения изображений (insecure) (не считая того, что мы определили IMGPROXY_ALLOW_ORIGIN). О добавлении в путь подписи (signature) можно почитать здесь, а пример функции на JavaScript для генерации подписи можно найти здесь.


Рассмотрим основной компонент приложения (App.jsx):


import './App.css'
// импортируем утилиту для формирования пути к изображению
import { getImageUrl } from './utils/image.utils'

function App() {
  return (
    <div className='App'>
      <h1>Imgproxy &amp; Cache API</h1>
      <div className='images'>
        <figure>
          <img src={getImageUrl({ src: '1.jpeg' })} alt='' />
          <figcaption>First image</figcaption>
        </figure>

        <figure>
          <img src={getImageUrl({ src: '2.jpeg', width: 320 })} alt='' />
          <figcaption>Second image with custom width</figcaption>
        </figure>

        <figure>
          <img src={getImageUrl({ src: '3.jpeg' })} alt='' />
          <figcaption>Fallback image</figcaption>
        </figure>
      </div>
    </div>
  )
}

export default App

В разметке мы пытаемся получить 3 изображения:


  • 1.jpeg с дефолтными настройками;
  • 2.jpeg с кастомной шириной;
  • несуществующее изображение 3.jpeg.

Находясь в директории client, выполняем команду yarn dev для запуска приложения и открываем вкладку браузера по адресу http://localhost:3000.





Видим, что изображения успешно загрузились. Причем, второе изображение квадратное, а вместо несуществующего файла imgproxy вернул резервное изображение (fallback.jpeg).


Откроем инструменты разработчика, перейдем на вкладку Network и выберем Img:





Обратите внимание на поля Type и Size: imgproxy вернул изображения в формате WebP и весят они более чем в 10 раз меньше оригиналов: 42.7, 21.4 и 10.9 Кб. Что касается размеров изображений, то они соответствуют заданным настройкам: 480x320, 320x320 и 480x320.


Изучим подробности какого-нибудь ответа imgproxy, например, для 1.jpeg.





Здесь нас интересуют следующие поля:


  • Request URL — путь к изображению, сформированный нашей утилитой;
  • Content-Length — размер изображения;
  • Content-Type — формат изображения;
  • Accept — по этому заголовку imgproxy определяет поддержку форматов изображений браузером пользователя.

Таким образом, мы успешно решили первую часть задачи: добились уменьшения размеров изображений более чем в 10 раз и преобразования их форматов в WebP. Однако если изучить значения поля Time на вкладке Network, то мы увидим, что время загрузки изображений составляет 300-600 мс. Допустим, что такое время является для нас неприемлемым. Что мы можем сделать, чтобы его уменьшить?


Ответ — кешировать изображения при первоначальном запуске приложения и впоследствии доставлять изображения из кеша. Существует несколько способов это сделать. Я решил прибегнуть к помощи СВ.


Сервис-воркер — это своего рода посредник между клиентом и сервером. Он может перехватывать HTTP-запросы, имеет доступ к Cache API и может общаться с приложением через Channel Messaging API. За счет кеширования критически важных для работы приложения ресурсов можно добиться того, что приложение будет работать даже при отсутствии подключения к сети (в режиме офлайн).


Рекомендую полистать соответствующую спецификацию.


Зарегистрируем СВ для нашего приложения. Для этого в client/index.html добавляем такие строки:


<script type="module" src="/src/main.jsx"></script>
<!-- ! -->
<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js').catch(console.error)
  }
</script>

Здесь мы проверяем поддержку СВ браузером и запускаем выполнение кода из файла sw.js.


Для того, чтобы наш СВ попал в сборку приложения, необходимо немного настроить vite. Для этого добавляем такую строку в vite.config.js:


export default defineConfig({
  // !
  publicDir: './sw',
  plugins: [react()]
})

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


// название кеша
// для инвалидации кеша достаточно изменить это название,
// например, на my-app_images-v2
const CACHE_NAME = 'my-app_images-v1'

// обработка активации нового СВ
// удаляем старый кеш - кеш с другими названиями (например, предыдущей версии)
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.map((key) => {
          // не трогаем не наш кеш
          if (key.includes('my-app-images') && key !== CACHE_NAME) {
            return caches.delete(key)
          }
        })
      )
    )
  )
  self.clients.claim()
})

// обрабатываем выполнение запроса из приложения (перехват запроса)
self.addEventListener('fetch', (event) => {
  // извлекаем путь из объекта запроса
  const { url } = event.request
  // извлекаем название пути из разобранного пути запроса
  const { pathname } = new URL(url)

  // если название пути включает слово `imgproxy`
  if (pathname.includes('imgproxy')) {
    console.log(pathname)
    // возвращаем ответ
    event.respondWith(
      caches
        // проверяем наличие в кеше ответа для данного названия пути
        .match(pathname)
        .then(async (response) => {
          // если такой ответ имеется
          if (response) {
            console.log('Image from cache')
            // возвращаем его
            return response
          }
          // если ответа в кеше для данного названия пути нет
          // выполняем запрос к `imgproxy`
          return fetch(event.request).then((response) =>
            // открываем наш кеш
            caches.open(CACHE_NAME).then((cache) => {
              // записываем ответ от `imgproxy` в кеш
              cache.put(pathname, response.clone())
              // и возвращаем ответ
              return response
            })
          )
        })
        .catch(console.error)
    )
  }
})

Посмотрим, как это работает (и работает ли?).


Перезапускаем сервер для клиента.





Изображения загружаются. В консоли имеются сообщения от СВ с названиями путей, содержащими imgproxy.


На вкладке Network можно увидеть, что запросы перехватываются СВ (почему-то в поле Size):





Обратите внимание: самым простым способом очистить кеш и "убить" СВ является нажатие кнопки Clear site data на вкладке Application в разделе Storage (убедитесь в наличии галочки у Unregister service workers в разделе Application):





Перезагружаем вкладку браузера:








Получаем от СВ сообщения о доставке изображений из кеша. На вкладке Network видим, что изображения загружаются практически мгновенно (3-5 мс). Кажется, что мы успешно решили вторую часть задачи. Не совсем.


Особенность номер один


Если изучить подробности выполнения запроса на вкладке Network, можно заметить отсутствие заголовка запроса Accept. Что это означает? Это означает риск того, что imgproxy будет возвращать изображения в формате WebP даже для тех браузеров, которые данный формат не поддерживают (потому что мы определили IMGPROXY_ENFORCE_WEBP=true). Это может закончиться тем, что браузер получит изображения, но не сможет их отрендерить. В чем причина отсутствия заголовка Accept в запросе?





На самом деле все просто. Наши запросы перехватываются и выполняются СВ. Если на вкладке Network выбрать Fetch/XHR, то можно увидеть дублирующиеся запросы, выполняемые СВ, в которых заголовок Accept присутствует:





Здесь есть один интересный момент.


Посмотрим на ответ, который возвращается imgproxy. Для этого добавим такую строку в sw.js:


return fetch(event.request).then((response) =>
  caches.open(CACHE_NAME).then((cache) => {
    // !
    console.log(response)
    cache.put(pathname, response.clone())
    return response
  })
)

Выполняем запросы:





Получаем в консоли странные объекты ответов с непрозрачным типом (type: 'opaque') и ok: false. К слову, если мы установим ограничение на запись в кеш только успешных запросов (в ответ на которые возвращаются ответы с ok: true):


return fetch(event.request).then((response) =>
  caches.open(CACHE_NAME).then((cache) => {
    console.log(response)
    // !
    if (response.ok) {
      cache.put(pathname, response.clone())
    }
    return response
  })
)

То наши изображения не будут кешироваться.


Если не вдаваться в подробности, суть здесь вот в чем: запрос на получение изображения выполняется с mode: 'no-cors' и credentials: include. В большинстве случаев это хорошо, поскольку позволяет получать изображения из других источников без настройки CORS. Когда браузер при разборе html встречает тег img, он выполняет fetch по адресу, указанному в src. При этом браузер автоматически формирует заголовок запроса Accept, в том числе, на основе значения поля destination объекта запроса (для img поле destination имеет значение image). В ответ на запрос с такими настройками возвращаются непрозрачные ответы.


Существует ли какой-то способ получить нормальные объекты ответов? Мы можем попробовать определить (перезаписать) настройки mode и credentials при выполнении fetch в СВ:


// !
return fetch(event.request, {
  mode: 'cors',
  credentials: 'omit'
}).then((response) =>
  caches.open(CACHE_NAME).then((cache) => {
    console.log(response)
    if (response.ok) {
      cache.put(pathname, response.clone())
    }
    return response
  })
)




Теперь при выполнении запросов мы получаем ответы с type: 'cors' и ok: true. Это означает, что теперь ответы соответствуют условию if (response.ok) и изображения будут кешироваться. К слову, проверка cors при выполнении запроса на получение изображения от imgproxy вполне согласуется с IMGPROXY_ALLOW_ORIGIN=http://localhost:3000.


Еще один интересный момент.


Если мы поместим в директорию images фавиконку favicon.png:





И выполним запрос на ее получение в приложении (App.jsx):


useEffect(() => {
  // шаблон фавиконки
  const faviconTemplate = `<link rel="icon" href=${getImageUrl({
    src: 'favicon.png',
    width: 64,
    height: 64
  })} />`

  // HTML-элемент `link`
  const favicon$ = new Range().createContextualFragment(faviconTemplate)
    .children[0]
  // вставляем фавиконку в `head`
  document.head.append(favicon$)
}, [])

То к своему удивлению обнаружим, что фавиконка не кешируется, а каждый раз запрашивается у imgproxy. Судя по тому, что мы не получаем путь к фавиконке в консоли (от СВ), при ее запросе используется какой-то другой механизм, нежели fetch (поскольку события fetch не возникает, СВ не может перехватить запрос на получение фавиконки).


Двигаемся дальше.


Особенность номер два


В режиме для разработки приложение работает, как ожидается. Но будет ли оно работать также в производственном режиме.


Установим serve для обслуживания сборки клиента:


yarn add serve
# or
npm i serve

И добавим команду для сборки клиента и запуска serve в package.json:


"scripts": {
  "dev": "vite",
  "build": "vite build",
  "start": "yarn build && serve -s dist -p 3000"
}

Команда start выполняет сборку клиента (с помощью vite) и запускает обслуживание статических файлов из директории dist по адресу http://localhost:3000.


Давайте "контейнеризуем" нашего клиента. Для этого создаем в директории client файл Dockerfile следующего содержания:


# дефолтная версия `Node.js`
ARG NODE_VERSION=16.13.1

FROM node:$NODE_VERSION

# рабочая директория
WORKDIR /client

# копируем указанные файлы
COPY package.json yarn.lock ./

# устанавливаем зависимости
RUN yarn

# копируем все остальное
COPY . .

# выполняем сборку и запускаем `serve`
CMD ["yarn", "start"]

Определяем настройки сервиса client в docker-compose.yml:


version: '3.9'
services:
  imgproxy:
    image: darthsim/imgproxy:v3.3.0
    env_file:
      - .env
    container_name: ${APP_NAME}_imgproxy
    ports:
      - 8080:8080
    volumes:
      - ./media:/media
    restart: on-failure
  # !
  client:
    env_file:
      - .env
    container_name: ${APP_NAME}_client
    # сборка выполняется на основе `Dockerfile` из директории `client`
    build: client
    ports:
      - 3000:3000
    restart: on-failure

Запускаем (перезапускаем) сервис с помощью команды docker compose up -d:





Видим, что наш сервис теперь состоит из двух контейнеров: my-app_imgproxy и my-app_client. Клиент, как и прежде, доступен по адресу http://localhost:3000.


Запускаем приложение. Изображения загружаются, СВ регистрируется.


Перезагружаем вкладку браузера. Мы ожидаем, что наши изображения будут доставлены из кеша, но этого не происходит!


Перезагружаем вкладку еще раз:





Теперь изображения доставляются из кеша, но это не совсем то, что мы хотим. Мы хотим, чтобы изображения кешировались после первого запуска приложения. Почему этого не происходит?


Опять же, если не вдаваться в подробности, суть такая: код СВ выполняется в фоновом режиме и асинхронно, поэтому СВ просто не успевает активироваться (activate) до выполнения запросов на получение изображений при первом запуске приложения. При повторном запуске СВ активирован (activated), перехватывает запросы и помещает изображения в кеш. При третьем запуске СВ активирован, перехватывает запросы и возвращает изображения из кеша.


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


  • рендерить все приложение после активации сервис воркера (я выбрал это решение; нашел я его здесь);
  • рендерить разный контент приложения в зависимости от ожидания/получения от СВ сообщения о его активации через Channel Messaging API (это решение показалось мне слишком громоздким; пример данного решения можно найти здесь (см. комментарии)).

Для того, чтобы рендеринг приложения происходил только после активации СВ необходимо немного переписать код, содержащийся в файле client/main.jsx:


import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

// выносим рендеринг приложения в отдельную функцию
const render = () =>
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('root')
  )

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('./sw.js')
    .then((reg) => {
      // если происходит установка СВ
      if (reg.installing) {
        // получаем устанавливаемый СВ
        const sw = reg.installing || reg.waiting
        // обрабатываем изменения состояния СВ
        sw.onstatechange = () => {
          // рендерим приложение после активации СВ
          if (sw.state === 'activated') {
            render()
          }
        }
      } else {
        // если СВ уже установлен, просто рендерим приложение
        render()
      }
    })
    .catch(console.error)
} else {
  // если СВ не поддерживается браузером, рендерим приложение
  render()
}

Соответственно, в index.html можно удалить эти строки:


<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js').catch(console.error)
  }
</script>

Выполняем команду docker compose up -d.


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





Таким образом, мы успешно решили обе задачи: добились уменьшения размера изображений более чем в 10 раз и преобразования формата изображений в WebP, а также практически мгновенной загрузки изображений при втором и последующих запусках приложения.


Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Благодарю за внимание и happy coding!




Комментарии (11)


  1. RokeAlvo
    15.03.2022 16:03
    +1

    Уже сколько раз видел статьи про кеширование изображений с помощью sw, и так и не могу понять, а зачем? Ведь браузер изображения и так кеширует.


    1. Pab10
      15.03.2022 17:03

      Выставлять заголовки на бэкенде не модно. Но в целом, как обзор технологий, статья годная.


    1. Aidar87
      16.03.2022 15:05

      например, для офлайн режима


      1. RokeAlvo
        16.03.2022 18:42

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


        1. Aidar87
          16.03.2022 18:51
          +1

          Вы наверное про Google workbox не слышали, можно всю статику принудительно закешировать


        1. aio350 Автор
          17.03.2022 10:05

          Руководство по Workbox: https://github.com/harryheman/React-Total/blob/main/md/wb/wb.md. Пример кеширования статики с помощью СВ при его установке: https://github.com/harryheman/Modern-HTML-Starter-Template/blob/main/service-worker.js


    1. demimurych
      18.03.2022 07:41

      1. В отличии от кеширования браузером, кеширование с использование sw дает вам полный контроль над вашим кешем. Браузер не дает Вам никаких инструментов по управлению кешем вообще. Как и не дает гарантий того, что кешироваться что-то вообще будет, не смотря на все ваши заголовки. А если и будет закешировано, то может быть удалено от туда уже через минуту работы. По сути, в случае если вы полагаетесь на браузер, вы ничего не знаете о том как обстоят дела с кешем.

      2. sw и cache api позволяет работать с кешем даже тогда когда связь отсутсвует.

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


  1. jesaiah4
    15.03.2022 22:40
    -1

    @aio350погоняли ваш Imgproxy, он так не хило жрет ресурсов для картинок 2k+ , не годится для обработки в реалтайме для высоконагруженных систем.

    Он умеет делать ресайз и сохранять конечную картинку на диске и потом при следующем запросе без обработки её использовать?

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

    Тоесть загружаешь 1.png , а тебе дают GUID в ответ 4593-053534-545-3453.png. Такое умеет?


    1. aio350 Автор
      16.03.2022 08:32
      +2

      1. Imgproxy, к сожалению, не мой. Вот его разработчик: https://github.com/DarthSim

      2. Сохранение файлов на диске и их последующее использование без запроса к imgproxy похоже на кеширование.

      3. Честно говоря, не знаю, полистайте доку: https://docs.imgproxy.net/


    1. RokeAlvo
      16.03.2022 19:11

      он так не хило жрет ресурсов для картинок 2k+ , не годится для обработки в реалтайме для высоконагруженных систем.

      Он умеет делать ресайз и сохранять конечную картинку на диске и потом при следующем запросе без обработки её использовать?

      nginx вам в помощь


  1. demimurych
    18.03.2022 07:49

    Тег figure решает специфические семантические задачи и не может использоваться как универсальное средство при верстке изображений.

    За исключерием случаев, когда для Вас не важно то, каким образом ваша верстка будет интерпретирована сторонними средсвами анализа контента. Только в этом случае, тег figure Вам тем более не нужен, и только будет бесполезно потреблять ресурсы.

    Постоянные запросы к favicon связана с особенностями работы devtools. Если Вы закроете devtools и перегрузите страницу, обнаружите что все работает как ожидается.