JavaScript HTML Components Without Frameworks

Little ToDo: JavaScript Components Without Frameworks

AnyWhichWay
codeburst
Published in
7 min readJan 6, 2018

--

Do you just need a couple of small components and don’t want the overhead of a framework? Do you want a foundation for building your own framework? Do you want the power of JSX without JSX?

There is little to do … using the power of string literals and argument de-structuring, restructuring, or defaults plus just 25 lines of support code you can have fully reactive self contained components. This tutorial will show you how.

Note, the illustrative code is subject to HTML injection and something like little-cleaner and/or DOMPurify should be used for production code.

After writing the article a reader pointed out that remove is a built-in DOM function and our ToDo component shadows it. A function is required to update the ToDo list, but perhaps should be named differently. The use of virtual DOMs in many frameworks helps avoid this type of issue.

If you like this style of programming, and want a tested mirco-framework, take a look at HyperApp components.

ToDo Components

Let’s use the classic ToDo use case as an example, i.e. write code to manage a simple list of tasks. Below is the component code presented without comments so you can first focus on the code. In a later section Analyzing The Code, the powerful, but perhaps obscure, use of destructuring and restructuring is explained. See if you can find the 6 lines of support code used. It is the same 3 lines in each component, ToDoView and ToDoList:

const ToDoView = function({title="",listid="",done=false},
el = document.createElement("todo")){
const attributes = {title,listid,done,...arguments[0]};
Object.assign(el,Mixins);
const keys = Object.keys(attributes);
keys.forEach(key => el.setAttribute(key,attributes[key],true));
el.id || (el.id = el.genId());
window[el.id] = el;
el.render = (listid) => el.innerHTML =
`<li id="${el.id}">${el.title}
<input type="checkbox" ${(el.done ? "checked" : "")}
onclick="(()=>${listid}.remove('${el.id}'))()">
</li>`;
return el;
}
const ToDoList = function({title="",todos=[]},
el = document.createElement("todolist")){
const attributes = {title,todos,...arguments[0]};
Object.assign(el,Mixins);
const keys = Object.keys(attributes);
keys.forEach(key => el.setAttribute(key,attributes[key],true));
el.id || (el.id = el.genId());
window[el.id] = el;
el.add = () => { // Add a task
const title = prompt("ToDo Name");
if(title) {
el.todos.push({title,id:el.genId()});
el.render();
}
}
el.remove = (id) => { // Remove a task
const i = todos.findIndex((item) => item.id===id);
if(i>=0) {
el.todos.splice(i,1);
el.render();
}
}
el.claim = () => { // Claim list
el.setAttribute("title","Your List");
}
el.render = () => el.innerHTML =
`<p>${el.title}</p>
<button onclick="${el.id}.add()">Add Task</button>
<button onclick="${el.id}.claim()">Claim</button>
<ul>
${todos.reduce((accum,todo) => accum +=
ToDoView(todo).render(el.id),"")}
</ul>`;
return el;
}
// Now use the components
const list = ToDoList({title:"My List"},
document.getElementById("todos"));
list.render();

The below text and buttons will display when the support code is in place:

Did you find the 6 lines of code?

Here they are, the same 3 in each component:

// Adds stuff stored in the Mixins object to the component.
Object.assign(el,Mixins);
// Note the extra argument to setAttribute, defined in Mixins.
// Enhances built-in setAttribute.
keys.forEach(key => el.setAttribute(key,attributes[key],true));
// genId is defined in Mixins. Generates random ids.
window.id = (el.id || (el.id = el.genId()));

Support Code

The support code is all contained in the Mixinobject:

const Mixins = {
// return attribute value
getAttribute(name) {
// if nothing on object itself, use built-in getAttribute
if(typeof(this[name])==="undefined") {
return HTMLElement.prototype.getAttribute.call(this,name);
}
return this[name];
},
// set attribute on object
// extra argument lazy=true if change should not be reactive
setAttribute(name,value,lazy) {
// auxiliary test function
const equal = (a,b) => {
const typea = typeof(a),
typeb = typeof(b);
// same type and one of
return typea === typeb &&
// identical
(a===b ||
// same length, size, order
(Array.isArray(a) && Array.isArray(b) && a.length===b.length
&& a.every((item,i) => b[i]===item)) ||
// all keys the same
(a && b && typea==="object" &&
Object.keys(a).every(key => equal(a[key],b[key])) &&
Object.keys(b).every(key => equal(a[key],b[key]))));
};

let type = typeof(value),
oldvalue = this.getAttribute(name);
const neq = !equal(oldvalue,value);

// only make changes if new and old value are not equal
// or change is non-reactive
if(neq || lazy) {
// remove object property and attribute if value is null
if(value==null) {
delete this[name];
this.removeAttribute(name);
}
// add to object if type is object
if(type==="object") this[name] = value;
// otherwise, use built-in setAttribute
else HTMLElement.prototype.setAttribute.call(this,name,value);
}
// render if !lazy (i.e. is reactive), renderable, value changed
if(!lazy && this.render && neq) this.render();
},
// not a great id generator, but good enough for demo
genId() {
return "id" + (Math.random()+"").substring(2);
}
}

The above is obviously more than 25 lines, but that’s with comments and formatting adjustments for the tutorial.

Analyzing The Code

Component definitions are just functions that take two arguments, an object with properties that provide attribute values and a target HTMLElement in which to render the component.

const ToDoList = function(attributes,el){...

Now comes the first part of destructuring magic! You can provide default values for object properties by destructuring an argument specification in a function definition. In our case we will default the titleto the empty string and the todo tasks, todos, as an empty array.

const ToDoList = function({title="",todos=[]},el){...

For reasons that will become obvious later, a default target element should also be provided. For this demo you could actually use any tag name. If you are going to enhance the capability of Mixins, you might want to use a special tag name.

const ToDoList = function({title="",todos=[]},
el = document.createElement("todos")){...

The first six lines of a component provide most of the rest of the core component capability.

First, an attribute object is created by using the defaults and an anonymously destructured copy of the first argument provided at runtime, …arguments[0]. This will be an object with properties representing the attribute values you actually want to use. By “restructuring” this as the second part of the attributesobject, defaults will be overridden and additional attributes can be added.

const ToDoList = function({title="",todos=[]},
el = document.createElement("todolist")){
const attributes = {title,todos,...arguments[0]};

Second, the functions defined in Mixinsare copied onto the HTMLElement to enhance functionality. In our case, this enhances setAttribute and getAttributewhile adding genId(). You could actually do a lot more with this. More on that after steps three and four.

const ToDoList = function({title="",todos=[]},
el = document.createElement("todolist")){
const attributes = {title,todos,...arguments[0]};
Object.assign(el,Mixins);

Third, attributes are assigned to the HTMLElement.

const ToDoList = function({title="",todos=[]},
el = document.createElement("todolist")){
const attributes = {title,todos,...arguments[0]};
Object.assign(el,Mixins);
const keys = Object.keys(attributes);
keys.forEach(key => el.setAttribute(key,attributes[key],true));

The fourth step ensures all component instances have unique ids and are accessible from the global scope. The global scope access is required in order to support UI event handlers.

const ToDoList = function({title="",todos=[]},
el = document.createElement("todolist")){
const attributes = {title,todos,...arguments[0]};
Object.assign(el,Mixins);
const keys = Object.keys(attributes);
keys.forEach(key => el.setAttribute(key,attributes[key],true));
el.id || (el.id = el.genId());
window[el.id] = el;

Since steps three and four are common to all components, we could actually add an initfunction to Mixinsthat would take attributesas an argument and perform both steps. For this tutorial, the code is left in each component for transparency.

With the exception of the final render function, the rest of the component code actually implements the component functionality and will vary greatly in nature. For this example, note the calls to renderto drive display after changes that do not involve setAttribute. A more complex implementation might enhance the initial setting of attributes in step three such that Proxies are exposed which would automatically re-render the component.

el.add = () => { // Add a task
const title = prompt("ToDo Name");
if(title) {
el.todos.push({title,id:el.genId()});
el.render();
}
}
el.remove = (id) => { // Remove a task
const i = todos.findIndex((item) => item.id===id);
if(i>=0) {
el.todos.splice(i,1);
el.render();
}
}
el.claim = () => { // Claim list
el.setAttribute("title","Your List");
}

By convention, the final function is render, which will usually be a template literal designed to format the attribute values into HTML. The render function is designed to return HMTL using the same convention as React and a number of other frameworks. However, unlike some other frameworks, internally the component is actually a DOM node and can be handed around, appended, removed, or manipulated using all of the standard DOM functions.

Note the following about the render functions below:

  1. Event handlers are attached using ${el.id}.
  2. Inside template literals, inlines such as reduce can be used to generate nested HTML for sub-components by concatenating the results of the sub-component’s render, which returns HTML as a string. This is almost as powerful as JSX.
  3. The ToDoView is created by the reduce function call inside the ToDoList's render function and provided with the id of the containing element so that event handlers on the ToDoViewscan communicate with the containing list.

ToDoList render:

el.render = () => el.innerHTML = `
<p>${el.title}</p>
<button onclick="${el.id}.add()">Add Task</button>
<button onclick="${el.id}.claim()">Claim</button>
<ul>
${todos.reduce((accum,todo) => accum +=
ToDoView(todo).render(el.id),"")}
</ul>`;

ToDoView render:

el.render = (listid) => el.innerHTML =
`<li id="${el.id}">${el.title}
<input type="checkbox" ${(el.done ? "checked" : "")}
onclick="(()=>${listid}.remove('${el.id}'))()">
</li>`;

With the above code in place for your components, you can either:

  1. Mount them into existing HTML elements, e.g. ToDoList({title:"My ToDos"},document.getElementById("mytodos").
  2. Or, create them directly and append them to other elements, e.g. const myapp = document.getElementById("myapp"); myapp.appendChild(ToDoList({title:"My ToDos"}));

You can run the full example on JSFiddle.

This tutorial is based on the edge version of the MIT licensed micro framework tlx, which provides a much more substantial Mixins in order to collapse boilerplate code, support a full page template engine and enhanced component functionality. Feel free to grab a copy and give us feedback. But then, maybe you don’t want a framework! Just grab the code from JSFiddle and enhance it. Either way, if you learned something new in this tutorial, give it a clap.

--

--

Changing Possible ... currently from the clouds around Seattle.