Встраивание модулей ECMAScript в HTML

Я экспериментировал с недавно добавленной нативной поддержкой модулей ECMAScript. в браузеры. Приятно, что наконец-то появилась возможность напрямую и чисто импортировать скрипты из JavaScript.

     /example.html ????     
<script type="module">
  import {example} from '/example.js';

  example();
</script>
     /example.js     
export function example() {
  document.body.appendChild(document.createTextNode("hello"));
};

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

     /inline-traditional.html ????     
<body>
<script>
  var example = {};

  example.example = function() {
    document.body.appendChild(document.createTextNode("hello"));
  };
</script>
<script>
  example.example();
</script>

Однако наивное встраивание файлов модулей, очевидно, не сработает, поскольку при этом будет удалено имя файла, используемое для идентификации модуля в других модулях. Сервер HTTP/2 push может быть каноническим способом справиться с этой ситуацией, но он по-прежнему не подходит для всех сред.

Можно ли выполнить эквивалентное преобразование с модулями ECMAScript?

Есть ли способ для <script type="module"> импортировать модуль, экспортированный другим в том же документе?


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

     /inline-name.html ????     
<script type="module" name="/example.js">
  export function example() {
    document.body.appendChild(document.createTextNode("hello"));
  };
</script>

<script type="module">
  import {example} from '/example.js';

  example();
</script>

Или, может быть, с помощью совершенно другой схемы ссылок, например, используемой для локальных ссылок SVG:

     /inline-id.html ????     
<script type="module" id="example">
  export function example() {
    document.body.appendChild(document.createTextNode("hello"));
  };
</script>
<script type="module">
  import {example} from '#example';

  example();
</script>

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


person Jeremy    schedule 06.05.2017    source источник
comment
Может быть, есть лучший способ добиться этого — возможно, с помощью Service Workers?   -  person Jeremy    schedule 06.09.2017
comment
Я не думаю, что доморощенный не соответствующий спецификации inline-module можно считать хорошим началом с модулями ES. Пакеты Webpack/Rollup по-прежнему незаменимы в производственной среде, особенно если вы боитесь блокировать запросы. Да, сервис-воркер выглядит как жизнеспособное решение, но он все равно должен делать запросы для предоставления данных... что может блокировать, кстати.   -  person Estus Flask    schedule 06.09.2017
comment
@estus Я представлял, как сервис-воркеры берут встроенные теги <script> и используют их для заполнения кеша, чтобы избежать дополнительных запросов. Возможно, они могли бы даже использовать стандартный type="module" с добавленным телом, если реализация достаточно умна.   -  person Jeremy    schedule 07.09.2017
comment
Возможно, я единственный, кому нравятся эти ????именные теги для фрагментов кода.   -  person Константин Ван    schedule 29.12.2017
comment
@JeremyBanks Пробовали ли вы встраивать внешние скрипты в качестве data-uris?   -  person TheAddonDepot    schedule 25.09.2018


Ответы (4)


Взламываем вместе наши собственные import from '#id'

Экспорт/импорт между встроенными скриптами изначально не поддерживается, но было забавно собрать реализацию для моих документов. Код-гольф до небольшого блока, я использую его так:

<script type="module" data-info="https://stackoverflow.com/a/43834063">let l,e,t
='script',p=/(from\s+|import\s+)['"](#[\w\-]+)['"]/g,x='textContent',d=document,
s,o;for(o of d.querySelectorAll(t+'[type=inline-module]'))l=d.createElement(t),o
.id?l.id=o.id:0,l.type='module',l[x]=o[x].replace(p,(u,a,z)=>(e=d.querySelector(
t+z+'[type=module][src]'))?a+`/* ${z} */'${e.src}'`:u),l.src=URL.createObjectURL
(new Blob([l[x]],{type:'application/java'+t})),o.replaceWith(l)//inline</script>

<script type="inline-module" id="utils">
  let n = 1;
  
  export const log = message => {
    const output = document.createElement('pre');
    output.textContent = `[${n++}] ${message}`;
    document.body.appendChild(output);
  };
</script>

<script type="inline-module" id="dogs">
  import {log} from '#utils';
  
  log("Exporting dog names.");
  
  export const names = ["Kayla", "Bentley", "Gilligan"];
</script>

<script type="inline-module">
  import {log} from '#utils';
  import {names as dogNames} from '#dogs';
  
  log(`Imported dog names: ${dogNames.join(", ")}.`);
</script>

Вместо <script type="module"> нам нужно определить наши элементы скрипта, используя пользовательский тип, например <script type="inline-module">. Это предотвращает попытки браузера выполнить их содержимое самостоятельно, оставляя их нам для обработки. Сценарий (полная версия ниже) находит все inline-module элементов сценария в документе и преобразует их в обычные элементы модуля сценария с нужным нам поведением.

Встроенные скрипты нельзя напрямую импортировать друг из друга, поэтому нам нужно предоставить скриптам импортируемые URL-адреса. Мы генерируем URL-адрес blob: для каждого из них, содержащий их код, и устанавливаем атрибут src для запуска с этого URL-адреса, а не для встроенного запуска. URL-адреса blob: действуют как обычные URL-адреса с сервера, поэтому их можно импортировать из других модулей. Каждый раз, когда мы видим последующую попытку импорта inline-module из '#example', где example — это идентификатор inline-module, который мы преобразовали, мы модифицируем этот импорт, чтобы вместо этого импортировать из URL-адреса blob:. Это поддерживает однократное выполнение и дедупликацию ссылок, которые должны иметь модули.

<script type="module" id="dogs" src="blob:https://example.com/9dc17f20-04ab-44cd-906e">
  import {log} from /* #utils */ 'blob:https://example.com/88fd6f1e-fdf4-4920-9a3b';

  log("Exporting dog names.");

  export const names = ["Kayla", "Bentley", "Gilligan"];
</script>

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

export {};

for (const original of document.querySelectorAll('script[type=inline-module]')) {
  const replacement = document.createElement('script');

  // Preserve the ID so the element can be selected for import.
  if (original.id) {
    replacement.id = original.id;
  }

  replacement.type = 'module';

  const transformedSource = original.textContent.replace(
    // Find anything that looks like an import from '#some-id'.
    /(from\s+|import\s+)['"](#[\w\-]+)['"]/g,
    (unmodified, action, selector) => {
      // If we can find a suitable script with that id...
      const refEl = document.querySelector('script[type=module][src]' + selector);
      return refEl ?
        // ..then update the import to use that script's src URL instead.
        `${action}/* ${selector} */ '${refEl.src}'` :
        unmodified;
    });

  // Include the updated code in the src attribute as a blob URL that can be re-imported.
  replacement.src = URL.createObjectURL(
    new Blob([transformedSource], {type: 'application/javascript'}));

  // Insert the updated code inline, for debugging (it will be ignored).
  replacement.textContent = transformedSource;

  original.replaceWith(replacement);
}

Предупреждения. эта простая реализация не обрабатывает элементы скрипта, добавленные после того, как исходный документ был проанализирован, и не позволяет импортировать элементы скрипта из других элементов скрипта, которые встречаются в документе после них. Если в документе есть элементы сценария module и inline-module, их относительный порядок выполнения может быть неверным. Преобразование исходного кода выполняется с использованием грубого регулярного выражения, которое не обрабатывает некоторые пограничные случаи, такие как точки в идентификаторах.

person Jeremy    schedule 07.05.2017
comment
Вы можете продолжить гольф регулярного выражения до /(from|import)\s+('|")(#[\w\-]+)\2/g - person Bergi; 07.09.2017
comment
У вас есть это в github или npm? - person dy_; 13.05.2020
comment
Интересно, возможно ли это с расширенным пользовательским элементом, таким как <script is="inline-module" type="module" id="a"></script> - person dy_; 01.12.2020
comment
Я решил проблему отсутствия периодов с помощью этого селектора запросов: const refEl = document.querySelector(`script[type=module][src][id="${selector}"]`); Я также сделал селектор более общим: /(from\s+|import\s+)['"](.*)['"]/g - person Connor Clark; 04.06.2021

Это возможно с сервисными работниками.

Поскольку сервис-воркер должен быть установлен до того, как он сможет обрабатывать страницу, для этого требуется отдельная страница для инициализации работника, чтобы избежать проблемы с курицей/яйком, или страница может быть перезагружена, когда работник будет готов.

Пример

Вот демонстрация, которая должна работать в современных браузерах, поддерживающих собственные модули ES и async..await (а именно Chrome):

index.html

<html>
  <head>
    <script>
      (async () => {
        try {
          const swInstalled = await navigator.serviceWorker.getRegistration('./');

          await navigator.serviceWorker.register('sw.js', { scope: './' })

          if (!swInstalled) {
            location.reload();
          }
        } catch (err) {
          console.error('Worker not registered', err);
        }
      })();
    </script>
  </head>

  <body>
    World,

    <script type="module" data-name="./example.js">
      export function example() {
        document.body.appendChild(document.createTextNode("hello"));
      };
    </script>

    <script type="module">
      import {example} from './example.js';

      example();
    </script>
  </body>
</html>

sw.js

self.addEventListener('fetch', e => {
  // parsed pages
  if (/^https:\/\/run.plnkr.co\/\w+\/$/.test(e.request.url)) {
    e.respondWith(parseResponse(e.request));
  // module files
  } else if (cachedModules.has(e.request.url)) {
    const moduleBody = cachedModules.get(e.request.url);
    const response = new Response(moduleBody,
      { headers: new Headers({ 'Content-Type' : 'text/javascript' }) }
    );
    e.respondWith(response);
  } else {
    e.respondWith(fetch(e.request));
  }
});

const cachedModules = new Map();

async function parseResponse(request) {
  const response = await fetch(request);
  if (!response.body)
    return response;

  const html = await response.text(); // HTML response can be modified further
  const moduleRegex = /<script type="module" data-name="([\w./]+)">([\s\S]*?)<\/script>/;
  const moduleScripts = html.match(new RegExp(moduleRegex.source, 'g'))
    .map(moduleScript => moduleScript.match(moduleRegex));

  for (const [, moduleName, moduleBody] of moduleScripts) {
    const moduleUrl = new URL(moduleName, request.url).href;
    cachedModules.set(moduleUrl, moduleBody);
  }
  const parsedResponse = new Response(html, response);
  return parsedResponse;
}

Тела скриптов кэшируются (также можно использовать собственные Cache) и возвращаются для соответствующих запросов модулей.

Обеспокоенность

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

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

  • Встроенные скрипты не являются модульными и противоречат концепции модулей ECMAScript (если только они не созданы из реальных модулей по шаблону на стороне сервера).

  • Инициализацию сервис-воркера следует выполнять на отдельной странице, чтобы избежать лишних запросов.

  • Решение ограничено одной страницей и не учитывает <base>.

  • Регулярное выражение используется только в демонстрационных целях. При использовании, как в приведенном выше примере, позволяет выполнять произвольный код JavaScript, доступный на странице. Вместо этого следует использовать проверенную библиотеку, такую ​​​​как parse5 (это приведет к снижению производительности, и, тем не менее, могут возникнуть проблемы с безопасностью). Никогда не используйте регулярные выражения для анализа DOM.

person Estus Flask    schedule 07.09.2017
comment
Я люблю это! Очень умно. - person Jeremy; 07.09.2017
comment
Это было бы еще более отвратительно, поэтому я, вероятно, не рекомендую это, но если мы переписываем index.html, то это дает нам способ синхронно определить, был ли загружен сервис-воркер, добавляя к странице некоторый атрибут. , и поэтому предотвратите неправильную загрузку/запуск чего-либо еще в первый раз, вместо того, чтобы ждать результата асинхронного getRegistration. - person Jeremy; 08.09.2017
comment
да. location.reload() плохо пахнет, но свидетельствует о проблеме. Как правило, я бы рекомендовал иметь отдельные ответы сервера для точек входа / и /?serviceWorkerInstalledOrNotSupported. - person Estus Flask; 08.09.2017

Я не верю, что это возможно.

Для встроенных скриптов вы застряли с одним из более традиционных способов модуляции кода, таким как пространство имен, которое вы продемонстрировали с использованием объектных литералов.

С webpack вы можете сделать разбиение кода, которое вы можете использовать для захвата очень минимального фрагмента кода при загрузке страницы, а затем постепенного захвата остального по мере необходимости. Webpack также имеет то преимущество, что позволяет вам использовать синтаксис модуля (плюс множество других улучшений ES201X) в гораздо большем количестве сред, чем просто Chrome Canary.

person bmceldowney    schedule 06.05.2017

Я подправил ответ Джереми с помощью эту статью, чтобы предотвратить выполнение скриптов

<script data-info="https://stackoverflow.com/a/43834063">
// awsome guy on [data-info] wrote 90% of this but I added the mutation/module-type part

let l,e,t='script',p=/(from\s+|import\s+)['"](#[\w\-]+)['"]/g,x='textContent',d=document,s,o;

let evls = event => (
  event.target.type === 'javascript/blocked', 
  event.preventDefault(),
  event.target.removeEventListener( 'beforescriptexecute', evls ) )

;(new MutationObserver( mutations => 
  mutations.forEach( ({ addedNodes }) => 
    addedNodes.forEach( node => 
      ( node.nodeType === 1 && node.matches( t+'[module-type=inline]' )
      && ( 
        node.type = 'javascript/blocked',
        node.addEventListener( 'beforescriptexecute', evls ),
      
        o = node,
        l=d.createElement(t),
        o.id?l.id=o.id:0,
        l.type='module',
        l[x]=o[x].replace(p,(u,a,z)=>
          (e=d.querySelector(t+z+'[type=module][src]'))
            ?a+`/* ${z} */'${e.src}'`
            :u),
        l.src=URL.createObjectURL(
          new Blob([l[x]],
          {type:'application/java'+t})),
        o.replaceWith(l)
      )//inline

) ) )))
.observe( document.documentElement, {
  childList: true,
  subtree: true
} )

// for(o of d.querySelectorAll(t+'[module-type=inline]'))
//   l=d.createElement(t),
//   o.id?l.id=o.id:0,
//   l.type='module',
//   l[x]=o[x].replace(p,(u,a,z)=>
//     (e=d.querySelector(t+z+'[type=module][src]'))
//       ?a+`/* ${z} */'${e.src}'`
//       :u),
//   l.src=URL.createObjectURL(
//     new Blob([l[x]],
//     {type:'application/java'+t})),
//   o.replaceWith(l)//inline</script>

Я надеюсь, что это решит проблему добавления динамического скрипта (с использованием MutationObserver), а не код с подсветкой синтаксиса (сохранение type=module), и я предполагаю, что с помощью того же MutationObserver можно было бы выполнять скрипты после добавления импортированных идентификаторов в ДОМ.

Пожалуйста, скажите мне, если это имеет проблемы!

person Nahej    schedule 01.07.2021