document.currentScript: что такое и с чем едят

от автора

Сначала я недооценил document.currentScript, но оказалось, что он отлично подходит для передачи параметров конфигурации прямо в теги <script> — и это далеко не все.

Порой я натыкаюсь на давно существующие браузерные API в JavaScript, о которых, по идее, я должен был узнать гораздо раньше. Например, window.screen или метод CSS.supports(). К счастью, я понял, что не один такой. Помню, как однажды упомянул window.screen в посте и получил неожиданно много комментариев от людей, которые тоже впервые о нем слышали. Это меня немного приободрило — я почувствовал себя не таким уж глупым.

Видимо, дело не в том, как давно существует API, а в том, насколько он полезен в реальных задачах. Если window.screen почти нигде не используется, о нем легко забыть.

Но иногда все же появляется неожиданный шанс применить одну из этих малоизвестных возможностей. Похоже, я как раз нашел такой случай для document.currentScript — и намерен использовать его по максимуму.

❯ Зачем он нужен

Достаточно просто взглянуть на его API, чтобы понять: он возвращает ссылку на тот элемент <script>, внутри которого выполняется текущий код:

<script>   console.log("название тега:", document.currentScript.tagName);   // название тега: SCRIPT   console.log(     "элемент script?",     document.currentScript instanceof HTMLScriptElement   );   // элемент script? true </script>

Поскольку возвращается сам элемент, к его свойствам можно обращаться так же, как и к любому другому DOM-узлу:

<script data-external-key="123urmom" defer>   console.log("внешний ключ:", document.currentScript.dataset.externalKey);   // внешний ключ: 123urmom    if (document.currentScript.defer) {     console.log("скрипт выполняется отложено");   }   // скрипт выполняется отложено </script>

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

Для модулей недоступен

Интересная особенность document.currentScript — он недоступен внутри модулей. Но что любопытно: при попытке обратиться к нему мы получим не undefined, а null.

<script type="module">   console.log(document.currentScript);   // null   console.log(document.doesNotExist);   // undefined </script>

Это предусмотрено спецификацией. Как только создается document, currentScript инициализируется значением null:

Атрибут currentScript при доступе должен возвращать последнее установленное значение. При создании document currentScript должен быть инициализирован значением null.

Поскольку после синхронного выполнения скрипта значение возвращается к исходному, то при выполнении асинхронного кода также возвращается null:

<script>   console.log(document.currentScript);   // <script> tag    setTimeout(() => {     console.log(document.currentScript);     // null   }, 1000); </script>

Исходя из этого, внутри <script type="module"> нет возможности получить текущий тег <script>. Единственное, что можно сделать — определить, выполняется ли скрипт как модуль, и для этого лучше всего проверять значение на null (проверка должна выполняться вне асинхронного кода):

function isInModule() {   return document.currentScript === null; }

Кстати, не стоит проверять import.meta, даже если делать это внутри try/catch. Само наличие этого выражения в теге <script> вызывает ошибку SyntaxError. Скрипт даже не нужно запускать — ошибка возникает при первом разборе содержимого браузером:

<script>   // При первом парсинге будет выброшена `SyntaxError`   function isInModule() {     try {       return !!import.meta;     } catch (e) {       return false;     }   };    // Также вызывает ошибку   console.log(typeof import?.meta); </script>

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

Этот API утратил популярность среди разработчиков и участников сообщества стандартизации, поскольку он предоставляет глобальный доступ к элементам script и SVG script. Поэтому он недоступен в новых контекстах, таких как выполнение модульных скриптов или скриптов в теневом DOM. В настоящее время ведется работа над новым решением, которое позволит идентифицировать выполняющийся скрипт в таких контекстах без глобального доступа — см. issue #1013.

Кстати, это обсуждение ведется уже давно — с 2016 года, и в нем участвует очень много людей. Пока окончательного решения нет, лучше всего просто получать нужный элемент напрямую:

<script type="module" id="moduleScript">   const scriptTag = document.getElementById("moduleScript");    // Работаем с элементом </script>

❯ Передача параметров конфигурации

На сайте PicPerf я использую таблицу цен Stripe, которую можно встроить с помощью нативного веб-компонента. Нужно загрузить скрипт, вставить элемент в HTML и задать пару атрибутов:

  <script     async     src="https://js.stripe.com/v3/pricing-table.js">   </script>    <stripe-pricing-table     pricing-table-id='prctbl_blahblahblah'     publishable-key="pk_test_blahblahblah"   >   </stripe-pricing-table>

Это работает хорошо, когда имеется доступ к переменным окружения во время рендеринга HTML, но мне хотелось встроить таблицу прямо в Markdown-файл. Markdown отлично поддерживает чистый HTML, но получить доступ к этим значениям не так просто, как использовать import.meta.env или process.env. Вместо этого пришлось бы динамически подставлять значения отдельно от разметки страницы.

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

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

## My Pricing Table  <div data-pricing-table></div>

<script>   document.querySelectorAll('[data-pricing-table]').forEach(table => {     table.innerHTML = `       <stripe-pricing-table         pricing-table-id="STAY_TUNED"         publishable-key="STANY_TUNED"         client-reference-id="picperf"       ></stripe-pricing-table>     `; }) </script>

На этом этапе мне не хватало только значений атрибутов. Один из вариантов — получить их на сервере и добавить в объект window, но такой подход выглядит не слишком аккуратным. Мне совсем не по душе разбрасываться глобальными переменными.

А можно было просто…

Если быть откровенным, я мог бы решить эту задачу за каких-то 14 секунд. Сайт PicPerf.io построен на Astro, который предоставляет директиву define:vars. С ее помощью передать серверные переменные в клиентский скрипт — проще простого:

--- const truth = "Taxation is theft."; ---  <style define:vars={{ truth }}>   console.log(truth);    // Taxation is theft. </style>

Однако в решении, которое занимает считанные секунды, нет ни веселья, ни материала для статьи 😀

К тому же, define:vars — это довольно специфичный способ решения задачи, который не используется другими платформами и системами управления контентом (с которыми мне приходилось работать).

Задача, которая встречается чаще, чем кажется

В системах управления контентом ограничения зачастую намеренно довольно жесткие. Редактор позволяет настраивать отдельные элементы разметки, но крайне редко — содержимое тегов <script>. И на это есть веские причины: здесь таится множество потенциальных угроз безопасности.

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

<!-- Сторонняя библиотека, но требуется настройка --> <script src="path/to/shared/signup-form.js"></script>

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

<div   id="app"   data-recaptcha-site-key="{{ siteKey }}" ></div>

import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App';  const appNode = document.getElementById('app'); const root = ReactDOM.createRoot(appNode);  root.render(   // Извлекаем значение из атрибута корневого элемента   <App recaptchaSiteKey={appNode.dataset.recaptchaSiteKey} /> );

Догадались, к чему я клоню? Атрибуты данных — это удобный и аккуратный способ передачи значений с сервера на клиент. В примере с SPA единственным, хотя и незначительным, неудобством остается необходимость предварительного получения элемента для доступа к его атрибутам.

Однако в моем случае использовался именно тег <script />, а не какой-либо другой элемент, и это позволило легко решить данную проблему. Свойство document.currentScript делает это за нас автоматически:

<script   data-stripe-pricing-table="{{pricingTableId}}"   data-stripe-publishable-key="{{publishableKey}}" >   const scriptData = document.currentScript.dataset;    document.querySelectorAll('[data-pricing-table]').forEach(table => {     table.innerHTML = `       <stripe-pricing-table         pricing-table-id="${scriptData.stripePricingTable}"         publishable-key="${scriptData.stripePublishableKey}"         client-reference-id="picperf"       ></stripe-pricing-table>     `;   }) </script>

Настоящее удовольствие. Никакой магии и проприетарных решений, никаких данных, засоряющих глобальную область видимости. И при этом можно с гордостью заявить в Х (Твиттере), что я «использую нативные возможности». Выигрыш по всем фронтам.

❯ Другие кейсы

Рассмотрим парочку других вариантов использования document.currentScript.

Рекомендации по установке

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

<script defer src="./script.js"></script>

// script.js  if (!document.currentScript.async) {   throw new Error("Скрипт должен загружаться асинхронно"); }  // Остальная часть библиотеки

Можно даже установить конкретное правило для размещения тега <script> на странице — например, чтобы он загружался сразу после открывающего тега <body>:

const isFirstBodyChild =   document.body.firstElementChild === document.currentScript;  if (!isFirstBodyChild) {   throw new Error(     "Этот скрипт ДОЛЖЕН загружаться сразу после открывающего тега <body>."   ); }

Такая ошибка однозначна и легко воспринимается:

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

Локальность поведения

Эту идею подсказал пользователь ShotgunPayDay на Reddit. Принцип локальности поведения (Locality of Behavior) гласит: поведение каждого блока кода должно быть очевидным при его проверке (об этом хорошо написал Карсон Гросс). В голову сразу приходят фреймворки с поддержкой однофайловых компонентов — все находится в одном месте и легко читается.

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

// form-submitter.js  const form = document.currentScript.previousElementSibling;  form.addEventListener("submit", async (e) => {   e.preventDefault();    const formData = new FormData(form);   const method = form.method || "POST";    const submitGet = () => fetch(`${form.action}?${params}`, {     method: "GET",   });    const submitPost = () => fetch(form.action, {     method: method,     body: formData,   });    const submit = method === "GET" ? submitGet : submitPost;   const response = await submit();    form.reset();    alert(response.ok ? "Успех" : "Ошибка"); });

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

<form action="/endpoint-one" method="POST">   <input type="text" name="firstName"/>   <input type="text" name="lastName"/>   <input type="submit" value="Submit" /> </form> <script src="form-submitter.js"></script>  <form action="/endpoint-two" method="POST">   <input type="email" name="emailAddress" />   <input type="submit" value="Submit" /> </form> <script src="form-submitter.js" ></script>

Сомневаюсь, что буду часто использовать такой подход, но хорошо знать, что он существует.

❯ Приятное ощущение

Очень приятно наконец-то понять, зачем нужны некоторые из этих давно существующих, но малоизвестных возможностей веба. Это вызывает у меня уважение к создателям API раннего Интернета — особенно учитывая, как часто им приходится иметь дело с претензиями современных разработчиков. Интересно, что еще я смогу открыть для себя. Возможно, искусственный интеллект уже встроен в спецификацию HTML, а мы просто его еще не обнаружили 😀


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


ссылка на оригинал статьи https://habr.com/ru/articles/918092/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *