October 9, 2023

Проблемы разработки изоморфных JS-библиотек

Автор статьи: Nick Fahrenkrog

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

Перевод:


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

Для контекста, Изоморфный JavaScript, также известный как Универсальный JavaScript, представляет собой JavaScript-код, который может выполняться в любой среде, включая Node.js или веб-браузер. Альтернативой Изоморфному JavaScript является создание отдельных, ориентированных на каждую среду библиотек, например, одной для Node.js и одной для браузера. Наличие одной библиотеки для всех сред предоставляет прямые преимущества, если вы можете справиться с вызовами, связанными с ее созданием.

Зачем создавать изоморфные библиотеки?

Как и многие команды разработчиков, команда веб-платформы DoorDash создает и поддерживает несколько библиотек, направленных на увеличение производительности разработчиков фронтенда. Разработчики DoorDash пишут код в разных средах JavaScript, включая приложения React и веб-сервисы Node.js. Кроме того, DoorDash переносит несколько страниц с клиентской рендеринга на серверную, поэтому граница между тем, в какой среде выполняется код, становится все более размытой. Все эти причины делают веским аргументом создание многих наших библиотек изоморфными, потому что та же логика должна работать во многих разных средах.

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

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

Пример функциональных требований изоморфной библиотеки

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

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

Некоторые примеры этой сложности могут включать:

  • Зависимость от любых API, которые являются собственными в одной среде (например, `document.cookies`, `fetch`, и т. д.), но не собственными в другой.
  • Наличие зависимостей, которые не являются изоморфными.
  • Функции, которые ведут себя по-разному в зависимости от среды выполнения.
  • Открытие параметров, которые не нужны во всех средах выполнения.

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

Более конкретно, этот пример имеет следующие требования:

  • Экспортирует одну асинхронную функцию с именем `isCoffeeOrderReady`, которая при необходимости принимает параметр `deviceId` и возвращает логическое значение.
  • Отправляет http POST-запрос с телом запроса ‘{deviceId: <deviceId>}’ на жестко закодированную конечную точку.
  • Может выполняться в Node.js или браузере.
  • Браузер будет читать `deviceId` напрямую из куки.
  • Использует постоянные соединения.


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


Вызов #1:

Выбор правильных зависимостей

Для иллюстрации допустим, что это Node <=16 и в нем не используются экспериментальные флаги. В Node 18 есть встроенная поддержка fetch.

Сначала мы должны определить, как сделать fetch-запрос. Браузер может отправлять fetch-запрос нативно, но для поддержки Node.js будет использоваться либо изоморфная библиотека, либо библиотека для Node.js. Если выбрана изоморфная библиотека, она должна соответствовать требованиям каждой среды по зависимостям. В этом случае выбранная библиотека может быть проанализирована с точки зрения ее влияния на размер пакета в браузере.

Для простоты мы будем использовать isomorphic-fetch, который внутренне использует node-fetch в Node.js и полифилл fetch GitHub в браузерах. Следующий код иллюстрирует, как выполнить fetch-запрос:

import fetch from 'isomorphic-fetch'
 
// Запрос: { deviceId: '111111' }
// Ответ: { isCoffeeOrderReady: true }
export const isCoffeeOrderReady = async (deviceId: string): Promise<boolean> => {
 const response = await fetch('https://<some-endpoint>.com/<some-path>', {
   method: 'POST',
   body: JSON.stringify({ deviceId })
 })
 return (await response.json()).isCoffeeOrderReady
}

Примечание: В целях краткости многие важные детали, такие как повторные попытки и обработка ошибок, будут проигнорированы.
Доступ к document.cookie

Код на этом этапе игнорирует множество требований. Например, в Node.js параметр `deviceId` будет использоваться как идентификатор, отправляемый в запросе fetch, но в браузере `deviceId` должен читаться напрямую из куки.

Чтобы проверить, находимся ли мы в браузере - что означает, что document.cookie должен быть определен - можно проверить, определено ли окно; `window` всегда должно быть определено в браузере и не определено глобально в Node. Этот фрагмент кода выглядит так:

`typeof window === "undefined"`.

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

Полный обновленный код выглядит следующим образом:

import fetch from 'isomorphic-fetch'
import * as cookie from 'cookie'
 
export const isCoffeeOrderReady = async (deviceId: string): Promise<boolean> => {
 let id = deviceId
 if (typeof window !== 'undefined') {
   const cookies = cookie.parse(document?.cookie)
   id = cookies.deviceId
 }
 const response = await fetch('https://<some-endpoint>.com/<some-path>', {
   method: 'POST',
   body: JSON.stringify({ deviceId: id })
 })
 return (await response.json()).isCoffeeOrderReady
}

Теперь, хотя мы ближе к соответствию требованиям библиотеки, мы внесли еще два вызова для изучения…


Вызов #2:

Создание единого API между средами

Предыдущее изменение кода все равно требует, чтобы функция `isCoffeeOrderReady` имела параметр `deviceId`, потому что он нужен в средах Node.js, но в браузере это значение игнорируется. Вместо этого значение `deviceId` читается напрямую из куки. Объявление функции для обеих сред должно быть разным - в браузерах функция не должна принимать аргументов, но в Node она должна требовать один аргумент, но так как она изоморфна, она не может.

Существуют следующие варианты:

  • API может быть описано, как показано выше, с обязательным параметром `deviceId`. Но это может ввести в заблуждение пользователей, потому что это значение должно быть передано в браузере, даже если оно будет проигнорировано.
  • Сделать `deviceId` необязательным. Этот вариант позволяет вызывать функцию без аргументов в среде браузера и с аргументом `deviceId` в среде Node. Однако это также означает, что функцию можно вызвать в Node.js без аргументов; Статический анализ TypeScript не может предотвратить этот недопустимый вызов API.

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


Вызов #3:

Гарантировать, что зависимости влияют только на предназначенные среды

Это изменение кода для `document.cookie` также ввело еще одну проблему: `cookie` будет установлен в средах Node.js, несмотря на то, что он вообще не используется в кодовом пути Node.js. Конечно, установка ненужных зависимостей в Node.js далеко не так вредна, как установка ненужных зависимостей в браузере, учитывая важность поддержания минимального размера пакета. Тем не менее, важно гарантировать, что ненужные зависимости не включаются в данную среду.

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


Использование постоянных соединений

Несмотря на то, что может показаться, что реализация постоянных соединений проста с самого начала, на самом деле это вызывает вызовы. Это связано с нашим первым вызовом - выбором правильных зависимостей. node-fetch не реализует идентичную спецификацию, как нативный браузерный fetch; одно из мест, где спецификации различаются, - это постоянные соединения. Для использования постоянного соединения в браузерном fetch добавьте флаг:

`fetch(url, { ..., keepalive: true })`

В node-fetch, однако, создайте экземпляры http.Agent и/или https.Agent и передайте их в аргументе agent в запрос fetch, как показано здесь:

const httpAgent = new http.Agent({ keepAlive: true })
const httpsAgent = new https.Agent({ keepAlive: true })
fetch(url, { agent: (url) => {
 if (url.protocol === "http:") {
   return httpAgent
 } else {
   return httpsAgent
 }
}

Здесь isomorphic-fetch использует node-fetch внутренне, но не предоставляет опцию agent. Это означает, что в среде Node.js нельзя правильно настроить постоянные соединения с isomorphic-fetch. Следовательно, следующим шагом должно быть использование node-fetch и нативных браузерных библиотек fetch отдельно.

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

Пример настройки этого в webpack с использованием TypeScript выглядит следующим образом:

{
   "main": "lib/index.js",
   "browser": "lib/browser.js",
   "typings": "lib/index.d.ts",
}

Также следует отметить, что, хотя "main" для Node.js и "browser" указывают на разные файлы индекса, можно использовать только один файл объявления типа. Это имеет смысл, учитывая, что целью изоморфной библиотеки является предоставление одного и того же API независимо от среды.

В качестве справки, вот список некоторых библиотек на JavaScript, которые используют этот шаблон именно с целью иметь изоморфный fetch:

  • isomorphic-unfetch
  • isomorphic-fetch
  • ky-universal
  • fetch-ponyfill

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


Вызов #4:

Тестирование в каждой среде

Теперь, когда библиотека готова, пришло время добавить тестирование. Большинство тестов придется написать дважды, чтобы убедиться, что все функции правильно работают в каждой среде. Изоморфизм связывает логику между всеми средами, и изменения в одной среде теперь должны быть протестированы в каждой среде. Могут возникнуть дополнительные вызовы при тестировании изоморфных библиотек в реалистичных сценариях. Например, Jest имеет только экспериментальную поддержку ESM.


Вызов #5:

Наблюдаемость - метрики и журналирование


Последнее, что следует учесть, это метрики, журналирование и обработка ошибок. Особенности наблюдаемости, такие как логирование и отправка метрик, могут различаться между средами. Node.js может использовать winston для логирования, а браузер может использовать window.console. Метрики могут быть отправлены в сторонний сервис в браузере, но в Node.js это может потребовать другого подхода. Для обработки ошибок в обеих средах, может потребоваться применение разных стратегий.

Завершение

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

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

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

Кроме того, библиотеки, такие как isomorphic-fetch, isomorphic-unfetch, ky-universal и fetch-ponyfill, могут служить отличными примерами реализации изоморфных библиотек и источниками вдохновения для создания своих собственных изоморфных библиотек.

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