Skip to content

Instantly share code, notes, and snippets.

@majo44
Last active February 9, 2019 02:47
Show Gist options
  • Save majo44/8467e2a818cd23bf5b3029c8d485862d to your computer and use it in GitHub Desktop.
Save majo44/8467e2a818cd23bf5b3029c8d485862d to your computer and use it in GitHub Desktop.
Experimental rendering engine
import { normalizeEventName } from './event.js'
/**
* Set of most popular simple attributes.
*/
const standardAttrs = {
class: true,
id: true,
type: true,
value: true
};
function camelToDash(str) {
return str.replace(/([A-Z])/g, (g) => '-' + g[0].toLowerCase());
}
function specialCases(lcName) {
const transformation = {
classname: 'class',
htmlfor: 'for',
xlinkactuate: 'xlink:actuate',
xlinkarcrole: 'xlink:arcrole',
xlinkhref: 'xlink:href',
xlinkrole: 'xlink:role',
xlinkshow: 'xlink:show',
xlinktitle: 'xlink:title',
xlinktype: 'xlink:type',
xmlbase: 'xml:base',
xmllang: 'xml:lang',
xmlspace: 'xml:space'
};
return transformation[lcName];
}
/**
* @param {Array.<{name: string, pointer: number}>} dynamicAttrs
* @param {Array.<*>} params
* @return {{key: string, ref: function(e: Element):void, attrs: Array.<*>, events}}
*/
export function parseDynamicAttributes(dynamicAttrs, params) {
let key, ref, attrs = [], events;
for (let i = 0; i < dynamicAttrs.length; i++) {
let attributeName = dynamicAttrs[i].name;
let value = params[dynamicAttrs[i].pointer];
if (value === false) {
value = undefined;
} else if (value === true) {
value = '';
}
// this is performance optimized statement, please think twice befor refactor :)
if (standardAttrs[attributeName]) {
// as standarAttrs are 80% cases when we are setting the attribute
// there is no need for future calculation
attrs.push(attributeName, value);
} else if (attributeName.charAt(0) === 'o' && attributeName.charAt(1) === 'n') {
// events
if (typeof value === 'string') {
attrs.push(attributeName, value);
} else {
const {name, capture} = normalizeEventName(attributeName);
if (!events) {
events = {};
}
events[name] = {listener: value, capture};
}
} else {
const lcAttributeName = attributeName.toLowerCase();
switch (lcAttributeName) {
case 'key':
// key attribute is used by incremental-dom
key = value;
break;
case 'ref':
// ref is used for referencing children
ref = value;
break;
default:
if (specialCases(lcAttributeName)) {
attributeName = specialCases(lcAttributeName);
} else if (lcAttributeName !== attributeName) {
if (typeof value === 'undefined' || typeof value === 'number' || typeof value === 'string') {
// for primitives if name is camelCase we will change the name to dashCase
attributeName = camelToDash(attributeName);
}
}
attrs.push(attributeName, value);
}
}
}
return {key, ref, attrs, events}
}
const _EVENTS_KEY = '__idom-events__';
// https://www.w3.org/TR/html5/webappapis.html#events
// paragraph 7.1.5.2.1 Global and DocumentAndElementEventHandles tables
const lowerCaseEvents = [
'abort',
'blur',
'cancel',
'canplay',
'canplaythrough',
'change',
'click',
'close',
'copy',
'cuechange',
'cut',
'dblclick',
'drag',
'dragend',
'dragenter',
'dragexit',
'dragleave',
'dragover',
'dragstart',
'drop',
'durationchange',
'emptied',
'ended',
'error',
'focus',
'input',
'invalid',
'keydown',
'keypress',
'keyup',
'load',
'loadeddata',
'loadedmetadata',
'loadstart',
'mousedown',
'mouseenter',
'mouseleave',
'mousemove',
'mouseout',
'mouseover',
'mouseup',
'paste',
'pause',
'play',
'playing',
'progress',
'ratechange',
'reset',
'resize',
'scroll',
'seeked',
'seeking',
'select',
'show',
'stalled',
'submit',
'suspend',
'timeupdate',
'toggle',
'volumechange',
'waiting',
'wheel'
];
/**
* Caching for do not slice each time.
* @type {Object.<string,{name: string, capture: boolean}>}
*/
let normalizedEventsCache = {};
/**
* Turn attribute name to event name and capture flag
* onClick -> click, false
* onClickCapture -> click, true
* onMouseMove -> mousemove, false
* onMyEvent -> myEvent, false
* @param {string} attrName
* @return {{name: string, capture: boolean}}
*/
export function normalizeEventName(attrName) {
return normalizedEventsCache[attrName] ||
(normalizedEventsCache[attrName] = calculateNormalizeEventName(attrName));
}
/**
* @param {string} attrName
* @return {{name: string, capture: boolean}}
*/
function calculateNormalizeEventName(attrName) {
let name = attrName.substr(2); // remove on
let capture = false;
if (name.slice(-7) === 'Capture') { // remove Capture
capture = true;
name = name.slice(0, -7);
}
const lcAttrName = name.toLowerCase();
name = lowerCaseEvents.indexOf(lcAttrName) !== -1
? lcAttrName
: lcAttrName[0] + name.substr(1); // turn first letter to lowercase
return {name, capture};
}
export function applyEvents(dom, events) {
// apply events handlers
const oldEvents = dom[_EVENTS_KEY];
if (events) {
const eventsNames = Object.keys(events);
const count = eventsNames.length;
for (let i = 0; i < count; i++) {
const name = eventsNames[i];
const {listener, capture} = events[name];
const oldEvent = oldEvents && oldEvents[name];
// adding handler only for new events, this prevents to attach handlers more the once
// if element is rerendered multiple times
if (!oldEvent || oldEvent.capture !== capture || oldEvent.listener !== listener) {
dom.addEventListener(name, listener, capture);
// if there was an old event, then remove it
if (oldEvent) {
dom.removeEventListener(name, oldEvent.listener, oldEvent.capture);
}
}
// remove processed event from old events list
if (oldEvents) {
delete oldEvents[name];
}
}
}
if (oldEvents) {
const eventsNames = Object.keys(oldEvents);
const count = eventsNames.length;
for (let i = 0; i < count; i++) {
const name = eventsNames[i];
const oldEvent = oldEvents[name];
dom.removeEventListener(name, oldEvent.listener, oldEvent.capture);
}
}
dom[_EVENTS_KEY] = events;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/incremental-dom/0.6.0/incremental-dom-min.js"></script>
</head>
<body>
<script defer src="index.js" type="module"></script>
</body>
</html>
import { parseDynamicAttributes } from './attrs.js';
import { applyEvents } from './event.js';
const MARKER = '_@_@_@_';
const {
elementOpen, text, elementClose, patch, skipNode, attributes,
applyProp, applyAttr, currentPointer } = IncrementalDOM;
const literalsCache = new WeakMap();
attributes.value = (el, name, value) => {
applyProp(el, name, value);
applyAttr(el, name, value);
};
attributes.disabled = attributes.checked = (el, name, value) => {
// for boolean attrs idom passes values as '' for true and undefined for false
const newValue = value === '';
applyProp(el, name, newValue);
applyAttr(el, name, value);
};
let paramsPointer = 0;
let localContext;
/**
* @param {Node} node
*/
function onStart(node) {
paramsPointer = 0;
return onChildren(node);
}
function onChildren(node) {
let children = Array.prototype.map.call(node.childNodes, onElement).filter(i => !!i);
return (params) => {
children.forEach((c) => c(params));
}
}
function renderChild(child) {
switch (typeof child) {
case 'function':
// for single we just calling it
renderChild(child());
break;
case 'string':
// for all primitives we just rendering them as text
text(child);
break;
case 'number':
// for all primitives we just rendering them as text
text(child.toString());
break;
case 'boolean':
case 'undefined':
// do not render booleans, null and undefined
break;
default: {
if (Array.isArray(child)) {
// for array we are rendering each of it
child.forEach((c) => renderChild(c));
} else if (child !== null) {
throw new Error('Unsuported type of child ' + child);
}
}
}
}
/**
* @param {Element | string} element
*/
function onElement(element) {
if (element.nodeType === 3 && element.nodeValue) {
const parts = element.nodeValue.split(MARKER);
if (parts.length < 2) {
if (parts[0]) {
return () => {
text(parts[0]);
};
} else {
return
}
} else {
const pointer = paramsPointer;
paramsPointer = paramsPointer + parts.length - 1;
return (params) => {
let localPointer = pointer;
for(let i = 0; i < parts.length; i++) {
if (parts[i]) {
text(parts[i]);
}
if (i < parts.length - 1) {
renderChild(params[localPointer++]);
}
}
};
}
}
let tag = element.tagName,
staticAttrs = [],
dynamicAttrs = [];
Array.prototype.forEach.call(element.attributes,
/**
* @param {Attr} attr
*/
(attr) => {
let parts = attr.value.split(MARKER);
if (parts.length === 1) {
staticAttrs.push(attr.name);
staticAttrs.push(attr.value);
} else if (attr.value === MARKER) {
dynamicAttrs.push(({name: attr.name, pointer: paramsPointer++}));
}
});
const children = onChildren(element);
return (params) => {
let skip = false;
let oldDom = currentPointer();
if (localContext && oldDom && oldDom.__localContext) {
skip = !diffArray(localContext.shouldUpdate, oldDom.__localContext.shouldUpdate);
}
if (!skip) {
const {key, ref, attrs, events} = parseDynamicAttributes(dynamicAttrs, params);
const dom = elementOpen.apply(null, [tag, key, staticAttrs, ...attrs]);
if (localContext) {
localContext.dom = dom;
dom.__localContext = localContext;
}
localContext = null;
applyEvents(dom, events);
children(params);
elementClose(tag);
if (ref) {
ref(dom);
}
} else {
localContext = null;
skipNode();
}
};
}
/**
* @param {Array.<*>} a1
* @param {Array.<*>} a2
*/
function diffArray(a1, a2) {
if (!a1 && !!a2) {
return true
} else if (a1 && a2 && a1.length === a2.length) {
for (let i = 0; i < a1.length; i++) {
if (a1[i] !== a2[i]) {
return true;
}
}
} else {
return true;
}
}
/**
* @param {Array.<string>} literal
*/
function createVNode(literal) {
const raw = literal.join(MARKER);
const parser = new DOMParser();
const parsed = parser.parseFromString(raw, 'application/xml');
return onStart(parsed);
}
/**
* @param {Array.<string>} literal
* @param {Array.<*>} params
*/
function h(literal, ...params) {
if (!literalsCache.has(literal)) {
literalsCache.set(literal, createVNode(literal));
}
const vnode = () => {
literalsCache.get(literal)(params);
};
vnode.vnode = true;
return vnode;
}
function render(what, where) {
if (typeof what === "function" && !what.vnode) {
render(what(), where);
} else {
patch(where, what);
}
}
/**
* Hook for preventing functional component rendering if provided values doesn't changed,
* from the previous rendering time.
* @param values values used to comparision
*/
function shouldUpdate(...values) {
if (!localContext) {
localContext = {}
}
localContext.shouldUpdate = values;
}
// TODO APPLICATION
let state = {
nextId: 0,
value: '',
items: []
};
const renderApp = () => render(todoApp(state), document.body);
const mergeState = (newState) => {
state = {
...state,
...newState
};
renderApp();
};
const onValueChanged = (e) =>
mergeState({
value: e.currentTarget.value
});
const onDelete = (id) =>
mergeState({
items: state.items.filter((i) => i.id !== id)
});
const onDone = (id) =>
mergeState({
items: state.items.map((i) => i.id === id ? {...i, done: !i.done} : i)
});
/**
* @param {KeyboardEvent} e
*/
const onKeyup = (e) => {
if (e.key === 'Enter') {
state.value && mergeState({
value: '',
nextId: state.nextId + 1,
items: [
...state.items,
{id: state.nextId.toString(), text: state.value, done: false}
]
});
} else {
mergeState({ value: e.currentTarget.value });
}
};
const form = (value) =>
h`<input type="text" value="${value}" onkeyup="${onKeyup}"/>`;
/**
* @param {{id: string, text:string, done: boolean}} item
*/
const item = (item) => () => {
shouldUpdate(item);
return h`<li key="${item.id}">
Item:
${!item.done ?
h`<span><b>${item.text}</b></span>` :
h`<span>${item.text}</span>`}
<button data-id="${item.id}" onclick="${() => onDelete(item.id)}">Delete</button>
<button data-id="${item.id}" onclick="${() => onDone(item.id)}">Done</button>
</li>`
};
const table = (items) => () => {
shouldUpdate(items);
return h`<ul>
${items.map(item)}
</ul>`
};
/**
* @param {{value: string, items: Array.<{id: string, text:string, done: boolean}>}} state
*/
const todoApp = (state) => {
return h`<div>
<h1>Toto Application</h1>
${form(state.value)}
${table(state.items)}
</div>`
};
renderApp();
{
"name": "szukam_dobrej_nazwy",
"version": "1.0.0",
"dependencies": {
},
"devDependencies": {
"browser-sync": "^2.26.3"
},
"scripts": {
"dev": "browser-sync start -s -w --open --ss node_modules --ss ."
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment