Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using ES6 classes to initialize a component scope (x-data) #270

Closed
thiagomajesk opened this issue Mar 12, 2020 · 38 comments
Closed

Using ES6 classes to initialize a component scope (x-data) #270

thiagomajesk opened this issue Mar 12, 2020 · 38 comments

Comments

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Mar 12, 2020

Hello, everyone!
I couldn't find any documentation on this specific use case and was wondering if there are any caveats to this approach. Testing a simple case on Codepen seems to work fine.

<div x-data="App.Greeter">
  <span x-text="`Hello ${name}`"></span>
  <br />
  <input type="text" x-model="name">  
</div>
class Greeter { 
  constructor() {
    this.name = "James"
  }
}

window.App = { 
    Greeter: new Greeter() 
}
@HugoDF
Copy link
Contributor

HugoDF commented Mar 12, 2020

There shouldn't be too many caveats, the usual this binding issues could occur.

Off the top of my head I don't think there'll be too many issues (if any), instances of a class (new MyName()) in JavaScript have very few differences to regular objects defined with object literal syntax const obj = {} especially the way they're used in Alpine.js (class is syntactic sugar over the prototype + constructor functions).

@thiagomajesk
Copy link
Contributor Author

Hey @HugoDF, what kind of this binding issues? (If you don't mind me asking)

@HugoDF
Copy link
Contributor

HugoDF commented Mar 13, 2020

The classic:

class Hello {
  hey() {
    return this.prop
  }
}

const hello = new Hello()
const { hey } = hello
hey() // error
``

@thiagomajesk
Copy link
Contributor Author

@HugoDF How would that be a problem using it with Alpine though? I mean, once I have the object instance passed to x-data.

@HugoDF
Copy link
Contributor

HugoDF commented Mar 13, 2020

Like I said I can't think of any, Alpine.js is pretty good with binding this

@thiagomajesk
Copy link
Contributor Author

@calebporzio Could you weigh in on this?

@HugoDF
Copy link
Contributor

HugoDF commented Mar 13, 2020

Put it another way, if Alpine manages to keep this accurate with object literals, I don't see any case where it would suddenly break for classes.

I think it's a case of "go for it and let us know if you find any issues"

@thiagomajesk
Copy link
Contributor Author

@HugoDF Ok then, I'll do it.
My primary concern was going off rails with the Alpine approach and having no support whatsoever. Thank you very much for your time, I'm closing this issue for now.

@thiagomajesk
Copy link
Contributor Author

thiagomajesk commented Mar 13, 2020

Hi, again @HugoDF.

I'm reopening this issue because I spotted a problem using the latest 2.1.2 version.
My previous example only works for version 2.0.0 and since I'm using promises, I had to update because of this: Proxies break Promises.
Right now, I can't get any reactivity on my component, any suggestions?

@thiagomajesk thiagomajesk reopened this Mar 13, 2020
@HugoDF
Copy link
Contributor

HugoDF commented Mar 13, 2020

The reactivity engine was switched in v2.1 you're probably going to need to switch to objects

@thiagomajesk
Copy link
Contributor Author

thiagomajesk commented Mar 13, 2020

I'm doing this right now, thanks for confirming.
So, I guess we are back to square one regarding using ES6 classes.
Anything that can be done to start this conversation or this is now just off the table?

@HugoDF
Copy link
Contributor

HugoDF commented Mar 13, 2020

I think it wasn't explicitly supported but if the reactivity engine we now use doesn't support it, I don't think this feature is worth going back to a hand-rolled proxy reactivity layer.

Although I think there might be some other discussions around that (the proxy/reactivity layer) since observable-membrane is causing issues on IE11.

@thiagomajesk
Copy link
Contributor Author

I see...
Well, if you don't mind I'm gonna leave this open for future investigation.
Also, I think it's worth checking with the people involved with observable-membrane if there are any plans to implement this feature natively.

@SimoTod
Copy link
Collaborator

SimoTod commented Mar 14, 2020

@thiagomajesk The issue seems to be here -> https://github.com/salesforce/observable-membrane/blob/a3215d6d4e1c7c39f4504a6a0234f3121926a80f/src/reactive-membrane.ts#L68

That line makes property reactive only if:

  • their prototype is an Object
  • their prototype is null
  • the prototype of their prototype is null
    (Javascript inheritance)

When using a ES6 class, its prototype is custom and different from Object.
In your case the prototype of the prototype is Object but if the class extended another class, the membrane would have to call getPrototype recursively up to the root.

There could be a reason why Salesforce don't support it but it's more a question for them (I've no clue, sorry).

@thiagomajesk
Copy link
Contributor Author

thiagomajesk commented Mar 16, 2020

Well, at least there's an official response: salesforce/observable-membrane#42 (comment).
The only workaround I can think of would be doing what Knockout does: Initialize the properties you want to be reactive instead of the whole object:

class Greeter {
  constructor() {
    this.name = observable("James")
  }
}

@SimoTod
Copy link
Collaborator

SimoTod commented Mar 16, 2020

I see. We had the same issue with the old proxy implementation, some objects such as Date, RegExp, etc didn't work before of the this binding.

@thiagomajesk
Copy link
Contributor Author

@SimoTod At least that comment sparked the conversation on the observable-membrane side.
I think we should wait to see the options we have.

@SimoTod
Copy link
Collaborator

SimoTod commented Mar 16, 2020

Yeah, the main problem is that salesforce is corporate so all changes will probably follow slow processes and developers need to be allocated to work on it. They can't really change it without asking the business if they use the membrane on one of their products, it's understandable.
Let's see what happens anyway.
Unfortunately, I think you should not use classes or try some workaround for now since it doesn't seem like something we can fix in Alpine.

@ryangjchandler
Copy link
Contributor

Hi guys, closing this issue due to inactivity. If you think this feature is a necessity, please open another issue for discussion.

@thiagomajesk
Copy link
Contributor Author

thiagomajesk commented Apr 11, 2020

Hi @ryangjchandler!
This is being looked at by @SimoTod already here: salesforce/observable-membrane#42. Please, keep it open so we can remember to track progress in this project too.

@ryangjchandler
Copy link
Contributor

No worries, the comment on the other side hasn't had much activity yet. I think we should keep this issue closed for now until we see some progress on Salesforce's end, what do you think? Happy to open the issue up again if you think it's beneficial!

@SimoTod
Copy link
Collaborator

SimoTod commented Apr 11, 2020

I've chatted to the salesforce guys and they don't have any plans to support this use case due to other complexities.
Unless we rollout our own membrane (but i think we prefer not to), I would say that it's not supported unfortunately.

@ryangjchandler
Copy link
Contributor

Yeah sounds about right, got that impression from the comments on the issue that side. I'll keep this issue closed then. It's possible we might be able to add support in the future, but there's no promises.

@thiagomajesk
Copy link
Contributor Author

Then it's settled! Thank you guys for all the effort so far 😄.

@ryangjchandler
Copy link
Contributor

Then it's settled! Thank you guys for all the effort so far 😄.

No need to thank me, thank @SimoTod! I'm just triaging the issues haha

@wgasowski
Copy link

Hi, I try to achieve something like this (use class in component).
Unfortunately second component is not reactive.

I've tried both build versions without success.

<div x-data="component1()">
  <h1>Component #1</h1>
  <input type="text" x-model="user.name">
  <div x-text="user.name"></div>
</div>

<div x-data="component2()">
  <h1>Component #2</h1>
  <input type="text" x-model="user.name">
  <div x-text="user.name"></div>
</div>
const component1 = () => {
  return {
    user: {
      name: 'UserName'
    }
  }
}

class User {
  constructor() {
    this.name = 'UserName'; 
  }
}
const component2 = () => {
  return {
    user: new User()
  }
}

Above example in codepen.

@SimoTod
Copy link
Collaborator

SimoTod commented Apr 18, 2020

Hi @wgasowski
ES6 classes are not supported by the current reactivity layer only literal objects as per documentation.
You can find more details in the conversation above.

@ryasan
Copy link

ryasan commented Feb 1, 2021

@SimoTod

May not be officially supported but there's a hackish kind of way to get it working with es6 classes. It seems that normal instantiation of a class doesn't work but if you make a copy of it instead it works. Also, methods need to use the function keyword below or Alpine won't be able to find the method for some reason:

interface IAccordionTrigger {
  open: boolean;
  toggleTrigger(): void;
}

abstract class AccordionTrigger implements IAccordionTrigger {
  public open = false;

  public toggleTrigger = function () {
    this.open = !this.open;
  };
}

class AccordionBottomTrigger extends AccordionTrigger  {
  public truncatePreviewText = function () {
    if (this.open) return false;
    return true;
  };
}

// Does not work:
window.accordionBottomTrigger = () => {
  return new AccordionBottomTrigger();
};

// Works:
window.accordionBottomTrigger = () => {
  return { ...new AccordionBottomTrigger() };
};

// Also works:
window.accordionBottomTrigger = () => {
  return Object.assign(new AccordionBottomTrigger());
};

@SimoTod
Copy link
Collaborator

SimoTod commented Feb 1, 2021

Yeah, that works because you are deconstructing the class into a literal object. You will lose all the class functions and just inherit the properties though.

@ryasan
Copy link

ryasan commented Feb 1, 2021

@SimoTod

I was having trouble getting the methods to work but when I defined the methods with the function keyword instead, they started working (i.e. Pressing the toggle button was actually toggling the open state and reflecting it in the DOM). So I was accessing all the properties and methods just fine.

Anyways this isn't a silver bullet solution. Classes should work without having to do doing anything extra.

@SimoTod
Copy link
Collaborator

SimoTod commented Feb 1, 2021

Yeah, what i mean is, if you get a class that you didn't write, with functions defines on the prototype and stuff like that, deconstructing the object will remove functionalities. If you wrote the class but you are just transforming it to an object literal you probably won't get much benefit compared to a normal. Object but yeah, it does work

@w-jerome
Copy link

w-jerome commented Jun 21, 2023

Hi everyone,

I'm here to complete this discussion with my own research.

I wanted to try using Alpine with classes to create components that were a bit advanced, and I thought that we could reassign the value of "this" in the object itself:

<div x-data="DropDown">
  <button @click="$component.toggle">
    Expand
  </button>
  <div x-show="$component.isOpen">
    Content...
  </div>
</div>
import Alpine from "https://cdn.skypack.dev/alpinejs@3.12.2";

class DropDown {
  constructor() {
    this.$component = this;
    this.isOpen = false;
  }

  toggle() {
    this.isOpen = !this.isOpen;
  }
}

Alpine.data('DropDown', () => (new DropDown()));

Alpine.start();

I'd like to make it clear that for the moment, I haven't done any further tests on the component's lifecycle, etc...

But I can already access the "Magics":

<div x-data="DropDown({ isOpen: false })">
  <button @click="$component.toggle">
    Expand
  </button>
  <div x-show="$component.isOpen">
    Content...
  </div>
</div>
class DropDown {
  constructor(options) {
    this.$component = this;
    this.isOpen = !!options.isOpen;
  }

  toggle() {
    this.isOpen = !this.isOpen;
  }
}

Alpine.data('DropDown', (options) => (new DropDown(options)));

@SvetoslavStefanov
Copy link

Hi everyone,

I'm here to complete this discussion with my own research.

I wanted to try using Alpine with classes to create components that were a bit advanced, and I thought that we could reassign the value of "this" in the object itself:

<div x-data="DropDown">
  <button @click="$component.toggle">
    Expand
  </button>
  <div x-show="$component.isOpen">
    Content...
  </div>
</div>
import Alpine from "https://cdn.skypack.dev/alpinejs@3.12.2";

class DropDown {
  constructor() {
    this.$component = this;
    this.isOpen = false;
  }

  toggle() {
    this.isOpen = !this.isOpen;
  }
}

Alpine.data('DropDown', () => (new DropDown()));

Alpine.start();

I'd like to make it clear that for the moment, I haven't done any further tests on the component's lifecycle, etc...

But I can already access the "Magics":

<div x-data="DropDown({ isOpen: false })">
  <button @click="$component.toggle">
    Expand
  </button>
  <div x-show="$component.isOpen">
    Content...
  </div>
</div>
class DropDown {
  constructor(options) {
    this.$component = this;
    this.isOpen = !!options.isOpen;
  }

  toggle() {
    this.isOpen = !this.isOpen;
  }
}

Alpine.data('DropDown', (options) => (new DropDown(options)));

Excellent idea, thank you!

Have you stumbled upon any problems since then?

I'm trying to build my frontend in Typescript + Webpack and to load these modules into Alpine. So far, so good, but I'm in the very beginning now.

@w-jerome
Copy link

So far, I haven't had any problems with this "architecture". From time to time, if I have nested components, I rename the $component variable with a more contextualized name. But in the end it's not too much of a problem, because it's often components that are used to being linked, for example: Accordion -> AccordionItem.

And sometimes it's handy: I have a $app or $menu component that can be accessed by several components: https://plnkr.co/edit/OtwNfcD7SvXN9WEg?preview

@Mtillmann
Copy link

The approach works well, but it should be noted that you also need use the $component-property for internal calls of the class instance:

class DropDown {
  constructor(options) {
    this.$component = this;
    this.isOpen = !!options.isOpen;
  }

  complexMethod() {
    this.methodA(); //<- no worky
    this.$component.methodA(); //<- works
  }

  methodA() {
  }
 
}

@w-jerome
Copy link

w-jerome commented Dec 4, 2023

It doesn't work if you make the call directly. I think it has to do with the way the Proxy is sent.

@francisfontoura
Copy link

Hi!

I think the constructed objects are "plained" before proxying. So, the prototype exclusive members (methods, private members, etc.) declared are lost. Non-private methods can be copied from prototype, as shown below, but private members cannot be used.

This seems to work for me:

<body x-data="test">
  <pre x-text="prop"></pre>
  <pre x-text="method()"></pre>
  <pre x-text="anotherMethod()"></pre>
  <script type="module">
    import { Alpine } from "./packages.js";

    class AlpineClassComponent {
      constructor() {
        const prototype = Object.getPrototypeOf(this);
        for (const prop of Object.getOwnPropertyNames(prototype)) {
          if (typeof prototype[prop] !== "function") continue;
          this[prop] = prototype[prop];
        }
      }
    }

    class Test extends AlpineClassComponent {
      prop = "prop";

      method() {
        return "method";
      }

      anotherMethod() {
        return `another ${this.method()}`;
      }
    }

    Alpine.data("test", () => new Test());

    Alpine.start();
  </script>
</body>

Output:

prop
method
another method

@w-jerome
Copy link

I like the idea, in the end it's a clone of the methods and in the end it's more or less the right thing to do. The proxy is really blocking if you want to do things differently, but this technique works well for me too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants