1

So I want to create and use native web components and describe them as HTML files with markup, CSS and Javascript bundled together in one file like how Vue does .vue files. The components would be loaded on a page from an external components.html file, for example, via fetch().

So far I can load the HTML and CSS without a problem, however the Javascript is "dead", the browser doesn't run it or recognize it. As I understand Vue requires a build step in order to 'compile' .vue files. There is no live loading of .vue. Well, I want to do live loading. Is that silly?

All the native web component 'frameworks' I see out there define their components entirely in Javascript but I want to do it more declaratively with HTML and without template literal HTML definitions. I basically want to have methods and possibly data attached to custom elements when they are instantiated. Also, eval() is not a viable option, right?

I guess it is good that the Javascript initially comes in dead, so it doesn't pollute the global scope. But then how can I read it and basically inject it into the custom element class?

Here is an example of my code, which works fine except for loading the Javascript in the component, since the methods object does not exist anywhere.

components.html

<template id="my-dog">
    <style>
        .fur {color: brown}
    </style>
    
    <img src="dog.gif" onclick="speak">
    <p class="fur">This is a dog</p>
    
    <script>
        methods = {
            speak(){alert("Woof!");}
        }
    </script>
</template>

template creation script

//(skipping the part where I fetch components.html)
//(and inject them into the page)
//(and then iterate over each template until...)
templates.forEach(x=>{createTemplate(x)}) //ie. createTemplate('my-dog')

function createTemplate(elementName){
    
    /*various code*/

    let elemClass =  class extends HTMLElement {
        constructor() {
            super(); 
                
            this.attachShadow({mode: 'open'})
                .appendChild(templateContent.cloneNode(true));

        }
    }
    // THIS WORKS! But how can I do the same for speak() function
    // and other functions and variables defined in the template?
    elemClass.prototype['test'] = ()=>{console.log("This is a test")}

    customElements.define(elementName, elemClass);
}
Moss
  • 3,695
  • 6
  • 40
  • 60
  • Oh, I mean the test function works when I use the custom element like ``. – Moss Oct 02 '21 at 04:57
  • "HTML-Imports" (part of the old Web Components V0 spec) did do `` – Danny '365CSI' Engelman Oct 02 '21 at 07:33
  • Please, add "vue" tag to your question. I am not expert in Vue, but your question related to this framework, so someone could find this question and help you. – dragomirik Oct 02 '21 at 08:14
  • Actually, the way you want to solve your problem isn't the best approach. Anyway, as one of the bad solutions you can call use window object (or document) and insert your js as described here: https://stackoverflow.com/questions/5132488/how-can-i-insert-a-script-into-html-head-dynamically-using-javascript . Take a not, that the code I can see above is Vue.js code, so it could not be executed as vanilla js outside the framework. – dragomirik Oct 02 '21 at 08:19
  • @dragomirik The question is not about Vue. I was just using that as an example of what I am aiming for, using native web components. – Moss Oct 03 '21 at 04:45

2 Answers2

0

Loading external HTML/CSS

See Dev.To blogpost: https://dev.to/dannyengelman/load-file-web-component-add-external-content-to-the-dom-1nd

Loading external <script>

<script> inside a <template> runs in Global scope once you clone its contents to the DOM

I have not tried Vue; Angular bluntly removes all <script> content from Templates.

One Vanilla workaround is to add an HTML element that triggers code within Element scope.

<img src onerror="[CODE]"> is the most likely candidate:

This then can call a GlobalFunction, or run this.getRootNode().host immediately.

console.log showing scope when executing Custom Elements:

<my-element id=ONE></my-element>
<my-element id=TWO></my-element>

<template id=scriptContainer>
  <script>
    console.log("script runs in Global scope!!");

    function GlobalFunction(scope, marker) {
      scope = scope.getRootNode().host || scope;
      console.log('run', marker, 'scope:', scope);
      //scope.elementMethod && scope.elementMethod(); // see JSFiddle
    }
  </script>

  <img src onerror="(()=>{
    // 'this' is the IMG scope, this.getRootNode().host is the Custom Element
    this.onerror = null;/* prevent endless loop if function generates an error */

    GlobalFunction(this,'fromIMGonerror');
  })()">

</template>

<my-element id=ONE></my-element>
<my-element id=TWO></my-element>

<script>
  console.log('START SCRIPT');
  customElements.define('my-element',
    class extends HTMLElement {
      connectedCallback() {
        console.log('connectedCallback', this.id, "clone Template");
        this.attachShadow({ mode: 'open' })
            .append(scriptContainer.content.cloneNode(true));
      }
    });
</script>

More detailed playground, including injecting SCRIPTs, at: https://jsfiddle.net/CustomElementsExamples/g134yp7v/

it is al about getting your scope right

Since Scripts run in Global scope you can get variables clashes.

let foo=42 in a template script will fail (in most browsers) if there is a GLOBAL let foo="bar";

So use an esoteric trigger "GlobalFunction" name and do not create global variables.

advanced

  • Yes, the same template script runs for every connected <my-element>

  • <img src onerror="GlobalFunction.bind(this.getRootNode().host)"> is ignored

  • This will run; but do not forget to reset that error function, or you will run into endless JS loops

    <template id=scriptContainer>
    <script>
    console.log("script runs in Global scope!!");
    
    function GlobalFunction() {
      console.log(this); // proper Custom Element scope
    }
    </script>
    
    <img src onerror="GlobalFunction.bind(this.getRootNode().host)(this.error=null)">
    

````

  • Less DOM invasive than an <IMG> is to use <style onload="...">
Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • I'm struggling to understand your explanation and examples. It's rather complicated. How does this allow me to have code written in a ` – Moss Oct 02 '21 at 21:38
  • Yes, it ain't easy. All the code above does what you want. – Danny '365CSI' Engelman Oct 03 '21 at 08:08
0

So I have managed to get something working using the method described here, which I discovered after following the link in Danny Engelman's comment. The key is basically to use an iframe, rather than AJAX until the day that we have HTML imports in browsers. By using the iframe method the code inside <script> tags stays alive. Then it gets pulled into your page and the iframe is deleted. So long as the code is still wrapped in a <template> tag it does not touch the global scope or actually do anything, until it is instantiated in a custom element.

Questions still remain for me about how to best handle global scope issues, but for now it works to just use a predetermined global variable or variables that are defined inside templates, and in the connectedCallback I check for those variables. If they exist I transfer their information onto the custom element, then wipe the global variables for the next connected element. There is probably some better way to do it.

Working example here: https://codepen.io/lomacar/project/editor/ZPQJgg

Now what would be nice is if I could read the scripts in the templates before they are used and store their code in one universal location that each custom element points to, rather than processing their code every time a custom element is connected and without ever spilling their guts into global namespace.

Better Solution

OK, in my search for avoiding global variables I discovered an entirely better way to do things, which I discovered here: Inlining ECMAScript Modules in HTML. I realized I needed to use modules, but the final problem was how to import a module from the same page. Well, it turns out the solution to that problem also makes the previous solution unnecessary, although you could combine the iframe tactic with this one if it is more performant or something. For my purposes it boils down to just a few lines added inside my createTemplate function, before the class definition.

const mod
const templateScript = templateContent.querySelector('script')?.textContent
if (templateScript) {
    const blob = new Blob([templateScript], {type: 'application/javascript'})
    import(URL.createObjectURL(blob)).then( r =>
         mod = r
    )
}

So it gets the script element from a template as a string, converts it to a blob, and then imports the blob as if it is an external module! At this point I can store the module wherever I want, such as on the custom elements in the constructor. Success!

Moss
  • 3,695
  • 6
  • 40
  • 60