slonoed

Разработка сторонних библиотек

May 22, 2017 • ☕️☕️ 12 min readRead in English

Довольно много продуктов используют сторонние (third party) библиотеки. Хорошим примером являются скрипты Google Analytics, Яндекс Метрики. Такие библиотеки грузятся с отдельного домена и предоставляют API к конечному продукту. Я занимался поддержкой и улучшением подобной библиотеки в компании flocktory. Расскажу об особенностях разработки такой библиотеки и освещу хорошие практики и расскажу как обойти основные ошибки. Доклад структурирован снизу вверх. Поэтому сначала я затрону вопросы написания кода, а к концу расскажу про общие вещи.

Принципы

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

  1. Не навреди (Безопасность)
  2. Работай правильно (Корректность)
  3. Тайминг важен (Оптимизация времени)
  4. Производительность важна

На самом деле пункты 3 и 4 могут меняться местами. Всё зависит от назначения продукта и сайтов, где он используется.

Рассмотрим каждый из них в отдельности.

Не навреди

Код библиотеки исполняется на стороннем сайте. Новые клиенты появляются каждый день. Поломка кода любого из сайтов может привести к огромным репутационным и денежным потерям.

Работай правильно

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

Тайминг важен

Для подобных библиотек важна скорость загрузки и старта. Ранний старт позволяет не пропустить первые действия пользователя.

Производительность важна

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

Глобальный объект

В самом идеальном варианте ваш код должен быть завёрнут в замыкание без какого либо способа быть вызванным снаружи. В случае, если вам нужно дать некий API, то лучшее, что вы можете себе позволить – это один глобальный объект, который не вызовет коллизий, например window.companyname или wingow.ga. Хорошей практикой будет дать возможность настраивать этот глобальный объект (noConflict), как это делает jquery (не навреди). Ваш глобальный объект не должен показывать наружу внутренности. Используйте замыкания, чтобы хранить ваши данные.

Каким должен быть глобальный объект? Варианты зависят от структуры вашего API.

Массив

Такой глобальный объект представляет собой массив, а вызов API выглядит как добавление нового элемента через метод push. Основным плюсом является то, что код сайта может использовать такой объект до того, как библиотека загрузилась: при загрузке библиотека достаёт элементы и делает вызовы к API. После чего подменяет метод push глобального объекта. Такой подход использует Google Analytics старой версии (до universal) и Google Tag Manager.

// Код сайта
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-XXXXX-X']);
_gaq.push(['_trackPageview']);

(function() {
  var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
  ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
  var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();

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

Функция

Такой подход использует Google Analytics (скрипт analytics.js). Плюсом является очевидный и понятный API — вызов функции. Однако нужно позаботиться о том, что переменная доступна до загрузки скрипта. Это можно сделать предоставив партнёрам готовый сниппет для вставки на сайт. Внутри сниппет должен создавать глобальную переменную и сохранять значения вызовов до того момента как библиотека будет загружена.

(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');

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

В этом варианте библиотека складывает вызовы в массив.

Объект

Может быть удобен в случае большого числа методов или иерархии методов. Примеры: jquery, moment, lodash. Используйте Object.freeze(), чтобы исключить изменения вашего объекта внешним кодом (работай правильно).

Никому нельзя доверять

Сайты до сих пор используют полифилы. Зачастую эти полифилы работают неправильно. Вы можете наткнуться на кривую реализацию window.Promise. Как-то я столкнулся со старой версией prototype.js, которая заменяла добавляла полифил в массивы, который ломал JSON.stringify. Или недавно столкнулся с тем, что метод map массива возвращал не совсем массив. Поэтому все необходимые зависимости нужно тащить с собой (работай правильно). Однако тут нужно держать баланс (тайминг важен). Например, притащить внутренний полифил Promise неплохая идея, если вы поддерживаете IE. В случае с prototype оказалось проще убедить клиента обновить версию, чем подключать свой сериализатор. Хорошим инструментом для упаковки полифилов является webpack и плагин ProvidePlugin. Его настройка проста и понятна:

new webpack.ProvidePlugin({
  Promise: 'promise-package-polyfill',
  fetch: 'fetch-package-polyfill'
}),

Как результат все window.Promise в вашем коде будут заменены на полифил. Думаю не надо объяснять, что основываться на наличии jquery на сайте не стоит 😁.

Сторонние зависимости

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

Обратная совместимость (работай правильно, не навреди)

Если ваш скрипт предоставляет какой-либо API, то он там навечно. Нельзя пойти ко всем кто использует ваш скрипт и попросить изменить код или обновить версию. Выпуск новой несовместимой версии приведёт лишь к тому, что у вас будет две версии на поддержку (привет python!). Если вам нужно кардинально новое поведение метода API, пусть это будет новый метод. Могу посоветовать отличный доклад Рика Хикки о зависимостях.

Запросы к серверу

Есть три основных способа взаимодействия вашей библиотеки с вашим сервером.

CORS

Поддерживается во всех браузерах (IE>=8). Хорош тем, что по сути это единственный нормальный API браузера для запроса к стороннему серверу. Вы можете использовать как XMLHttpRequest так и fetch. Дьявол кроется в деталях: любые непростые запросы будут генерировать дополнительный OPTIONS запрос. Простыми запросами считаются запросы, которые используют методы GET, POST или HEAD и содержат только заголовки из списка:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type со значением application/x-www-form-urlencoded, multipart/form-data или text/plain

Обратите внимание: никаких Cookies. А значит при попытке передать куки будут делаться OPTIONS запросы. Чем плохи дополнительные запросы? Некоторые прокси их блокируют. В итоге ваша библиотека может перестать отправлять запросы на сервер (работай правильно).

Iframe

Интересным вариантом является создание скрытого iframe, в котором вы открываете страницу на своём домене. С этим фреймом можно общаться через postMessage, а он в свою очередь будет делать запросы к серверу. Так как домен фрейма и сервера совпадают, то проблемы с CORS нет. Бонусом вы получаете localStorage, который работает кроссдоменно. Делая такой механизм не забывайте об опасностях XSS атак. Основным минусом такого подхода является дополнительная загрузка фрейма (тайминг важен). К тому же сафари блокирует куки и localStorage внутри фреймов.

JSONP

Самый простой и везде работающий вариант. Если размещаете кэлбеки на window, а не на своём объекте, то не забывайте давать им достаточно сложные (рандомные имена). Минусом подхода является сложность кеширования запросов.

Трекинг пользователя

Вам скорее всего захочется понимать, что два запроса сделаны одним пользователем. Список способов очень зависит от того насколько чувствительной является идентификатор. Стандартный способ – это использование куки. Однако в случае jsonp и CORS мы попадаем под правило third-party cookies. Это значит, что большинство браузеров по умолчанию не будет отсылать куки на ваш домен, если пользователь никогда его не посещал. Iframe лишен этого недостатка, куки будут отсылаться. Но это не будет работать в safari. Если перехват идентификатора третьими лицами не ведёт к каким либо чувствительным последствиям, то можно передавать идентификатор при каждом запросе и хранить его в куке сайта. Лучший вариант — комбинировать оба способа.

Тестирование

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

Кеширование

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

Есть два основных подхода

Загрузчик

Скрипт, который подключается на сайте является некешируемым файлом. Единственное его назначение — добавить тег script со ссылкой на основной бандл. В ссылке есть хеш бандла, а сам бандл имеет максимальное время кеша. Такой подход позволяет быстро деплоиться и при этом иметь большой кеш. Минус очевидный — при первом заходе и после выкатки придётся грузить два скрипта (тайминг важен).

Небольшой кеш бандла

Если первая загрузка очень важна, а сессии пользователя короткие, то вариант с одним файлом и небольшим кешем может оказаться лучше. Тут важно посчитать выгоду от быстрой первой загрузки и потерю на стоимости трафика. Удобно иметь отдельную настройку, которой можно менять кеш файла. Тогда перед деплоем можно сбросить кеш в 0, подождать инвалидации у всех пользователей и накатывать апдейт. А после увеличить время кеша.

Ошибки и логирование

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

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

window.addEventListener('unhandledrejection', event => {
  if (isOurError(event)) {
    // log
  }
});
window.addEventListener('error', event => {
  if (isOurError(event)) {
    // log
  }
});

Сами логи стоит разделить по уровню и дать возможность в продакшене менять уровень. Это позволит тестерам вылавливать предупреждения. Я использовал вот такой простой логер.

Все критические ошибки необходимо отправлять на сервер для последующего анализа. Помимо текста ошибки и стека стоит отправлять информацию касающуюся браузера (user agent, разрешение экрана, прочее) и стейт самой библиотеки (версия, вызванные методы API и так далее). Это сильно упростит поиск плавающих ошибок. Для хранения большого количества ошибок на сервере неплохо подходит эластик.

Поддержка браузеров

Поддержка того или иного браузера всегда зависит от количества денег, которое можно получить с пользователей этого браузера и затратами на поддержку. Но есть нюансы: могут быть браузеры, которые ломают ваш код самым неприятным образом (привет Яндекс Браузер!) и узнаете вы об этом только после того, как к вам придут злые клиенты, которых пользователи пинали в саппорте. И поэтому может быть полезна модель белого списка: набор браузеров, где библиотека будет запускаться. Для всех остальных она будет выключена или выполнять минимум функций. При этом важно собирать статистику всех браузеров, чтобы понять когда стоит добавить поддержку. Не забывайте следить за новыми версиями. Желательно проверять всё ещё в бета-версиях.


Если у вас есть вопросы или вы хотите обсудить текст, то напишите мне в twitter.