@devmore/vanilact
Version:
Pure vanila javascript.
858 lines (785 loc) • 20.7 kB
text/typescript
/**
* Refresent the virtual DOM
*/
interface VNode {
type: string | Function | symbol;
props: {
[ key: string ]: any;
children: VNode[];
ref?: { current: any; };
};
}
/**
* Internal fiber structure for reconciliation
*/
interface Fiber {
type?: string | Function | symbol;
props: { [ key: string ]: any; children: VNode[]; ref?: { current: any; }; };
dom?: HTMLElement | Text | DocumentFragment | null | undefined;
parent?: Fiber | null | undefined;
child?: Fiber | null | undefined;
sibling?: Fiber | null | undefined;
alternate?: Fiber | null | undefined;
effectTag?: "UPDATE" | "PLACEMENT" | "DELETION";
hooks?: Hook[];
instance?: IComponent | null;
}
/**
* Hook for useState
*/
interface Hook {
state?: any;
queue?: ( ( state: any ) => any )[];
deps?: any[];
cleanup?: () => void;
ref?: { current: any; };
}
/**
* Global variables
*/
let nextUnitOfWork: Fiber | null = null;
let currentRoot: Fiber | null = null;
let wipRoot: Fiber | null = null;
let deletions: Fiber[] = [];
let wipFiber: Fiber | null = null;
let hookIndex: number = 0;
let pendingEffects: ( () => void )[] = [];
let rootComponent: VNode | null;
let rootContainer: HTMLElement;
let idleCallbackId: number | null = null;
/**
* Check if key is an event
* @param key
* @returns
*/
const isEvent = ( key: string ): boolean => key.startsWith( "on" );
/**
* Check if key is a property
* @param key
* @returns
*/
const isProperty = ( key: string ): boolean => key !== "children" && key !== "ref" && !isEvent( key );
/**
* Check if the property or attribute is new
* @param prev
* @param next
* @returns
*/
const isNew = ( prev: { [ key: string ]: any; }, next: { [ key: string ]: any; } ) => ( key: string ): boolean => prev[ key ] !== next[ key ];
/**
* Check if the property or attribute is removed
* @param prev
* @param next
* @returns
*/
const isGone = ( prev: { [ key: string ]: any; }, next: { [ key: string ]: any; } ) => ( key: string ): boolean => !( key in next );
/**
* Use to create fragment node.
*/
const Fragment = Symbol( 'react.fragment' );
/**
* Check if obj is VNode
* @param obj
* @returns
*/
const isVNode = ( obj: any ): obj is VNode => {
return typeof obj === 'object' && obj !== null && 'type' in obj && 'props' in obj;
};
/**
* Check if obj is a function
* @param obj
* @returns
*/
const isFunctionComponent = ( obj: any ): obj is Function => {
return typeof obj === 'function' && !( obj.prototype && obj.prototype.render );
};
/**
* Check if obj is a class component
* @param obj
* @returns
*/
const isClassComponent = ( obj: any ): obj is IComponent => {
return typeof obj === 'function' && obj.prototype && typeof obj.prototype.render === 'function';
};
/**
* Check if string is html
* @param str
* @returns
*/
const isHTML = ( str ) => {
const doc = new DOMParser().parseFromString( str, "text/html" );
return Array.from( doc.body.childNodes ).some( ( node ) => node.nodeType === 1 );
};
/**
* Class Component Interface
*/
class IComponent {
props: any;
state: any;
_fiber: Fiber | null = null;
constructor ( props: any ) {
this.props = props;
this.state = {};
}
setState ( partialState: any ) {
this.state = { ...this.state, ...partialState };
wipRoot = {
dom: currentRoot!.dom,
props: currentRoot!.props,
alternate: currentRoot
};
nextUnitOfWork = wipRoot;
deletions = [];
}
forceUpdate () {
wipRoot = {
dom: currentRoot!.dom,
props: currentRoot!.props,
alternate: currentRoot
};
nextUnitOfWork = wipRoot;
deletions = [];
}
render (): VNode | null {
return null;
}
onMount () { }
onUpdate () { }
onUnmount () { }
}
/**
* Create VNode object
* @param type
* @param props
* @param children
* @returns
*/
function createElement ( type: string | Function | symbol, props: { [ key: string ]: any; } | null, ...children: ( VNode | string | number )[] ): VNode {
if ( typeof type === 'string' && isHTML( type ) ) {
return {
type: 'RAW_HTML',
props: {
dangerouslySetInnerHTML: { __html: type },
children: []
}
};
}
return {
type,
props: {
...props,
children: children.map( child =>
typeof child === "object"
? child
: createTextElement( child )
),
},
};
}
/**
* Create VNode object that handles string
* @param text
* @returns
*/
function createTextElement ( text: string | number ): VNode {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
/**
* Create DOM element
* @param fiber
* @returns
*/
function createDom ( fiber: Fiber ): HTMLElement | Text | DocumentFragment {
if ( fiber.type === 'RAW_HTML' ) {
return document.createRange().createContextualFragment( fiber.props.dangerouslySetInnerHTML.__html );
}
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode( "" )
: document.createElement( fiber.type as string );
updateDom( dom, {}, fiber.props );
return dom;
}
/**
* Update dom element attribute
* @param dom
* @param prevProps
* @param nextProps
*/
function updateDom ( dom: HTMLElement | Text | DocumentFragment, prevProps: { [ key: string ]: any; }, nextProps: { [ key: string ]: any; } ): void {
const normalizedNextProps = { ...nextProps };
if ( normalizedNextProps.class ) {
normalizedNextProps.className = normalizedNextProps.class;
delete normalizedNextProps.class;
}
// Remove old or changed event listeners
Object.keys( prevProps )
.filter( isEvent )
.filter(
( key: string ) =>
!( key in normalizedNextProps ) ||
isNew( prevProps, normalizedNextProps )( key )
)
.forEach( ( name: string ) => {
const eventType = name.toLowerCase().substring( 2 );
dom.removeEventListener(
eventType,
prevProps[ name ]
);
} );
// Remove old properties
Object.keys( prevProps )
.filter( isProperty )
.filter( isGone( prevProps, normalizedNextProps ) )
.forEach( ( name: string ) => {
if ( name.includes( '-' ) ) {
( dom as HTMLElement ).removeAttribute( name );
} else if ( name === 'className' ) {
( dom as HTMLElement ).className = '';
} else {
( dom as any )[ name ] = '';
}
} );
// Set new or changed properties
Object.keys( normalizedNextProps )
.filter( isProperty )
.filter( isNew( prevProps, normalizedNextProps ) )
.forEach( ( name: string ) => {
if ( name.includes( '-' ) ) {
( dom as HTMLElement ).setAttribute( name, normalizedNextProps[ name ] );
} else if ( name === 'style' && typeof normalizedNextProps[ name ] === 'object' ) {
Object.assign( ( dom as HTMLElement ).style, normalizedNextProps[ name ] );
} else {
( dom as any )[ name ] = normalizedNextProps[ name ];
}
} );
// Add event listeners
Object.keys( normalizedNextProps )
.filter( isEvent )
.filter( isNew( prevProps, normalizedNextProps ) )
.forEach( ( name: string ) => {
const eventType = name.toLowerCase().substring( 2 );
dom.addEventListener(
eventType,
normalizedNextProps[ name ]
);
} );
}
/**
* Mount the virtual dom to the dom
*/
function commitRoot (): void {
deletions.forEach( commitWork );
commitWork( wipRoot!.child! );
// Run pending effects after commit
pendingEffects.forEach( effect => effect() );
pendingEffects = [];
// Call lifecycle methods for class component
callLifecycleMethods( wipRoot );
// Reset
currentRoot = wipRoot;
wipRoot = null;
}
/**
* Call class component lifecycle
* @param fiber
* @returns
*/
function callLifecycleMethods ( fiber: Fiber | null | undefined ) {
if ( !fiber ) return;
if ( fiber.instance ) {
if ( fiber.effectTag === "PLACEMENT" ) {
fiber.instance.onMount();
} else if ( fiber.effectTag === "UPDATE" ) {
fiber.instance.onUpdate();
}
}
callLifecycleMethods( fiber.child );
callLifecycleMethods( fiber.sibling );
}
/**
* Apply the effect tag or render and update nodes
* @param fiber
* @returns
*/
function commitWork ( fiber: Fiber | null | undefined ): void {
if ( !fiber ) {
return;
}
let domParentFiber: Fiber = fiber.parent!;
while ( !domParentFiber.dom ) {
domParentFiber = domParentFiber.parent!;
}
const domParent = domParentFiber.dom!;
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild( fiber.dom );
} else if (
fiber.effectTag === "UPDATE" &&
fiber.dom != null
) {
updateDom(
fiber.dom,
fiber.alternate!.props,
fiber.props
);
} else if ( fiber.effectTag === "DELETION" ) {
if ( fiber.instance ) {
fiber.instance.onUnmount?.();
}
commitDeletion( fiber, domParent );
}
commitWork( fiber.child );
commitWork( fiber.sibling );
}
/**
* Remove all queued element or attribute
* @param fiber
* @param domParent
*/
function commitDeletion ( fiber: Fiber, domParent: HTMLElement | Text | DocumentFragment ): void {
if ( fiber.dom ) {
domParent.removeChild( fiber.dom );
} else {
commitDeletion( fiber.child!, domParent );
}
}
/**
* Signal requestIdleCallback execute the work.
* @param element
* @param container
*/
function render ( element: VNode, container: HTMLElement ): void {
wipRoot = {
dom: container,
props: {
children: [ element ],
},
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
/**
* Render loop.
* @param deadline
*/
function workLoop ( deadline: IdleDeadline ): void {
let shouldYield = false;
while ( nextUnitOfWork && !shouldYield ) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
);
shouldYield = deadline.timeRemaining() < 1;
}
if ( !nextUnitOfWork && wipRoot ) {
commitRoot();
}
requestIdleCallback( workLoop );
}
/**
* Render each fiber without blocking the thread process
* @param fiber
* @returns
*/
function performUnitOfWork ( fiber: Fiber ): Fiber | null {
const isFunctionComponent =
typeof fiber.type === "function";
if ( isFunctionComponent ) {
if ( ( fiber.type as Function ).prototype instanceof IComponent ) {
updateClassComponent( fiber );
} else {
updateFunctionComponent( fiber );
}
} else if ( fiber.type === Fragment ) {
updateFragmentComponent( fiber );
} else {
updateHostComponent( fiber );
}
if ( fiber.child ) {
return fiber.child;
}
let nextFiber: Fiber | null = fiber;
while ( nextFiber ) {
if ( nextFiber.sibling ) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent!;
}
return null;
}
/**
* Start requestIdleCallback loop.
*/
function startWorkLoop () {
idleCallbackId = requestIdleCallback( workLoop );
}
/**
* Update function component
* @param fiber
*/
function updateFunctionComponent ( fiber: Fiber ): void {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [ typeof fiber.type === 'function' ? fiber.type( fiber.props ) : null ].filter( e => e );
reconcileChildren( fiber, children );
}
/**
* Update class component
* @param fiber
*/
function updateClassComponent ( fiber: Fiber ): void {
const ComponentClass = fiber.type as any;
if ( fiber.alternate && fiber.alternate.instance ) {
fiber.instance = fiber.alternate.instance;
fiber.instance.props = fiber.props;
fiber.instance._fiber = fiber;
} else {
fiber.instance = new ComponentClass( fiber.props );
fiber.instance!._fiber = fiber;
}
const children = [ fiber.instance!.render() ].filter( e => e ) as VNode[];
reconcileChildren( fiber, children );
}
/**
* Update fragment
* @param fiber
*/
function updateFragmentComponent ( fiber: Fiber ): void {
reconcileChildren( fiber, fiber.props.children );
}
/**
* Call rerender action and update data.
* @param initial
* @returns
*/
function useState<T> ( initial: T ): [ T, ( action: ( state: T ) => T ) => void ] {
const oldHook =
wipFiber!.alternate &&
wipFiber!.alternate.hooks &&
wipFiber!.alternate.hooks[ hookIndex ];
const hook: Hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions!.forEach( ( action: ( state: any ) => any ) => {
hook.state = action( hook.state );
} );
const setState = ( action: ( state: T ) => T ) => {
hook.queue!.push( action );
wipRoot = {
dom: currentRoot!.dom,
props: currentRoot!.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber!.hooks!.push( hook );
hookIndex++;
return [ hook.state, setState ];
}
/**
* Update parent component
* @param fiber
*/
function updateHostComponent ( fiber: Fiber ): void {
if ( !fiber.dom ) {
fiber.dom = createDom( fiber );
}
if ( fiber?.props?.ref ) {
fiber.props.ref.current = fiber.dom;
}
reconcileChildren( fiber, fiber.props.children );
}
/**
* Update VNode children
* @param wipFiber
* @param elements
*/
function reconcileChildren ( wipFiber: Fiber, elements: VNode[] ): void {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling: Fiber | null = null;
while ( index < elements.length || oldFiber != null ) {
const element = elements[ index ];
let newFiber: Fiber | null = null;
const sameType =
oldFiber &&
element &&
element.type === oldFiber.type;
if ( sameType ) {
newFiber = {
type: oldFiber!.type,
props: element.props,
dom: oldFiber!.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if ( element && !sameType ) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if ( oldFiber && !sameType ) {
oldFiber.effectTag = "DELETION";
deletions.push( oldFiber );
if ( index === 0 ) {
wipFiber.child = null;
} else if ( prevSibling ) {
prevSibling.sibling = null;
}
}
if ( oldFiber ) {
oldFiber = oldFiber.sibling;
}
if ( index === 0 ) {
wipFiber.child = newFiber;
} else if ( element ) {
prevSibling!.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
/**
* Check if previous value is the same to the new value.
* @param a
* @param b
* @returns
*/
function depsEqual ( a: any[], b: any[] ): boolean {
if ( a.length !== b.length ) return false;
return a.every( ( val, i ) => val === b[ i ] );
}
/**
* Run post action or callback
* @param effect
* @param deps
*/
function useEffect ( effect: () => void | ( () => void ), deps: any[] ): void {
const oldHook =
wipFiber!.alternate &&
wipFiber!.alternate.hooks &&
wipFiber!.alternate.hooks[ hookIndex ];
const hasChanged = !oldHook || !depsEqual( oldHook.deps!, deps );
const hook: Hook = {
deps,
cleanup: oldHook?.cleanup,
};
wipFiber!.hooks!.push( hook );
if ( hasChanged ) {
if ( oldHook?.cleanup ) {
oldHook.cleanup!();
}
pendingEffects.push( () => {
const cleanup = effect();
if ( typeof cleanup === "function" ) {
hook.cleanup = cleanup;
}
} );
}
hookIndex++;
}
/**
* Load component asyncronously
* @param loader
* @returns
*/
function lazy ( loader: () => Promise<any> ): Function {
return function LazyComponent ( props: any ) {
const [ Component, setComponent ] = useState<any>( null );
useEffect( () => {
loader().then( ( module ) => {
setComponent( prev => prev = module.default || module );
} );
}, [] );
if ( !Component ) {
return createElement( "div", null, "" );
}
return createElement( Component, props );
};
}
/**
* Change component view based or route
* @param pathname
* @param routePattern
* @returns
*/
function matchRoute ( pathname, routePattern ) {
const pathParts = pathname.split( '/' ).filter( e => e );
const routeParts = routePattern.split( '/' ).filter( e => e );
if ( pathParts.length !== routeParts.length ) return null;
const params = {};
for ( let i = 0; i < pathParts.length; i++ ) {
if ( routeParts[ i ].startsWith( ':' ) ) {
const key = routeParts[ i ].slice( 1 );
params[ key ] = pathParts[ i ];
} else if ( pathParts[ i ] !== routeParts[ i ] ) {
return null;
}
}
return params;
}
/**
* Route component that will be use in component based routing
* @param param0
* @returns
*/
function Router ( { routes, errorViews = [] }: { routes: { path: string; component: Function, middlewares: ( () => boolean )[]; }[], errorViews: { path: string; component: Function; }[]; } ) {
const currentPath = window.location.pathname;
for ( const { path, component, middlewares } of routes ) {
const params = matchRoute( currentPath, path );
if ( params ) {
if ( middlewares && middlewares.length > 0 ) {
for ( const fn of ( middlewares || [] ) ) {
if ( typeof fn === 'function' && !( fn() ) ) {
let fallback401: any = errorViews?.find( s => ( s as any ).statusCode === 401 );
if ( !fallback401 ) {
fallback401 = {
component: ( () =>
createElement( 'center', { style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)" },
createElement( 'h1', {}, '401 Access Denied' )
)
)
};
}
return createElement( fallback401.component, fallback401.props );
}
}
}
return createElement( component, { params } );
}
}
let fallback404: any = errorViews?.find( s => ( s as any ).statusCode === 404 );
if ( !fallback404 ) {
fallback404 = {
component: ( () =>
createElement( 'center', { style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)" },
createElement( 'h1', {}, '404 Not Found' )
)
)
};
}
return createElement( fallback404.component, fallback404.props );
}
/**
* Trigger change of page event
* @param to
* @param params
*/
function navigate ( to, params = {} ) {
let arrParams: string[] = [];
if ( params ) {
Object.entries( params )
.map( ( [ k, v ] ) => {
arrParams.push(
`${ encodeURIComponent( decodeURIComponent( k ) ) }=${ encodeURIComponent( decodeURIComponent( v as string ) ) }`
);
} );
}
window.history.pushState( {}, '',
[ to, arrParams.filter( e => e ).join( "&" ) ]
.filter( e => e )
.join( "?" )
);
window.dispatchEvent( new PopStateEvent( "popstate" ) );
rerender();
}
/**
* Rerender whole fiber
*/
function rerender () {
if ( idleCallbackId !== null ) {
cancelIdleCallback( idleCallbackId );
idleCallbackId = null;
}
rootContainer.innerHTML = "";
nextUnitOfWork = null;
currentRoot = null;
wipRoot = null;
deletions = [];
wipFiber = null;
hookIndex = 0;
pendingEffects = [];
startWorkLoop();
render( rootComponent!, rootContainer );
}
/**
* Create reference object of the element
* @param initial
* @returns
*/
function createRef ( initial = null ) {
let ref = { current: initial };
return ref;
};
/**
* Start app
* @param root
* @returns
*/
function createApp ( root ) {
rootContainer = root;
return {
render ( component: VNode | Function | IComponent ) {
if ( isVNode( component ) )
rootComponent = component as VNode;
else
rootComponent = createElement( component as any, {} );
window.addEventListener( "popstate", rerender );
rerender();
}
};
}
/**
* Get the reference of the dom element.
* @param initialValue
* @returns
*/
function useRef<T> ( initialValue: T ): { current: T; } {
const oldHook =
wipFiber!.alternate &&
wipFiber!.alternate.hooks &&
wipFiber!.alternate.hooks[ hookIndex ];
const hook: Hook = {
ref: oldHook ? oldHook.ref : { current: initialValue },
};
wipFiber!.hooks!.push( hook );
hookIndex++;
return hook.ref!;
}
/**
* Expose functions.
*/
export {
createElement,
useState,
useEffect,
lazy,
Router,
navigate,
Fragment,
createRef,
createApp,
render,
IComponent,
useRef
};