Почему при написании кода не стоит смешивать воедино браузерный и серверный 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 и пр. будут вносить свои уникальные неочевидности, то поддерживать общую кодовую базу станет намного сложнее и дороже, повысится вероятность ошибок в коде, увеличится документация, поднимется порог входа новых разработчиков в проект и пр. В итоге может получиться так, что решения, которые кому-то изначально показались удобными, могут превратить дальнейшую разработку в кошмар, а такая перспектива мало кому может понравиться.