This is somewhat similar to this question:
Adding script tag to React/JSX
But in my case I am loading a script like this:
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','ID');</script>
<!-- End Google Tag Manager -->
Now I know there is a npm package for the google tag manager but I am curious if I would like to do this in a custom way how would I go about?
In the above question I see a lot of:
const script = document.createElement("script");
script.src = "https://use.typekit.net/foobar.js";
script.async = true;
document.body.appendChild(script);
Which is fine but if I have a function inside of the loaded script how would I go about executing this correctly?
To add a random script like this, you could:
Add the script to your index.html
Paste the code to a file and use an import statement.
Dynamically load the script once the user does something, using code splitting.
1. Adding the script to your HTML
Just stick the script tags in your index.html file, preferably at the end of the body tags. If using create-react-app, the index.html file is located in the public directory:
<body>
<div id="root"></div>
<script>/* your script here */</script>
</body>
2. Import from file
Alternatively, you could paste the script into a .js file, and import it from anywhere in your code. A good place to import general scripts would be in your index.js entry point file. This approach has the benefit of including the script with the rest of your js bundle, enabling minification and tree shaking.
// index.js
import "../path/to/your/script-file";
3. Code splitting
Lastly, if you would like to dynamically load a piece of js code in a certain point in time, while making sure it isn't part of your starting bundle, you could do code splitting, using dynamic imports. https://create-react-app.dev/docs/code-splitting
function App() {
function handleLoadScript() {
import('./your-script')
.then(({ functionFromModule }) => {
// Use functionFromModule
})
.catch(err => {
// Handle failure
});
};
return <button onClick={handleLoadScript}>Load</button>;
}
Usually, one can update an HTML element in react using the dangerouslySetInnerHTML prop.
But for the case of a script that is to be executed, this won't work, as discussed in this other SO question.
An option you have to achieve this, is appending the element inside a new document context, using the document Range API, createContextualFragment
Working example below.
Note that I've tweaked your script a bit to show some ways to customize it.
const { useState, useRef, useEffect, memo } = React;
const MyCustomScriptComponent = () => {
const [includeScript, setIncludeScript] = useState(false)
// just some examples of customizing the literal script definition
const labelName = 'dataLayer'
const gtmId = 'ID' // your GTM id
// declare the custom <script> literal string
const scriptToInject = `
<script>
(function(w,d,s,l,i){
const gtmStart = new Date().getTime();
w[l]=w[l]||[];w[l].push({'gtm.start':
gtmStart,event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
console.log("loaded at gtmStart:", gtmStart);
})(window,document,'script','${labelName}','${gtmId}');
console.log("fetching GTM using id '${gtmId}'");
</script>`
const InjectScript = memo(({ script }) => {
const divRef = useRef(null);
useEffect(() => {
if (divRef.current === null) {
return;
}
// create a contextual fragment that will execute the script
// beware of security concerns!!
const doc = document
.createRange()
.createContextualFragment(script)
// clear the div HTML, and append the doc fragment with the script
divRef.current.innerHTML = ''
divRef.current.appendChild(doc)
})
return <div ref={divRef} />
})
const toggleIncludeScript = () => setIncludeScript((include) => !include)
return (
<div>
{includeScript && <InjectScript script={scriptToInject} />}
<p>Custom script {includeScript ? 'loaded!' : 'not loaded.'}</p>
<button onClick={toggleIncludeScript}>Click to load</button>
</div>
)
}
ReactDOM.render(<MyCustomScriptComponent />, document.getElementById('app'))
Try it live on codepen.
For additional reference, you can find more alternatives to inject a script in this medium post.
Related
I wonder how I would add an external script to an React application and make its functions available. Let's take this script https://www.cssscript.com/confetti-falling-animation/
In a "not-react-application" I would add it like this in my DOM
<script src="confetti.js"></script>
and then call it's functions like this
startConfetti();
or
stopConfetti();
However, this does not work in React. I know that I can add a <script /> tag like this:
useEffect(() => {
const script = document.createElement('script');
script.src = './confetti.js';
script.async = true;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
}
}, []);
But this does not make the functions startConfetti() or stopConfetti() available. They are undefined.
How would I add the script and its functionalities in a React App?
Add the script in index.html's head tag, like so (with the correct path to the JavaScript file):
<script src="confetti.js"></script>
Then in your React component, you could get it with the help of window object, this way:
const startConfetti = window.startConfetti;
startConfetti();
When loading scripts without using modules, I could use the document.currentscript to access custom attributes.
E.g.
<script src="js/myscript.js" data-custom-attribute="my-value"></script>
Within myscript.js I can then do the following
// will contain "my-value"
const myAttribute = document.currentScript.getAttribute('data-custom-attribute');
If I load the javascript using module syntax however, the currentScript is set to null and this doesn't work.
<script src="js/myscript.js" data-custom-attribute="my-value" type="module"></script>
Is there a way to still access such attributes within the javascript file?
From MDN
The Document.currentScript property returns the element whose
script is currently being processed and isn't a JavaScript module.
(For modules use import.meta instead.)
import.meta does not provide the data-attribute. Here are a few alternatives:
[edit may 2022] The import from the original snippet does not exist anymore. Though it's still loaded from cdn.jsdelivr.net, this may in the future not be the case. So, added a new snippet using a more persistent library.
<script data-custom-attribute="my-value" type="module">
import {Logger} from
"https://cdn.jsdelivr.net/gh/KooiInc/DOM-Utilities#v1.1.2/SmallHelpers.js";
const log = Logger();
log(`document.currentScript is ${document.currentScript}`);
log(`But with querySelector ... data-custom-attribute from module script: ${
document.querySelector("script[type=module]").dataset.customAttribute}`);
log(`Or more precize: document.querySelector("script[data-custom-attribute]"): ${
document.querySelector("script[data-custom-attribute]")
.dataset.customAttribute}`);
log(`Or retrieve and filter document.scripts: ${[...document.scripts]
.find(scrpt => scrpt.dataset.customAttribute).dataset.customAttribute}`);
</script>
<script type="module" data-custom-attribute="my-value">
import $ from "https://kooiinc.github.io/JQL/lib/JQLBundle.js";
$.setStyle(`#result`, {whiteSpace: `nowrap`});
$.setStyle(`#result div`, {marginTop: `0.3rem`});
$.setStyle(`code`, {color: `green`});
const log = (...lines) =>
lines.forEach( line => $(`#result`).html(`<div>${line}</div>`, true));
const indent = `<br> `;
// --------------------------------------------------------------------------
log(`<code>document.currentScript</code> = ${document.currentScript}`);
log(`But<br>
<code>document.querySelector("script[type=module]")
.dataset.customAttribute</code> = "${
document.querySelector("script[type=module]").dataset.customAttribute}"`);
log(`Or targeted exactly<br>
<code>document.querySelector("script[data-custom-attribute]")
${indent}.dataset.customAttribute</code> = "${
document.querySelector("script[data-custom-attribute]")
.dataset.customAttribute}"`);
log(`Or retrieve from <code>document.scripts</code><br>
<code>[...document.scripts].find(scrpt =>
${indent}scrpt.dataset.customAttribute).dataset.customAttribute</code> = "${
[...document.scripts].find(scrpt => scrpt.dataset.customAttribute)
.dataset.customAttribute}"`);
</script>
<div id="result"></result>
I don't have a solution to this problem, but I wanted to point out that there can also be a problem with non module scripts not showing document.currentScript.getAttribute(...). The problem may be specific to Firefox. Reference.
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 cleanly from JavaScript.
/example.html 🔍
<script type="module">
import {example} from '/example.js';
example();
</script>
/example.js
export function example() {
document.body.appendChild(document.createTextNode("hello"));
};
However, this only allows me to import modules that are defined by separate external JavaScript files. I usually prefer to inline some scripts used for the initial rendering, so their requests don't block the rest of the page. With a traditional informally-structured library, that might look like this:
/inline-traditional.html 🔍
<body>
<script>
var example = {};
example.example = function() {
document.body.appendChild(document.createTextNode("hello"));
};
</script>
<script>
example.example();
</script>
However, naively inlining modules files obviously won't work, since it would remove the filename used to identify the module to other modules. HTTP/2 server push may be the canonical way to handle this situation, but it's still not an option in all environments.
Is it possible to perform an equivalent transformation with ECMAScript modules?
Is there any way for a <script type="module"> to import a module exported by another in the same document?
I imagine this could work by allowing the script to specify a file path, and behave as though it had already been downloaded or pushed from the path.
/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>
Or maybe by an entirely different reference scheme, such as is used for local SVG references:
/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>
But neither of these hypotheticals actually work, and I haven't seen an alternative which does.
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.
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.
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.
I tweaked Jeremy's answer with the use of this article to prevent scripts from executing
<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>
I'm hoping that this solves the dynamic-script-appending issue (using MutationObserver), vs-code not syntax-highlighting (preserving type=module) and I imagine that using the same MutationObserver one could execute scripts once the imported ids are added to the DOM.
Please tell me if this has issues!
We can use blob and importmap to import inline scripts.
https://github.com/xitu/inline-module
<div id="app"></div>
<script type="inline-module" id="foo">
const foo = 'bar';
export {foo};
</script>
<script src="https://unpkg.com/inline-module/index.js" setup></script>
<script type="module">
import {foo} from '#foo';
app.textContent = foo;
</script>
I am dynamically adding a <script> tag to the document <head> on page load based on the environment.
The Function:
export const loadScript = () => {
// load script tag into head
const HEAD = document.getElementsByTagName('head')
const SCRIPT_TAG = document.createElement('script')
SCRIPT_TAG.setAttribute('src', process.env.SCRIPT_SRC)
SCRIPT_TAG.setAttribute('async', true)
HEAD[0].append(SCRIPT_TAG)
}
I want to write a test that checks if once the loadScript() function is run that the <script> tag made it into the head. Our environment is set up with Jest, and I haven't found a satisfactory example that demonstrates how to do it, or works.
I am new to testing, and would appreciate any solutions, or hints offered.
I suppose the easiest way to test it would be something like this:
test('loadScript', () => {
process.env.SCRIPT_SRC = 'the-src';
loadScript();
expect(document.head.innerHTML).toBe('<script src="the-src" async="true"></script>');
});
This works because the default test environment for Jest is jsdom which simulates the document.
test('loadScript', () => {
loadScript();
const script = document.getElementsByTagName('script')[0];
// trigger the callback
script.onreadystatechange(); // or script.onLoad();
expect("something which you have on load").toBe('expected result on load');
});
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 cleanly from JavaScript.
/example.html 🔍
<script type="module">
import {example} from '/example.js';
example();
</script>
/example.js
export function example() {
document.body.appendChild(document.createTextNode("hello"));
};
However, this only allows me to import modules that are defined by separate external JavaScript files. I usually prefer to inline some scripts used for the initial rendering, so their requests don't block the rest of the page. With a traditional informally-structured library, that might look like this:
/inline-traditional.html 🔍
<body>
<script>
var example = {};
example.example = function() {
document.body.appendChild(document.createTextNode("hello"));
};
</script>
<script>
example.example();
</script>
However, naively inlining modules files obviously won't work, since it would remove the filename used to identify the module to other modules. HTTP/2 server push may be the canonical way to handle this situation, but it's still not an option in all environments.
Is it possible to perform an equivalent transformation with ECMAScript modules?
Is there any way for a <script type="module"> to import a module exported by another in the same document?
I imagine this could work by allowing the script to specify a file path, and behave as though it had already been downloaded or pushed from the path.
/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>
Or maybe by an entirely different reference scheme, such as is used for local SVG references:
/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>
But neither of these hypotheticals actually work, and I haven't seen an alternative which does.
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.
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.
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.
I tweaked Jeremy's answer with the use of this article to prevent scripts from executing
<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>
I'm hoping that this solves the dynamic-script-appending issue (using MutationObserver), vs-code not syntax-highlighting (preserving type=module) and I imagine that using the same MutationObserver one could execute scripts once the imported ids are added to the DOM.
Please tell me if this has issues!
We can use blob and importmap to import inline scripts.
https://github.com/xitu/inline-module
<div id="app"></div>
<script type="inline-module" id="foo">
const foo = 'bar';
export {foo};
</script>
<script src="https://unpkg.com/inline-module/index.js" setup></script>
<script type="module">
import {foo} from '#foo';
app.textContent = foo;
</script>