JavaScript MVVM lib within 200 LOC (part 1)
Build your own rendering engine We’re not going to re-invent the wheel. We’re going to do simple things that help you have deep understanding of how it is implemented. However, the code will not totally useless. The code is actually part of my library named HtmlJs. Hopefully after ...
Build your own rendering engine
We’re not going to re-invent the wheel. We’re going to do simple things that help you have deep understanding of how it is implemented. However, the code will not totally useless. The code is actually part of my library named HtmlJs. Hopefully after reading this article, you can contribute to it. I'll pay about $5 for an accepted pull request. All steps are well constructed in the last tutorial of HtmlJs, click here to go. You can play with it or read all steps tutorial here.
Firstly, some thought on principles of design. The client code should be:
- Pure JavaScript to generate DOM, no parsing or compiling
- Cool, fluent API for save typing effort
- Callback to render lists, instead of template
To fulfill the conditions, the client code should be like
var cartItems = [ { name: 'iPhone', total: 900, quantity: 1}, { name: 'Samsung galaxy', total: 850, quantity: 1} ]; html('#my-table').tbody.each(cartItems, function(cartItem, index) { html.tr // render table row .td.text(cartItem.name).$ // render item name .td.text('$').text(cartItem.total).$ // render item total .td.button.text('X').className('some-classname') // render delete button });
Let's start
Define a function to query an element
// wrapper function (function () { var ctx = null; window.html = function(selector) { if (typeof selector === 'string') { selector = document.querySelector(selector); } ctx = selector; return html; } })();
In this function, we save a context that passed into html function. Next, we’ll have a function that get the context in client code. NOTE that from now on, we must put all the code inside the most outer function.
Object.defineProperty(html, 'context', { get: function() { return ctx; } });
Now, we have a some of all HTML tags. We should add all HTML tags after html namespace
var tags = ['a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', 'blockquote', 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', 'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark', 'menu', 'menuitem', 'meta', 'meter', 'nav', 'object', 'ol', 'ptgroup', 'option', 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video'];
Then the code to render them with fluent API. To get fluent API, all we need to do is simply return html in each function
function defineTag(tag) { Object.defineProperty(html, tag, { get: function () { var ele = document.createElement(tag); if (ctx == null) { ctx = ele; } else { ctx.appendChild(ele); ctx = ele; } return html; } }); } tags.forEach(function(tag) { defineTag(tag); });
The code above will be called after typing somthing like html(document.body).div. This client code will generate a DIV element. The generated element will be appended into html context (body of document). That is the selected element that you pass into html query.
We got a rendering engine until now. The engine queries an element just like jQuery and render HTML tags from it. To test the engine, we have to add text binding and value binding because you don’t want to render HTML tags without any text. Just 10 LOC. The text binding will create a text node, and append it inside the wrapper function.
html.text = function(textContent) { var textNode = document.createTextNode(textContent); ctx.appendChild(textNode); return html; }; html.value = function(val) { ctx.value = val; return html; };
Test our code
Add the code below to console to test what we’ve done so far. The code just append a DIV, an input, and a text node to document body.
html(document.body).input.value('Hello HtmlJs'); html(document.body).text('Hello HtmlJs');
The code will generate an input and a text node. They both contains "Hello HtmlJs" string. However, the code above is quite verbose, it should be in fluent API. To get the API to be fluent, then we have to add ending symbol after html namespace. After ending symbol, we can continue rendering children without select a node again.
Object.defineProperty(html, '$', { get: function() { ctx = ctx.parentElement; return html; } });
To bind event using fluent API, we should add all HTML events after html namespace like the way of HTML tags. However, events in MVVM pattern needs data instead of UI objects. So we need to call event handlers and pass data into it. This technique can be represented by wishful API like.
function clickHanlder(e, model) { alert(model); } html(document.body).input.value('HtmlJs').onClick(clickHandler, 'John Doe');
To add extra parameter to event handlers, we just need to keep event handler references, then calling them with the parameter. Add the code inside the wrapper function. Note that we return html object in most of functions for fluent API (principle n0 2).
var events = ['Click', 'Contextmenu', 'Dblclick', 'Mousedown', 'Mouseenter', 'Mouseleave', 'Mousemove', 'Mouseover', 'Mouseout', 'Mouseup', 'Keydown', 'Keypress', 'Keyup', 'Abort', 'Beforeunload', 'Error', 'Hashchange', 'Load', 'Resize', 'Scroll', 'Unload', 'Blur', 'Change', 'Input', 'Focus', 'Focusin', 'Focusout', 'Inputting', 'Invalid', 'Reset', 'Search', 'Select', 'Submit', 'Drag', 'Dragend', 'Dragenter', 'Dragleave', 'Dragover', 'Dragstart', 'Drop', 'Copy', 'Cut', 'Paste', 'Afterprint', 'Beforeprint', 'Canplay', 'Canplaythrough', 'Durationchange', 'Emptied', 'Ended', 'Error', 'Loadeddata', 'Loadedmetadata', 'Loadstart', 'Pause', 'Play', 'Playing', 'Progress', 'Ratechange', 'Seeked', 'Seeking', 'Stalled', 'Suspend', 'Timeupdate', 'Volumechange', 'Waiting', 'Animationend', 'Animationiteration', 'Animationstart', 'Transitionend', 'Message', 'Online', 'Offline', 'Popstate', 'Show', 'Storage', 'Toggle', 'Wheel', 'Compositionend', 'Compositionstart']; events.forEach(function (eventName) { html['on' + eventName] = function (eventListener, model) { ctx.addEventListener(eventName.toLowerCase(), function (e) { eventListener.call(this, e, model); }); return html; }; });
To complete the rendering engine, we just need to add some special binding like css attr (stands for attribute) and classname These bindings are enough to add any kind of HTML attribute.
Actually attr is enough, for example we can add css like this
html(document.body).input.value('Hello world!').attr({style: 'border: 1px solid #ccc'});
However, it's not cool.
To do this task shorter, we should add css binding like
html(document.body).input.value('Hello world').css({border: '1px solid #ccc'});
Is it better?
Each binding just take the do simple thing, grabing the context and set property to it (using DOM API), append the code inside the wrapper function.
html.css = function(cssObj) { for (var i in cssObj) { if (cssObj.hasOwnProperty(i)) { ctx.style[getFCamalCase(i)] = cssObj[i]; } } return html; }; html.attr = function(attrObj) { for (var prop in attrObj) { if (attrObj.hasOwnProperty(prop)) { ctx.setAttribute(prop, attrObj[prop]); } } return html; }; html.className = function(className) { ctx.className += ctx.className === ' ? className : ' ' + className; return html; };
We’ve finished our rendering engine. Here is full code of our engine
// wrapper function (function () { var ctx = null; window.html = function (selector) { if (typeof selector === 'string') { selector = document.querySelector(selector); } ctx = selector; return html; } Object.defineProperty(html, 'context', { get: function () { return ctx; } }); var tags = ['a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', 'blockquote', 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', 'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark', 'menu', 'menuitem', 'meta', 'meter', 'nav', 'object', 'ol', 'ptgroup', 'option', 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video']; function defineTag(tag) { Object.defineProperty(html, tag, { get: function () { var ele = document.createElement(tag); if (ctx == null) { ctx = ele; } else { ctx.appendChild(ele); ctx = ele; } return html; } }); } tags.forEach(function (tag) { defineTag(tag); }); html.text = function (textContent) { var textNode = document.createTextNode(textContent); ctx.appendChild(textNode); return html; }; html.value = function (val) { ctx.value = val; return html; }; Object.defineProperty(html, '$', { get: function () { ctx = ctx.parentElement; return html; } }); var events = ['Click', 'Contextmenu', 'Dblclick', 'Mousedown', 'Mouseenter', 'Mouseleave', 'Mousemove', 'Mouseover', 'Mouseout', 'Mouseup', 'Keydown', 'Keypress', 'Keyup', 'Abort', 'Beforeunload', 'Error', 'Hashchange', 'Load', 'Resize', 'Scroll', 'Unload', 'Blur', 'Change', 'Input', 'Focus', 'Focusin', 'Focusout', 'Inputting', 'Invalid', 'Reset', 'Search', 'Select', 'Submit', 'Drag', 'Dragend', 'Dragenter', 'Dragleave', 'Dragover', 'Dragstart', 'Drop', 'Copy', 'Cut', 'Paste', 'Afterprint', 'Beforeprint', 'Canplay', 'Canplaythrough', 'Durationchange', 'Emptied', 'Ended', 'Error', 'Loadeddata', 'Loadedmetadata', 'Loadstart', 'Pause', 'Play', 'Playing', 'Progress', 'Ratechange', 'Seeked', 'Seeking', 'Stalled', 'Suspend', 'Timeupdate', 'Volumechange', 'Waiting', 'Animationend', 'Animationiteration', 'Animationstart', 'Transitionend', 'Message', 'Online', 'Offline', 'Popstate', 'Show', 'Storage', 'Toggle', 'Wheel', 'Compositionend', 'Compositionstart']; events.forEach(function (eventName) { html['on' + eventName] = function (eventListener, model) { ctx.addEventListener(eventName.toLowerCase(), function (e) { eventListener.call(this, e, model); }); return html; }; }); html.css = function (cssObj) { for (var i in cssObj) { if (cssObj.hasOwnProperty(i)) { ctx.style[getFCamalCase(i)] = cssObj[i]; } } return html; }; html.attr = function (attrObj) { for (var prop in attrObj) { if (attrObj.hasOwnProperty(prop)) { ctx.setAttribute(prop, attrObj[prop]); } } return html; }; html.className = function (className) { ctx.className += ctx.className === ' ? className : ' ' + className; return html; }; })();
Within 100 LOC, we have a simple cool and full-feature engine to render DOM. No more complicated parsing, compiling, and bugging template. One more thing to consider is performance. You can find many on the internet that DOM is far better and less buggy than HTML manipulation. Nothing can beat DOM in term of performance, take a look at an example here. Our engine totally uses DOM so that it is definitely the fastest one.
For a long time people think that DOM manipulation is cumbersome and verbose. Consider the example below to see what would be like with traditional DOM.
var input = document.createElement('input'); input.value = 'Hello world'; input.style.border = '1px solid #ccc'; document.appendChild(input);
It would take 4 lines of code to create an input and add some properties to that one. Compare to html engine above, we just need 1 line of code to that. Consider the following code
html(document.body).div.input.value('Hello world').css({border: '1px solid #ccc'}).$;
We still have more than 100 LOC to finish the tutorial. In next section, we’ll add observable class to observe data and update DOM based on data.
NhanNguyen11 30-12-2016