osemacce-framework
Version:
A frontend framework to teach web developers how frontend frameworks work.
857 lines (838 loc) • 25.5 kB
JavaScript
const ARRAY_DIFF_OP = {
ADD: 'add',
REMOVE: 'remove',
MOVE: 'move',
NOOP: 'noop',
};
function withoutNulls(arr) {
return arr.filter((item) => item != null)
}
function arraysDiff(oldArray, newArray) {
return {
added: newArray.filter(
(newItem) => !oldArray.includes(newItem)
),
removed: oldArray.filter(
(oldItem) => !newArray.includes(oldItem)
),
}
}
class ArrayWithOriginalIndices {
#array = []
#originalIndices = []
#equalsFn
constructor(array, equalsFn) {
this.#array = [...array];
this.#originalIndices = array.map((_, i) => i);
this.#equalsFn = equalsFn;
}
get length() {
return this.#array.length
}
isRemoval(index, newArray) {
if (index >= this.length) {
return false
}
const item = this.#array[index];
const indexInNewArray = newArray.findIndex((newItem) =>
this.#equalsFn(item, newItem)
);
return indexInNewArray === -1
}
removeItem(index) {
const operation = {
op: ARRAY_DIFF_OP.REMOVE,
index,
item: this.#array[index],
};
this.#array.splice(index, 1);
this.#originalIndices.splice(index, 1);
return operation
}
isNoop(index, newArray) {
if (index >= this.length) {
return false
}
const item = this.#array[index];
const newItem = newArray[index];
return this.#equalsFn(item, newItem)
}
originalIndexAt(index) {
return this.#originalIndices[index]
}
noopItem(index) {
return {
op: ARRAY_DIFF_OP.NOOP,
originalIndex: this.originalIndexAt(index),
index,
item: this.#array[index],
}
}
isAddition(item, fromIdx) {
return this.findIndexFrom(item, fromIdx) === -1
}
findIndexFrom(item, fromIndex) {
for (let i = fromIndex; i < this.length; i++) {
if (this.#equalsFn(item, this.#array[i])) {
return i
}
}
return -1
}
addItem(item, index) {
const operation = {
op: ARRAY_DIFF_OP.ADD,
index,
item,
};
this.#array.splice(index, 0, item);
this.#originalIndices.splice(index, 0, -1);
return operation
}
moveItem(item, toIndex) {
const fromIndex = this.findIndexFrom(item, toIndex);
const operation = {
op: ARRAY_DIFF_OP.MOVE,
originalIndex: this.originalIndexAt(fromIndex),
from: fromIndex,
index: toIndex,
item: this.#array[fromIndex],
};
const [_item] = this.#array.splice(fromIndex, 1);
this.#array.splice(toIndex, 0, _item);
const [originalIndex] =
this.#originalIndices.splice(fromIndex, 1);
this.#originalIndices.splice(toIndex, 0, originalIndex);
return operation
}
removeItemsAfter(index) {
const operations = [];
while (this.length > index) {
operations.push(this.removeItem(index));
}
return operations
}
}
function arraysDiffSequence(oldArray, newArray, equalsFn = (a, b) => a === b) {
const sequence = [];
const array = new ArrayWithOriginalIndices(oldArray, equalsFn);
for (let index = 0; index < newArray.length; index++) {
if (array.isRemoval(index, newArray)) {
sequence.push(array.removeItem(index));
index--;
continue
}
if (array.isNoop(index, newArray)) {
sequence.push(array.noopItem(index));
continue
}
const item = newArray[index];
if (array.isAddition(item, index)) {
sequence.push(array.addItem(item, index));
continue
}
sequence.push(array.moveItem(item, index));
}
sequence.push(...array.removeItemsAfter(newArray.length));
return sequence
}
const DOM_TYPES = {
TEXT: 'text',
ELEMENT: 'element',
FRAGMENT: 'fragment',
COMPONENT: 'component',
};
function h(tag, props = {}, children = []) {
const type =
typeof tag === 'string' ? DOM_TYPES.ELEMENT : DOM_TYPES.COMPONENT;
return {
tag,
props,
type,
children: mapTextNodes(withoutNulls(children)),
}
}
function mapTextNodes(children) {
return children.map((child) =>
typeof child === 'string' ? hString(child) : child
)
}
function hString(str) {
return { type: DOM_TYPES.TEXT, value: str }
}
function hFragment(vNodes) {
return {
type: DOM_TYPES.FRAGMENT,
children: mapTextNodes(withoutNulls(vNodes)),
}
}
function extractChildren(vdom) {
if (vdom.children == null) {
return []
}
const children = [];
for (const child of vdom.children) {
if (child.type === DOM_TYPES.FRAGMENT) {
children.push(...extractChildren(child));
} else {
children.push(child);
}
}
return children
}
function setAttributes(el, attrs) {
const { class: className, style, ...otherAttrs } = attrs;
if (className) {
setClass(el, className);
}
if (style) {
Object.entries(style).forEach(([prop, value]) => {
setStyle(el, prop, value);
});
}
for (const [name, value] of Object.entries(otherAttrs)) {
setAttribute(el, name, value);
}
}
function setClass(el, className) {
el.className = '';
if (typeof className === 'string') {
el.className = className;
}
if (Array.isArray(className)) {
el.classList.add(...className);
}
}
function setStyle(el, name, value) {
el.style[name] = value;
}
function removeStyle(el, name) {
el.style[name] = null;
}
function setAttribute(el, name, value) {
if (value == null) {
removeAttribute(el, name);
} else if (name.startsWith('data-')) {
el.setAttribute(name, value);
} else {
el[name] = value;
}
}
function removeAttribute(el, name) {
el[name] = null;
el.removeAttribute(name);
}
function addEventListeners(listeners = {}, el, hostComponent = null) {
const addedListeners = {};
Object.entries(listeners).forEach(([eventName, handler]) => {
const listener = addEventListener(eventName, handler, el, hostComponent);
addedListeners[eventName] = listener;
});
return addedListeners
}
function addEventListener(eventName, handler, el, hostComponent = null) {
function boundHandler() {
hostComponent
? handler.apply(hostComponent, arguments)
: handler(...arguments);
}
el.addEventListener(eventName, boundHandler);
return boundHandler
}
function removeEventListeners(listeners = {}, el) {
Object.entries(listeners).forEach(([eventName, handler]) => {
el.removeEventListener(eventName, handler);
});
}
function extractPropsAndEvents(vdom) {
const { on: events = {}, ...props } = vdom.props;
delete props.key;
return { props, events }
}
let isScheduled = false;
const jobs = [];
function enqueueJob(job) {
jobs.push(job);
scheduleUpdate();
}
function scheduleUpdate() {
if (isScheduled) return
isScheduled = true;
queueMicrotask(processJobs);
}
function processJobs() {
while (jobs.length > 0) {
const job = jobs.shift();
const result = job();
Promise.resolve(result).then(
() => {
},
(error) => {
console.error(`[scheduler]: ${error}`);
}
);
}
isScheduled = false;
}
function nextTick() {
scheduleUpdate();
return flushPromises()
}
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve))
}
function mountDOM(vdom, parentEl, index, hostComponent = null) {
switch (vdom.type) {
case DOM_TYPES.TEXT: {
createTextNode(vdom, parentEl, index);
break
}
case DOM_TYPES.ELEMENT: {
createElementNode(vdom, parentEl, index, hostComponent);
break
}
case DOM_TYPES.FRAGMENT: {
createFragmentNodes(vdom, parentEl, index, hostComponent);
break
}
case DOM_TYPES.COMPONENT: {
createComponentNode(vdom, parentEl, index, hostComponent);
enqueueJob(() => vdom.component.onMounted());
break
}
default: {
throw new Error(`Can't mount DOM of type: ${vdom.type}`)
}
}
}
function insert(el, parentEl, index) {
if (index == null) {
parentEl.append(el);
return
}
if (index < 0) {
throw new Error(
`Index must be a positive integer, got ${index}`)
}
const children = parentEl.childNodes;
if (index >= children.length) {
parentEl.append(el);
} else {
parentEl.insertBefore(el, children[index]);
}
}
function createTextNode(vdom, parentEl, index) {
const { value } = vdom;
const textNode = document.createTextNode(value);
vdom.el = textNode;
insert(textNode, parentEl, index);
}
function createElementNode(vdom, parentEl, index, hostComponent) {
const { tag, children } = vdom;
const element = document.createElement(tag);
addProps(element, vdom, hostComponent);
vdom.el = element;
children.forEach((child) =>
mountDOM(child, element, null, hostComponent)
);
insert(element, parentEl, index);
}
function addProps(el, vdom, hostComponent) {
const { props: attrs, events } = extractPropsAndEvents(vdom);
vdom.listeners = addEventListeners(events, el, hostComponent);
setAttributes(el, attrs);
}
function createFragmentNodes(
vdom,
parentEl,
index,
hostComponent
) {
const { children } = vdom;
vdom.el = parentEl;
children.forEach((child, i) =>
mountDOM(
child,
parentEl,
index ? index + i : null,
hostComponent
)
);
}
function createComponentNode(vdom, parentEl, index, hostComponent) {
const Component = vdom.tag;
const { props, events } = extractPropsAndEvents(vdom);
const component = new Component(props, events, hostComponent);
component.mount(parentEl, index);
vdom.component = component;
vdom.el = component.firstElement;
}
function destroyDOM(vdom) {
const { type } = vdom;
switch (type) {
case DOM_TYPES.TEXT: {
removeTextNode(vdom);
break
}
case DOM_TYPES.ELEMENT: {
removeElementNode(vdom);
break
}
case DOM_TYPES.FRAGMENT: {
removeFragmentNodes(vdom);
break
}
case DOM_TYPES.COMPONENT: {
vdom.component.unmount();
enqueueJob(() => vdom.component.onUnmounted());
break
}
default: {
throw new Error(`Can't destroy DOM of type: ${type}`)
}
}
delete vdom.el;
}
function removeTextNode(vdom) {
const { el } = vdom;
el.remove();
}
function removeElementNode(vdom) {
const { el, children, listeners } = vdom;
el.remove();
children.forEach(destroyDOM);
if (listeners) {
removeEventListeners(listeners, el);
delete vdom.listeners;
}
}
function removeFragmentNodes(vdom) {
const { children } = vdom;
children.forEach(destroyDOM);
}
function createApp(RootComponent, props = {}) {
let parentEl = null;
let isMounted = false;
let vdom = null;
function reset() {
parentEl = null;
isMounted = false;
vdom = null;
} return {
mount(_parentEl) {
if (isMounted) {
throw new Error('The application is already mounted')
}
parentEl = _parentEl;
vdom = h(RootComponent, props);
mountDOM(vdom, parentEl);
isMounted = true;
},
unmount() {
if (!isMounted) {
throw new Error('The application is not mounted')
}
destroyDOM(vdom);
reset();
},
}
}
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var fastDeepEqual;
var hasRequiredFastDeepEqual;
function requireFastDeepEqual () {
if (hasRequiredFastDeepEqual) return fastDeepEqual;
hasRequiredFastDeepEqual = 1;
fastDeepEqual = function equal(a, b) {
if (a === b) return true;
if (a && b && typeof a == 'object' && typeof b == 'object') {
if (a.constructor !== b.constructor) return false;
var length, i, keys;
if (Array.isArray(a)) {
length = a.length;
if (length != b.length) return false;
for (i = length; i-- !== 0;)
if (!equal(a[i], b[i])) return false;
return true;
}
if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags;
if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
keys = Object.keys(a);
length = keys.length;
if (length !== Object.keys(b).length) return false;
for (i = length; i-- !== 0;)
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false;
for (i = length; i-- !== 0;) {
var key = keys[i];
if (!equal(a[key], b[key])) return false;
}
return true;
}
return a!==a && b!==b;
};
return fastDeepEqual;
}
var fastDeepEqualExports = requireFastDeepEqual();
var equal = /*@__PURE__*/getDefaultExportFromCjs(fastDeepEqualExports);
class Dispatcher {
#subs = new Map()
#afterHandlers = []
subscribe(commandName, handler) {
if (!this.#subs.has(commandName)) {
this.#subs.set(commandName, []);
}
const handlers = this.#subs.get(commandName);
if (handlers.includes(handler)) {
return () => { }
}
handlers.push(handler);
return () => {
const idx = handlers.indexOf(handler);
handlers.splice(idx, 1);
}
}
afterEveryCommand(handler) {
this.#afterHandlers.push(handler);
return () => {
const idx = this.#afterHandlers.indexOf(handler);
this.#afterHandlers.splice(idx, 1);
}
}
dispatch(commandName, payload) {
if (this.#subs.has(commandName)) {
this.#subs.get(commandName).forEach((handler) => handler(payload));
} else {
console.warn(`No handlers for command: ${commandName}`);
}
this.#afterHandlers.forEach((handler) => handler());
}
}
function areNodesEqual(nodeOne, nodeTwo) {
if (nodeOne.type !== nodeTwo.type) {
return false
}
if (nodeOne.type === DOM_TYPES.ELEMENT) {
const { tag: tagOne, props: { key: keyOne } } = nodeOne;
const { tag: tagTwo, props: { key: keyTwo } } = nodeTwo;
return tagOne === tagTwo && keyOne === keyTwo
}
if (nodeOne.type === DOM_TYPES.COMPONENT) {
const { tag: componentOne, props: { key: keyOne } } = nodeOne;
const { tag: componentTwo, props: { key: keyTwo } } = nodeTwo;
return componentOne === componentTwo && keyOne === keyTwo
}
return true
}
function objectsDiff(oldObj, newObj) {
const oldKeys = Object.keys(oldObj);
const newKeys = Object.keys(newObj);
return {
added: newKeys.filter((key) => !(key in oldObj)),
removed: oldKeys.filter((key) => !(key in newObj)),
updated: newKeys.filter(
(key) => key in oldObj && oldObj[key] !== newObj[key]
),
}
}
function hasOwnProperty(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}
function isNotEmptyString(str) {
return str !== ''
}
function isNotBlankOrEmptyString(str) {
return isNotEmptyString(str.trim())
}
function patchDOM(oldVdom, newVdom, parentEl, hostComponent = null) {
if (!areNodesEqual(oldVdom, newVdom)) {
const index = findIndexInParent(parentEl, oldVdom.el);
destroyDOM(oldVdom);
mountDOM(newVdom, parentEl, index, hostComponent);
return newVdom
}
newVdom.el = oldVdom.el;
switch (newVdom.type) {
case DOM_TYPES.TEXT: {
patchText(oldVdom, newVdom);
return newVdom
}
case DOM_TYPES.ELEMENT: {
patchElement(oldVdom, newVdom, hostComponent);
break
}
case DOM_TYPES.COMPONENT: {
patchComponent(oldVdom, newVdom);
break
}
}
patchChildren(oldVdom, newVdom, hostComponent);
return newVdom
}
function findIndexInParent(parentEl, el) {
const index = Array.from(parentEl.childNodes).indexOf(el);
if (index < 0) {
return null
}
return index
}
function patchElement(oldVdom, newVdom, hostComponent) {
const el = oldVdom.el;
const {
class: oldClass,
style: oldStyle,
on: oldEvents,
...oldAttrs
} = oldVdom.props;
const {
class: newClass,
style: newStyle,
on: newEvents,
...newAttrs
} = newVdom.props;
const { listeners: oldListeners } = oldVdom;
patchAttrs(el, oldAttrs, newAttrs);
patchClasses(el, oldClass, newClass);
patchStyles(el, oldStyle, newStyle);
newVdom.listeners = patchEvents(el, oldListeners, oldEvents, newEvents, hostComponent);
}
function patchAttrs(el, oldAttrs, newAttrs) {
const { added, removed, updated } = objectsDiff(oldAttrs, newAttrs);
for (const attr of removed) {
removeAttribute(el, attr);
}
for (const attr of added.concat(updated)) {
setAttribute(el, attr, newAttrs[attr]);
}
}
function patchClasses(el, oldClass, newClass) {
const oldClasses = toClassList(oldClass);
const newClasses = toClassList(newClass);
const { added, removed } =
arraysDiff(oldClasses, newClasses);
if (removed.length > 0) {
el.classList.remove(...removed);
}
if (added.length > 0) {
el.classList.add(...added);
}
}
function toClassList(classes = '') {
return Array.isArray(classes)
? classes.filter(isNotBlankOrEmptyString)
: classes.split(/(\s+)/)
.filter(isNotBlankOrEmptyString)
}
function patchText(oldVdom, newVdom) {
const el = oldVdom.el;
const { value: oldText } = oldVdom;
const { value: newText } = newVdom;
if (oldText !== newText) {
el.nodeValue = newText;
}
}
function patchStyles(el, oldStyle = {}, newStyle = {}) {
const { added, removed, updated } = objectsDiff(oldStyle, newStyle);
for (const style of removed) {
removeStyle(el, style);
}
for (const style of added.concat(updated)) {
setStyle(el, style, newStyle[style]);
}
}
function patchEvents(
el,
oldListeners = {},
oldEvents = {},
newEvents = {},
hostComponent
) {
const { removed, added, updated } =
objectsDiff(oldEvents, newEvents);
for (const eventName of removed.concat(updated)) {
el.removeEventListener(eventName, oldListeners[eventName]);
}
const addedListeners = {};
for (const eventName of added.concat(updated)) {
const listener =
addEventListener(eventName, newEvents[eventName], el, hostComponent);
addedListeners[eventName] = listener;
}
return addedListeners
}
function patchChildren(oldVdom, newVdom, hostComponent) {
const oldChildren = extractChildren(oldVdom);
const newChildren = extractChildren(newVdom);
const parentEl = oldVdom.el;
const diffSeq = arraysDiffSequence(
oldChildren,
newChildren,
areNodesEqual
);
for (const operation of diffSeq) {
const { from, index, item } = operation;
const offset = hostComponent?.offset ?? 0;
switch (operation.op) {
case ARRAY_DIFF_OP.ADD: {
mountDOM(item, parentEl, index + offset, hostComponent);
break
}
case ARRAY_DIFF_OP.REMOVE: {
destroyDOM(item);
break
}
case ARRAY_DIFF_OP.MOVE: {
const el = oldChildren[from].el;
const elAtTargetIndex = parentEl.childNodes[index + offset];
parentEl.insertBefore(el, elAtTargetIndex);
patchDOM(
oldChildren[from],
newChildren[index],
parentEl,
hostComponent
);
break
}
case ARRAY_DIFF_OP.NOOP: {
patchDOM(
oldChildren[from],
newChildren[index],
parentEl,
hostComponent
);
break
}
}
}
}
function patchComponent(oldVdom, newVdom) {
const { component } = oldVdom;
const { props } = extractPropsAndEvents(newVdom);
component.updateProps(props);
newVdom.component = component;
newVdom.el = component.firstElement;
}
const emptyFn = () => { };
function defineComponent({ render, state, onMounted = emptyFn, onUnmounted = emptyFn, ...methods }) {
class Component {
#isMounted = false
#vdom = null
#hostEl = null
#eventHandlers = null
#parentComponent = null
#dispatcher = new Dispatcher()
#subscriptions = []
constructor(
props = {},
eventHandlers = {},
parentComponent = null,
) {
this.props = props;
this.state = state ? state(props) : {};
this.#eventHandlers = eventHandlers;
this.#parentComponent = parentComponent;
}
onMounted() {
return Promise.resolve(onMounted.call(this))
}
onUnmounted() {
return Promise.resolve(onUnmounted.call(this))
}
get elements() {
if (this.#vdom == null) {
return []
}
if (this.#vdom.type === DOM_TYPES.FRAGMENT) {
return extractChildren(this.#vdom).flatMap((child) => {
if (child.type === DOM_TYPES.COMPONENT) {
return child.component.elements
}
return [child.el]
})
}
return [this.#vdom.el]
}
get firstElement() {
return this.elements[0]
}
get offset() {
if (this.#vdom.type === DOM_TYPES.FRAGMENT) {
return Array.from(this.#hostEl.children).indexOf(this.firstElement)
}
return 0
}
emit(eventName, payload) {
this.#dispatcher.dispatch(eventName, payload);
}
#wireEventHandlers() {
this.#subscriptions = Object.entries(this.#eventHandlers).map(
([eventName, handler]) =>
this.#wireEventHandler(eventName, handler)
);
}
#wireEventHandler(eventName, handler) {
return this.#dispatcher.subscribe(eventName, (payload) => {
if (this.#parentComponent) {
handler.call(this.#parentComponent, payload);
} else {
handler(payload);
}
})
}
updateProps(props) {
const newProps = { ...this.props, ...props };
if (equal(this.props, newProps)) {
return
}
this.props = newProps;
this.#patch();
}
updateState(state) {
this.state = { ...this.state, ...state };
this.#patch();
}
render() {
return render.call(this)
}
mount(hostEl, index = null) {
if (this.#isMounted) {
throw new Error('Component is already mounted')
}
this.#vdom = this.render();
mountDOM(this.#vdom, hostEl, index, this);
this.#wireEventHandlers();
this.#hostEl = hostEl;
this.#isMounted = true;
}
unmount() {
if (!this.#isMounted) {
throw new Error('Component is not mounted')
}
destroyDOM(this.#vdom);
this.#subscriptions.forEach((unsubscribe) => unsubscribe());
this.#vdom = null;
this.#hostEl = null;
this.#isMounted = false;
this.#subscriptions = [];
}
#patch() {
if (!this.#isMounted) {
throw new Error('Component is not mounted')
}
const vdom = this.render();
this.#vdom = patchDOM(this.#vdom, vdom, this.#hostEl, this);
}
}
for (const methodName in methods) {
if (hasOwnProperty(Component, methodName)) {
throw new Error(
`Method "${methodName}()" already exists in the component.`
)
}
Component.prototype[methodName] = methods[methodName];
}
return Component
}
export { DOM_TYPES, createApp, defineComponent, h, hFragment, hString, nextTick };