11ty / eleventy-plugin-webc

Adds support for WebC *.webc files to Eleventy
https://www.11ty.dev/docs/languages/webc/
120 stars 10 forks source link

[proposal/feature request] Add ability to scope component scripts #79

Open Gyanreyer opened 1 year ago

Gyanreyer commented 1 year ago

Hello! Sorry to drop a massive proposal on you, if it's too much or there's a better way to submit things like this, I'm happy to move it elsewhere!

So I've been building lots of little sites with WebC and it just keeps getting better and better, thanks for the amazing work! I know Adding More JavaScript To Your Page is generally not a priority considering what Eleventy is going for, but I wanted to share some thoughts I've been having regarding writing scripts for WebC components and how I feel like the DX could potentially be improved.

The problem

Sometimes, I want to write a script in a component file which provides some enhancements to all of the instances of that component, ie setting up an IntersectionObserver which triggers an animation on the element(s) when scrolled into view. Doing so looks something like this:

<!-- my-element.webc -->
<div webc:root="override" class="my-element">
  I'm going to animate in when you scroll me into view!
</div>
<style webc:scoped>
  :host { color: red; }
</style>
<script>
  // Pain point 1: sometimes I need to wrap scripts in an IIFE to provide peace of mind that I won't have variable name collisions with scripts from other components
  (()=>{
    const observer = new IntersectionObserver(...);

    // Pain point 2: Even if I don't need it, I have to add my own custom "my-element" class because there's no way to reference
    // unique classes applied from `webc:scoped`. It would be nice to have an easier way to access instances of the component
    // that I wrote the script for!
    const componentInstances = document.getElementsByClassName("my-element");

    for(const el of componentInstances) {
      observer.observe(el);
    }
  })();
</script>

Overall, these are obviously not massive problems as I have found workarounds for them which aren't too bad, but it feels like it could be improved.

Proposed change 1: Allow setting webc:scoped on <script> tags

Scoped script tags will be bundled into a scoped IIFE like in my example above, providing a way to keep your component-specific logic scoped instead of being intermingled with all of the other bundled scripts on the page.

<div webc:root="override">
  I am a component
</div>
<script webc:scoped>
  const hello = "world";
  console.log(hello);
</script>

builds to...

<div>I am a component</div>
(function(){
  const hello = "world";
  console.log(hello);
}))();

Like with scoped styles, you could provide a value to webc:scoped which represents an ID for the scoped function. Separate scripts with the same scope will be bundled together into the same function.

<div webc:root="override">
  I am a component
</div>
<script webc:scoped="hello">
  const hello = "world";
  console.log(hello);
</script>
<script webc:scoped="hello">
  console.log("I am in the 'hello' scope too!");
</script>

<script webc:scoped>
  console.log("I am in the default unnamed scope.");
</script>
<script webc:scoped>
  console.log("No scope names here");
</script>

builds to...

<div>I am a component</div>
(function() {
  const hello = "world";
  console.log(hello);
  console.log("I am in the 'hello' scope too!");
}))();

(function(){
  console.log("I am in the default unnamed scope.");
  console.log("No scope names here");
})();

Like with styles, an unnamed scope should produce a random scope id which is unique to the component. Custom-named scopes can potentially collide if two separate components have scripts which use the same scoped id.

Note: I went with an anonymous function over an arrow function in an effort to maximize browser support.

Proposed change 2: Add a new webc:bind attribute for scoped scripts

This is where the fun really starts! Building on scoped scripts, you could also allow scripts to be bound to instances of the component element. The script's contents would be bundled into a function which is invoked once for each instance of the element and bound to that instance, so you may access it via this.

This would be incredibly useful for setup tasks like adding event listeners, observing elements with IntersectionObserver, etc.

In order to facilitate this, we will need to apply a scoped class to the element in the same way that webc:scoped works for styles. If a value is provided to webc:scoped, that value will be added as a class on the element. If no value is provided, we will generate one that is unique to the component; ideally, this would be the exact same unique class that is used for scoped styles.

<div webc:root="override">
  I am a component
</div>
<style webc:scoped>
  :host {
    color: red;
  }
</style>
<script webc:scoped webc:bind>
  this.addEventListener("click", () => console.log("You clicked me!"))
</script>

builds to...

<div class="asdf1234">I am a component</div>
.asdf1234 { color: red; }
(function() {
  function bound() {
    this.addEventListener("click", () => console.log("You clicked me!"))
  };

  const elements = document.getElementsByClassName("asdf1234");
  for(let i = 0; i < elements.length; i++){
    bound.call(elements[i]);
  }
})();

If your component has a mix of bound and unbound scoped scripts, the unbound ones should be placed first. This would allow you to perform setup for shared variables/logic that you want to be available to each bound script.

<div webc:root="override">
  I am a component
</div>
<script webc:scoped="my-component">
  // Creating the observer in an unbound scoped script so we can share one instance between all elements
  const observer = new IntersectionObserver(...);
</script>
<script webc:scoped="my-component" webc:bind>
  observer.observe(this);
</script>

builds to...

<div class="my-component">I am a component</div>
(function() {
  const observer = new IntersectionObserver(...);

  function bound() {
    observer.observe(this);
  };

  const elements = document.getElementsByClassName("my-component");
  for(let i = 0; i < elements.length; i++){
    bound.call(elements[i]);
  }
})();

I think this would be incredibly helpful and take WebC components a step even closer to matching the benefits of a native web component. Of course, I'm not married to anything in this proposal and I understand that it would be relatively complex to implement. In fact, I could very easily be overlooking some reasons why this might not even be possible or make sense! But if any portions of it do sound interesting, then I would love to help make it a reality!

CanIGetaPR commented 1 year ago

All you need to create a scope are braces, no need for IIFE.

So, maybe to simplify your idea, all that needs to be done is have the webc:scoped use a unique id generator for the component instance. And "binding" should maybe be automatic within webc:scoped.

Then have the following:

{ this = document.getElementById(uniqueElementId);

[user scoped code goes here]

}

So, this would refer to the web component. But that means the component element needs to be kept on the page (webc:nokeep would be no bueno here) I suppose a compile error can pop up if webc:nokeep is used with script webc:scoped.

Gyanreyer commented 1 year ago

I hadn't realized that IE11 supports block scopes even though it doesn't support all ES6 features, so yeah that's probably fine.

I like having a distinction between normal scopes and "bound" scoped scripts to enable writing shared setup code (like initializing an IntersectionObserver) which doesn't need to be duplicated across every instance of the component. There are other ways to get around that, but it feels like it wouldn't be as big of an improvement over how things work right now as it could be.

Also for what it's worth, if you have a ton of instances of a component on a page, having an individual code block for each one seems like it could balloon very quickly, hence why I proposed encapsulating the bound code in a single re-usable function which we can then call with each instance of the component element.