30

Instant page rendering and seamless navigation for SPAs

 5 years ago
source link: https://www.tuicool.com/articles/hit/z2ymE3a
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Single Page Applications SPAs nowadays are probably the latest trend when building web applications and this comes for two reasons: a) they offer a smoothless user experience with no page reloads and b) the existence of so many javascript frameworks that supports them. They are known though for several unwanted behaviors such as that they need to be loaded first and make at least one API call before showing the initial view, displaying a loader until that call ends and that it’s difficult to keep the code clean either in the back end or front end when the app grows, having too many views with different shared components such as sidebars. For the initial rendering issue, you will find Server Side rendering solutions that involve webpack plugins or server-side middleware. Those kinds of middlewares though may raise new issues such as decrease overall performance or strange server-side behavior (aggregating requests on the first load) . This post will introduce a new technique that can set the initial state on the client when the page loads plus provides a seamless SPA route navigation from views with entirely different structures and reusable components while keeping both your backend and frontend code clean. Before continuing and explain the technique I strongly recommend you to download the associated repository and understand what the technique solves. The app is also deployed here .

The project is built with ASP.NET Core and Vue.js on the front but don’t worry, it’s just a few lines and you can do the same with other frameworks as well, what matters is the technique, not the framework used.

Explaining the app

Build an fire the application on your favorite browser. The app is a fiction Sports Betting website having the following views:

  • Home : This view has a Header , a Sidebar , some content and aFooter
    e6ZBjyb.png!web
  • Offers : same as the home view with different content coming from a OffersController controller
    FJJBVrn.png!web
  • Article : Clicking on item in the Offers view you will navigate to the Article view where you can see that the sidebar is missing
    IJvqymq.png!web
  • League : Clicking an item from the sidebar you will navigate to the league view where there are two components: a Live bet widget and the actual content of the selected league. When the league changes the widget remains the same
    ym6R3yJ.png!web

Play around with the app and ask yourself the following questions:

  • If the landing page is the Article view what happens when navigating to Home or Offers ? Do their controller actions need to know about the sidebar and if so how?
  • When switching leagues from the sidebar, does the action needs to know about the live bet widget? Notice that in this case the route remains the same, only the resource changes

Open the network tab and check what happens in the traffic. You will find that shared-required components such as the sidebar and the widget are only retrieved only if they don’t exist on the application’s store state. More over the JSON response is broken in two parts, d: which contains the action’s data specific related to the view, for example the OffersController/Index action return result and s: which contains any shared structural data. The following screenshot shows the response when the landing page is the Article and then navigating to Offers:

q67Zn2b.png!web

Of course you will find that when loading the page, no API calls required to display the initial view regardless of the route. So what happens and all the actions know what to render? Time to dig in and explain the technique.

The technique

The solution is based in a new term named Structural Components :

Definition

Structural Components are the components that their state is shared across either different routes or different resources for the same route.

In our demo app we have the following structural components:

  • Header and footer because their state is used for all views/routes
  • Sidebar because its state is used for home , offers and league routes
  • Bet of day widget because its state is shared by different resources of the same route

Now that we have defined what Structural components are we can see how backend and frontend understand what’s missing and what’s not.

Server side

ASP.NET Core MVC ResultFilter attributes are applied on the MVC actions to describe the structural requirements of the route. Result filters can be used to alter the response of an MVC action based on certain conditions. The base class of our result filter is StructureResult . An instance of a StructuralResult contains a list of StuctureRequirement .

BFZbEn6.png!web

A structural requirement defines the requirement a follow:

public class StuctureRequirement
{
    public string StoreProperty { get; set; }
    public string Alias { get; set; }

    public Func<object, object> function;
}

The StoreProperty maps the client’s store application property while the Alias is the parameter added in the query string by the client when that property is found . If the parameter not found in the query string then the server will call the Func function to retrieve the data for this requirement. The result will be added on a property named StoreProperty in the s: object of the result. Let’s see the example where we define the DefaultStructureResult to define the filter result for the routes where Header, Footer and Sidebar are required.

public class DefaultStructureResult : StructureResult
{
    public DefaultStructureResult(IContentRepository contentRepository) : base(contentRepository)
    {
        StuctureRequirement headerRequirement = new StuctureRequirement();
        headerRequirement.StoreProperty = "header";
        headerRequirement.Alias = "h";
        headerRequirement.function = (x) => contentRepository.GetHeaderContent("Sports Betting");

        StuctureRequirement footerRequirement = new StuctureRequirement();
        footerRequirement.StoreProperty = "footer";
        footerRequirement.Alias = "f";
        footerRequirement.function = (x) => contentRepository.GetFooterContent();

        StuctureRequirement sidebarRequirement = new StuctureRequirement();
        sidebarRequirement.StoreProperty = "sidebar";
        sidebarRequirement.Alias = "s";
        sidebarRequirement.function = (x) => contentRepository.GetSports();

        AddStructureRequirement(headerRequirement);
        AddStructureRequirement(footerRequirement);
        AddStructureRequirement(sidebarRequirement);
    }
}

The DefaultStructureResult result is applied on an MVC action as follow:

[ServiceFilter(typeof(DefaultStructureResult))]
public IActionResult Index()
{
    var result = new OffersVM {Offers = _offers};
    return ResolveResult(result);
}

ResolveResult method is responsible to return a ViewResult on a Page load request or a JSON response if it’s an API request.

public class BaseController : Controller
{
    protected IActionResult ResolveResult(object data = null)
    {
        var nameTokenValue = (string)RouteData.DataTokens["Name"];

        if (nameTokenValue != "default_api")
        {
            return View("../Home/Index", data);
        }

        return Ok(data);
    }
}

We used a ServiceFilter because we want to use Dependency Injection. Now back to the base StructureResult class where all the magic happens. Let’s break it step by step cause it is crucial to understand how it works. First we have the list of requirements..

public abstract class StructureResult : Attribute, IResultFilter
{
    private readonly IContentRepository _contentRepository;

    private List<StuctureRequirement> _requirements;

    protected StructureResult(IContentRepository contentRepository)
    {
        _contentRepository = contentRepository;
        _requirements = new List<StuctureRequirement>();
    }
    // code omitted

The OnResultExecuting method starts by checking if there’s a ns parameter and if so ignores any structure requirements. You want this behavior cause sometimes you just want to make a pure api call and get the default result, ignoring any structure requirements applied to the action.

public void OnResultExecuting(ResultExecutingContext context)
{
    if (!string.IsNullOrEmpty(context.HttpContext.Request.Query["ns"]))
        return;
        // code omitted

The purpose of a StructureResult is to return any required structure data back to the client. The data can be returned in two different forms depending on either if it’s a full page load or an api call. If it’s a full page load then viewResult won’t be null and if it’s an API call then objectResult won’t be null.

var objectResult = context.Result as ObjectResult;
var viewResult = context.Result as ViewResult;

The part that checks the requirements is the following:

// Check requirements
foreach (var requirement in _requirements)
{
    if (string.IsNullOrEmpty(context.HttpContext.Request.Query[requirement.Alias]))
    {
        var val = requirement.function.Invoke(null);
        jobj.Add(requirement.StoreProperty, JToken.FromObject(val));
    }
}

It is generic and depends on the requirements added on the custom StructuralResult applied on the action. Structural components data are stored in a JObject . If it’s a full page load the final result which is a Partial representation of the client’s store state is stored in ViewBag.INITIAL_STATE and is instantly available on the client.

JObject initialData = null;
if (viewResult.ViewData["INITIAL_STATE"] != null)
{
    initialData = JObject.Parse(viewResult.ViewData["INITIAL_STATE"].ToString());
    jobj.Merge(initialData, new JsonMergeSettings
    {
        // union array values together to avoid duplicates
        MergeArrayHandling = MergeArrayHandling.Union
    });
}

viewResult.ViewData = new ViewDataDictionary(new Microsoft.AspNetCore.Mvc.ModelBinding.EmptyModelMetadataProvider(),
    new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary()) { { "INITIAL_STATE", jobj.ToString() } };

The if statement above is needed in case you have applied more that one StructuralResult filters on the action invoked. When the page finishes loading, the result is available inside a div and the client needs to parse it to a JSON object and merge it with the default initial state.

INIT_STATE: (state, initState) => {
    store.replaceState(Object.assign({}, store.state, initState));
}

The changes will be instantly applied on the components connected to the store and you don’t have to make any api calls.

If it was an api call then the result is stored in a s: property on the response:

else if (objectResult != null)
{
    var notFirstIteration = objectResult.Value != null && JObject.FromObject(objectResult.Value).ContainsKey("s");
    JToken previousValue = null;

    if (notFirstIteration)
    {
        previousValue = JObject.FromObject(objectResult.Value)["d"];
        jobj.Merge(JObject.FromObject(objectResult.Value)["s"], new JsonMergeSettings
        {
            // union array values together to avoid duplicates
            MergeArrayHandling = MergeArrayHandling.Union
        });
    }

    objectResult.Value = new
    {
        d = !notFirstIteration ? objectResult.Value : previousValue,
        s = jobj
    };
}

Client side

The only thing required for the client side for this technique to work is to use a store that keeps the application’s state and reactively pushes any changes made to components, a pattern known as State management pattern . The demo uses Vue.js for simplicity and has the Vuex library to store application’s state. In case you use angular on the front you can use the @ngrx/store . The client needs to define the structural components requirements per route and the best place to do this is in the route definition. Here’s the structural definitions:

const RouteStructureConfigs = {
    defaultStructure : [
        { title: 'header', alias: 'h', getter: 'getHeader', commits: [ { property: 'header', mutation: 'SET_HEADER' }] },
        { title: 'footer', alias: 'f', getter: 'getFooter', commits: [ { property: 'footer', mutation: 'SET_FOOTER' }] },
        { title: 'sidebar', alias: 's', getter: 'getSports', commits: [ { property: 'sidebar', mutation: 'SET_SIDEBAR' }]}
    ],
    noSidebarStructure : [
        { title: 'header', alias: 'h', getter: 'getHeader', commits: [ { property: 'footer', mutation: 'SET_HEADER' }] },
        { title: 'footer', alias: 'f', getter: 'getFooter', commits: [ { property: 'footer', mutation: 'SET_FOOTER' }]}
    ],
    liveBetStructure: [
        { title: 'live', alias: 'l', getter: 'getLiveBet', commits: [{ property: 'live', mutation: 'SET_LIVE_BET' }] }
    ]
}

A structure array defines the requirements and each requirement has an alias which should match the alias on the backend. It defines a getter function which tells which property should look for in the store. @ngrx/store should have a related getter method to check a state’s property. The commits array defines the mutations that should run when the response is returned from the server. Here’s the routes definitions:

const router = new VueRouter({
    mode: 'history',
    routes: [
        { name: 'home', path: '/', component: homeComponent, 
            meta: 
            {
                requiredStructures: RouteStructureConfigs.defaultStructure
            } 
        },
        { name: 'offers', path: '/offers', component: offerComponent,
            meta:
            {
                requiredStructures: RouteStructureConfigs.defaultStructure
            } 
        },
        { name: 'article', path: '/offers/:id', component: articleComponent,
            meta:
            {
                requiredStructures: RouteStructureConfigs.noSidebarStructure
            }
        },
        {
            name: 'league', path: '/league/:sport/:id', component: leagueComponent,
            meta:
            {
                requiredStructures: [...RouteStructureConfigs.defaultStructure, ...RouteStructureConfigs.liveBetStructure]
            }
        }
    ]
})

Following is the method that builds the request’s uri before sending an API call:

const buildUrl = (component, url) => {
    
    var structures = component.$router.currentRoute.meta.requiredStructures;

    // Checking structural required components..
    structures.forEach(conf => {
        var type =  typeof(component.$store.getters[conf.getter]);
        var value = component.$store.getters[conf.getter];
        console.log(conf);
        console.log(conf.title + ' : ' +  component.$store.getters[conf.getter] + ' : ' + type);
        if( 
            (type === 'string' && value !== '') || 
            (type === 'object' && Array.isArray(value) && value.length > 0)
        ){
            url = updateQueryStringParameter(url, conf.alias, true);
        }
    });

    console.log(url);
    return url;
}

The code retrieves the structural requirements/definitions for the current route and uses the aliases to build the uri. When the response is returned the updateStructures method is called to run the related mutations if required.

function updateStructures(component, response) {
    
    var structures = component.$router.currentRoute.meta.requiredStructures;

    structures.forEach(conf => {

        conf.commits.forEach(com => {
            if(response.data.s[com.property]) {
                console.log('found ' + com.property + ' component..');
                component.$store.commit(com.mutation, response.data.s[com.property]);
                console.log('comitted action: ' + com.mutation);
            }
        });
    });
}

When you have a full page load things are even more easy. The only thing to do is run a single mutation that merges the server’s INITIAL_STATE object with the default state:

INIT_STATE: (state, initState) => {
    store.replaceState(Object.assign({}, store.state, initState));
},

The entire process is described in the following diagram:

JnqEB3V.png!web

That’s it we finished! We saw how to return and set an initial state on the first page load and how to transition between different routes with entirely different structures. The key to the solution is the definition of the term Structural Components and should be easily applied on different back end and front end frameworks. You can download the repository associated with the post here .

In case you find my blog’s content interesting, register your email to receive notifications of new posts and follow chsakell’s Blog on its Facebook or Twitter accounts.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK