zliq
Version:
slim and quick framework in low loc
352 lines (305 loc) • 10 kB
JavaScript
import { isStream } from "./streamy";
export const TEXT_NODE = "#text";
export function diff(
parentElement,
oldElement,
newChild,
oldChild,
cacheContainer
) {
// if there is no element on the parent to diff against yet,
// we create the element here to make diffing later on more uniform
if (oldElement === null) {
oldElement = createNode(newChild.tag, newChild.children);
if (parentElement) {
parentElement.appendChild(oldElement);
}
}
let newElement = oldElement;
let isCaching = newChild.props && newChild.props.id;
try {
// for keyed/idd elements, we recall unchanged elements
if (isCaching) {
newElement = diffCachedElement(
oldElement,
newChild,
oldChild,
cacheContainer
);
} else {
newElement = diffElement(oldElement, newChild, oldChild, cacheContainer);
}
} catch (err) {
// on errors, show an error element instead of crashing
newElement = {
tag: "div",
props: {
style: "border: 1px solid red; color: red;"
},
children: ["FAULTY ELEMENT"]
};
console.error("[ERROR]: An element failed to render.\n", err);
}
return newElement;
}
function diffCachedElement(
oldElement,
{ tag, props, children, version },
{ props: oldProps },
cacheContainer
) {
let id = props.id;
let gotCreated = false;
let gotUpdated = false;
// if there is no cache, create one
if (cacheContainer[id] === undefined) {
cacheContainer[id] = {
element: document.createElement(tag),
vdom: {
tag,
props: {},
children: []
}
};
gotCreated = true;
}
let elementCache = cacheContainer[id];
// ignore update if version equals cache
if (version !== elementCache.version) {
diffAttributes(elementCache.element, props, oldProps);
diffChildren(
elementCache.element,
children,
elementCache.vdom.children,
cacheContainer
);
elementCache.version = version;
elementCache.vdom.props = props;
elementCache.vdom.children = children;
gotUpdated = true;
}
if (gotCreated) {
triggerLifecycle(elementCache.element, props, "created");
} else if (gotUpdated) {
triggerLifecycle(elementCache.element, props, "updated");
}
// elements are updated in place, so only insert cached element if it's not already there
if (oldElement !== elementCache.element) {
oldElement.parentElement.replaceChild(elementCache.element, oldElement);
triggerLifecycle(elementCache.element, props, "mounted");
}
return elementCache.element;
}
function diffElement(
element,
{ tag, props, children: newChildren, version: newVersion },
{ props: oldProps, children: oldChildren, version: oldVersion },
cacheContainer
) {
let initialRender = oldVersion === -1 || oldVersion === undefined;
// text nodes behave differently then normal dom elements
if (isTextNode(element) && tag === TEXT_NODE) {
updateTextNode(element, newChildren[0]);
return element;
}
// if the node types do not differ, we reuse the old node
// we reuse the existing node to save time rerendering it
// we do not reuse/mutate cached (id) elements as this will mutate the cache
if (shouldRecycleElement(element, props, tag) === false) {
let newElement = createNode(tag, newChildren);
element.parentElement.replaceChild(newElement, element);
element = newElement;
// there are no children anymore on the newly created node
oldChildren = [];
}
diffAttributes(element, props, oldProps);
// sometimes you might want to skip updates to children on renderer elements i.e. if externals handle this component
let isolated = props && props.isolated !== undefined;
// text nodes we don't want to handle children like with other elements
// and for isolated components we want to skip all updates after the first render
if (tag !== TEXT_NODE && (!isolated || initialRender)) {
diffChildren(element, newChildren, oldChildren, cacheContainer);
}
if (initialRender) {
triggerLifecycle(element, props, "created");
}
if (newVersion > 0) {
triggerLifecycle(element, props, "updated");
}
return element;
}
// this removes nodes at the end of the children, that are not needed anymore in the current state for recycling
function removeNotNeededNodes(parentElements, newChildren, oldChildren) {
let remaining = parentElements.childNodes.length;
if (oldChildren.length !== remaining) {
console.warn(
"ZLIQ: Something other then ZLIQ has manipulated the children of the element",
parentElements,
". This can lead to sideffects. Consider using the 'isolated' attribute for this element to prevent updates."
);
}
for (; remaining > newChildren.length; remaining--) {
let childToRemove = parentElements.childNodes[remaining - 1];
parentElements.removeChild(childToRemove);
if (oldChildren.length < remaining) {
continue;
} else {
let { cycle } = oldChildren[remaining - 1];
triggerLifecycle(childToRemove, { cycle }, "removed");
}
}
}
function updateExistingNodes(
parentElement,
newChildren,
oldChildren,
cacheContainer
) {
let nodes = parentElement.childNodes;
for (let i = 0; i < nodes.length && i < newChildren.length; i++) {
diff(
parentElement,
nodes[i],
newChildren[i],
oldChildren[i] || {},
cacheContainer
);
}
}
function addNewNodes(parentElement, newChildren, cacheContainer) {
for (let i = parentElement.childNodes.length; i < newChildren.length; i++) {
let { tag, props, children, version } = newChildren[i];
let newElement = createNode(tag, children);
parentElement.appendChild(newElement);
diff(parentElement, newElement, newChildren[i], {}, cacheContainer);
if (props && props.cycle && props.cycle.mounted && !props.id) {
console.error(
"The 'mounted' lifecycle event is only called on elements with id. As elements are updated in place, it is hard to define when a normal element is mounted."
);
}
}
}
function diffAttributes(element, props, oldProps = {}) {
if (props !== undefined) {
Object.keys(props).map(function applyPropertyToElement(attribute) {
applyAttribute(element, attribute, props[attribute]);
});
Object.keys(oldProps).map(function removeNotNeededAttributes(oldAttribute) {
if (props[oldAttribute] === undefined) {
element.removeAttribute(oldAttribute);
}
});
}
}
function applyAttribute(element, attribute, value) {
// allow for any custom attribute to be set on the element
if (attribute.startsWith("*")) {
element.setAttribute(attribute.substr(1), value);
return;
}
if (attribute === "class") {
element.className = value || ""; // "" in the case of a class stream returning null
// we leave the possibility to define styles as strings
// but we allow styles to be defined as an object
} else if (attribute === "style" && typeof value !== "string") {
const cssText = value
? Object.keys(value)
.map(key => key + ":" + value[key] + ";")
.join(" ")
: "";
element.style.cssText = cssText;
// other propertys are just added as is to the DOM
} else {
if (element[attribute] !== undefined) {
if (value === null) {
element[attribute] = undefined;
} else {
element[attribute] = value;
}
}
// also remove attributes on null to allow better handling of streams
// streams don't emit on undefined
if (value === null) {
element[attribute] = undefined;
} else {
element[attribute] = value;
}
}
}
function diffChildren(
element,
newChildren = [],
oldChildren = [],
cacheContainer
) {
if (newChildren.length === 0 && oldChildren.length === 0) {
return;
}
let oldChildNodes = element.childNodes;
let unifiedNewChildren = unifyChildren(newChildren);
let unifiedOldChildren = unifyChildren(oldChildren);
updateExistingNodes(
element,
unifiedNewChildren,
unifiedOldChildren,
cacheContainer
);
removeNotNeededNodes(element, unifiedNewChildren, oldChildren);
addNewNodes(element, unifiedNewChildren, cacheContainer);
}
/* HELPERS */
/*
* jsx has children mixed as vdom-elements and numbers or strings
* to consistently treat these children similar in the code we transform those numbers and strings
* into vdom-elements with the tag #text that have one child with their value
*/
function unifyChildren(children) {
return children.map(child => {
// if there is no tag we assume it's a number or a string
if (!isStream(child) && child.tag === undefined) {
return {
tag: TEXT_NODE,
children: [child],
version: 0
};
} else {
return child;
}
});
}
// create text_nodes from numbers or strings
// create domNodes from regular vdom descriptions
export function createNode(tag, children) {
if (tag === TEXT_NODE) {
return document.createTextNode(children[0]);
} else {
return document.createElement(tag);
}
}
// TODO use React like effects for lifecycle events
// shorthand to call a cycle event for an element if existing
export function triggerLifecycle(element, { cycle } = {}, event) {
if (cycle && cycle[event]) {
cycle[event](element);
}
}
function nodeTypeDiffers(element, tag) {
return element.nodeName.toLowerCase() !== tag;
}
function isTextNode(element) {
return element instanceof window.Text;
}
function updateTextNode(element, value) {
if (element.nodeValue !== value) {
element.nodeValue = value;
}
}
// we want to recycle elements to save time on creating and inserting nodes into the dom
// we don't want to manipulate elements that go into the cache, because they would mutate in the cache as well
function shouldRecycleElement(oldElement, props, tag) {
return (
!isTextNode(oldElement) &&
oldElement.id === "" &&
!nodeTypeDiffers(oldElement, tag)
);
}