Оптимизация загрузки JavaScript компонентов сайта для Google Page Speed

Оптимизация загрузки JavaScript компонентов сайта для Google Page Speed

Несколько недель назад автор просматривал некоторые показатели производительности своего сайта. В частности, хотел посмотреть, как сайт справляется с first input delay (FID). Мой сайт - это просто блог (и на нем мало JavaScript), поэтому я ожидал увидеть довольно хорошие результаты.

Input delay, составляющая менее 100 миллисекунд, обычно это воспринимается пользователями как мгновенно, поэтому цель производительности, к которой должен стремиться любой сайт - FID <100 мс для 99% загрузок страницы.

К удивлению, FID сайта автора составил 254 мс. 

Короче говоря, не удаляя никакой функциональности с моего сайта, я смог получить свой FID менее 100 мс . Но я уверен, что вам, читателям, будет интереснее:

  • Как я подошел к диагностике проблемы.
  • Какие конкретные стратегии и приемы я использовал, чтобы это исправить.

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

Я называю стратегию: бездействовать до срочности (idle until urgent) .

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

Я называю стратегию: бездействовать до срочности.

Пишет автор

Проблема с производительностью сайта 

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

Причина может быть в задержке, если основной поток браузера занят чем-то другим (обычно выполняет код JavaScript). Таким образом, чтобы диагностировать более высокий, чем ожидалось FID, вы должны начать с создания трассировки производительности вашего сайта по мере его загрузки и искать отдельные задачи в главном потоке, выполнение которых занимает много времени. Затем, определив эти длинные задачи, вы можете попытаться разбить их на более мелкие задачи.

Вот что я нашел, когда делал трассировку производительности моего сайта:

site performance

Отслеживание производительности JavaScript сайта при загрузке страницы (с включенным network/CPU throttling)

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

bundle performance

Часть этого кода это webpack boilerplate и babel polyfills, но большая часть этого кода принадлежит main функции, выполнение которой само по себе занимает 183 мс:

main

В Main функции всего лишь инициализируются  UI компоненты и затем запускается аналитика:

const main = () => {
  drawer.init();
  contentLoader.init();
  breakpoints.init();
  alerts.init();

  analytics.init();
};

main();

Так что же так долго выполняется?

Если вы посмотрите на диаграмму, вы не увидите никаких отдельных функций, которые явно занимают большую часть времени. Большинство отдельных функций выполняются менее чем за 1 мс, но когда вы добавляете их все, требуется более 100 мс для их запуска в одном синхронном стеке вызовов.

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

На первый взгляд может показаться, что очевидное решение состоит в том, чтобы расставить приоритеты для каждого из компонентов в функции main () (они на самом деле уже в порядке приоритетов), сразу инициализировать компоненты с наивысшим приоритетом, а затем отложить инициализацию других компонентов как subsequent task.

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

  • Откладывание инициализации UI компонента помогает, только если компонент еще не отрисован. Если он уже обработан, тогда отложенная инициализация создает риск того, что пользователь пытается с ним взаимодействовать, и он еще не готов.
  • Во многих случаях все UI компоненты либо одинаково важны, либо зависят друг от друга, поэтому их необходимо инициализировать одновременно.
  • Иногда отдельным компонентам требуется достаточно много времени для инициализации, чтобы они заблокировали основной поток, даже если они выполняются в своих собственных задачах.

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

Жадные компоненты

Прекрасный пример компонента, которому действительно нужно разбить код инициализации, можно увидеть, приблизив масштаб к этой трассировке производительности. Посередине функции main () вы увидите, что один из компонентов использует API-интерфейс Intl.DateTimeFormat:

date

Создание этого объекта заняло 13,47 миллисекунд!

Дело в том, что экземпляр Intl.DateTimeFormat создается в конструкторе компонента, но на самом деле он не используется, пока он не понадобится другим компонентам, которые ссылаются на него для форматирования дат. Однако этот компонент не знает, когда на него будут ссылаться, поэтому он сразу создает экземпляр объекта Intl.DateTimeFormat.

Но правильная ли это code evaluation strategy?

Code evaluation strategy

При выборе Code evaluation strategy для потенциально дорогого кода большинство разработчиков выбирают одно из следующего:

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

Это, вероятно, две самые популярные стратегии оценки, но после опыта рефакторинга сайта автора статьи, он пришел к тому, что это, вероятно, ваши два худших варианта.

Недостатки Eager evaluation

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

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

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

Недостатки Lazy evaluation

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

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

Для некоторых вещей (например, загрузка дополнительного контента из сети) имеет смысл отложить его до тех пор, пока пользователь не запросит его. Но для большей части кода, который вы обрабатываете (например, чтение из localStorage, обработка больших наборов данных и т.д), Вы определенно хотите, чтобы это произошло до того, как это понадобится для работы юзеру вашего сайта.

Другие варианты

Другие стратегии оценки, которые вы можете выбрать из всех, используют что-то между двумя предыдущими подходами. Автор не уверен, что следующие две стратегии имеют официальные названия, но он их назвал отложенной оценкой (deferred evaluation)  и простой оценкой (idle evaluation):

  • Deferred evaluation: где вы планируете запускать код в будущей задаче, вы используете что-то вроде setTimeout.
  • Idle evaluation: тип отложенной оценки, при котором вы используете API, например requestIdleCallback, для планирования выполнения кода.

Обе эти опции обычно лучше, чем Lazy evaluation или Eager evaluation, потому что они с гораздо меньшей вероятностью приведут к отдельным долгим выполнениям задач, которые блокируют ввод. Это связано с тем, что, хотя браузеры не могут прерывать какую-либо отдельную таску для ответа (это может привести к сбою сайтов), они могут запускать задачу между очередями запланированных задач, и большинство браузеров делают это, когда эта задача вызвана пользователем. Это называется input prioritization.

Другими словами: если вы убедитесь, что весь ваш код выполняется в коротких, определенных задачах (предпочтительно менее 50 мс), ваш код никогда не будет блокировать user input.

Важно:

Хотя браузеры могут выполнять input callbacks перед queue tasks, они не могут запускать обратные input callbacks перед queued microtasks. А поскольку промисы и асинхронные функции выполняются как microtasks, преобразование вашего синхронного кода в код на основе промисов, не помешает ему заблокировать user input!

Если вы не знакомы с разницей между задачами и микрозадачами, посмотрите это видео!

Учитывая то, что мы обсудили, можно переписать мою функцию main() для использования setTimeout() и requestIdleCallback(), чтобы разбить код инициализации на отдельные задачи:

const main = () => {
  setTimeout(() => drawer.init(), 0);
  setTimeout(() => contentLoader.init(), 0);
  setTimeout(() => breakpoints.init(), 0);
  setTimeout(() => alerts.init(), 0);

  requestIdleCallback(() => analytics.init());
};

main();

Однако, хотя это лучше, чем раньше (много небольших задач против одной длинной задачи), как мы выяснили выше, это, вероятно, все еще недостаточно хорошо. Например, если я отложу инициализацию моих компонентов пользовательского интерфейса (в частности, contentLoader и box), они с меньшей вероятностью будут блокировать user input, но они также рискуют быть не готовыми, когда пользователь пытается взаимодействовать с ними!

И хотя откладывание моей аналитики с помощью requestIdleCallback(), вероятно, является хорошей идеей, любые взаимодействия, которые меня волнуют до следующего периода простоя, будут пропущены. И если до того, как пользователь покинет страницу, времени простоя нет, эти обратные вызовы могут вообще никогда не выполняться!

Так что, если у всех стратегий есть недостатки, какую из них выбрать?

Idle Until Urgent

Idle Until Urgent имеет недостатки, которые мы описали в предыдущем разделе. В худшем случае он имеет те же характеристики производительности, что и lazy loading, и в лучшем случае он вообще не блокирует интерактивность, поскольку выполнение происходит в idle периоды.

Стоит также упомянуть, что эта стратегия работает как для отдельных задач (computing values idly), так и для нескольких задач (упорядоченная очередь задач). Сначала я объясню вариант с одной задачей (idle value), потому что его немного легче понять.

Idle values

Выше было показано, что объекты Intl.DateTimeFormat могут быть довольно дорогими для инициализации, поэтому, если экземпляр не нужен сразу, лучше инициализировать его в течение idle периода простоя. Конечно, как только это необходимо, вы хотите, чтобы он существовал, так что это идеальный кандидат для Idle Until Urgent evaluation.

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

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    this.formatter = new Intl.DateTimeFormat('en-US', {
      timeZone: 'America/Los_Angeles',
    });
  }

  handleUserClick() {
    console.log(this.formatter.format(new Date()));
  }
}

Экземпляры MyComponent выше делают две вещи в своем конструкторе:

  • Добавляет event listener для взаимодействия с пользователем.
  • Создает объект Intl.DateTimeFormat.

Этот компонент прекрасно иллюстрирует, почему вам часто нужно разделять задачи внутри отдельного компонента (а не только на уровне компонента).

В этом случае действительно важно, чтобы event listener'ы запускались сразу, но не важно, чтобы экземпляр Intl.DateTimeFormat создавался до тех пор, пока он не понадобится обработчику событий. Конечно, мы не хотим создавать объект Intl.DateTimeFormat в обработчике событий, потому что тогда его медлительность задержит запуск этого события.

Итак, вот как мы можем обновить этот код, чтобы использовать стратегию ожидания до срочности (idle-until-urgent). Обратите внимание, я использую вспомогательный класс IdleValue, который я объясню ниже:

import {IdleValue} from './path/to/IdleValue.mjs';

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    this.formatter = new IdleValue(() => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }

  handleUserClick() {
    console.log(this.formatter.getValue().format(new Date()));
  }
}

Как видите, этот код не сильно отличается от предыдущей версии, но вместо назначения this.formatter новому объекту Intl.DateTimeFormat мы назначем this.formatter объекту IdleValue, который я передаю для инициализации функции.

Класс IdleValue работает так, как он планирует запуск функции инициализации в течение следующего idle периода. Если idle период происходит до обращения к экземпляру IdleValue, блокировка не происходит и значение может быть возвращено немедленно по запросу. Но если, с другой стороны, на значение ссылаются до следующего idle периода, тогда запланированный callback в "режиме ожидания" отменяется, и функция инициализации запускается немедленно.

Вот суть того, как реализован класс IdleValue

export class IdleValue {
  constructor(init) {
    this._init = init;
    this._value;
    this._idleHandle = requestIdleCallback(() => {
      this._value = this._init();
    });
  }

  getValue() {
    if (this._value === undefined) {
      cancelIdleCallback(this._idleHandle);
      this._value = this._init();
    }
    return this._value;
  }

  // ...
}

Хотя включение класса IdleValue в моем примере выше не потребовало многих изменений, оно технически изменило публичный API (this.formatter и this.formatter.getValue()).

Если вы находитесь в ситуации, когда вы хотите использовать класс IdleValue, но не можете изменить свой публичный API, вы можете использовать класс IdleValueс геттерами ES2015:

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    this._formatter = new IdleValue(() => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }

  get formatter() {
    return this._formatter.getValue();
  }

  // ...
}

Так же вы можете использовать хэлпер defineIdleProperty() (который использует Object.defineProperty() под капотом):

import {defineIdleProperty} from './path/to/defineIdleProperty.mjs';

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());

    defineIdleProperty(this, 'formatter', () => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }

  // ...
}

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

Хотя в этом примере используется объект Intl.DateTimeFormat, он также, вероятно, является хорошим кандидатом для любого из следующих экшинов:

  • Обработка больших наборов values.
  • Получение значения из localStorage (или cookie).
  • Запуск getComputedStyle(), getBoundingClientRect() или любого другого API, который может потребовать пересчета стиля или макета в основном потоке.

Idle task queues

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

В таких случаях вам действительно нужна очередь, в которой вы можете запланировать выполнение нескольких задач (функций), когда браузер имеет idle time. Очередь будет запускать задачи, когда это возможно, и она будет приостанавливать выполнение задач, когда ей необходимо вернуться к браузеру (например, если пользователь взаимодействует с сайтом).

Для этого я создал класс IdleQueue, и вы можете использовать его следующим образом:

import {IdleQueue} from './path/to/IdleQueue.mjs';

const queue = new IdleQueue();

queue.pushTask(() => {
  // Some expensive function that can run idly...
});

queue.pushTask(() => {
  // Some other task that depends on the above
  // expensive function having already run...
});

Примечание:

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

Как и в случае idly-initialized property strategy, показанной выше, очереди бездействующих задач также могут запускаться немедленно в тех случаях, когда требуется немедленный результат их выполнения («срочный» случай).

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

В идеальном мире все API-интерфейсы JavaScript были бы неблокирующими, асинхронными и состоящими из небольших кусков кода, которые могут по желанию возвращаться в основной поток. Но в реальном мире у нас часто нет другого выбора, кроме как быть синхронными из-за устаревшей code base или интеграции со сторонними библиотеками, которые мы не контролируем.

Сильная строна idle-until-urgent pattern в том что он может быть легко применен к большинству веб-сайтов без необходимости масштабного переписывания архитектуры.

Guaranteeing the urgent

Я упоминал выше, что requestIdleCallback() не дает никаких гарантий того, что callback когда-либо будет выполняться. И когда я разговариваю с разработчиками о requestIdleCallback(), это основное объяснение, которое я слышу, почему они его не используют. Во многих случаях вероятность того, что код может не работать, является достаточной причиной, чтобы не использовать его - чтобы обеспечить безопасность и синхронность своего кода (и, следовательно, блокировки).

Прекрасный пример этого - код google analytics. Проблема с аналитикой в том, что во многих случаях его нужно запускать, когда страница выгружается (например, отслеживание кликов исходящих ссылок и т.д), И в таких случаях requestIdleCallback() просто не является опцией, поскольку callback никогда не запускается. А поскольку библиотеки аналитики не знают, когда в жизненном цикле страницы их пользователи будут вызывать свои API, они также склонны избегать рискованных действий и выполнять весь свой код синхронно (что является нежелательным, поскольку аналитический код определенно не критичен для взаимодействия с пользователем).

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

Persisting application state

Рассмотрим случай с Redux, который сохраняет состояние приложения в памяти, но также должен сохранять его в постоянном хранилище (например, localStorage), чтобы его можно было перезагрузить при следующем посещении страницы пользователем.

Большинство приложений с Redux, которые хранят состояние в localStorage, используют метод debounce, примерно эквивалентный следующему:

let debounceTimeout;

// Persist state changes to localStorage using a 1000ms debounce.
store.subscribe(() => {
  // Clear pending writes since there are new changes to save.
  clearTimeout(debounceTimeout);

  // Schedule the save with a 1000ms timeout (debounce),
  // so frequent changes aren't saved unnecessarily.
  debounceTimeout = setTimeout(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  }, 1000);
});

Хотя использование техники debounce лучше, чем ничего, это не идеальное решение. Проблема в том, что нет гарантии того, что когда запущенная функция не запустится, она не заблокирует основной поток в критическое для пользователя время.

Намного лучше запланировать запись localStorage на время простоя. Вы можете преобразовать приведенный выше код из debounce strategy в idle-until-urgent strategy следующим образом:

const queue = new IdleQueue({ensureTasksRun: true});

// Persist state changes when the browser is idle, and
// only persist the most recent changes to avoid extra work.
store.subscribe(() => {
  // Clear pending writes since there are new changes to save.
  queue.clearPendingTasks();

  // Schedule the save to run when idle.
  queue.pushTask(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  });
});

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

Аналитика

Другой идеальный вариант использования idle-until-urgent это аналитический код. Вот пример того, как вы можете использовать класс IdleQueue для планирования отправки ваших аналитических данных таким образом, чтобы гарантировать, что они будут отправлены, даже если пользователь закрывает вкладку или уходит до следующего idle периода.

const queue = new IdleQueue({ensureTasksRun: true});

const signupBtn = document.getElementById('signup');
signupBtn.addEventListener('click', () => {
  // Instead of sending the event immediately, add it to the idle queue.
  // The idle queue will ensure the event is sent even if the user
  // closes the tab or navigates away.
  queue.pushTask(() => {
    ga('send', 'event', {
      eventCategory: 'Signup Button',
      eventAction: 'click',
    });
  });
});

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

На самом деле, это хорошая идея - запускать весь код аналитики как "idly", включая код инициализации. А для библиотек, таких как analytics.js, чей API уже фактически является очередью, просто добавить эти команды в наш экземпляр IdleQueue.

Например, вы можете преобразовать последнюю часть стандартного фрагмента установки analytics.js из этого:

ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');

В это:

const queue = new IdleQueue({ensureTasksRun: true});

queue.pushTask(() => ga('create', 'UA-XXXXX-Y', 'auto'));
queue.pushTask(() => ga('send', 'pageview'));

Поддержеваемые браузеры для requestIdleCallback функции:

support browsers requestidlecallback

Для обратной совместимости можно написать запасной вариант с SetTimeout(все упомянутые здесь вспомогательные классы и методы используют этот "запасной вариант").

И даже в браузерах, которые изначально не поддерживают requestIdleCallback(), откат к setTimeout определенно все же лучше, чем использование этой стратегии, потому что браузеры могут по-прежнему устанавливать приоритеты ввода перед задачами, поставленными в очередь через setTimeout().

Насколько это на самом деле улучшает производительность?

В начале этой статьи, автор писал, что придумал эту стратегию, пытаясь улучшить ценность FID моего сайта. Он пытался разделить весь код, который запускался, как только основной bundle был загружен, но ему также нужно было убедиться, что мой сайт продолжает работать с некоторыми сторонними библиотеками, которые имеют только синхронные API (например, analytics.js).

Трассировка, которую мы смотрели до внедрения idle-until-urgent, имела одну задачу которая выполнялась 233 мс. Она содержала весь код инициализации. После реализации методов, которые мы рассмотрели в статье, вы можете видеть, что сейчас больше мелких и более коротких задач. На самом деле, самая длинная из них сейчас запускается всего 37 мс!

after performance log

Здесь очень важно подчеркнуть, что выполняется тот же объем работы, что и раньше, сейчас он распределен по нескольким задачам и выполняется в idle период.

И поскольку ни одна таска не превышает 50 мс, они не влиют на показатель Time to interactive (TTI), что очень хорошо для оценки быстродействия сайта:

stats

Длительность загрузки сраницы при FID сократилась на 67%

Code version FID (p99) FID (p95) FID (p50)
Before idle-until-urgent 254ms 20ms 3ms
After idle-until-urgent 85ms 16ms 3ms

Источник:  https://philipwalton.com/articles/idle-until-urgent/

0 113 02.04.2020 18:18

Комментарии:

Пожалуйста авторизируйтесь, чтобы получить возможность оставлять комментарии