Inlining ECMAScript Modules in HTML

前端 未结 3 1910
情话喂你
情话喂你 2020-12-07 20:51

I\'ve been experimenting with new native ECMAScript module support that has recently been added to browsers. It\'s pleasant to finally be able import scripts directly and cl

相关标签:
3条回答
  • 2020-12-07 21:10

    I don't believe that's possible.

    For inline scripts you're stuck with one of the more traditional ways of modularizing code, like the namespacing you demonstrated using object literals.

    With webpack you can do code splitting which you could use to grab a very minimal chunk of code on page load and then incrementally grab the rest as needed. Webpack also has the advantage of allowing you to use the module syntax (plus a ton of other ES201X improvements) in way more environments than just Chrome Canary.

    0 讨论(0)
  • 2020-12-07 21:17

    This is possible with service workers.

    Since a service worker should be installed before it will be able to process a page, this requires to have a separate page to initialize a worker to avoid chicken/egg problem - or a page can reloaded when a worker is ready.

    Example

    Here's a demo that is supposed to be workable in modern browsers that support native ES modules and async..await (namely 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;
    }
    

    Script bodies are being cached (native Cache can be used as well) and returned for respective module requests.

    Concerns

    • The approach is inferior to the application built and chunked with bundling tool like Webpack or Rollup in terms of performance, flexibility, solidity and browser support - especially if blocking concurrent requests are the primary concern.

    • Inline scripts increase bandwidth usage. This is naturally avoided when scripts are loaded once and cached by the browser.

    • Inline scripts aren't modular and contradict the concept of ECMAScript modules (unless they are generated from real modules by server-side template).

    • Service worker initialization should be performed on a separate page to avoid unnecessary requests.

    • The solution is limited to a single page and doesn't take <base> into account.

    • A regular expression is used for demonstration purposes only. When used like in the example above it enables the execution of arbitrary JavaScript code that is available on the page. A proven library like parse5 should be used instead (it will result in performance overhead, and still, there may be security concerns). Never use regular expressions to parse the DOM.

    0 讨论(0)
  • 2020-12-07 21:33

    Hacking Together Our Own import from '#id'

    Exports/imports between inline scripts aren't natively supported, but it was a fun exercise to hack together an implementation for my documents. Code-golfed down to a small block, I use it like this:

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

    Instead of <script type="module">, we need to define our script elements using a custom type like <script type="inline-module">. This prevents the browser from trying to execute their contents itself, leaving them for us to handle. The script (full version below) finds all inline-module script elements in the document, and transforms them into regular script module elements with the behaviour we want.

    Inline scripts can't be directly imported from each other, so we need to give the scripts importable URLs. We generate a blob: URL for each of them, containing their code, and set the src attribute to run from that URL instead of running inline. The blob: URLs acts like normal URLs from the server, so they can be imported from other modules. Each time we see a subsequent inline-module trying to import from '#example', where example is the ID of a inline-module we've transformed, we modify that import to import from the blob: URL instead. This maintains the one-time execution and reference deduplication that modules are supposed to have.

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

    The execution of module script elements is always deferred until after the document is parsed, so we don't need to worry about trying to support the way that traditional script elements can modify the document while it's still being parsed.

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

    Warnings: this simple implementation doesn't handle script elements added after the initial document has been parsed, or allow script elements to import from other script elements that occur after them in the document. If you have both module and inline-module script elements in a document, their relative execution order may not be correct. The source code transformation is performed using a crude regex that won't handle some edge cases such as periods in IDs.

    0 讨论(0)
提交回复
热议问题