Last active
February 9, 2019 02:47
-
-
Save majo44/8467e2a818cd23bf5b3029c8d485862d to your computer and use it in GitHub Desktop.
Experimental rendering engine
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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