Сначала я недооценил 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>

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


К тому же, 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, а мы просто его еще не обнаружили :D




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

Комментарии (0)