getify/LABjs

[FIXED] Page-based loading

sergeevabc opened this issue · 7 comments

There are a lot of JS loaders, really. But none of them, at least within documentation, addresses a problem I'm eager to solve. That is to use single <script> line per whole project which inits page-based loading of dependencies.

Let's create a simple project with index, portfolio, and contacts pages.

Microframework will help us with routing:

$app->get('/portfolio/',
    function () use ($app) {
        $prices = array(
              "price_mine"  => "90",
              "price_rival" => "120"
        );
        $app->render('portfolio.twig', $prices);
    })->name('portfolio');

Template engine will help us with rendering:

{% extends "base.twig" %}
{% block content %}
     <p>Aren't you sick and tired to pay {{price_rival}}…</p>.
{% endblock content %}

And the missing part in every page is <script src="/static/init.js"></script> that works as follows

  • all pages load jQuery from CDN or from my server as fallback,
  • index loads SoundManager to serve audio salutation,
  • portfolio loads Lightbox to serve images,
  • contacts loads Forms validator.

What's the best way to achieve it?
And how to do that with LABjs whether it's possible?

Thank you in advance.
Warm regards from Russia, Alexander.

I have to admit I'm not terribly certain I understand quite what you're asking for.

On the surface, it looks like you're asking for how to build a special type of framework for dependency loading that abstracts on the "page" (as seen in the URL by a router) as the unit of loading dependency. If that's what you're asking for, that's quite an ambitious task, and I just want to level-set that I won't be authoring that for you here, nor should you expect any other open-source project to just do that for you, in their documentation.

OTOH, that's an admirable type of project to do, so I'd encourage you to build one to your suiting, and make sure you open-source it as you do, so that the community can contribute to and benefit from it as you explore those use cases.

Now, at a simpler level, if you're asking how could my script loader (LABjs) used in such a theoretical project, that IS something I can speak to.

LABjs is a dynamic script loader, which means you can load one or more scripts at any time, not just at page-load time. You can, at any time, request 1 or more scripts, using .script(..) API calls, and they will default to loading in parallel and running ASAP. If you need for there to be an enforced execution order, you can call .wait(..) calls in between them, which will not affect loading but will affect execution order to preserve it.

So, whatever higher-level abstractions you build in such a page-loader framework, at the point when you need to fetch a script, it's a simple as invoking $LAB.script(..) and then using a .wait(..) with a callback to be notified when it finishes. That's LABjs's strong suit, is making that sort of on-demand loading incredibly simple.

It would be up to you implement all the other abstraction on top of LABjs, however.

@getify, thank you for sharing your thoughts.

You see, I'm just an end-user seeking for a way to simplify small projects management. That's why I switched from plain hierarchy (with numerous index.php inside subfolders) to PHP microframework with routes (single index.php in root serves everything) and template engine (which inheritance feature is a true time-saver). Then I mastered CSS preprocessor.

Now it's time for javascripts — it's convenient to keep all rules in one file instead of writing lazyloads per page, isn't it?
However, even major loaders like LABjs have no built-in functionality to solve the problem, right?

Your point is clear: if condition is true, then scriptloader will gladly serve any scripts.
But the core question is what's the best way to create conditional route system in init.js?

Maybe take a look at existing frameworks, many of which have routing built-in. For instance: Backbone http://backbonetutorials.com/what-is-a-router/

@getify , is there a fallback functionality built-in (still speaking about LABjs)?
I'm confused as one developer says yes, another says no and offers a tricky, but enormous workaround.

There are no "undocumented features" of LABjs. What you see (in the documentation) is what you get.

As such, the first link indicates that someone who has extended LABjs with some additional behavior. I'm not aware of how they did it. He mentions doing a follow-up blog post about that topic, but I didn't find it.

The second link is a gist that I wrote myself, in an attempt to help others see how to work-around the situation. What you see, clearly, from that snippet, is that it's based on a brittle set of assumptions about timeouts. Because the actual underlying web platform's functionality doesn't give us a way to do this directly, we have to do hacks. This was the level of hack that I deemed at the time to _not_ be worth rolling into the main core LABjs, but instead left to devs to do themselves as they see fit.

For example, here's a fork of my gist which adds in a bit more functionality: https://gist.github.com/mondaychen/1598350

This is the sort of task that I think belongs wrapped around the lib (LABjs) rather than inside it, because coming up with a single way to do it which most or all could agree on is extremely unlikely. If the web platform ever answers my call to add something like this natively, then I will wrap that into LABjs.

@getify, as a developer wannabe and a foreigner, I appreciate your comprehensive replies in plain English.

Let me share a basic solution to original case I have come up with.

  1. Download lab.min.js.
  2. Rename it to init.js.
  3. Add <script src="/js/init.js"></script> into <HEAD> of all pages.
  4. Add the following code inside init.js.
...original lab.min.js goes here...

// Parse URL to get last argument before trailing slash as page name, or set / for root page
// e.g. http://domain.tld/portfolio/dell/ -> dell, http://domain.tld/ -> /
var path = location.pathname.split("/"),
    page = path[path.length-2] || "/";

// Scripts for all pages
$LAB
        .queueScript("/js/lib/jquery.js")
        .queueWait();

// Scripts per page
switch (page) {
    case '/':
        $LAB
        .queueScript("/js/lib/soundmanager2.js");
        break;
    case 'portfolio':
        $LAB
        .queueScript("/js/lib/lightbox.js");
        break;
    case 'contacts':
        $LAB
        .queueScript("/js/lib/forms.js");
        break;
}

// Load last instructions and execute the queue, cutie
$LAB
        .queueScript("/js/letsrock.js")
        .runQueue();

Solution is basic indeed, yet it serves the purpose without overhead of extra <script> attributes and 3rd-party routers.
Unfortunately, fallback is not built-in and it's not that obvious how to use gist fork you mentioned above with this chain.

@sergeevabc
What you've shown is conceptually similar to how I use LABjs in my own projects. I call it making a "bootsrapper" file, and I usually call it "load.js", but I inline the minified production LABjs code, then I include the $LAB chain as necessary for my own project. Then I load just that one file with a normal <script> tag, at the bottom of the <body>, and it's responsible for loading up the rest of the code for the page.

I have some light conditional stuff in there, so what you're showing is much more complex/capable (deciding files based upon the page you're in), but conceptually it's the same idea. I endorse that as a good pattern.

If you got yourself a more and more complex/capable mechanism for doing conditional loading based on the page URL (routing), you might make yourself a little mini-lib (similar to LABjs) that you just drop in, instead of having that sort of ad-hoc custom code there. That'd make it easier for you to re-use on other projects later, and be a great candidate for releasing as open-source as a help to others in the community as a contrib.

As for how to do "fallback" style loading,

What you may want to do in the above code is the same concept, but instead of using the queueScript() and queueWait() (which are simple helpers built statically onto the $LAB API), you'd want to do queueing yourself.

For example:

// NOTE: I've slightly modified this function to pass in a $LAB chain to use.

function loadOrFallback($L,scripts,idx) {   
   function testAndFallback() {   
      clearTimeout(fallback_timeout);   
      if (successfully_loaded) return; // already loaded successfully, so just bail   
      try {   
         scripts[idx].test();   
         successfully_loaded = true; // won't execute if the previous "test" fails   
         scripts[idx].success();   
      } catch(err) {   
         if (idx < scripts.length-1) loadOrFallback($L,scripts,idx+1);   
      }   
   }   

   if (idx == null) idx = 0;   
   $L = $L.script(scripts[idx].src).wait(testAndFallback);   
   var fallback_timeout = setTimeout(testAndFallback,10*1000); // only wait 10 seconds
   return $L;
}



var _queue = [];

// page 1?
if (document.location.href.match(/ ... /)) {
   _queue.push([
      {src:"http://mycdn.com/page-1.js", test: ..., success: ...),   
      {src:"http://anothercdn.com/page-1.js", test: ..., success: ...),   
      {src:"page-1.js", test: ..., success: ...)
   ]);
}
// page 2?
else if (...) {
   _queue.push("page-2-dependency-a.js", false, "page-2-dependency-b.js", false);
   _queue.push([
      {src:"http://mycdn.com/page-2.js", test: ..., success: ...),   
      {src:"http://anothercdn.com/page-2.js", test: ..., success: ...),   
      {src:"page-2.js", test: ..., success: ...)
   ]);
}
// page 3?
...

// later, process the queue
var i, $L;

for (i=0; i<_queue.length; i++) {
   if (Object.prototype.toString.call(_queue[i]) === "[object Array]") {
      $L = loadOrFallback($L,_queue[i]);
   }
   else if (typeof _queue[i] === "boolean") {
      $L = $L.wait();
   }
   else if (typeof _queue[i] === "string") {
      $L = $L.script(_queue[i]);
   }
   else if (typeof _queue[i] === "function") {
      $L = $L.wait(_queue[i]);
   }
}

NOTE: this for-loop process is what I call "simulated chaining", and I wrote about it awhile back in this blog post: http://blog.getify.com/simulated-chaining-in-javascript/

Now, the huge caveat: $LAB does not allow you to basically "pause" a chain, because that functionality doesn't exist in the underlying web platform. What I mean by this is, we can't really combine the idea of having chained dependencies, as I've shown above, with the idea of having one part of the chain that sorta forks off and re-tries or tries other fallback URLs, while holding up the rest of the chain. You can't pause the main chain and fork off this child processing for one item in the main chain.

Bottom line: the above code might kinda work like you want, but you may also notice mal-behavior in the fallback case, because the way the above code works is that if a script has to fallback or be retried, it's doing that in a separate $LAB chain from the main chain processing the dependencies, and as such, you may have code that depends on your file having loaded, but that code didn't wait for all the different attempts at that one file.

You can probably see now why this topic is so complicated that it's not something I was ever going to bury inside of LABjs. You end up having to make a whole bunch of judgement calls about how to handle these functionality quirks and deficiencies. It's just one of those "hard" things you have to figure out how it works for you, even though there may be a dozen other ways to do it that others would prefer.