在 HTML 中内联 ECMAScript 模块

Posted

技术标签:

【中文标题】在 HTML 中内联 ECMAScript 模块【英文标题】:Inlining ECMAScript Modules in HTML 【发布时间】:2017-10-04 15:57:03 【问题描述】:

我一直在试验最近添加到浏览器中的新 native ECMAScript module support。终于能够从 javascript 中直接、干净地导入脚本了,真是令人高兴。

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

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

但是,这只允许我导入由单独的 external JavaScript 文件定义的模块。我通常更喜欢内联一些用于初始渲染的脚本,因此它们的请求不会阻塞页面的其余部分。使用传统的非正式结构的库,可能看起来像这样:

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

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

但是,天真地内联模块文件显然是行不通的,因为它会删除用于将模块标识给其他模块的文件名。 HTTP/2 服务器推送可能是处理这种情况的规范方式,但它仍然不是所有环境中的选项。

是否可以使用 ECMAScript 模块执行等效转换?

&lt;script type="module"&gt; 有什么方法可以在同一个文档中导入另一个导出的模块?


我想这可以通过允许脚本指定文件路径来工作,并且表现得好像它已经从路径下载或推送了一样。

     /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>

但是这些假设都没有真正起作用,而且我还没有看到一个替代方案。

【问题讨论】:

也许有更好的方法来实现这一点——可能使用 Service Worker? 我不认为自制不符合规范的inline-module 可以被认为是 ES 模块的良好开端。 Webpack/Rollup 包在生产环境中仍然是不可或缺的——尤其是如果你害怕阻塞请求的话。是的,Service Worker 看起来是一个可行的解决方案——但它仍然应该发出请求以提供数据……顺便说一句,这可能会阻塞。 @estus 我想象使用服务工作者来获取内联的&lt;script&gt; 标签并使用它们来填充缓存,以避免额外的请求。如果实现足够聪明,这些甚至可以使用标准的 type="module" 并添加主体。 我可能是唯一一个喜欢代码 sn-ps 的 ????name tags 的人。 @JeremyBanks 您是否尝试过将外部脚本嵌入为 data-uris? 【参考方案1】:

我不相信这是可能的。

对于内联脚本,您会被一种更传统的代码模块化方法所困扰,例如您使用对象文字演示的命名空间。

使用webpack,您可以使用code splitting 在页面加载时抓取极少的代码块,然后根据需要逐步抓取其余代码。 Webpack 还具有允许您在比 Chrome Canary 更多的环境中使用模块语法(以及大量其他 ES201X 改进)的优势。

【讨论】:

【参考方案2】:

一起破解我们自己的import from '#id'

本机不支持内联脚本之间的导出/导入,但将我的文档的实现组合在一起是一个有趣的练习。代码打到一个小块,我这样使用它:

<script type="module" data-info="https://***.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>

我们需要使用&lt;script type="inline-module"&gt; 等自定义类型来定义我们的脚本元素,而不是&lt;script type="module"&gt;。这可以防止浏览器尝试自己执行它们的内容,让我们来处理它们。该脚本(完整版如下)查找文档中的所有inline-module 脚本元素,并将它们转换为具有我们想要的行为的常规脚本模块元素。

内联脚本不能直接相互导入,所以我们需要给脚本提供可导入的 URL。我们为它们中的每一个生成一个blob: URL,包含它们的代码,并将src 属性设置为从该URL 运行而不是内联运行。 blob: URL 就像来自服务器的普通 URL,所以它们可以从其他模块导入。每次我们看到后续的inline-module 尝试从'#example' 导入,其中example 是我们已转换的inline-module 的ID,我们将该导入修改为从blob: URL 导入。这维护了模块应该具有的一次性执行和引用重复数据删除。

<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);

警告:这个简单的实现不处理在初始文档被解析后添加的脚本元素,或者允许从文档中出现在它们之后的其他脚本元素导入脚本元素。如果文档中同时具有moduleinline-module 脚本元素,则它们的相对执行顺序可能不正确。源代码转换是使用粗略的正则表达式执行的,它不会处理一些边缘情况,例如 ID 中的句点。

【讨论】:

您可以进一步将正则表达式发送到/(from|import)\s+('|")(#[\w\-]+)\2/g 你在 github 或 npm 中有那个吗? 我想知道扩展自定义元素是否可以使用 &lt;script is="inline-module" type="module" id="a"&gt;&lt;/script&gt; 我通过使用这个查询选择器解决了“无句点”问题:const refEl = document.querySelector(`script[type=module][src][id="$selector"]`); 我还让选择器看起来更通用:/(from\s+|import\s+)['"](.*)['"]/g【参考方案3】:

服务工作者可以做到这一点。

由于应该先安装 service worker,然后它才能处理页面,因此需要有一个单独的页面来初始化 worker 以避免鸡/蛋问题 - 或者当 worker 准备好时可以重新加载页面。

示例

这是一个demo,它应该可以在支持原生 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 模块的概念相矛盾(除非它们是通过服务器端模板从真实模块生成的)。

Service Worker 初始化应在单独的页面上执行,以避免不必要的请求。

解决方案仅限于单个页面,不考虑&lt;base&gt;

正则表达式仅用于演示目的。当在上面的示例中使用时,它可以执行页面上可用的任意 JavaScript 代码。应该使用像parse5 这样经过验证的库(它会导致性能开销,并且仍然可能存在安全问题)。 切勿使用正则表达式解析 DOM

【讨论】:

我喜欢它!非常聪明。 这会更恶心,所以我可能不推荐它,但如果我们要重写 index.html,那么这给了我们一种同步检测 service worker 是否被加载的方法,通过让它向页面添加一些属性,从而防止其他任何东西第一次加载/运行不当,而不是等待异步 getRegistration 结果。 是的。 location.reload() 闻起来不好,但证明了这个问题。一般来说,我建议为 //?serviceWorkerInstalledOrNotSupported 入口点设置单独的服务器响应。 如何安装 Service Worker?这些是通过 HTTP 传递的。 @Melab 你能澄清一下吗?安装在&lt;script&gt;完成。【参考方案4】:

我使用this article to prevent scripts from executing 调整了Jeremy's 答案

<script data-info="https://***.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),vs-code 不是语法突出显示(保留类型 = 模块),我想使用相同的 MutationObserver 可以在导入后执行脚本id 被添加到 DOM。

如果有问题请告诉我!

【讨论】:

以上是关于在 HTML 中内联 ECMAScript 模块的主要内容,如果未能解决你的问题,请参考以下文章

淘宝前端团队:HTML 压缩服务治理

如何使用 ECMAScript 6 模块导入 PlotlyJS

在 Babeljs 配置中使用原生 ECMAScript 模块

import()提供了动态加载 ECMAScript 模块的功能

ECMAScript2015~2021全特性学习宝典

全面认识ECMAScript模块