3
\$\begingroup\$

The full question is... "Please, how can this code base or project (for client-side routing using Knockout and HTML5 History API) be further enhanced as regards security, production-readiness, ES6, etc.?".

I intend that this eventually stays on somewhere like GitHub where anyone who has been faced with the challenge of creating an SPA app can easily use this to start off.

The idea is to do page-to-page navigation using partial views as Knockout components (and get something similar to that of Angular, React, etc.). This project uses ASP.NET MVC 5, but can be used with any web development framework.

The project works just like if Angular/React/etc. were used. No hash bang is needed. Full page loading from any URL based on the site domain works. Navigation works, etc. The full project folder (ASP.NET MVC 5), for anyone who is interested in running it in full is on Google Drive.

On page load, an controller's action returns an empty view but with a full featured layout view. And as soon as the page loads on the client-side, a call is made to the server (via the same page URL, but with a query string, say template=1) to supply the partial view for the current page as the template of a custom component (say "partial-page-custom-component") that represents the main page content. The same URL, using another query string (say viewModel=1), is used to fetch the view model for the said component. Then, HTML5 History API is used to handle URL changes, back and forward browser navigation, clicking of elements that reference the same domain, etc.

RequireJS and the likes are not used to load the JS needed for the view model. Instead, eval() is used to run the code from the server directly in order to construct the view model for the page. There is another global view model that handles global data-binding affairs.

The following paragraphs detail the basics of the workings of the project/code.

The JS code for doing the main client-side routing and data-binding using Knockout and HTML5 History API...

var globalData = {
        lastNavigatedUrl: null
    };

    function GlobalViewModel() {
    var self = this;
    self.pageTitle = ko.observable("");
    self.partialPageComponentUrl = ko.observable("");
    self.partialPageComponentTemplateUrl = ko.pureComputed(function () {
        if (self.partialPageComponentUrl() !== "") {
            var ppcu = self.partialPageComponentUrl();
            var templatePartInQueryString = "template=1";
            var templatePartAttachmentInQueryString = (ppcu.indexOf("?") >= 0) ? ("&" + templatePartInQueryString) : ("?" + templatePartInQueryString);

            if (ppcu.indexOf("#") >= 0) {
                var indexOfHashInPpcu = ppcu.indexOf("#");
                var hashPartAttachmentInPpcu = ppcu.substring(indexOfHashInPpcu);
                var ppcuPartWithoutHashPart = ppcu.substring(0, indexOfHashInPpcu);
                return ppcuPartWithoutHashPart + templatePartAttachmentInQueryString + hashPartAttachmentInPpcu;
            }
            else {
                return ppcu + templatePartAttachmentInQueryString;
            }
        } else {
            return "";
        }
    });
    self.partialPageComponentViewModelUrl = ko.pureComputed(function () {
        if (self.partialPageComponentUrl() !== "") {
            var ppcu = self.partialPageComponentUrl();
            var viewModelPartInQueryString = "viewModel=1";
            var viewModelPartAttachmentInQueryString = (ppcu.indexOf("?") >= 0) ? ("&" + viewModelPartInQueryString) : ("?" + viewModelPartInQueryString);

            if (ppcu.indexOf("#") >= 0) {
                var indexOfHashInPpcu = ppcu.indexOf("#");
                var hashPartAttachmentInPpcu = ppcu.substring(indexOfHashInPpcu);
                var ppcuPartWithoutHashPart = ppcu.substring(0, indexOfHashInPpcu);
                return ppcuPartWithoutHashPart + viewModelPartAttachmentInQueryString + hashPartAttachmentInPpcu;
            }
            else {
                return ppcu + viewModelPartAttachmentInQueryString;
            }
        } else {
            return "";
        }
    });
    self.partialPageComponentName = ko.observable("partial-page-custom-component-placeholder");
}

var globalViewModel = new GlobalViewModel();

function indicateScrollTargetElement() {
    $(window.location.hash).addClass("scroll-target-element");
    setTimeout(function () { $(window.location.hash).removeClass("scroll-target-element"); }, 2000);
}
function setUpComponents() {
    ko.components.register("partial-page-custom-component-placeholder", {
        template: "<span>It's the partial page custom component placeholder.</span>",
        viewModel: null
    });

    var customComponentTemplateLoader = {
        loadTemplate: function (name, templateConfig, callback) {
            // This line can be used to run the writable computed observable (of globalViewModel) (for fixing placeholder UI that indicates the page is loading) to enable the placeholder UI.
            if (name === "partial-page-custom-component") {
                $.ajax({
                    asyn: false,
                    type: "GET",
                    url: globalViewModel.partialPageComponentTemplateUrl(),
                    success: function (customComponentTemplate) {
                        ko.components.defaultLoader.loadTemplate(name, customComponentTemplate, callback);
                        // This line can be used to run the writable computed observable (of globalViewModel) (for fixing placeholder UI that indicates the page is loading) to disable the placeholder UI.

                        if (window.location.href.indexOf("#") >= 0) {
                            $(window.location.hash).get(0).scrollIntoView();
                            indicateScrollTargetElement();
                        }
                        else {
                            $(window).scrollTop(0);
                        }
                    }
                });
            } else {
                callback(null);
            }
        }
    };
    ko.components.loaders.unshift(customComponentTemplateLoader);

    var customComponentViewModelLoader = {
        loadViewModel: function (name, viewModelConfig, callback) {
            if (name === "partial-page-custom-component") {
                var customComponentViewModel = function (params) {
                    var self = this;
                    $.ajax({
                        async: false,
                        type: "GET",
                        url: params.partialPageComponentViewModelUrl_params(),
                        success: function (customComponentViewModelJsCode) {
                            eval(customComponentViewModelJsCode.replace("<script>", "").replace("</script>", ""));
                        }
                    });
                };
                ko.components.defaultLoader.loadViewModel(name, customComponentViewModel, callback);
            } else {
                callback(null);
            }
        }
    };
    ko.components.loaders.unshift(customComponentViewModelLoader);

    ko.components.register("partial-page-custom-component", {
        template: {
            dummy: ""
        },
        viewModel: {
            dummy: null
        }
    });

    ko.applyBindings(globalViewModel, $("html").get(0));
}

function navigateToPartialPageComponent(shouldReplaceStateBefore, stateWrtReplaceStateBefore, shouldPushStateBefore, stateWrtPushStateBefore, swapOrderWrtStateBefore, partialPageComponentUrlToNavigateTo, shouldReplaceStateAfter, stateWrtReplaceStateAfter, shouldPushStateAfter, stateWrtPushStateAfter, swapOrderWrtStateAfter) {
    // Somewhere in this code can be used to run the writable computed observable (of globalViewModel) (for fixing placeholder UI that indicates the page is loading) to enable or disable the placeholder UI.

    if (swapOrderWrtStateBefore === false) {
        if (shouldReplaceStateBefore === true) {
            window.history.replaceState(stateWrtReplaceStateBefore, "", partialPageComponentUrlToNavigateTo);
        }
        if (shouldPushStateBefore === true) {
            window.history.pushState(stateWrtPushStateBefore, "", partialPageComponentUrlToNavigateTo);
        }
    }
    else if (swapOrderWrtStateBefore === true) {
        if (shouldPushStateBefore === true) {
            window.history.pushState(stateWrtPushStateBefore, "", partialPageComponentUrlToNavigateTo);
        }
        if (shouldReplaceStateBefore === true) {
            window.history.replaceState(stateWrtReplaceStateBefore, "", partialPageComponentUrlToNavigateTo);
        }
    }

    globalViewModel.partialPageComponentName("partial-page-custom-component-placeholder");// This helps to do mediation work to make sure that the new component, if has the same name as the old active component, still gets rendered.
    globalViewModel.partialPageComponentUrl("");

    ko.components.clearCachedDefinition("partial-page-custom-component");

    globalViewModel.partialPageComponentUrl(partialPageComponentUrlToNavigateTo);
    globalViewModel.partialPageComponentName("partial-page-custom-component");

    if (swapOrderWrtStateAfter === false) {
        if (shouldReplaceStateAfter === true) {
            window.history.replaceState(stateWrtReplaceStateAfter, "", partialPageComponentUrlToNavigateTo);
        }
        if (shouldPushStateAfter === true) {
            window.history.pushState(stateWrtPushStateAfter, "", partialPageComponentUrlToNavigateTo);
        }
    }
    else if (swapOrderWrtStateAfter === true) {
        if (shouldPushStateAfter === true) {
            window.history.pushState(stateWrtPushStateAfter, "", partialPageComponentUrlToNavigateTo);
        }
        if (shouldReplaceStateAfter === true) {
            window.history.replaceState(stateWrtReplaceStateAfter, "", partialPageComponentUrlToNavigateTo);
        }
    }

    globalData.lastNavigatedUrl = partialPageComponentUrlToNavigateTo;
}

function isNextUrlHashIfAnyForLastNavigatedUrl(nextUrl, previousUrl) {
    if (previousUrl === null) {
        return false;
    }

    if (nextUrl.indexOf("#") >= 0 && (nextUrl.substring(0, nextUrl.indexOf("#")) === ((previousUrl.indexOf("#") >= 0) ? previousUrl.substring(0, previousUrl.indexOf("#")) : previousUrl))) {
        return true;
    }
    else {
        return false;
    }
}

function setUpNavigation() {
    $(window).on("popstate", function (evnt) {
        if (isNextUrlHashIfAnyForLastNavigatedUrl(window.location.href, globalData.lastNavigatedUrl) === false) {
            navigateToPartialPageComponent(false, {}, false, {}, false, window.location.href, false, {}, false, {}, false);
        }
        else {
            indicateScrollTargetElement();
        }
    });

    $(document).on("click", "a[href^='/'], area[href^='/']", null, function (evnt) {
        if (isNextUrlHashIfAnyForLastNavigatedUrl(this.href, globalData.lastNavigatedUrl) === false) {
            navigateToPartialPageComponent(false, {}, false, {}, false, this.href, false, {}, true, {}, false);
            evnt.preventDefault();
        }
    });
}

function processNavigationOnPageLoad() {
    navigateToPartialPageComponent(true, {}, false, {}, false, window.location.href, false, {}, false, {}, false);
}

$(function () {
    setUpComponents();
    setUpNavigation();
    processNavigationOnPageLoad();
});

The HomeController (or any other controller code) looks like thus...

public class HomeController : Controller
    {
        public ActionResult Index(string extraData = "")
        {
            Response.AppendHeader("Cache-Control", "no-store");// I included this because of some client-side caching issues that happened.

            if (extraData == "blablo")
            {
                ViewBag.ExtraIndexData = "The extra data to Index.";
            }

            if (Request.QueryString["template"] == "1")
            {
                ViewBag.Mode = "template";
                return PartialView("IndexPartial");
            }

            if (Request.QueryString["viewModel"] == "1")
            {
                ViewBag.Mode = "viewModel";
                return PartialView("IndexPartial");// This can be replaced by actual JS code, but I decided to put the partial view's JS code into <script> elements inside the partial view.
            }

            return View("Empty");// This is an actual empty view that uses the layout view. If the request is not for retrieving the template or view model, this is returned but does not display any page content but just the header and footer aspects of the web page as specified in the layout view.
        }

        public ActionResult About()
        {
            Response.AppendHeader("Cache-Control", "no-store");

            ViewBag.Message = "Your application description page.";

            if (Request.QueryString["template"] == "1")
            {
                ViewBag.Mode = "template";
                return PartialView("AboutPartial");
            }

            if (Request.QueryString["viewModel"] == "1")
            {
                ViewBag.Mode = "viewModel";
                return PartialView("AboutPartial");
            }

            return View("Empty");
        }
    }

A typical partial view code is as follows...

    @{
            string randomString = Guid.NewGuid().ToString();
            ViewBag.Title = "Home Page";
        }

        @if (ViewBag.Mode == "template")
        {
            <div>@randomString</div>
            <div><span>Page title: </span><span data-bind="text: $parent.pageTitle"></span></div>
        <span data-bind="text: someDataaa"></span>
            }

            @if (ViewBag.Mode == "viewModel")
            {
                <script>
                    globalViewModel.pageTitle("Home");
                    self.someDataaa = ko.observable("This is a content for the index page.");
                </script>
            }

And a typical layout view can be like thus...

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title data-bind="text: pageTitle"></title>
        @Styles.Render("~/Content/css")
        @Scripts.Render("~/bundles/modernizr")
    </head>
    <body>
        <input type="hidden" id="is-full-page" />
        <div class="navbar navbar-inverse navbar-fixed-top">
            <div class="container">
                <div class="navbar-header">
                    <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                    </button>
                    @Html.ActionLink("Application name", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })
                </div>
                <div class="navbar-collapse collapse">
                    <ul class="nav navbar-nav">
                        <li><a href="/">Home</a></li>
                        <li><a href="/Home/About">About</a></li>
                        <li><a href="/Home/Contact">Contact</a></li>
                    </ul>
                </div>
            </div>
        </div>
        <div class="container body-content">
            <div id="partial-page-component-container" data-bind="component: {
                    name: partialPageComponentName,
                    params: { partialPageComponentViewModelUrl_params: partialPageComponentViewModelUrl }
                 }">
            </div>
            @RenderBody()
            <hr />
            <footer>
                <p>&copy; @DateTime.Now.Year - My ASP.NET Application</p>
            </footer>
        </div>
        <style>
            .scroll-target-element {
                background-color: green !important;
            }
        </style>
        @Scripts.Render("~/bundles/jquery")
        @Scripts.Render("~/bundles/bootstrap")
        @RenderSection("scripts", required: false)
        <script src="~/Scripts/knockout-3.4.2.js"></script>
        <script src="~/Scripts/mine/my-knockout-routing.js"></script>
    </body>
    </html>
\$\endgroup\$
  • \$\begingroup\$ This is the intended goal when I asked a more niche question, which is about how to use custom components, and was answered by the great Michael Best. \$\endgroup\$ – Olumide Oct 4 '18 at 12:41
  • \$\begingroup\$ The use of JS eval() is not encouraged, but I feel that if it can be ensured that the JS code is authentic, there'll there'll be no issues, especially since untrusted third-party JS code will not be used with the eval(). \$\endgroup\$ – Olumide Oct 6 '18 at 16:35
  • \$\begingroup\$ Please, I need some answers on this. Thanks in advance. I really like what Angular and co. have helped to solve, but I'm not comfortable putting a lot (including routing) into the client-side when there are more than robust server-side technologies like ASP.NET Core (or MVC) and the power of C#, and the advantage of having to do some server-side view rendering with Razor. There are lots of other reasons I am going for this. \$\endgroup\$ – Olumide Oct 6 '18 at 16:38
  • \$\begingroup\$ Of course, ReactJs and co. can be used to manage JS code and dependencies. This is just to create something generic. \$\endgroup\$ – Olumide Oct 6 '18 at 18:00

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.