cuel
v0.0.7
Published
Tiny, powerful data binding & web application framework
Downloads
6
Maintainers
Readme
cuel
Tiny, powerful data binding & web application framework
Hello world!
<!doctype html>
<html><body>
<hello-world></hello-world>
<script type=module>
import {cuel} from "../cuel.mjs"
cuel('hello-world', {}, '{{greet}} {{whom}}!');
const element = document.querySelector('hello-world');
Object.assign(element, {greet: 'Hello', whom: 'world'});
</script>
</body></html>
Table of Contents
Features
- 2-Way binding between DOM and JavaScript Objects
- full DOM access
- scoped binding
- web component based view-modules with JS-code, HTML-templates and styles
- dynamically binding added DOM
- conditional rendering
- iterative rendering (rendering from array-data)
- ponyfill-style non-intrusive behavior, that only ever applies where you invoke it
- all that at < 700 lines of code
Why?
I want a framework that is so small, that it's easy to maintain. Because if you use it, you own it. You have to update it until it falls out of fashion and then take over maintenance completely. Cuel is less than 700 lines of nice readable code. It relies on pretty few concepts making it quite easy to fully understand what's going on in a codebase that is very maintainable.
I want a framework, that just works and works without magic. No compilation, no custom language like JSX. Cuel code will be easily readable by developers 20 years from now. Because it heavily leverages standards.
I want a framework, that gives me awesome performance where I need it. A decent web app should clock in at maybe 100K, not megabytes. Cuel is 5K gzipped. And this is not optimized for size. No magic means I know what's going on and can optimize for CPU or RAM where needed.
Finally I want a framework that helps me structuring script and DOM but does not get in my way. I want easy power, native DOM APIs where I need them and nice bindings where I don't.
Demo
Cuel comes with an app demo, that shows most features you'll need to write web apps. You may
git clone https://codeberg.org/schrotie/cuel.git
cd cuel
npm install
npm start
This should open the demo in your browser. Note this will only work in Chrome or Firefox as of this writing. Other browsers need importmaps support as described below! Check out the source code in demo/app/ and see in your browser, what it does. Change it! The browser should automatically reload. I guess, that's the quickest way to learn Cuel for most of you.
You may come back to the documentation in order to understand, how the features work, what's actually going on there.
Documentation
I designed cuel to give me decoupled dynamic view components on the simplest possible code base. It does this by leveraging web components and these are a very fine choice for this. However, web components can be many, many things and cuel was designed with one very specific use case in mind.
Thus cuel may help you with all your web component related coding and I'd be very happy if it did. But I designed it to decouple my apps into several views and decouple these view's logic from the notoriously hard-to-test-with DOM APIs.
Cuel gives you precisely one function and that is a pimped up version of the built in
customElements.define(name, constructor, options)
Indeed, whatever you can pass to customElements.define
, you can pass to
cuel
! And more - cuel's key feature is, that you can pass it a template in
which you define mustache/handlebars style bindings. When your component is
instantiated, the template will become DOM ("light" or "shadow" depending on how
you passed the template) and the instance of your component's JavaScript class
will have accessors to directly read and write the bound DOM-stuff.
Because cuel was written for cookie cutting apps into view modules, it defaults
to rendering light
DOM - usually you don't want your view components
completely decoupled from your app's CSS as is the case for
shadow DOM.
So instead of the last options
argument, you may just pass your template and
have it rendered a light DOM, when your component is connected
:
cuel('hello-world', class extends HTMLElement {}, 'Hello world!');
When you then (or before) put <hello-world></hello-world>
into your HTML, that
will read "Hello world!" (yes, looks like a questionable deal put like this,
but bear with me). In order to further minimize boilerplate, cuel also lets you
provide a mixin instead of a class extending HTMLElement
. It will create an
HTMLElement
itself using your mixing. So in the simplest case you just do
cuel('hello-world', {}, 'Hello world!');
You only need to provide an HTMLElement if you want to customize the
constructor
method.
So cuel
accepts two mandatory and one optional argument. The first argument
is a string giving the tag-name of your custom element. The second argument is
a class
or a mixin implementing the element. And the last argument - if
provided - is a light-DOM-template-string or an options object.
The options object may have the following properties:
extends
see custom elements documentation for detailstemplate
light-DOM-template-string ORshadow
shadow-DOM-template-stringifTemplate
map of subtemplates that are conditionally renderedloopTemplate
map of subtemplates that are iteratively rendered
We'll cover ifTemplate
and loopTemplate
in detail below.
import 'cuel'
, Development & Deployment
I hedge a deep and well-fostered hate against having a build step in my development cycle and my apps work without one. If you want to import cuel you have two options: import the bundled cuel.min.mjs or the source cuel.mjs. You may use absolute paths to their position in your node_modules folder.
I recommend using the source version and doing all your bundling yourself.
If you want to import it with a nice `import {cuel} from 'cuel', you should use import-maps which are supported in the major browsers with the exception of IE 12 (aka "Safari").
Put something like this into the head of your HTML:
<script type="importmap">
{"imports": {"cuel": "./node_modules/cuel/cuel.mjs"}}
</script>
and you're good to go for development. You can then just import {cuel} from 'cuel'
in your code and develop on your sources, not some weird artifact that
resembles what you developed after having that artifact and the browser jump
throw a dozen or so hoops.
For production you absolutely want to have a build step that bundles your app. I recommend esbuild for this. It's the perfect tool for this task.
data binding
Introduction
Cuel supports a variant of the handlebars/mustache style binding that may be somewhat familiar by now. Just put a name in double curly brackets into your template and have cuel create a binding for you:
cuel('hello-world', {
doSomething() {
this.content = 'Hello world!'; // set the content of this element
console.log(this.content); // -> Hello world!
}
}, '{{content}}');
This creates a text node inside the <hello-world>
tags and when doSomething
is called, sets the text of the node to "Hello world!". This is a text content
binding. There are eight types of bindings in two categories. The categories
are content bindings and element bindings. Element bindings bind something of
the respective DOM element itself, while content bindings affect an element's
content. What you get depends on where in your template and how you define it:
<div ...{{elementBinding}}> {{contentBinding}}</div>
One general note that applies to most binding type: Only null
means nothing.
Setting null
on an attribute will remove it, setting false
will set the
text "false", though.
Binding Types
So here are examples for all binding types:
| Type | Binding | Example Code (demo/app/) |
|------|---------|--------------------------|
| Attribute | attributeName={{accessorA}}
| binding-types.mjs |
| Property | .domPropertyName={{accessorP}}
| binding-types.mjs |
| Event | !domEventName={{accessorE}}
| binding-types.mjs |
| Method | domNodeMethod()={{methodName}}
| binding-types.mjs |
| Node | \*={{accessorN}}
| binding-types.mjs |
| Text | {{accessorT}}
| binding-types.mjs |
| if | {{accessorI}}
| conditional-rendering.mjs |
| loop | {{accessorL}}
| looped-rendering.mjs |
The first five are element bindings, Text, if and loop are content bindings.
Atributes, Properties and Text
Note that on the object implementing your custom element everything translates to properties, except for DOM methods, which become methods on the object, too. You can always access bound stuff from the JavaScript object.
If you take the attribute binding from the table above, for example,
element.accessorA
(or this.accessorA
in a method of the element!) will
return the current value of the attribute of the element. The attribute value
is not stored somewhere, but read from the DOM when you call the property getter
element.accessorA
. It will then call element.getAttribute('attributeName')
.
You can also always do element.accessorA = 'x'
. This will invoke
element.setAttribute('attributeName', 'x')
.
This works exactly the same with property and text bindings.
You may "stuff" attribute, property, and text bindings. If you bind a class
attribute for example: <div class="oneClass {{boundClass}}">
, then setting
element.boundClass
will always leave "oneClass" untouched.
Events, Methods and Nodes
You cannot get events (element.accessorE
will raise an exception) and you
cannot set methods or nodes (element.methodName = 'x'
and
element.accessorN = 'x'
will each raise exceptions).
Nodes you can only get and then have full access to their DOM APIs.
Methods can only be called. element.methodName()
will call the node's
domNodeMethod
with the same arguments.
You can assign to events, but you should assign DOM events to them, e.g.
element.accessorE = new CustomEvent('foo', {detail: 'bar'})
. This will emit
the event on the bound node. You may also just pass the custom event ini and
cuel will generate the event for you:
cuel('event-binding',
{emit() {this.event = {detail: 'data', bubbles: true};}},
'<div !bang={{event}}></div>'
);
When element.emit()
is called cuel will do
divElement.dispatchEvent(new CustomEvent('bang', {detail: 'data', bubbles: true}));
Change Notifications
Cuel can also track changes and events in the DOM for you. It will do that, if it finds a setter for the bound property on the bound object. Let's assume the JavaScript class of the object bound in the table above looks like this:
class MyDomHandler {
set accessorA(a) {}
set accessorE(e) {}
set accessorT(t) {}
}
Then Cuel will call the respective setter when the bound attribute or text
changes, or accessorE if the event domEventName
is triggered on the bound DOM
node.
Note that you can then not set the respective DOM property yourself using that accessor! This inherently prevents circular bindings. There are still ways to shoot yourself in the foot with circular bindings, but not that easyly.
class, mix-in & the render
method
As noted above, you can pass cuel a class or a mix-in.
cuel('cl-ass', class extends HTMLElement {});
cuel('mix-in', {});
Now, customElements.define
, which cuel calls for you, requires something
extending HTMLElement
so cuel will create one for you if it does not get one,
adding the methods from the mix-in
to it. In any case it will add
a render
method to your element, which will be called when the element is
connected to the DOM. For this it will create a connectedCallback
which
renders your element.
Note: in case you do not provide a connectedCallback
, the connectedCallback
created by cuel will do the initial rendering and there will be no extra render
method!
However, if you provide a connectedCallback
yourself, cuel will generate a
render
method for you. Thus, if you provide a connectedCallback
, you should
probably call this.render()
in your connectedCallback
method - or elsewhere
in the lifecycle of your element.
cuel('mix-in',
{
connectedCallback() {
this.render();
}
},
'Hello world!'
);
ifTemplate
ifTemplate
is cuel's conditional rendering facility. Conditionally rendered
DOM ist put into separate named HTML templates. Your component adds the
conditionally rendered part to its (your component's) template by addressing
it (in curly braces) by the sub-template's name. Your component must also list
the sub-template in its ifTemplate
option:
cuel('if-template', {}, {
template: '{{showFirst}}{{showSecond}}',
ifTemplate: {
showFirst: 'first',
showSecond: 'second',
}
});
const ift = document.createElement('if-template');
document.body.append(ift);
ift.showFirst = true; // now you see "first"
ift.showFirst = false; // now you don't
When a cuel element has ifTemplate
properties, cuel adds conditional
properties with the names of the conditional templates to the parent element.
When such a property gets a trueish value, the respective sub-template is
rendered, when it gets a falseish value, it gets removed.
Custom Conditions
You can define your own conditions in the ifTemplate
option:
cuel('if-template', {}, {
template: '{{myIf}}',
ifTemplate: {myIf: {
if: x => x % 2,
template: 'odd',
}},
});
const ift = document.createElement('if-template');
document.body.append(ift);
ift.myIf = 3; // now you see "odd"
ift.myIf = 2; // now you don't
dynamic binding
Consider this code:
const ift = document.createElement('if-template');
document.body.append(ift);
ift.showFirst = true;
cuel('if-template', {}, {
template: '{{showFirst}}{{showSecond}}',
ifTemplate: {showFirst: 'first'},
});
Note how the element is first created, then its conditional property is set and
only then it is defined. Before that definition, showFirst
has no special
meaning, it's just a random property name with the value true
added to an
unknown custom element. But when cuel instantiates your custom element it will
pick up the already existing property and in this case render the consitional
sub-template text "first".
Cuel does that with all bound properties.
loopTemplate
loopTemplate
is somewhat similar to ifTemplate
. But it will instantiate its
content once for each element of the array you set to the respectively named
property.
cuel('loop-template', {}, {
template: '<ul>{{list}}</ul>',
loopTemplate: { list: '<li>{{label}}: {{value}}</li>'}
});
const loopt = document.createElement('loop-template');
document.body.append(loopt);
loopt.list = [
{label: 'first item', value: 1},
{label: 'second item', value: 2},
];
This renders an unordered list with two items:
- first item: 1
- second item: 2
chunk
loopTemplate
supports the chunk
option to perform chunked rendering:
cuel('loop-template', {}, {
template: '<ul>{{list}}</ul>',
loopTemplate: { list: {
template: '<li>{{value}}</li>',
chunk: 500,
}}
});
const loopt = document.createElement('loop-template');
document.body.append(loopt);
loopt.list = (new Array(100000)).fill(0).map(() => ({a: Math.random()}));
This renders a list with 100.000 items. On my computer that takes a few seconds, your milage may vary. Now that's a lot of items. More realistic cases render fewer items but with complex DOM and more computations to render the dynamic items. So cases where the user experiences lag do occur.
The above example renders without impeding user interaction though. It does so by chunking the rendering: it renders 500 items at each iteration and then returns the control to the browser. Cuel repeats that until all 100.000 items are rendered.
You should aim to render only what the user is currently seeing and then you will almost never experience performance problems when rendering. However, if you render big arrays or very complex DOM, you may use chunked rendering. That way the browser won't freeze while rendering.
Nested Content-Proxies
Cuel implements data bindings with proxy properties. Your custom element gets properties (getters and setters) that allow you to access the DOM properties of bound stuff in your light- or shadow-DOM.
With ifTemplate
and loopTemplate
you nest templates inside your custom
element's template. Now nested templates may contain their own bindings. In
particular loopTemplate
cannot bind to simple proxy properties of your
element, because there is an array of things. Since loopTemplate
needs
something special there, ifTemplate
simply behaves in a similar fashion.
Consider this code:
cuel('if-template', {}, {
template: '{{conditional}}',
ifTemplate: {conditional: '{{nested}}'},
});
const ift = document.createElement('if-template');
document.body.append(ift);
ift.conditional = {nested: 'foo'}; // now you see 'foo'
ift.conditional.nested = 'bar'; // now you see 'bar'
Behind the scenes cuel creates custom elements for your nested templates. And
these custom elements have proxy properties for their data binding. So above,
ift.conditional
is a proxy property of your custom element and it is a
specialized (specialized as conditional) node-accessor. See the binding types
above. A conditional is a node-binding, i.e. the first kind of content
binding.
The getter returns the custom element created for your nested template. And
that custom element has a proxy property nested
. The nested
property of the
conditional
custom element is a text-(content-)accessor with which you can get
and set the inner text of your template.
You may begin to see, how this is not just consistent with the way things have
to be for loopTemplate
, but genuinely useful. It may be a bit of hard to grasp
recursive logic at first, but when you get it, it is rather simple, just the
same as cuel is anyway. And it is pretty useful to be able to assign your
data-objects to your DOM-objects and have them rendered as expected.
Now for loopTemplate
s the node accessor will return an array of custom
elements (each of the same type). Those will have their own proxy properties
(accessors) as defined in your loop template.
Thus you can as easily access you nested dynamic DOM, as you can the rest of your template. However, cuel does nothing to prevent you from shooting yourself into your foot. If you try to access DOM that isn't there (possibly not there, yet, because you render chunks), you'll get an exception.
However, if you want change notifications on your nested DOM, your out of luck. Or rather, you should implement another cuel custom element that is conditionally (or loopedly) rendered and implements its own change handling. Keep in mind, though, that events may bubble: in many cases you just want to handle an event triggered in you dynamic DOM and you can add a handler on a parent element of that dynamic DOM.
"Root" Bindings
In the above example we showed how nested proxies have their own proxy property accessors. In order to set these properties, you need to pass an object with the respective properties. For complex nested proxies this is ideal. However, if you want to set one simple property, that approach is pretty cumbersome. In order to alleviate this, cuel supports a special syntax here for binding the whole passed value instead of individual properties:
cuel('if-template', {}, {
template: '{{conditional}}',
ifTemplate: {conditional: '{{*}}'},
});
const ift = document.createElement('if-template');
document.body.append(ift);
ift.conditional = 'foo'; // now you see 'foo'
ift.conditional = 'bar'; // now you see 'bar'
ift.conditional = null; // now you see naught
Design Considerations
This section does not document any of cuel's functionality, but explains some peculiarities of cuel that I encountered while developing a somewhat complex application on top of it. I also try to give guidance on how to deal with these peculiarities.
SPA Architecture
Cuel is designed to be as minimal and maintainable as possible while offering a minimal reasonable feature set for covering the data binding side of modern SPAs. The standard architecture of a modern SPA looks something like this:
- Data Store
- Data logic
- Data Binding
- DOM
The by far most complex and extensive code lives behind the DOM part - it's the API to the native browser code. Cuel fills the Data Binding part with custom-elements and proxies. The value of good data binding is that it simplifies the code that uses it, and makes it easier to test and argue about!
Now a complex data binding API is counterproductive to that. And that API itself needs to be tested and argued about. Thus I tried to find a compromise that will force you to adapt to that simplicity in some places and makes it easy to fall back on the full complexity of the native APIs where necessary.
The traditional "god frameworks" (like Angular, Vue, React) cover all aspects of modern SPAs. Cuel only addresses a part of that. You should fill the other parts with something else.
State Management
I recommend adding some state management framework. You can also write your own if you feel confident about that. I use my own JavaScript proxy based state management library called xt8. If you have no idea what to use: if your app is rather complex and you'll have several people working on it, redux will be a solid choice. If your app is extremely simple you may skip the separate sate management and manage state inside your cuel components. If your in between xt8 or another simpler state manager may be a good choice.
No Extra Properties
One peculiarity of cuel that I encountered again and again is that it's very restrictive with regards to the data you can throw at it. Suppose you have a component like this:
cuel('my-component', {}, `
{{shallow}}
{{deep.a}}
{{deep.b}}
`);
Now this will work:
Object.assign(myComponent, {shallow: 'foo', deep: {a: 'bar', b: 'baz'}, c: 'c'});
But this will not:
Object.assign(myComponent, {deep: {a: 'bar', b: 'baz', c: 'c'}});
The latter will throw an exception. Nested proxy objects may receive incomplete data but are not extensible. One reason for doing this is that I want to force consistency between the data model and the DOM binding. Often an additional property that is not bound will be a bug. On the other hand, if I supported that, cuel would have to manage the additional properties you add to the proxies. Remember, cuel does not manage state, it just offers proxy accessors to DOM APIs. State is the root of much evil in programming so cuel tries to play it safe there.
That means you'll have to consider this in your data model design.
Multiplexing
Another similar point is the following:
cuel('my-component', {}, `{{a}}{{a}}`);
Often you'll want one model property to affect multiple properties in the DOM - and vice versa. While this would be possible (I implemented it in cuel's predecessor bindom), it is quite a can of worms. In my opinion the value of simplicity discussed above trumps the usefulness of this feature.
However, it is relatively simple and straightforward to add this at another point in your code. Assuming you'll use some state management framework it is advisable to write some code that facilitates connecting the state to the databinding. In some cases you'll call somewhat complex actions and do data transformations in your cuel components, but in others, you'll just want state values represented in DOM, and DOM events triggering state actions.
Write connector code for that, it's likely just a couple of dozen lines of code. And in that it's straightforward to add multiplexing. The reason is, that there you will know the direction of data bindings while cuel defaults to read/write accessors with optional change notification where it's more complex to allow for multiplexing.
Events
Speaking of events - cuel does not notify you about property changes. That means if you have an input element and you want your code triggered when the value changes, you should add an event handler for that:
cuel('my-component', {
set userText(evt) {
changeUserTextState(this.userText);
}
}, `<input type="text" .value="{{userText}}" !change={{userTextChange}}/>`
);
It is quite possible to automate away the event handling. But that is not a
transparent API. Cuel would need to choose the event for you (change
, input
,
keyDown
, etc.) and it would need to cover several other cases like checkbox
value and so on. All the while that would not save you all that much code on
your side. So the call for simplicity again trumps such features.
As a simple guideline: state changes should usually trigger DOM write accessors while DOM events should call state action handlers.