Blog-Archiv

Samstag, 20. Juni 2015

JS Asynchronous Waiting

JavaScript is a single-threaded language. JS can not create a parallel running activity that does one thing while another activity thread does another thing. Which implicates that JS needs no synchronization, and concurrency problems do not occur in a JS environment. We should be happy, one less piece of complexity!

But how do we do when we want to set a CSS-class onto an HTML element, but the element is not present yet?
We can't spawn a thread, but we can use the global predefined JS function setTimeout() to do that later when the element might already exist. That function accepts a callback function and a millisecond timeout as parameters, and it will call the callback function when the timeout has elapsed.

var timeout = 4000; // 4 seconds

setTimeout(
  function() {
    alert("The timeout of "+timeout+" ms elapsed!");
  },
  timeout
);

When you put this JS code into a web page, you will see a dialog after 4 seconds. This will not repeat.

The technique of invoking things later occurs in many UI environments. In Java/Swing it is the SwingUtilities.invokeLater() method, where the event-queue is used to post a Runnable instance for later execution.

This Blog develops a JS wait() function that permits asynchronous function execution at a time when some condition becomes true. Similar to Promises.

Specification

Here are the User Stories for the "wait module".

  • Condition and Success Action
    I want to execute a success function as soon as a condition function returns true. Because I do not know at what time the condition function will return true (it might e.g. check the existence of a SCRIPT element), it must be called periodically by the browser until it returns true. Then, and only then, the success function must be called.

  • Time Parameters
    I want to define a maximum time for evaluating the condition, and I want to specify how frequently the evaluation is tried, both as millisecond durations. The software must check that the evaluation interval is not bigger than the maximum wait time. When I pass zero as maximum, or a negative time amount, I want to wait unlimited for the condition (page lifetime).

  • More Conditions
    Maybe there will be several condition functions. For success, either all of them must be true at the same time, or each of them must have been true at least once before the evaluation expires, this must be configurable.

  • Given-Up Action
    Additionally to the condition and success functions, optionally I want to pass a givenUp function to be called when the condition never got true until the evaluation expires.

  • Progress Action
    Further I optionally want to pass a progress function that should be called on each evaluation attempt. This could be useful for displaying the elapsed time, or to animate some activity indicator. Additionally this function should have the opportunity to cancel the waiting, by returning false instead of true.

Design

Here is the well-documented interface of what will be usable from outside, as JS code.

    /**
     * Creates a wait object for given millisecond parameters.
     * @param maximumWaitMillis optional, default 4000 milliseconds, the time
     *     after which wait should return unsuccessfully when condition never
     *     became true.
     * @param delayMillis optional, default 100 milliseconds, the time after
     *     which the condition should be tested again.
     * @param allMustBeTrueAtSameTime optional, default false, when true,
     *     all conditions must be true at same time, it is not enough that
     *     a condition gets true shortly for a time while others are false.
     */
    var waitFactory = function(maximumWaitMillis, delayMillis, allMustBeTrueAtSameTime)
    {
      var that = {};

      ....

      /**
       * Waits for 1-n conditions to become true.
       * @param conditions required, an array of functions that all must return
       *     true for success() to be called.
       * @param success required, a function to be executed when all conditions
       *     returned true.
       * @param givenUp optional, a function to be executed when time ran out.
       * @param progress optional, a function to be executed any time the
       *     condition is checked.
       */
      that.waitForAll = function(conditions, success, givenUp, progress) {
        ....
      };

      /**
       * Convenience function to wait for just one condition.
       */
      that.waitFor = function(condition, success, givenUp, progress) {
        that.waitForAll([ condition ], success, progress, givenUp);
      };
      
      return that;
    };

And here is the (really important!) example usage.

  var idToFind = "idToFind";

  waitFactory(4000).waitFor( // wait 4 seconds
    function() { return document.getElementById(idToFind) !== undefined; },
    function() { alert("The element "+idToFind+" exists!"); }
    function() { alert("Sorry, the element "+idToFind+" does not exists!"); }
    function() { return document.getElementById("cancelBox").checked; }
  );

This will wait 4 seconds for the existence of element with id "idToFind". In case the element is already present, or appears within the given amount of time, an alert-dialog will pop up and tell about it. Else another alert will appear after 4 seconds and tell that waiting time elapsed unsuccessfully. When the user clicks at the checkbox with id "cancelBox", the wait loop will end at the next evaluation attempt.

Implementation

There are some things I want to have done properly:

  • default value definitions must be once-and-only-once
  • assert that
    • required parameters are really given, and their values are in the allowed range, throw an exception when not
    • a started waitInstance is not started again before its time expired
  • avoid code duplication: the processing of a single condition must forward to the processing of several conditions

Generally I try to avoid instance variables, I prefer parameters wherever possible. The more instance vars you have, the more vulnerable your object is to state conflicts. With parameters nothing can go wrong, you have the things that you need at the place where it is needed. This is also nice for refactoring.

Default Value Definitions

Defaults must be assigned before assertions check parameter values.

    var waitFactory = function(maximumWaitMillis, delayMillis, allMustBeTrueAtSameTime)
    {
      if (delayMillis === undefined || delayMillis <= 0)
        delayMillis = 100;

      if (maximumWaitMillis === undefined)
        maximumWaitMillis = 4000;
        
      ....

Here time defaults are defined when parameters were not given. The default for allMustBeTrueAtSameTime is false, but this needs not to be defined here as long as you do not compare the parameter directly to true or false: if ( ! allMustBeTrueAtSameTime ). This will be true when allMustBeTrueAtSameTime is either false or undefined.

I let pass through maximumWaitMillis === 0 or <= 0, this will permit to wait "forever".

Mind that I do not use the maximumWaitMillis = maximumWaitMillis || 4000; pattern, because this would also strike when maximumWaitMillis is zero!

Parameter Assertions

Assertions should reject erroneous calls as early as possible, with an adequate message. If you implement functions that do not check their own preconditions, you implement incalculable risks.

    var waitFactory = function(maximumWaitMillis, delayMillis, allMustBeTrueAtSameTime)
    {
      .... // default definitions

      if (delayMillis >= maximumWaitMillis)
        throw "Maximum wait time ("+maximumWaitMillis+") must be greater than evaluation delay ("+delayMillis+")!";
        
      ....

A delayMillis evaluation interval that is greater than the maximumWaitMillis time would prevent success in any case, this makes no sense.

      that.waitForAll = function(conditions, success, givenUp, progress) {
        if ( ! conditions || ! conditions.length || ! success )
          throw "Expected conditions and success function!";
          
        for (var i = 0; i < conditions.length; i++)
          if ( ! conditions[i] )
            throw "Expected condition at index "+i;
        
        ....

Undefined functions in the conditions array do not make sense, so it is checked here. Mind that this does not call the success function, it just tests whether the function pointer is undefined.
The givenUp and progress functions are optional, thus they are not checked.

Conditions Aggregation

Now I want to wrap the (possibly several) condition functions into one function that tests them all in two different ways:

  1. either all of them must have been true at least once before wait time expires
  2. or all of them must be true at the same evaluation attempt.
      var conditionChecks;

      that.waitForAll = function(conditions, success, givenUp, progress) {
        .... // parameter assertions
        
        var condition = function() {
          var result = true;
          for (var i = 0; i < conditions.length; i++) {
            var condition = conditions[i];
            if (conditionChecks) { // when caller defined an array, use it to buffer results
              if ( ! conditionChecks[i] && ! (conditionChecks[i] = condition()) )
                result = false;
            }
            else if ( ! condition() ) {
              return false;
            }
          }
          return result;
        };

        startToWait(condition, success, givenUp, progress);
      };

The conditions parameter is an array containing all condition functions. The conditionChecks variable will be initialized to an array holding returned booleans when allMustBeTrueAtSameTime is true, or undefined when not.

Now look at the local condition() function:

  1. The first variant is achieved when the conditionChecks array has been defined (is not undefined).
  2. The second variant will take place when it is undefined, because then the loop is broken by the return statement whenever one of the conditions is false.

Mind that the local condition() function is not executed here, it is passed to startToWait() for later usage.

Why is the condition() function a local inner function? Because that way I don't need to store the conditions parameter into an instance-variable that.conditions. As I said, I prefer parameters.

This aggregation works for a single condition as well as for several of them.

Start to Wait

      var working;

      var startToWait = function(condition, success, givenUp, progress) {
        if (working)
          throw "Can not wait for other conditions while working!";
          
        setWorking(true);
        wait(condition, success, givenUp, progress, undefined);
      };
    
      var setWorking = function(work) {
        working = work;
        
        if ( ! allMustBeTrueAtSameTime )
          conditionChecks = [];
      };

Here comes the private part of this JS module. The startToWait() function will not be visible outside, nevertheless it is inseparably connected to all variables and functions within the waitFactory function, and they all together will survive the call of their factory as a closure, being accessible via the that object. This is the JS encapsulation mechanism.

The startToWait() function asserts that the object is idle and not in a timeout loop. Then it sets the new state and delegates to wait() which does the real work.

The setWorking() function encapsulates the state. Any function that wants to set the working variable should call this function. It initializes the conditionChecks array to a value according to the top-level parameter allMustBeTrueAtSameTime. When this is true, it will always be undefined, else it will be set to a new array.

Wait

This is the final part that concentrates on setTimeout() calls.

      var wait = function(condition, success, givenUp, progress, alreadyDelayedMillis) {
        if (alreadyDelayedMillis === undefined)
          alreadyDelayedMillis = 0;
        
        if (progress)
          if ( ! progress(alreadyDelayedMillis, maximumWaitMillis) )
            return;
              
        if (maximumWaitMillis > 0 && alreadyDelayedMillis >= maximumWaitMillis) {
          if (givenUp)
            givenUp(maximumWaitMillis);
          setWorking(false);
        }
        else if (condition()) {
          success(alreadyDelayedMillis);
          setWorking(false);
        }
        else {
          setTimeout(
            function() {
              wait(condition, success, givenUp, progress, (maximumWaitMillis > 0) ? alreadyDelayedMillis + delayMillis : 0);
            },
            delayMillis
          );
        }
      };

Look at the alreadyDelayedMillis parameter. This will contain the already elapsed milliseconds. When you go back to the startToWait() implementation, you see that I call the wait() function with undefined as last parameter, which is exactly alreadyDelayedMillis. All further recursive calls of wait() will pass the parameter correctly. The parameter is checked at the start of the function, and initialized to zero when it is undefined. Thus it will count from zero to given maximum time. This again is to avoid an instance variable.

Everything else is "as expected".
First the progress() function is called when it was given. Then the maximum time is checked, and givenUp() is called when exceeded, and the working state is reset to idle. Else the condition() aggregate is called, and success() is executed when it returned true, inclusive resetting working state to idle. In any other case the predefined JS function setTimeout() is used to schedule another call to wait(), this time with increased alreadyDelayedMillis.

Test

You can find the full source at bottom of this page. Next is a manual test page that lets try out all waitFactory() parameters. Especially the allMustBeTrueAtSameTime parameter should be tested.

As soon as Start was pressed, a script regularly checks whether the elements "Booh" and "Waah" exist in the document. When yes, the text log below will be green, when not and time runs out, it will be red. You can create and delete the elements by pressing the according buttons.


Maximum wait: Check interval: ms

All conditions must be true at same time


Elapsed: .... of .... ms


Full Source

You can always go to my homepage to visit the current state of this project.

Click here to see source code.




Keine Kommentare: