Ускоряем сервис с клиентской стороны в несколько раз. Ajax + предзагрузка в фоне + при наведении курсора

в 9:42, , рубрики: ajax, Блог компании Luxoft, Веб-разработка, Клиентская оптимизация, предзагрузка страниц, ускорение сайта, метки: , ,

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

Ускоряем сервис с клиентской стороны в несколько раз. Ajax + предзагрузка в фоне + при наведении курсора

Ajax-переходы между страницами с моментальным возвратом назад

Буквально пару дней назад на эту тему была статья. Так сложилось, что моя статья на тот момент уже была готова, поэтому часть про ajax частично повторит коллегу. Воспримите ее как yet another опыт и доказательство того как это просто. Кроме того, моя альтернатива затронет несколько важных аспектов, которые не были рассмотрены у коллеги, и более глубоко погрузится в практическую часть, позволив с нуля полностью реализовать навигацию на базе кода из статьи.

Реализовать ajax-переходы в простом виде не так уж сложно. Сразу оговоримся:
Наверняка существуют готовые библиотеки, которые позволяют это сделать еще проще и правильнее. Цель этой статьи показать что это несложно сделать руками. Зачем? Например, чтобы адаптировать скрипт именно для вашего сайта. Пример в статье покрывает далеко не все аспекты ajax-переходов (например, не будет затронута работа без history api (для таких браузеров сохранится обычная навигация), или контроль версий скриптов). Все аспекты не поместятся в статью, но обязательно будут упомянуты.

Поясните вкратце, о чем говорит иностранец?

Видели ВКонтакте аудиоплеер, работающий без остановки при переходах между страницами? Это потому что страницы не перезагружаются, при клике на каждую ссылку javascript посылает запрос на сервер, получает в ответ страницу, и заменяет ей содержимое текущей страницы. Очевидные плюсы такого подхода (кроме возможности сделать непрерывный плеер): более глубокое дробление сайта на компоненты позволяет выиграть в скорости загрузки страницы (например, можно не перезагружать статичную шапку сайта), а уже загруженные скрипты не нужно загружать повторно что экономит запросы, что тоже дает прирост скорости (не всегда эту задачу решает кэш в браузере, например в случае подключаемых сторонних динамических библиотек). В моем случае переход на ajax продиктован невозможностью кешировать Google Charts, js которых отдавался больше секунды при каждом переходе и не кешировался, а сохранять его локально запрещала лицензия.

Подготовка серверной стороны.

MVC, JSON, упаковка содержимого страницы.

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

При переходах по ссылкам страница теперь будет отдаваться в JavaScript, поэтому ее надо упаковать в каком-то формате. Для простоты используем JSON.
Разделим данные на компоненты: html код, inline-скрипты, заголовок страницы, и информацию о redirect-е. Напишем функцию, которая будет получать на вход эти компоненты, и возвращать в JS результат запроса при клике на ссылку (ниже псевдокод, его основой был php, но у вас может быть другой язык):

function response(html, options = null) {
	define res; //Объявление массива с данными для JSON
	res['html'] = html; //поле html содержит html-код страницы.
						//Положительный ответ всегда будет содержать параметр html, а негативный – error.
	if (options) {
		if (options['script']) {
			res['script'] = options['script'];
		}
		if (options['redirect']) {
			res['redirect'] = options['redirect'];
		}
		if (options['title']) {
			res['title'] = options['title'];
		}
	}

	//Обозначает что ответ сервера будет в формате json и кодировке UTF-8
	header('Content-Type: application/json; charset=UTF-8');

	print json_encode($res); //Заворачиваем массив res в json и отдаем результат по http.
	exit(); //Прекращаем выполнение скрипта сразу после того как json выведен.
}

Теперь чтобы отдать страницу при ajax-переходе достаточно будет вызвать функцию response().
Например, для php, это будет выглядеть так:

function wrapAbout($employees) {
	return "<div>Компания основана в 2012 году. Количество сотрудников на сегодня: {$employees}</div>";
}
$employees = getEmployeesNumber();
$html = wrapAbout($employees);
response($html, array('title' => 'О компании'));
Обработка ошибок.

Не всегда возможно вернуть страницу. Информацию об ошибках мы будем обрабатывать отдельно.
Для этого напишем функцию:

function error(num, msg) {
	define res; //Массив, который будет содержать данные JSON
	define res['error']; //Массив с информацией об ошибке
	res['error']['num'] = $num; //Номер ошибки
	res['error']['msg'] = $msg; //Сообщение на случай если номер ошибки не обработан на клиенте
	
	print json_encode(res);
	exit();
}

Теперь, если слетело исключение, или переданы неправильные данные, реагируем так:


if ($_POST['password'] != $password) {
	error(1, 'Invalid password');
}
Поддержка прямых запросов.

А что если пользователь не переходил по ссылке, а зашел напрямую, или нажал F5? Такие случаи тоже нужно обрабатывать. Для этого мы и оптимизировали код так, чтобы возвращалась строчка с html. Достаточно отдавать этот html напрямую, а не в виде json в случаях, когда запрос пришел не из ajax. Как это понять? При запросах из ajax будем передавать дополнительный параметр ajax=1, так мы всегда сможем различить откуда пришел запрос и как отдавать результат.

Клиентская часть

Подготовка клиентской части

Здесь нас тоже ждет небольшой рефакторинг. Появляется такое понятие как «состояние страницы». Мы больше не перемещаемся между страницами, все происходит на одной, поэтому состояния нужно изолировать. Мы ведь хотим работать с кнопкой back, моментально возвращаясь к предыдущему состоянию страницы, и при этом хотим иметь в окружении javascript все переменные. Для этого будем хранить состояние в одной сущности, хранящей полное текущее состояние. Звучит страшно, выглядит тривиально:

cur = {somevar: true};

Будем хранить все переменные страницы внутри cur — тогда мы легко сможем сохранять ее состояние.
Заведем вспомогательный кэш, где будет храниться содержимое предыдущих страниц на пару шагов назад:

var globalPageHtml = [];
Обработка ссылок

Ссылки больше не ссылки, а кнопки, по нажатию на которые выполняется JavaScript, загружающий другую страницу через ajax. При этом нужно сохранить возможность обычного перехода, в случае если ajax-навигация недоступна (например, если не поддерживается history api, и мы не можем поменять URL, а использовать хэш-навигацию не хотим). И нужно поддерживать ctrl+click для открытия ссылки в новой вкладке. Переделаем все ссылки подобным образом:

<a onclick="go('/someurl', null, event); return false;" href="/someurl">Some link</a>

Что такое go? Это самый главный метод, именно он будет заниматься навигацией, о нем будет ниже.

Работа с URL и history

Для работы с историей навигации воспользуемся популярной библиотекой history.js. Это позволит в будущем поддержать заодно и хэш-навигацию, но пока мы строим примитивную ajax навигацию, а значит отстающие от прогресса браузеры можно оставить за бортом.
Проинициализируем работу с History (предполагается что проект использует jquery), чтобы поддержать работу с кнопкой back:


var History = window.History;
History.enabled = !!(window.history && history.pushState); //Temp turn off html4
if (History.enabled) {
    $(window).bind('statechange', function() {
      var State = History.getState();

      //replacingState - бит состояния, который проставляется при ajax-переходе по ссылке. В этом случае данные в URL меняем мы сами, а не происходит смена состояния из-за нажатия кнопки back в браузере или на клавиатуре. Эта переменная позволяет отличить когда нужно отреагировать на смену состояния ajax-переходом назад и восстановить предыдущее состояние.
      if (!cur.replacingState) {
        cur = State.data; //Состояние, если оно есть, хранится в history api, который абстрагирован для нас через history.js
        if (globalPageHtml[State.url]) { // Есть ли страница у нас в кэше?
          $('#content').html(globalPageHtml[State.url].html); //Если есть, то вставляем ее содержимое в блок content, куда мы помещаем html всех страниц на которые переходим. Html сохранили в глобальном кэше.
          if (globalPageHtml[State.url].scripts) {// Если на странице были скрипты, необходимые для ее инициализации, их надо исполнить, jquery вырезает выполнение скриптов при вставке в DOM.
            var fragment = document.createDocumentFragment();
            globalPageHtml[State.url].scripts.each(
            function(nodeIndex, scriptNode) {
              fragment.appendChild(scriptNode); //Хороший способ исполнить скрипты, сохранив информацию о них на странице для последующего сохранения в кэш - вставить их в DOM.
            });
            document.getElementById('content').appendChild(fragment); //А чтобы не дергать DOM много раз, вставим все скрипты единым фрагментом.
          }
          window.scrollTo(globalPageHtml[State.url].scroll[0], globalPageHtml[State.url].scroll[1]); //Важно не забыть при переходе назад восстановить скроллинг страницы, его мы тоже храним.
          delete globalPageHtml[State.url]; //Чистим кэш
        } else {
          go(State.url, null, null, true); //Бывает так что кнопка "назад" нажата, а страницы в кэше нет (например, когда мы сделали несколько подряд прямых заходов на URL внутри сервиса). Тогда мы делаем прямой переход по ссылке, но не сохраняем состояние текущей страницы.
        }
      }

      cur.replacingState = false;
  });
}
Основной JavaScript для Ajax-переходов

Вот и добрались до самого важного. Функция go вызывается при нажатии на ссылку:


function go(url, query, event, back) {
  if (!History.enabled || (event && (event.ctrlKey || event.metaKey))) { //Работаем с ctrl (и command в маках) и отсутствием history api
    var requestString = url;
    if (query) {
      requestString += query;
    }
    if (!History.enabled) {
      window.location = requestString; //Если не умеем history api, то просто переходим по ссылке
    } else {
      window.open(requestString, '_blank'); //А если был нажат ctrl, то открываем ссылку в новом окне/табе, несмотря на ajax
    }
    return;
  }

  var query = query || {};

  var urlString = (url.length > 0) ? url : $(location).attr('href').split('?', 1); //Если URL не задан (пустая строка), то используем текущий
  if (jQuery.param(query).length > 0) {
    urlString += '?' + jQuery.param(query); //Склеиваем параметры для запроса (для удобства мы передаем их сюда в виде массива)
  }
  urlString = window.decodeURI(urlString); //Эскейпим внутренности запроса. Строковое представление запроса понадобится нам для подставления его в URL через history api. Ведь если мы, например, не поддерживаем rewrite, и хотим перейти в пост номер N не по красивой ссылке /post/N, а по-старинке /post?item=N - мы все еще хотим увидеть это при ajax переходе.

  $.extend(query, {ajax: 1}); //Добавляем информацию о том что у нас ajax запрос (она нужна только серверу, в строку url мы ее не передаем)

  $.ajax({
      url: url,
      global: false,
      type: "GET",
      data: query,
      dataType: "json",
      success: function (res) {
        if (res.html) { //ajax ответ всегда содержит поле html, по его наличию мы понимаем что все в порядке, и сервер не вернул фигню
          if (!back) { //Выше мы поняли, что не хотим сохранять состояние страницы, если была нажата кнопка назад
            var currentUrl = $(location).attr('href');
            globalPageHtml[currentUrl] = new Object(); //Запишем состояние страницы в кэш, ключом будет ее url
            globalPageHtml[currentUrl].html = $('#content').html();
            globalPageHtml[currentUrl].scroll = getScrollXY(); //Не забываем про состояние скроллинга
            globalPageHtml[currentUrl].scripts = $('script:not([src])'); //Часто забывают про скрипты. Но без них состояние страницы неполноценно. Мы уже договорились выше хранить все состояние в объекте cur. Но бывает так, что какие-то скрипты все равно остаются на странице (например, их вставляют некподконтрольные вас сторонние модули), и тогда единственное решение - их сохранить, чтобы потом инициализировать страницу заново (disclaimer: это будет работать не для всех случаев). Сохраняем только inline блоки кода, сторонние скрипты не являются частью системы и в рамках нашей примитивной системы загружены с самого начала (замечание об этом будет ниже).

            //Начинаем работать со state, history api, и урлом
            cur.replacingState = true;
            History.replaceState(cur, $(document).attr('title') || null, window.decodeURI(currentUrl) || null); //Текущее пустое состояние заменяем на содержимое объекта cur, который потом, при возврате на эту страницу через back, подставим обратно
            cur.replacingState = true;
            History.pushState(null, res.title || null, urlString); //Заменяем URL и заголовок на новые
          }

          window.scrollTo(0, 0); //На новой странице скроллинг надо сбросить
          document.getElementById('content').innerHTML = res.html; //И, наконец, обновить содержимое страницы

          cur = {};

          $('script:not([src])').each( //Исполняем скрипты (они не исполняются при обращении через innerHTML, а метод html() у jquery и вовсе вырезал бы нам скрипты перед вставкой)
          function(nodeIndex, scriptNode) {
            try {eval(scriptNode.innerHTML);} catch(e) {/*Do nothing*/}
          });
        } else {
          errorAlert(res.error || 'Unable to load page ' + url); //errorAlert - метод, который показывает окошко с ошибкой, может быть любой
        }
      },
      error: function (res) {
        errorAlert(res.error || 'Unable to load page ' + url);
      }
   });
}

Все! Ajax-навигация готова.

Важные замечания об аспектах, не учтенных в этом подходе

Более серьезная ajax навигация будет учитывать работу с хэшами для IE, но на этом ограничения реализованной нами системы не ограничиваются.

Загрузка разных скриптов для разных страниц

В текущем виде предполагается что все JS-библиотеки будут загружены при первом заходе на любую страницу. Это хорошо, когда у вас небольшой сервис, но в крупном проекте хотелось бы загружать только тот js, который актуален только для данного раздела сайта. Поэтому более серьезная ajax-навигация должна уметь загружать и выгружать сторонние js-модули. Реализовать это несложно: достаточно передавать с сервера еще и список url скриптов, которые надо загрузить, использовать какой-нибудь готовый загрузчик, а при следующем ajax переходе выкидывать загруженный код (а можно и оставить, если борьба только за скорость, память на такое не жалко).

Версии скриптов

В js-коде баг и вы только что выкатили новую версию? Проблемка: при ajax-навигации скрипты могут не обновляться днями — открытая в браузере страница, внутри которой скрипты не скачиваются при каждом запросе, а давно лежат в памяти, мешает пользователю получить обновление. Выход есть: передавать номера версий js-модулей с сервера при каждом ajax переходе. Если модуль обновился, загружать его новую версию, вытеснив из памяти предыдущую. То же касается css.

Глобальный кэш ajax

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

Предзагрузка страниц

После того как у нас есть ajax-навигация, нам доступны простые, но супер функциональные фишки для ускорения работы сервиса. Эффект от этих простых приемов превосходит самые смелые ожидания: работа сайта становится моментальной. И поможет нам в этом предзагрузка. Ее можно разделить на два принципиально разных приема: предзагрузка в фоне, и при наведении курсора. Первый доступен для фиксированного небольшого количества разделов сайта, связанных с текущей страницей. Например, если на странице Настройки есть пять табов — мы можем после загрузки основной страницы в фоне загрузить эти табы, чтобы при клике они сразу открывались. Такой прием не прокатит для списка товаров (и вообще любого списка ссылок) — нельзя подгрузить страницу каждого товара из результатов поиска, если список больше 5 элементов. Тут поможет предзагрузка при наведении мыши.

Предзагрузка в фоне

Допустим у нас есть несколько вкладок (вместо вкладок могут быть любые разделы и ссылки, которые разумно открывать моментально на открытой странице). Мы находимся в одной из них. Просто выполним в конце загрузки страницы метод preloadTabs(currentTab); В него передадим текущую вкладку, чтобы север вернул информацию только о недостающих вкладках, не загружая уже открытую. Общий смысл подхода: вместо того чтобы сразу загружать информацию о соседних вкладках, что занимает время, загрузим только одну вкладку, а остальные загрузим в фоне сразу после готовности открытой вкладки. Эффект фантастический. Разберем на примере:


function preloadTabs(current) {
    cur.preloading = 1;
    //Любым способом отправляем ajax запрос к предзагрузчику (мы уже умеем отвечать на ajax запросы и отдавать страницы одной строчкой, так что проблем это не вызовет), передав ему текущую страницу. Получив ответ, выполним в качестве callback функцию ниже (в ней createElement создает новые DOM-ноды, можете заменить на свой любимый способ это делать - код на правах псевдокода, его работа не проверялась).
   function(data, tab1Html, tab2Html, tab3Html) {
      var page = $('#content');

      if (cur.section != 'tab1') {
        page.append(createElement('div', {innerHTML: tab1Html}));
      }

      if (cur.section != 'tab2') {
        page.appendChild(ce('div', {innerHTML: tab2Html}));
      }

      if (cur.section != 'tab3') {
        page.appendChild(ce('div', {innerHTML: tab3Html}));
      }

      cur.preloading = 2; //0 - табы не предзагружены, 1 - запрос уже отправлен и табы предзагружаются, 2 - табы готовы
    }

Функция, которая будет стоять в обработчике кликов по ссылкам и собственно переключать табы:


function switchTab(section) {
    if (cur.section == section) { //Переключаемся сами на себя
      return;
    }

    var doSwitch = function(section) {
      hideCurrentSection(); //скрываем блок с id равным текущей открытой секции
      cur.section = section; //меняем id секции
      showCurrentSection(); //показываем новую секцию, функция делает видимым блок, который был добавлен через append (с display:none разумеется) в DOM при предзагрузке
      //Не забываем менять урл раздела через history или хэштеги, если это актуально
    };

    if (!cur.preloading || cur.preloading == 1) { // Если еще не предзагрузили
      $('#load_progress').show(); //индикатор загрузки, если кликнули по ссылке раньше, чем она предзагрзилась
      if (cur.preloading != 1) {
        preloadTabs(cur.section);
      }

      var waitPreload = setInterval(function() {
        if (cur.preloaded == 2) { //Табы загружены
          $('#load_progress').hide(); //индикатор загрузки можно убирать
          clearInterval(waitPreload);
          doSwitch(section);
        }
      }, 100);
    } else {
      doSwitch(section);
    }
  }

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

Предзагрузка при наведении мыши

Почти при каждом клике есть значительная (в миллисекундах) задержка между самим кликом, и периодом, когда курсор наведен на ссылку. Это позволяет успеть загрузить ссылку до момента нажатия на нее, превратив переход в моментальный. Тут нужно быть осторожнее: а что, если пользователь ведет курсор из точки A в B, и по дороге попалась наша ссылка (а заодно пара десятков других — не будем же мы бомбить браузер и сервис ajax запросами, если курсор просто проехался по ссылкам). Оказывается, перед кликом курсор задерживается над ссылкой, и можно вычислить такой минимальный диапазон времени между кликом, но после наведения курсора на ссылку, за который можно успеть загрузить страницу, но загрузка начнется через достаточное время, чтобы точно понять что мы навели курсор с намерением кликнуть, а не просто проездом. Дополнительно будем кешировать строго по одной ссылке, если предзагружаем новую, то старая вытесняется из памяти.

По наведению курсора будем вызывать функцию (например, для списка друзей загружаем профили пользователей):


function preloadUser(user_id) {
  cur.pendingPreloadUid = user_id;
  cur.pendingPreload = setTimeout(doPreloadUser, 50); //50ms - достаточно чтобы понять что курсор задержался над ссылкой
}

По событию mouseout (курсор увели со ссылки), будем вызывать:


function cancelPreloadUser() {
  clearTimeout(cur.pendingPreload);
}

А если удалось дождаться 50ms, то предзагрузим ссылку:


function doPreloadUser() {
    var uid = cur.pendingPreloadUid;
    if (cur.cachedUser && (uid != cur.cachedUser.uid)) { //Если что-то уже закешировано другое, надо удалить это из памяти, чтобы всегда только одна ссылка занимала место
      unloadUser(cur.cachedUser.uid);
    } else {
      cur.cachedUser = new Object();
    }
    cur.cachedUser.uid = uid;

    //Здесь делаем ajax запрос, и сохраняем результат в глобальный кэш. Лучше всего в аяксовый кэш, который сохраняет результаты любых запросов, тогда загруженного пользователя не нужно будет специально показывать в отдельном методе, достаточно будет просто обычным образом запросить аякс переход по ссылке, результат для которой уже будет готов, потому что мы заранее положили его в кэш. Но можно и просто сохранить в cachedUser, и вывести отдельным методом.
}

Не забываем выгружать ссылку, если пришел запрос на предзагрузку новой, в функции unloadUser(uid) нужно очищать глобальный кэш.

Ускоряем сервис с клиентской стороны в несколько раз. Ajax + предзагрузка в фоне + при наведении курсора

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

Автор: Silf

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js