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

В большинстве случаев с ростом реальной производительности улучшается и Perceived Performance. А когда реальная производительность не может быть с легкостью увеличена, существует возможность поднять видимую. В своем докладе на Frontend Live 2020 бывший разработчик Avito Frontend Architecture Алексей Охрименко рассказал о приемах, которые улучшают ощущение скорости там, где ускорить уже нельзя.

Производительность — обязательное условие успеха современных Web-приложений. Есть множество исследований от Amazon и Google, которые говорят, что понижение производительности прямо пропорционально потере денег и ведет к слабой удовлетворенности вашими сервисами (что снова заставляет терять деньги).

Первопричина полученного стресса — ожидание. Научно доказано, что оно может вызывать:

  • беспокойство; 

  • неуверенность; 

  • дискомфорт;

  • раздражение; 

  • скуку.

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

Самое простое решение — все делать быстро. Но прежде, чем вы скажете: «Спасибо, капитан!», хочу напомнить, что увеличение производительности ведет к прямому уменьшению времени ожидания.

Не игнорируйте этот момент: вы обязательно должны его учитывать.

Для этого есть множество разнообразных техник, в том числе:

  • lighthouse;

  • devTools profiler.

Но даже если все сделать идеально, этого может оказаться недостаточно.

Есть интересная история про аэропорт Houston. Она отлично вписывается и в современные реалии разработчиков.

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

Люди часто жаловались на долгое ожидание багажа в аэропорту. И инженеры аэропорта Huston не спали ночами, работали сверхурочно, но реализовали выдачу багажа всего за 8 минут. Однако клиенты все равно продолжали жаловаться. 

Инженеры не знали, что делать. И они решили попробовать один трюк. Высадив людей как можно дальше от зоны выдачи багажа, они вынудили их быть в активном ожидании. Пока те добирались до места назначения, багаж уже ждал их. В результате все жалобы прекратились. Но самое интересное, что реальное время выдачи багажа осталось абсолютно таким же: 8 минут.

Почему же клиенты пришли в восторг от происходящего, хотя реальная производительность никак не изменилась?

Perceived Performance

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

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

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

Все это базируется на том, что люди не очень хорошо умеют измерять реальную производительность. И именно эту особенность можно эксплуатировать.

Все дальнейшие примеры будут для Angular приложений, но, несмотря на это, они применимы к любому современному  фреймворку. Приступим!

Не блокируйте пользователя

Первый совет: ни в коем случае не стоит блокировать пользователя. Вы можете сейчас сказать: «Я и не блокирую!».

Попробуйте узнать себя в этом примере:

Допустим, мы кликаем на удаление какого-то элемента, запрашиваем данные, после чего удаляем. 

Все равно не узнаете? А так?

Спиннеры — это не выход! Хоть они и могут стать ленивым способом разобраться с неудобной ситуацией.

Что можно предложить в качестве альтернативы спиннеру?

Можно нажать на кнопку «Удалить» и показать статус этой кнопки (item удаляется только для одного элемента), не демонстрируя спиннер. В дальнейшем можно отправить запрос на сервер, и когда он придет, показать, что элемент удалился, либо — при ошибке — передать данные о ней. Вы можете возразить: «Но я могу делать только 1 запрос за раз!» — это ограничение бэкенда. С помощью RxJs и оператора concat можно буквально одной строчкой кода создать минимальную очередь:

import { concat } from 'rxjs';

const source = concat(
    myService.saveStuff({ test: 'swag '}),
    myService.saveStuff({ test: 'yolo '})
);

Более серьезная имплементация, конечно, займет больше, чем одну строчку. 

Вы можете сказать: «Я не могу не блокировать компонент потому, что он состоит из какой-то логики. То есть существуют набор элементов и действия, которые я не могу совершать».

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

<ngx-spinner [fullScreen]="false">
    <p class="loading">Loading Awesomeness...</p> 
</ngx-spinner>	

В Angular есть ngx-spinner, который поддерживает такой функционал.

Это, как минимум, уменьшит время ожидания, и пользователь сможет в этот момент сделать хоть что-то. 

Обманывайте

Обман зачастую базируется на иллюзиях скорости.

Допустим, есть два спиннера с точками. Если увеличить количество точек в одном из них, кажется, что он начинает крутиться быстрее. На самом деле скорость спиннеров одинакова.

Иллюзия базируется на простом эффекте: чем больше точек, тем больше Motion Blur (размытие движения). Этот трюк используется в мультфильмах. Возможно, вы видели, что когда койот бежит очень быстро, у него размываются ноги, и вместо них появляется облако.

Где можно применить такую "иллюзию"?

  • Progress Bar;

Есть исследование, показывающее, что если добавить полоски, которые идут в обратном направлении внутри Progress Bar, то визуально он выглядит как более быстрый. В такой настройке можно получить до 12% ускорения, просто применив дополнительный скин. И пользователи воспримут работу, прошедшую под этим Progress Bar, на 12% быстрее. Вот пример того, как можно реализовать такой слайдер на CSS

  • Скелетон;

Если спиннеры — это плохо, что же тогда показать на экране? Можно продемонстрировать силуэт, некий образ того контента, который будет отображен.

Скелетон — это некое схематическое отображение сайта до момента его загрузки:

В этом примере мы видим, где будут расположены чаты.

Существует исследование, которое показывает, что люди воспринимают скелетоны быстрее от 10 до 20%.

То есть по ощущениям пользователей, сайты со скелетоном работают быстрее.

Существует огромное количество нужных компонентов для Angular, React, View.  К примеру, для Angular есть ngx-skeleton-loader, в котором можно прописать внешний вид и сконфигурировать его. После чего мы получим наш скелетон:

<ngx-skeleton-loader
    appearance="circle"
    [theme]="{ width: '80px', height: '80px' }"
    >
</ngx-skeleton-loader>

Будьте оптимистами

Помните CS:GO? Вы забежали за стену и вас убивают! Это наверняка читеры, вы же были за стеной! На самом деле это Latency/Lag compensation.

В мире frontend это называется Optimistic Updates. Самый яркий пример это Twitter

Клик отрабатывает моментально, при этом мы видим что запрос на backend отработал только через 2 секунды.

Как реализовать Optimistic Updates у себя? Давайте рассмотрим самый простой пример http запроса:

addItem(item) {
    return this.http.post<Item>('/add-item', item)
        .subscribe(res => {
            this.items.push(res);
        }, error => {
            this.showErrorMessage(error);
        })
}

Теперь применим к нему Optimistic Update:

addItem(item) {
    this.items.push(item); // делаем заранее
    return this.http.post<Item>('/add-item', item)
        .subscribe(
            res => {
                // ничего не делаем в случае успеха
            }, error => {
                // в случае ошибки - откатываем состояние
                this.items.splice(this.items.indexOf(item), 1);
                this.showErrorMessage(error);
            }
        );
}

Для State Managers все немного сложнее, но для каждого есть своя реализация и свои паттерны для реализации Optimistic Updates. В этой статье я не буду приводить примеры, их легко можно найти под подходящий State Manager (redux, mobx, appollo-graphql, etc.)

Экспоненциальная выдержка

Следующее, что необходимо проявлять — это не обычную выдержку, а экспоненциальную. Optimistic Updates имеют одну проблему: мы обязаны откатить состояния приложения назад в случае наличия проблемы с запросом и показать сообщение об ошибке. Но эту ситуацию можно сгладить. Давайте посмотрим на простой пример с Optimistic Update:

addItem(item) {
    return this.http.post<Item>('/add-item', item)
        .subscribe(res => {
            this.items.push(res);
        }, error => {
            this.showErrorMessage(error);
        })
}

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

addItem(item) {
    this.items.push(item); // делаем заранее
    return this.http.post<Item>('/add-item', item)
        .pipe(retry()) // повторяем запрос
        .subscribe(
            // ... 
        );
}

Это одна из best practice в энтерпрайз-приложениях, потому что бэкенд может не работать по разным причинам. Например, происходит деплой или хитрая маршрутизация. В любом случае очень хорошее решение: попробовать повторить. Ведь никакое API не дает 100% гарантии, 99,9% uptime. 

Но есть маленький нюанс. Если мы будем повторять бесконечно, то в какой-то момент повалим наш сервер. Поэтому обязательно поставьте ограничение. Например, повторять максимум 3 раза.

addItem(item) {
    this.items.push(item); // делаем заранее
    return this.http.post<Item>('/add-item', item)
        .pipe(
            retry(3)
        )
        .subscribe(
            // ... 
        );
}

Но даже с этим сценарием мы сами себе можем сделать DDOS (Distributed Denial of Service). На это попадались многие компании. Например, Apple с запуском своего сервиса MobileMe. 

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

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

Best practice: применять exponential backoff. В rxjs есть хороший дополнительный npm пакет backoff-rxjs, который за вас имплементирует данный паттерн.

import { retryBackoff } from 'backoff-rxjs';

addItem(item) {
    this.items.push(item);
    return this.http.post<Item>('/add-item', item)
        .pipe(
            retryBackoff({
                initialInterval: 100,
                maxRetries: 3,
                resetOnSuccess: true
            })
        )
        .subscribe(
            // ... 
        );
}

Имплементация очень простая, 10 строчек кода. Здесь вы можете обойтись одной. Указываете интервал, через который начнутся повторы, количество попыток, и сбрасывать ли увеличивающийся таймер. То есть вы увеличиваете по экспоненте каждую следующую попытку: первую делаете через 1 с, следующую через 2 с, потом через 4 с и т.д.

Играя с этими настройками, вы можете настраивать их под ваше API.

Следующий совет очень простой — добавить Math.random() для initialInterval:

import { retryBackoff } from 'backoff-rxjs';

addItem(item) {
    this.items.push(item);
    return this.http.post<Item>('/add-item', item)
        .pipe(
            retryBackoff({
                initialInterval: Math.random() * 100,
                maxRetries: 3,
                resetOnSuccess: true
            })
        )
        .subscribe(
            // ... 
        );
}

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

Предугадывайте!

Как уменьшить ожидание, когда невозможно ускорить процесс?

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

  • Предзагрузка картинок;

Один из самых простых примеров — это предзагрузка картинок. К примеру, у вас есть SPA-приложение. Вы открываете одну страничку, а на следующей есть набор иконок. Вы можете очень простым скриптом написать preload и загружать на конкретной странице картинки, необходимые для другой:

var images = [];
function preload() {
  for (var i = 0; i < arguments.length; i++) {
    images[i] = new Image();
    images[i].src = preload.arguments[i];
  }
}

preload(
  "http://domain.tld/image-001.jpg",
  "http://domain.tld/image-002.jpg",
  "http://domain.tld/image-003.jpg"
)

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

  • Предзагрузка 18+

Наверняка вы сталкивались в HTML со стеком link, который позволяет переподключить stylesheets:

<link 
  rel="stylesheet" 
  href="styles/main.css"
>

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

<link 
  rel="preload" 
  href="styles/main.css" 
  as="style"
>

Можно указать атрибут rel ="preload", сослаться в ссылке на наш элемент (href="styles/main.css"), и в атрибуте as описать тип предзагружаемого контента.

  • prefetch.

Еще один вариант — это prefetch:

<link 
  rel="prefetch" 
  href="styles/main.css"
>

Главное — запомнить, что preload и prefetch — два самых полезных инструмента. Отличие preload от prefetch в том, что preload заставляет браузер делать запрос, принуждает его. Обычно это имеет смысл, если вы предзагружаете ресурс на текущей странице, к примеру, hero images (большую картинку). 

ОК, это уже лучше, но есть одна маленькая проблема. 

Если взять какой-нибудь среднестатистический сайт и начать префетчить все JavaScript модули, то средний рост по больнице составляет 3 МБ. Если мы будем префетчить только то, что видим на странице, получаем примерно половину — 1,2 МБ. Ситуация получше, но все равно не очень.

Что же делать?

Давайте добавим Machine Learning

Сделать это можно с помощью библиотеки Guess.js. Она создана разработчиками Google и интегрирована с Google Analytics.

Анализируя паттерны поведения пользователей и, подключаясь к вашему приложению и системе сборки, она может интеллектуально делать prefetch только 7% на странице.

При этом эта библиотека будет точна на 90%. Загрузив всего 7%, она угадает желания 90% пользователей. В результате вы выигрываете и от prefetching/preloading, и от того, что не загружаете все подряд. Guess.js — это идеальный баланс.

Сейчас Guess.js работает из коробки с Angular, Nuxt js, Next.js и Gatsby. Подключение очень легкое.

Поговорим о Click-ах

Что еще можно сделать, чтобы уменьшить ожидание?

<div (сlick)="onClick()">
    Это button! Привет Вадим ;)
</div>

Как предугадать, на что кликнет пользователь? Есть очевидный ответ.

http://instantclick.io/click-test
http://instantclick.io/click-test

У нас есть событие, которое называется mousedown. Оно срабатывает в среднем на 100-200 мс раньше, чем событие Click. 

Применяется очень просто:

  <div (onmousedown)="onClick()">
    Это button! Привет Вадим ;)
  </div>

Просто поменяв click на mousedown, мы можем выиграть 100-200 мс.

Очень важное замечание (спасибо RouR что обратил внимание на это), На mousedown вы можете только начать скачивать ресурсы следующей страницы, предзагружать контент, в общем выполнять любую подготовительную работу и только после события click — «визуально» выполнять действие (выполнять переход, обрисовывать элементы)

Если вы будете выполнять действие на mousedown или hover - вы сломаете не только UX но и Accessibility. Будьте осторожны

Но мы можем заранее предсказать, что пользователь кликнет на ссылку, еще до того как сработает не только mousedown но и hover :)

Я пытаюсь кликнуть на ссылку, и сайт мне говорит, что он знал об этом на 240-500 мс раньше того, как я это сделал o_O.

Опять магия ML? Нет. Существует паттерн: когда мы хотим на что-то кликнуть, мы замедляем движение (чтобы  было легче попасть мышкой на нужный элемент).

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

Библиотека называется futurelink. Ее можно использовать абсолютно с любым фреймворком:

var futurelink = require('futurelink');

futurelink({
    links: document.querySelectorAll('a'),
    future: function (link) {
        // `link` is probably going to be clicked soon
        // Preload everything you can!
    }
});

Вы передаете ей те DOM элементы, которые должны участвовать в прогнозе. Как только библиотека предсказывает нажатие на элемент, сработает callback. Вы можете его обработать и начать загружать страницу или залогинить пользователя заранее. 

Что пользователь хочет получить при переходе на страницу? В большинстве сценариев: HTML, CSS и немного картинок.

Все это можно реализовать за счет серверного рендеринга SSR.

В Angular для этого достаточно добавить одну команду:

ng add @nguniversal/express-engine

В большинстве случаев это работает замечательно, и у вас появится Server-Side Rendering.

Но что, если вы не на Angular? Или у вас большой проект, и вы понимаете, что внедрение серверного рендеринга потребует довольно большого количества усилий?

Здесь можно воспользоваться статическим prerender: отрендерить страницы заранее, превратить их в HTML. Для этого есть классный плагин для webpack, который называется  PrerenderSPAPlugin:

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      staticDir: path.join(__dirname, 'dist'),
      routes: [ '/', '/about', '/some/deep/nested/route' ],
    })
  ]
}

В нем нужно указываете директорию, куда вы хотите сохранять пререндеренные urls, и перечислить те, для которых нужно отрендерить контент заранее.

Но вы можете сделать все еще проще: зайти в свое SPA приложение и написать:

document.documentElement.outerHTML

получить пререндеренный HTML и воспользоваться им: сохранить это в файл. И вот у вас за пару минут появилась пререндеренная страница. Если ваша страница меняется очень редко, это неплохой вариант (как бы глупо он ни выглядел).

Заключение

Несмотря на то что Perceived Performance — очень субъективная метрика, ее можно и нужно измерять. Об этом говорилось в докладе Виктора Русаковича на FrontendConf 2019.

Он рассказывал о том, что в Скелетоне есть анимация в плавном фоне, и слева направо она воспринимается на 68% быстрее, чем справа налево. Есть разные исследования, которые показывают, что неправильно примененные техники Perceived Performance могут визуально сделать сайт хуже. Поэтому обязательно тестируйте.

Сделать это можно при помощи сервиса под названием Яндекс.Толока.

Он позволяет выбирать, какой из двух объектов лучше. Можно сделать два видео с разной анимацией и предложить пользователям оценить, какой из них быстрее. Это стоит недорого, скейлится очень хорошо. В сервисе зарегистрировано огромное количество людей из разных регионов, то есть можно делать разнообразные выборки по разным параметрам. Благодаря этому есть возможность провести серьезное UX-исследование за небольшую сумму. 

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

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

Подписывайтесь на Telegram канал obenjiro_notes и Twitter obenjiro.

Хотите стать спикером FrontendConf 2021? Если вам есть что сказать, вы хотите подискутировать или поделиться опытом — подавайте заявку на доклад

Билеты можно приобрести здесь. Вы можете успеть купить их до повышения цены!

Следите за нашими новостями — Telegram, Twitter, VK и FB и присоединяйтесь к обсуждениям.