Why not build frontend frameworks on raw HTML, CSS or JS?

· OKZGN ·

5 min read Original article ↗
const UpdaterExcludedNodes = new Set([
    '#document-fragment',

    'SCRIPT', 'STYLE', 'LINK', 'NOSCRIPT',
    'META', 'BASE', 'TITLE',
    'TEMPLATE', 'SLOT',
    'IFRAME', 'OBJECT', 'EMBED',
    'SOURCE', 'TRACK', 'PORTAL',
    'CANVAS', 'SVG', 'MATH', 'svg', 'math',
    'semantics', 'annotation', 'annotation-xml',

    'script', 'style', 'link', 'desc', 'title',
    'metadata', 'defs', 'symbol', 'clipPath',
    'mask', 'pattern', 'foreignObject', 'g',
    'path', 'rect', 'circle', 'line', 'polygon',
    'use', 'text', 'polyline', 'ellipse', 'image',
    'tspan', 'linearGradient', 'radialGradient',
    'stop', 'marker', 'filter',
]);

function UpdaterStart(targetNode, handlers) {
    function Updater(mutations) {
        for (const mutation of mutations) {
            let target = mutation.target;
            switch (mutation.type) {
                case 'attributes': {
                    if (!IncludedNodes.has(target)) {
                        break;
                    }

                    const attributeName = mutation.attributeName;
                    const nodeAttributes = IncludedAttrs.get(target);
                    const oldValue = nodeAttributes[attributeName];
                    const newValue = target.getAttribute(attributeName);

                    if (oldValue === newValue) {
                        break;
                    }

                    if (oldValue === undefined) {
                        nodeAttributes[attributeName] = newValue;
                        handlers.attributes.added(target, attributeName, newValue, oldValue);
                    }
                    else if (newValue === null) {
                        Reflect.deleteProperty(nodeAttributes, attributeName);
                        handlers.attributes.removed(target, attributeName, newValue, oldValue);
                    }
                    else {
                        nodeAttributes[attributeName] = newValue;
                        handlers.attributes.modified(target, attributeName, newValue, oldValue);
                    }
                    break;
                }

                case 'characterData': {
                    if (!IncludedNodes.has(target)) {
                        break;
                    }

                    let oldValue = IncludedCommentsAndTexts.get(target);

                    if (oldValue === target.data || !handlers.characterData[target.nodeType]) {
                        break;
                    }

                    IncludedCommentsAndTexts.set(target, target.data);
                    handlers.characterData[target.nodeType].modified(target, target.data, oldValue);
                    break;
                }

                case 'childList': {
                    if (UpdaterExcludedNodes.has(target.nodeName) || !IncludedNodes.has(target)) {
                        break;
                    }

                    if (mutation.removedNodes.length) {
                        ProcessNodes(target, mutation.removedNodes, 'removed');
                    }

                    if (mutation.addedNodes.length) {
                        ProcessNodes(target, mutation.addedNodes, 'added');
                    }
                    break;
                }
            }
        }
    }

    function ProcessNodes(parentNode, nodes, action) {
        for (let i = 0; i < nodes.length; i++) {
            ProcessTree(parentNode, nodes[i], action);
        }
    }

    function ProcessTree(parentNode, rootNode, action) {
        const added = (action === 'added');
        if (UpdaterExcludedNodes.has(rootNode.nodeName) ||
            (added && IncludedNodes.get(rootNode) === parentNode) ||
            (!added && !IncludedNodes.has(rootNode))) {
            return;
        }

        const type = rootNode.nodeType;
        switch (type) {
            case Node.COMMENT_NODE:
            case Node.TEXT_NODE:
                if (added) {
                    IncludedNodes.set(rootNode, parentNode);
                    IncludedCommentsAndTexts.set(rootNode, rootNode.data);
                    handlers.childList[type][action](rootNode, parentNode, rootNode.data);
                }
                else {
                    handlers.childList[type][action](rootNode, parentNode, IncludedCommentsAndTexts.get(rootNode));
                    IncludedCommentsAndTexts.delete(rootNode);
                    IncludedNodes.delete(rootNode);
                }
            break;

            case Node.ELEMENT_NODE:
                const hasAttrs = handlers.attributes && rootNode.hasAttributes();

                if (added) {
                    IncludedNodes.set(rootNode, parentNode);
                    handlers.childList[type][action](rootNode, parentNode);

                    let attrs = Object.create(null);
                    IncludedAttrs.set(rootNode, attrs);

                    if (hasAttrs) {
                        for (const attr of rootNode.attributes) {
                            attrs[attr.name] = attr.value;
                            handlers.attributes.added(rootNode, attr.name, attr.value, undefined);
                        }
                    }
                }

                try {
                    let child = rootNode.firstChild;
                    while (child) {
                        const current = child.nextSibling;
                        ProcessTree(rootNode, child, action);
                        child = current;
                    }
                }
                catch (e) {
                    if (e instanceof RangeError) {
                        throw new Error('Updater > ProcessTree: ' + e.message);
                    }
                    throw e;
                }

                if (!added) {
                    if (hasAttrs) {
                        for (const attr of rootNode.attributes) {
                            handlers.attributes.removed(rootNode, attr.name, null, attr.value);
                        }
                    }

                    IncludedAttrs.delete(rootNode);

                    handlers.childList[type][action](rootNode, parentNode);
                    IncludedNodes.delete(rootNode);
                }
        }
    }

    const IncludedCommentsAndTexts = new WeakMap();
    const IncludedAttrs = new WeakMap();
    const IncludedNodes = new WeakMap();

    try {
        const ObserverConfig = Object.create(null);
        ObserverConfig.subtree = true;

        try {
            ObserverConfig.attributes = !!handlers.attributes;
            if (ObserverConfig.attributes) {
                if(!(handlers.attributes.added && handlers.attributes.removed && handlers.attributes.modified)) {
                    throw new Error('attributes: added, removed, modified required functions.');
                }
            }

            ObserverConfig.characterData = !!handlers.characterData;
            if (ObserverConfig.characterData) {
                if (!(handlers.characterData[Node.COMMENT_NODE].modified && handlers.characterData[Node.TEXT_NODE].modified)) {
                    throw new Error('characterData: [Node.COMMENT_NODE].modified && [Node.TEXT_NODE].modified required functions.');
                }
            }

            ObserverConfig.childList = !!handlers.childList;
            if (ObserverConfig.childList) {
                if (!(handlers.childList[Node.COMMENT_NODE].added && handlers.childList[Node.COMMENT_NODE].removed &&
                    handlers.childList[Node.TEXT_NODE].added && handlers.childList[Node.TEXT_NODE].removed &&
                    handlers.childList[Node.ELEMENT_NODE].added && handlers.childList[Node.ELEMENT_NODE].removed)
                ) {
                    throw new Error('childList: [Node.ELEMENT_NODE], [Node.TEXT_NODE], [Node.COMMENT_NODE] each with added && removed required functions.');
                }
            }
        }
        catch (e) {
            throw new Error('ObserverConfig: ' + e.message);
        }

        const Observer = new MutationObserver(Updater);
        Observer.observe(targetNode, ObserverConfig);

        if (targetNode.nodeType === Node.DOCUMENT_NODE) {
            IncludedNodes.set(targetNode, null);
            if (targetNode.documentElement) {
                ProcessTree(targetNode, targetNode.documentElement, 'added');
            }
            return Observer;
        }

        ProcessTree(targetNode.parentNode, targetNode, 'added');
        return Observer;
    }
    catch (e) {
        throw new Error('UpdaterStart > Observer > Initialization: ' + e.message);
    }
}

JavaScript

const XVars_prefix = '${';
const XVars_postfix = '}';
const XVarsNamesRE = /[^a-z_0-9]/i;
const XVarsNodes = new WeakMap();
const XVarsSuscriptors = new Map();
const XElementDataStr = 'data-x';
const XElements = new WeakSet();

const UpdaterObserver = UpdaterStart(document, {
    childList: {
        [Node.ELEMENT_NODE]: {
            added: (elementNode, parentNode) => {
                const hasX = elementNode.hasAttribute(XElementDataStr);
                if (hasX) {
                console.log('X ELEMENT ADDED:', elementNode);
                XElements.add(elementNode);
                }
            },
            removed: (elementNode, parentNode) => {
                if (XElements.has(elementNode)) {
                XElements.delete(elementNode);
                console.log('X ELEMENT REMOVED:', elementNode);
                }
            }
        },

        [Node.COMMENT_NODE]: {
            added: (commentNode, parentNode, newValue) => {
                // console.log('COMMENT ADD:', commentNode, parentNode, newValue);
            },
            removed: (commentNode, parentNode, oldValue) => {
                // console.log('COMMENT DEL:', commentNode, parentNode, oldValue);
            }
        },

        [Node.TEXT_NODE]: {
            added: (textNode, parentNode, newValue) => {
                if (newValue.trim() === '') { return; }

                const XParent = parentNode.closest('[' + XElementDataStr + ']');
                if (XParent && XElements.has(XParent) && newValue.includes(XVars_prefix)) {
                const varsList = [];
                const varsTemplate = [];
                const vars = newValue
                    .split(XVars_prefix)
                    .reduce((array, str) => {
                    if (str.includes(XVars_postfix)){
                        let varPos = str.indexOf(XVars_postfix) + 1;
                        let varName = str.slice(0, varPos - 1);

                        if (!XVarsNamesRE.test(varName)) {
                        varsList.push(varName);
                        varsTemplate.push(() => { return XState[varName]; });
                        varsTemplate.push(str.slice(varPos));
                        return array.concat(str);
                        }
                    }

                    varsTemplate.push(str);
                    return array.concat(str);
                    }, []);

                if (varsList.length) {
                    for (let varName of varsList) {
                    if (!XVarsSuscriptors.has(varName)) {
                        XVarsSuscriptors.set(varName, new Set());
                    }

                    const xVarSuscriptor = XVarsSuscriptors.get(varName);
                    if (!xVarSuscriptor.has(textNode)){
                        xVarSuscriptor.add(textNode);
                    }

                    if (!XVarsNodes.has(textNode)) {
                        XVarsNodes.set(textNode, varsTemplate);
                    }
                    }

                    console.log('X TEXT ADD:', varsList, varsTemplate);
                    for (let varName of varsList) {
                    XState[varName] = XState[varName] || '';
                    }
                }
                }
            },
            removed: (textNode, parentNode, oldValue) => {
                // console.log('TEXT DEL:', textNode, parentNode, oldValue);
                for (const [varName, nodeSet] of XVarsSuscriptors.entries()) {
                    nodeSet.delete(textNode);
                    if (nodeSet.size === 0) {
                        XVarsSuscriptors.delete(varName);
                    }
                }
            }
        }
    }
});

let XStateBatchState = false;
let XStateBatch = new Set();
const XState = State({
    onSet: (mutation) => {
    const xVarName = mutation.path[0];

    if (XVarsSuscriptors.has(xVarName)) {
        const subs = XVarsSuscriptors.get(xVarName);
        if (subs) {
            for (const node of subs) {
                XStateBatch.add(node);
            }
        }
    }

    XStateBatchExecution();
    },

    onDelete: (mutation) => {

    }
});

function XStateBatchExecution() {
    if (XStateBatchState) {
        return;
    }

    XStateBatchState = true;

    requestAnimationFrame(() => {
        XStateBatchState = false;

        for (const suscriptor of XStateBatch.values()) {
            if (XVarsNodes.has(suscriptor)) {
                const varValue = XVarsNodes.get(suscriptor).map((varPart) => (typeof varPart === 'function' ? varPart() : varPart)).join('');

                switch (suscriptor.nodeType) {
                case Node.TEXT_NODE:
                    suscriptor.data = varValue;
                break;
                }
            }
        }

        XStateBatch.clear();
    });
}

HTML

<head>
  <!-- [Previous STATE Minimal Implementation JavaScript Code File] -->
  <script src="./test/State.js"></script>

  <!-- [Previous UPDATER Minimal Implementation JavaScript Code File] -->
  <script src="./test/Updater.js"></script>
  <!-- [Previous UPDATER Usage Example JavaScript Code] -->
  <script src="./test/Updater.usage.js"></script>
  <script>
    // TESTING REACTIVE VALUES
    setInterval(() => {
      XState['VALUE_1'] = Math.round(Math.random() * Date.now());
      XState['VALUE_2'] = Math.round(Math.random() * Date.now());
      XState['VALUE_3'] = Math.round(Math.random() * Date.now());
    }, 567);
  </script>
</head>
<body>
  <div data-x>
    <p><b>Reactive value:</b> ${VALUE_1}, ${VALUE_2}</p>
    <p><b>Another reactive value:</b> ${VALUE_3}</p>
  </div>
</body>