Open mcjazzyfunky opened 4 years ago
IMHO it makes sense to provide (if somehow possible) meta information about the supported properties, attributes and events and also about the type of the properties. This makes for example auto-converting of (string) attributes to properties and vice versa much easier.
Moreover, explicit default property value declaration may help that reading element properties or attributes (like someElement.something
/ someElement.getAttribute('something')
) will work out of the box (unfortunately, when using declarative, explicit default prop values then there's a good change that you'll get some subtle problems in TS typing - at least with the current TS version ... this will hopefully change in future).
In case that it is not really clear what I mean please find here simple demos where these features are used (FYI: These demos use a completely unimportant "just for fun" pre-alpha toy library, these demos are only meant as an example for a function based web component library with the above mentioned features):
[Edit: Updated demos] https://codesandbox.io/s/tender-water-g56mc?file=/src/index.js https://codesandbox.io/s/nameless-brook-hc5bf?file=/src/index.js https://codesandbox.io/s/gracious-tereshkova-ftlyx?file=/src/index.js https://codesandbox.io/s/wispy-darkness-tex5c?file=/src/index.js
Copy-pasting what I wrote in a reddit discussion https://www.reddit.com/r/javascript/comments/g1zj87/crankjs_an_alternative_to_reactjs_with_built_in/fnjwa5o?utm_source=share&utm_medium=web2x:
I actually think you can add public methods to function components in React using the āuseImperativeHandleā hook, but I think the API is kinda hinky. I agree with you 100%, one good metric for a framework is if its components can be exported and embedded in other frameworks, and I think web components play a key role in providing a uniform, imperative interface. I was gonna provide a way to create web components with Crank but didnāt get the chance to figure out the API yet.
I sketched out what I wanted in my head: I want the whole props/attr stuff to be normalized, I want to reuse the generator pattern that Crank does for stateful components, and I want declarative JSX, not templates. But because you need to respond to each prop/attr individually, the API is gonna have to be a little different. I thought maybe something like this:
CrankWebComponent.register("my-video", function *(instance) { instance.play = () => { this.playing = true; } for (const [name, value] of this) { // some code which responds to each new property yield ( <div> <video /> </div> ); } });
As you can see, not fully fleshed out, but the idea is that you would just provide a generator function and Crank would create the WebComponent class for you and normalize the props/attr changes?
I dunno, I think web components get a bad rap, especially cuz itās 4 separate technologies and most people havenāt even tried using them, and Iām really excited to try experimenting with them.
I really like the idea of a web component interface, and think it could be really important for solving the problem that Reactās useImperativeHandle
/class refs
solves. I think whatever web component library we create should be:
The one thing is that we canāt really iterate over this
and get props, because web components are mutated using individual props, and you have to respond to each prop update and set other props based on each individual prop update. It would be nice to have a conventional way to deal with attr/props mismatches too.
Lots of room to explore. I think this is a really important feature and Iām curious to hear what peopleās thoughts are.
[Important: We should concentrate first on enhancing the general Crank design patterns ... "web components" should really not have a high priority at the moment... :wink:]
I am very strongly of the opinion that for inspiration it's always good to see code examples of how others are trying to solve the problems you are currently trying to solve (independent whether you are a big fan of those solutions or not). So please allow me to show you another example using this web component toy library I have already mentiond above.
Hope you say to yourself "Mmmh, okay ... I see ... but I can do better ... hold my beer ..." ;-) and try to find better and in particular more Crankish solutions ...
This demo shows one possible answer to React's useImperativeHandle
(be aware that that little c
thingy is basically something like Cranks this
/Context
, also please be aware that everything here works completely (!) different than with React - don't be confused). [Off-topic: In near future I will - again for inspiration - show a similar little demo that will show a way to handle slots (aka. children
'n stuff) and also CSS with custom element's that use shadow DOM (it is a bit more challenging than it might sound) ... to be continued :-)]
component('simple-counter', {
props: {
label: prop.str.opt('Counter'),
initialCount: prop.num.opt(0)
},
methods: ['reset']
}, (c, props) => {
const
[state, setState] = useState(c, { count: props.initialCount }),
onIncrement = () => setState('count', it => it + 1)
useMethods(c, {
reset(count = 0) {
setState({ count })
}
})
return () => html`
<button @click=${onIncrement}>{props.label} {state.count}</button>
`
})
Please find a running demo here: https://codesandbox.io/s/dazzling-spence-s1zme?file=/src/index.js
Again, for inspiration, here's a litte demo of a custom element that uses it's own (dedicated) CSS styles and has two different slots. Again, the goal is to find a better and more Crankish solution than this. For those who are not yet familiar with web components: Custom elements can (but they do not have to!) use a so called "Shadow DOM" which isolated the CSS classes of the document from the CSS classes dedicated for the custom element. If you want to use CSS classes inside of the custom element's "Shadow DOM" you have to add the corresponding style element to the component's shadow DOM itself (for example if the document uses Bootstrap then "Shadow DOM" custom elements cannot use those Bootstrap CSS classes of the document - you'll have to load the Bootstrap styles also inside of the custom element to use them). Also it's important to know that if your custom element uses slots it always has to use "Shadow DOM".
Please find here an example implementation using this web component toy library I have already used for the examples above.
The important part is the implementation of component InfoBox
aka <info-box ...>
especially all occurrences of the word "slot".
My previous demos used lit-html
, which is a great library, but unfortunately I personally prefer to implement in TypeScript and lit-html
does not allow the same level of type safety as you are used with React and TSX. So this time I've tried to show a way how it's possible (in theory) to be fully typesafe using JSX (by using <InfoBox...>
instead of <info-box>
... and as a little gimmick that InfoBox
function does out-of-the-box also allow a non-JSX way to build virtual DOM trees (in case you want to implement in pure ECMAScript) => see "demo-2".
BTW: That toy library is still buggy as hell :-(... don't expect that anything else is working beyond these little demos:
https://codesandbox.io/s/js-elements-demo-uxkfu?file=/src/index.js
@mcjazzyfunky Hmmm definitely not a cranky API but interesting and impressive.
An interesting conversation on web components: https://twitter.com/RyanCarniato/status/1257806356947464193
The fact, that the stateful components in my demos are based on a complete different pattern than Crank is not important here (just replace the function argument with a crank component function and you have a more Cranky API). What I've tried to show is that Brian's example above
CrankWebComponent.register(tagName, crankyFunc)
- generator-based
- synchronous
- JSX not templates
could be extended to something like:
const MyComponent = registerCrankComponent(tagName, options, crankyFunc)
where the argument options
allows to specify some meta data like the involved props, possible default props, prop types, method names, slot names etc.
If the register/registerCrankComponent
function will return something (function or whatever - not a string) that can be used as first argument of the createElement
function then the whole thing could be properly typeable in TS (<my-component ...>
will normally not be type safe, but <MyComponent ...>
will).
// [Edit]
// Or as an alternative maybe something like this,
// in case those web components are completely based
// on "Crank.js the library" and not only "Crank the design pattern":
const MyComponent = component(config, crankyFunc)
CrankWebComponent.register(tagName, MyComponent)
[Edit] A bit later, I doubt that this "alternative" is really working the way I wanted it to work. I think MyComponent
must know the tagName
so something like the first suggestion might be better.
Hmmmh, I think I've changed my mind a bit here. Above API suggestions were (more or less):
CrankWebComponent.register(tagName, crankyFunction)
After shortening the function name and adding an argument to provide component meta information (which and what props does the component have, which props are optional, which are required, and what are the default prop values?) you get:
defineCrankElement(tagName, meta, crankyFunction)
In the suggestions above crankyFunction
was always meant to be either a "normal" function or an async function or a sync generator function or an asnc generator function.
My following proposal is different: Why not just ALWAYS use a pure, normal function for that third argument (nothing async and no generators). Just implement the whole complex component that shall be used as web component completely as a usual crank component function and then just wrap it directly as a web component, where I think a pure function will completely do the job.
Please find here a demo that hopefully shows what I mean: » Demo (in the demo I use a slightly different form - which I personally like better: defineCrankElement(tagName, { props?, slots?, render })
. Custom components often need imperative methods, a topic which is not handled in the demo. I think a single second argument ref
or setMethods
for that render
method should work.
[Edit] Updated demo to also show this setMethods
functionality.
@mcjazzyfunky Interesting! I like the prop/attr normalization system you got going.
The big thing web components need is an imperative API; thatās what motivates their usage above and beyond just regular Crank components. For instance, if I do document.getElementsByTagName("crank-counter")[0]
in your example, the element should have custom methods or properties which allow me to affect rendering. Like in the above example it would make sense for there to be an imperative reset
method, which resets the counter to the initial value. And you should probably also be able to get/set the label of the counter.
The big thing web components need is an imperative API
Like said, a second argument for that render
method (which could be called ref
or setMethods
or whatever name is preferred) will do the job.
I've updated my demo above to show this setMethods
stuff (basically the couterpart to React's useImperativeHandle
) and some imperative component prop updating:
https://codesandbox.io/s/crank-webcomponent-demo-forked-0d0km?file=/src/index.js
A good thing about this defineCrankElement
approach is that if you use a simple adapter pattern, then the
implementation of definePreactElement
is only about 5 additional lines of code away.
Same for a defineDyoElement
etc. (only a defineReactElement
will be more complicated, as React is not very web component friendly)
PS: The demos do not show how to handle events but it will be just:
defineCrankElement('crank-counter', {
props: {
...,
onSomeEvent: prop.func.opt()
},
...
... not necessarily very easy to implement, but doable.
@mcjazzyfunky Wow thatās getting there! One thought I have. The benefit of inheritance is that you can just define methods directly on the class, rather than as a callback inside the component, which feels a little mind-bending. It means that your Crank components have to be aware of your webcomponent logic, which feels off to me. I do like the idea that the web component class only takes a single pure function. That simplifies a lot of things, and Iām not sure why I wanted the web component API to use generator functions in the first place. What about something like this?
class MyComponent extends McJazzyFunkyComponent {
constructor() {
super({count: prop.num.req(), label: prop.str.opt('Count')}, (props) => (
<Counter count={props.count} label={props.label} />
));
}
reset() {
this.count = 0; // triggers internal connectedCallback logic
}
}
Youāre free to use whatever API you want of course, just brainstorming some API ideas.
It means that your Crank components have to be aware of your webcomponent logic
Actually that was the idea (maybe not the best idea :smile:): Write a common Crank component that has all properties and imperative methods that you want and then with a few lines of code wrap that Crank component 1:1 in a custom element class (even if you do not see the class in my demo - under the hood there is a custom element class of course). After that you have a Crank component and a custom element component that have both equal features. If that does not make sense for let's say more sophisticated components, then I think the whole idea itself may not be really helpful.
In your latest examples you made the Counter
component stateless and instead the web component stateful.
But then: Why does the the custom element as both a writable count
property plus a reset
method?
Anyway, as that topic is not really very urgent, I think it makes sense to wait for other API proposals and ideas and reevaluate again in some weeks.
Okay, maybe my last proposal will not fit all needs.
Please find here a modification of the demo where the configuration parameter main
is basically a common Crank function (all four function types supported). The only difference is that the crank context will be passed as second argument and also there is a third argument setMethods
(using this
here would feel a bit odd IMHO, but that's just a not-so-important detail, I guess).
https://codesandbox.io/s/crank-webcomponent-demo-forked-uxi0m?file=/src/index.js
defineCrankElement('crank-counter', {
props: {
initialCount: prop.num.opt(0),
label: prop.str.opt('Counter')
},
methods: ['reset'],
*main(props, ctx, setMethods) {
let count = props.initialCount
const onIncrement = () => {
++count
ctx.refresh()
}
setMethods({
reset: (n = 0) => {
count = n
ctx.refresh()
}
})
for (props of ctx) {
yield (
<button onclick={onIncrement}>
{props.label}: {count}
</button>
)
}
}
})
I personally prefer this function based syntax. But I guess most folks will prefer a class-based solution (I think this is more or less a matter of taste). Unfortunately it will take some time till this decorator and field declaration features will be available in the ECMAScript standard.
// Abstract class CrankComponent does NOT extend anything
// (especially not HTMLElement).
// CrankComponent implements the Crank context interface.
@component('crank-counter') // will register custom element
class Counter extends CrankComponent {
@prop(Number)
initialCount = 0
@prop(String)
label = 'Counter'
@state() // with auto-refresh support
count = 0
@method()
reset(n: number = 0) {
this.count = n
}
*main() {
this.count = this.initialCount
const onIncrement = () => (++this.count)
while (true) {
yield (
<button onclick={onIncrement}>
{this.label}: {this.count}
</button>
)
}
}
}
[Edit -hours later] Hmm, maybe I prefer this syntax to the one that I have implemented in the demo above (shortening defineCrankElement
to defineElement
and using this
again).
Maybe that looks a bit more crank-esque.
https://codesandbox.io/s/crank-webcomponent-demo-forked-ydsbt?file=/src/index.js
const counterMeta = {
name: 'crank-counter',
props: {
initialCount: prop.num.opt(0),
label: prop.str.opt('Counter')
},
methods: ['reset']
}
defineElement(counterMeta, function* (props, setMethods) {
let count = props.initialCount
const onIncrement = () => {
++count
this.refresh()
}
setMethods({
reset: (n = 0) => {
count = n
this.refresh()
}
})
for (props of this) {
yield (
<button onclick={onIncrement}>
{props.label}: {count}
</button>
)
}
})
Wouldn't it be great to implement custom elements in a "crankish" š way? This is a discussion thread to gather all ideas to be found about the question how to use Crank.js or Crank.js patterns to implement custom elements.
This is a brainstorming, nobody expects a fully sophisticated proposal. So please share every idea that comes to your mind: Requirements, API suggestions, best practices , dos and don'ts, known pitfalls etc.
Here's a list of some popular web component libraries for inspiration: