0.0.7 • Published 2 months ago

cuel v0.0.7

Weekly downloads
-
License
MIT
Repository
-
Last release
2 months ago

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 details
  • template light-DOM-template-string OR
  • shadow shadow-DOM-template-string
  • ifTemplate map of subtemplates that are conditionally rendered
  • loopTemplate 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:

TypeBindingExample Code (demo/app/)
AttributeattributeName={{accessorA}}binding-types.mjs
Property.domPropertyName={{accessorP}}binding-types.mjs
Event!domEventName={{accessorE}}binding-types.mjs
MethoddomNodeMethod()={{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 loopTemplates 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.

0.0.7

2 months ago

0.0.6

3 months ago

0.0.5

3 months ago

0.0.4

5 months ago

0.0.3

5 months ago

0.0.2

5 months ago

0.0.1

5 months ago