Авторский проект IT-специалиста Олега Барабанова Персональные публикации на тему IT и не только…

Почему при написании кода не стоит смешивать воедино браузерный и серверный JS на примере Puppeteer

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

Недавно в процессе работы с документацией и примерами использования части API популярной JS библиотеки Puppeteer я обратил внимание на частые примеры кода, в которых писался воедино браузерный и серверный JavaScript. Фактически, смешивался код, части которого в одно время исполняются в разных системных окружениях. Объяснялось это удобством написания, хотя судя по вопросам на таких сайтах, как Stackoverflow, это нередко приводит к различным казусам.

Чтобы понять о чем идет речь, рассмотрим некий абстрактный пример:

…
const customVar = "Hello";
evaluate(() => {
  customVar += " World"; // <= Можно ли при ревью кода ожидать тут сюрприз?
});
…

Как вы думаете, при таком явном замыкании переменной customVar, можно ли получить внутри переданной стрелочной функции ошибку customVar is not defined? Как ни странно - можно! Просто эта ошибка случится в другом контексте исполнения. Например, если функция evaluate() сериализует принимаемую функцию в строку, а затем передает стороннему JavaScript окружению (допустим браузеру), который в свою очередь исполняет строку как код через eval(), то с большой вероятностью вы словите ошибку customVar is not defined из-за отсутствия в этом стороннем окружении переменной customVar.

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

Пара слов о Puppeteer

Puppeteer - это популярная JavaScript библиотека, которая предоставляет высокоуровневый API для управления браузером Chrome/Chromium через DevTools Protocol.

Часто Puppeteer используют для e2e (end-to-end, сквозное) тестирования, например когда нужно протестировать целое веб-приложение в эмулированной пользовательской среде (в данном случае это браузер). Помимо e2e тестирования, Puppeteer вполне можно использовать для интеграционных и unit-тестов, особенно когда программа опирается на низкоуровневые возможности браузера. Примером подобного случая является применение OffscreenCanvas, который крайне проблематично эмулировать в силу его низкоуровневых возможностей.

Сам по себе Puppeteer работает на сервере, а для взаимодействия с JavaScript, который работает в браузерном окружении, предусмотрены такие методы, как Page.evaluate(), Page.evaluateHandle(), Page.evaluateOnNewDocument(). Через аргументы этих функций передается функция, которая выполняется на стороне браузера, как например:

await page.evaluate(() => {
  document.querySelector('button[type=submit]').click();
});

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

Почему я не рекомендую смешивать подобный код

Давайте рассмотрим чуть более сложный пример из документации:

…
const result = await frame.evaluate(() => {
  return Promise.resolve(8 * 7);
});
console.log(result); // => "56"

Как видим, данный метод может даже возвращать значение. Такая стрелочная функция, как в примере, без проблем стрингифицируется и исполняется как JS в браузере. Полученный результат также стрингифицируется и возвращается обратно (через тот же DevTools Protocol). Соответственно, если при рефакторинге мы не будем знать про данную особенность работы метода и захотим избавиться от магических чисел в коде, заменив их на именованные константы, то мы получим ошибку:

…
const x = 8;
const y = 7;

const result = await frame.evaluate(() => {
  return Promise.resolve(x * y); // BROWSER ERROR => x is not defined !
});

Вышепредставленный пример синтаксически корректен, но тем не менее ошибочен, поскольку, как я уже говорил, переданная стрелочная функция сериализуется (стрингифицируется) и впоследующем исполняется (как JS) в браузере, в котором соответсвенно константы x и y не определены. Хуже, если подобные переменные есть (или случайно совпали после минимификации кода) и принадлежат они какому-то стороннему браузерному скрипту, тогда вопрос отладки кода или последствия работы всего приложения могут быть непредсказуемыми.

Как решить подобную проблему

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

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

// Клиент (браузер)

const x = 8;
const y = 7;

function testCalcFunc() {
  return Promise.resolve(x * y); // BROWSER ERROR => x is not defined !
});
// Сервер (с Puppeteer)
…
const result = await frame.evaluate('testCalcFunc()');

Клиентский файл можно делать в виде модуля, для полноценного использования операторов import/export, но в таком случае надо реализовывать систему сборки так, чтобы браузерный скрипт не подвергался минимификации (чтобы названия классов и методов не менялись) .

Заключение

Не следует жертвовать очевидность кода в угоду его красоте, поскольку в последующем может привести к множеству проблем, особенно при работе в команде.

„Всегда пишите код так, будто сопровождать его будет склонный к насилию психопат, который знает, где вы живете.“

Martin Golding

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