Monday 2 March 2015

Client-Side Includes without JQuery

So the other day I was working on a flat web project, no server side functionality and no option to publish/compile, but I really wanted to use includes to help manage my code and allow me edit common code in a single location instead of almost twenty.

What to do?

After a little bit of complaining about how unfair the universe is I sat down and looked for a solution.

I wasn't the first person to see the need for HTML client side includes and a quick google will show up several projects out there, but they all require a framework (AngularJS, JQuery, etc) and I really wanted to avoid using a framework if I could. Partly for size constraints but mostly because i couldn't see that a simple client side include should need a framework.

So I ended up writing my own called Syringe because it injects client-side code.

The first shock was the synchronous ajax calls had been deprecated in the main page rendering thread. No more render blocking ajax for all of us who liked to live dangerously and thumb our noses at best practice javascript!

So realistically I had to post process the DOM after the page load, make an asynchronous call for the content and paste it in.

I started with a standard scope wrapper:


(function(global) {
    // global is window when running in the usual browser environment.

    "use strict";

    if (global.synject) { return; } // Syringe already loaded

    global.synject = function () {
        return true;
    }


})(this);


This is where I start a lot of my libs, it wraps up scope so I don't have to worry about clashing vars, and gives me a way to stop the lib from being called more than once. Don't worry I had other plans for the syringe function but that s for later...

The next step for me way to create an XMLHttp object (syntax depending on the browser). As always when you need to run code in different environments, don't attempt to identify the environment (browser in this case) focus on testing the functionality as it's a much more stable approach.


    ...

    // Generate xmlHttp
    function getXmlhttp() {
        if (typeof XMLHttpRequest !== 'undefined') {
            return new XMLHttpRequest();
        }
        var xobjects = [
            "MSXML2.XmlHttp.5.0",
            "MSXML2.XmlHttp.4.0",
            "MSXML2.XmlHttp.3.0",
            "Microsoft.XmlHttp"
        ];
        var xmlhttp;
        for(var i = 0; i < xobjects.length; i++) {
            try {
                xmlhttp = new ActiveXObject(xobjects[i]);
                break;
            } catch (e) {
            }
        }
        return xmlhttp;
    };

    // Get include content via ajax
    function callXmlhttp(url, callback) {
        var x = getXmlhttp();
        x.open("get", url);
        x.onreadystatechange = function() {
            if (x.readyState == 4) {
                callback(x.responseText)
            }
        };
        x.send()
    };

    ...


Quite frankly this code is a little overkill, and it could be smaller but most browsers will not get past the first two lines of the detection code and the wrapper for the ajax calls just means I can pass a URL to get and method call when it comes through. I should add some error handling later on but for the time being this will do.

Now I want users to be able to use any kind of path that the browser could recognise so I dropped in a function to use the browsers brains to give me the fully qualified URLS


    ...

    // Get URL path information
    function getFullURL(link) {
        var parser = document.createElement('a');
        parser.href = link;
        return parser.href;
    }

    ...


You can pass that code "/somefile.html". "../../somefile.html", "somefile.html", "//www.somedomain.com/somefile.html" or any other valid asset path and it will return the full path the browser would use to get to it.


The last bit of browser juggling was a function to get DOM elements, again check the function not the browser.


    ...

    // Get elements
    function getElements(root, type) {
        if( !root.querySelectorAll ) {
            return root.getElementsByTagName(type);
        }else{
            return root.querySelectorAll(type);
        }
    }

    ...


Here I pass a root element and an element name, e.g. var domset = getElements(document, 'div');

This is where the rubber met the road, I added an onload event to the document and a method to search for any divs with a "synject" attribute and process them.


    ...

    // Add an event document
    document.addEventListener('DOMContentLoaded', function () {
        loadSynjectors();
    });


    // Search root for divs with sybject tagging
    function loadSynjectors(root) {
        var synjectors = getSynjectors(root)
        if (synjectors != null) {
            for (var i = 0; i < synjectors.length; i++) {
                var url = getFullURL(synjectors[i].getAttribute("synject"));
                if (synjectors[i].getAttribute("cache") == "false" || !cache[url] ) {
                    // get file
                    fetchContent(url, synjectors[i]);
                }
                else {
                    // use cache
                    synjectors[i].innerHTML = cache[url];
                    synjectors[i].setAttribute("synjected", "true");
                    loadSynjectors(synjectors[i]);
                }
            }
        }
    }

    // Build call to fetch and handle content
    function fetchContent(url, target) {
        callXmlhttp(url, function() {
            return function(content) {
                target.innerHTML = content;
                target.setAttribute("synjected", "true");
                loadSynjectors(target);
                cache[url] = content;
            }
        }());
    }

    // Get elements by tag and attributes
    function getSynjectors(root) {
        var divs = [];
        var synjectors = [];
        root = (root != null)?root:document;
        divs = getElements(root, 'div');
        for (var i = 0; i < divs.length; i++) {
            if (divs[i].hasAttribute("synject") && !divs[i].hasAttribute("synjected")) {
                synjectors.push(divs[i])
            }
        }
        if (synjectors.length > 0) {
            return synjectors;
        }
        else {
            return null;
        }
    }


    ...


Crude but effective, html was retrieved and pasted into the body inside the div elements, however Javascript includes were a problem. So I added a new function I could call after pasting in html, it examines the innerHtml of an element (div) and then it detects script tags and evals the contents.

    ...

    // Simulate the normal processing of Javascript
    function executeJS(target){
        var scriptreg = '(?:<script(.*)?>)((\n|.)*?)(?:</script>)'; //Regex <script> tags.
        var match = new RegExp(scriptreg, 'img');
        var scripts = target.innerHTML.match(match);
        if (scripts) {
            for (var s = 0; s < scripts.length; s++) {
                var js = '';
                var match = new RegExp(scriptreg, 'im');
                js = scripts[s].match(match)[2];
                window.eval('try{' + js + '}catch(e){}');
                target.innerHTML = target.innerHTML.replace(scripts[s], '')
            }
        }
    }

    ...


Which seemed like a good first step but it broke whenever it encountered a document.write... well broke may be the wrong word, calling a document.write method after the page has loaded replaces the entire contents of the page... but I consider that "broke".

And yes, I know document.write should be avoided but there still libs that rely on it and I didn't want my code breaking because other people hadn't updated their approach. The solution is quite simple, I monkey patch the offending function by storing it in a local variable and redefining it with a function that simply put the string given it into a variable, then after the eval I replaced the script tag in the source with the collected output, flush the variable for the next script, and finally restore document.write.

    ...

    // Simulate the normal processing of Javascript
    function executeJS(target){
        var scriptreg = '(?:<script(.*)?>)((\n|.)*?)(?:</script>)'; //Regex <script> tags.
        var match = new RegExp(scriptreg, 'img');
        var scripts = target.innerHTML.match(match);
        var dwoutput = '';
        var doc = document.write; // Store document.write for overloading.
        document.write = function (content) {
            dwoutput += content;
        };
        if (scripts) {
            for (var s = 0; s < scripts.length; s++) {
                var js = '';
                var match = new RegExp(scriptreg, 'im');
                js = scripts[s].match(match)[2];
                window.eval('try{' + js + '}catch(e){}');
                target.innerHTML = target.innerHTML.replace(scripts[s], dwoutput)
                dwoutput = '';
            }
        }
        document.write = doc; // Restore document.write
    }

    ...


This was closer but still not perfect. Not all script tags have code to be executed directly, some simply point to a file to include.

Now the reflex solution here to add a script element to the head where they belong, but again I found myself thinking about legacy code and the best way to support it, so I added a method that would drop in a script element as a child of the element the code was being inserted into and I upgraded my executeJS code to correctly detect remote scripts.

    ...

    // Append a acript to the head
    function appendScript(target, path, type) {
        type = (type)? type:"text/javascript";
        var js = document.createElement("script");
        js.type = type;
        js.src = path;
        target.appendChild(js);
    }

    // Simulate the normal processing of Javascript
    function executeJS(target){
        var scriptreg = '(?:<script(.*)?>)((\n|.)*?)(?:</script>)'; //Regex <script> tags.
        var match = new RegExp(scriptreg, 'img');
        var scripts = target.innerHTML.match(match);
        var dwoutput = '';
        var doc = document.write; // Store document.write for overloading.
        document.write = function (content) {
            dwoutput += content;
        };
        if (scripts) {
            for (var s = 0; s < scripts.length; s++) {
                var js = '';
                var match = new RegExp(scriptreg, 'im');
                var src = null;
                var type = '';
                var tag = scripts[s].match(match)[1];
                if (tag) {
                    type = tag.match(/type=[\"\']([^\"\']*)[\"\']/);
                    if (type) {
                        type = type[1];
                    }
                    src = tag.match(/src=[\"\']([^\"\']*)[\"\']/);
                }
                if (src) {
                    src = src[1];
                    appendScript(target, src, type);
                    target.innerHTML = target.innerHTML.replace(scripts[s], '')
                }
                else {
                    js = scripts[s].match(match)[2];
                    window.eval('try{' + js + '}catch(e){}');
                    target.innerHTML = target.innerHTML.replace(scripts[s], dwoutput)
                    dwoutput = '';
                }
            }
        }
        document.write = doc; // Restore document.write
    }

    ...


And my code was almost done. I went back and added in a queue so that if the same code was being included in 20 different places and the first include fetch had not finished processing it would simply add the other destinations in without kicking off another 20 http calls.

Finally I added spans to the supported elements (they have less formatting impact that divs) and I threw in one last method to allow includes to be programatically called on any element by id.

    ...

    // Force an include to a target DOM element
    global.synject = function (target, path) {
        if (!target) {return;}
        if (typeof target == 'string' || target instanceof String) {
            target = document.getElementById(target);
        }
        var url = getFullURL(path);
        if (que[url]) {
            que[url]["targets"].push(target);
        }
        else {
            que[url] = {};
            que[url]["targets"] = [];
            que[url]["targets"].push(target)
            fetchContent(url);
        }
    }

    ...


Told you I had plans the global.synject() function...

The project is up on GitHub if your interested and I already have a couple of enhancements in mind (recursive scripts put in place by document.write calls will not work for instance) plus, of course, I welcome any suggestions or bug fixes.

Once you minify the code it's only about 2.2kb and closer to 1.2kb zipped. While it's far from perfect or the most beautiful code I've ever written, it does get the job done and I've hit no problems with it so far.

No comments:

Post a Comment