I have a web page where different parts of it all need the same back-end data. Each is isolated, so they each end up eventually making the same calls to the back-end.
What is the best way to avoid making a call to the web server when one is already in progress and initiated by a different piece of code on the same web page?
Here's an example. I'll use setTimeout to simulate an asynchronous call.
Let's assume there's an async function that returns the list of contacts, which is basically a simple array of strings in this example:
var getContacts = function() {
log('Calling back-end to get contact list.');
return new Promise(function(resolve, reject) {
setTimeout(function() {
log('New data received from back-end.');
resolve(["Mary","Frank","Klaus"]);
}, 3000);
});
};
Now, let's create three separate functions that each call the above function for different purposes.
Dump out the list of contacts:
var dumpContacts = function() {
getContacts().then(function(contacts) {
for( var i = 0; i < contacts.length; i++ ) {
log( "Contact " + (i + 1) + ": " + contacts[i] );
}
});
};
Determine if a particular contact is in the list:
var contactExists = function(contactName) {
return getContacts().then(function(contacts) {
return contacts.indexOf(contactName) >= 0 ? true : false;
});
};
Get the name of the first contact:
var getFirstContact = function() {
return getContacts().then(function(contacts) {
if ( contacts.length > 0 ) {
return contacts[0];
}
});
};
And here is some example code to use these three functions:
// Show all contacts
dumpContacts();
// Does contact 'Jane' exist?
contactExists("Jane").then(function(exists){
log("Contact 'Jane' exist: " + exists);
});
getFirstContact().then(function(firstContact){
log("first contact: " + firstContact);
});
The above routines make use of a global log() function. console.log() could be used instead. The above log() function log's to the browser window and is implemented as follows:
function log() {
var args = Array.prototype.slice.call(arguments).join(", ");
console.log(args);
var output = document.getElementById('output');
output.innerHTML += args + "<br/>";
}
and requires the following in the html:
<div id='output'><br/></div>
When the above code is run, you will see:
Calling back-end to get contact list.
and
New data received from back-end.
three times, which is unnecessary.
How can this be fixed?
This sample is on Plunker can be executed:
http://plnkr.co/edit/6ysbNTf1lSf5b7L3sJxQ?p=preview
Just cache the result in the function making the call:
function cache(promiseReturningFn){
var cachedVal = null; // start without cached value
function cached(){
if(cachedVal) return cachedVal; // prefer cached result
cachedVal = promiseReturningFn.apply(this, arguments); // delegate
return cachedVal; // after we saved it, return it
}
cached.flush = function(){ cachedVal = undefined; };
return cached;
}
This has the caveat of failing for actual results that are null but otherwise it gets the job done nicely.
You can now cache any promise returning function - the version above only caches ignoring arguments - but you can construct a similar one that has a Map and caches based on different arguments too - but let's focus on your use case.
var getContactsCached = cache(getContacts);
getContactsCached();
getContactsCached();
getContactsCached(); // only one async call ever made
The cache method is actually not even related to promises - all it does is take a function and cache its result - you can use it for anything. In fact if you're using a library like underscore you can use _.memoize
to do it for you already.
If the desire is to reduce the number of unnecessary calls to the back-end, then hang on to the promise and while it's still unresolved, return it it for new calls rather than issuing another call to the back-end.
Here's a routine that converts an async function, one that returns a promise, into one that's only called while the promise is still unresolved.
var makeThrottleFunction = function (asyncFunction) {
var currentPromiser = getPromise = function() {
var promise = new Promise(function(resolve, reject) {
asyncFunction().then(function(value) {
resolve(value);
currentPromiser = getPromise;
}).catch(function(e) {
reject(e);
currentPromiser = getPromise;
});
});
currentPromiser = function() {
return promise;
};
return promise;
}
return function () {
return currentPromiser();
};
};
In your routine, you can convert getContacts
like so:
var getContacts = makeThrottleFunction(getContacts);
Or pass the entire body of the function directly.
Keep in mind that this will only work for parameterless calls to the back-end.
Example plunker code: http://plnkr.co/edit/4JTtHmFTZmiHugWNnlo9?p=preview
Edit, Updated
Removed "nested" ternary
pattern; added
- a)
dfd.err()
, .catch()
to handle Promise.reject(/* reason ? */)
arguments
passed to dfd.fn()
;
- b)
args === ""
within dfd.process()
to handle ""
: empty String
passed as argument
to dfd.fn()
- c) substituted "chaining"
.then()
calls for then.apply(dfd.promise, [contactExists, getFirstContact])
Native Error()
passed as argument
:dfd.fn(new Error("error"))
handled at global
scope; dfd.fn()
still returns dfd.promise
. Could possibly adjust before or at dfd.process()
, to return "early" at Error
or pass Error
to dfd.err()
; depending on requirement. Not addressed at js
below.
Try
var dfd = {
// set `active` : `false`
"active": false,
// set `promise`: `undefined`
"promise": void 0,
// process `arguments`, if any, passed to `dfd.fn`
"process": function process(args) {
// return `Function` call, `arguments`,
// or "current" `dfd.promise`;
// were `args`:`arguments` passed ?
// handle `""` empty `String` passed as `args`
return args === "" || !!args
// if `args`:`Function`, call `args` with `this`:`dfd`,
// or, set `args` as `value`, `reason`
// of "next" `dfd.promise`
// return "next" `dfd.promise`
? args instanceof Function && args.call(this) || args
// set `dfd.active`:`false`
// when "current" `dfd.promise`:`Promise` `fulfilled`,
// return "current" `dfd.promise`
: this.active = true && this.promise
},
// handle `fulfilled` `Promise.reject(/* `reason` ? */)`,
// passed as `args` to `dfd.fn`
"err": function err(e) {
// notify , log `reason`:`Promise.reject(/* `reason` ? */)`, if any,
// or, log `undefined` , if no `reason` passed: `Promise.reject()`
console.log("rejected `Promise` reason:", e || void 0);
},
// do stuff
"fn": function fn(args /* , do other stuff */) {
// set `_dfd` : `this` : `dfd` object
var _dfd = this;
// if "current" `dfd.promise`:`Promise` processing,
// wait for `fulfilled` `dfd.promise`;
// return `dfd.promise`
_dfd.promise = !_dfd.active
// set, reset `dfd.promise`
// process call to `dfd.async`;
// `args`:`arguments` passed to `dfd.fn` ?,
// if `args` passed, are `args` `function` ?,
// if `args` `function`, call `args` with
// `this`:`dfd`;
// or, return `args`
? _dfd.process(args)
// if `_dfd.active`, `_dfd.promise` defined,
// return "current" `_dfd.promise`
: _dfd.promise.then(function(deferred) {
// `deferred`:`_dfd.promise`
// do stuff with `deferred`,
// do other stuff,
// return "current", "next" `deferred`
return deferred
})
// handle `args`:`fulfilled`,
// `Promise.reject(/* `reason` ? */)`
.catch(_dfd.err);
return Promise.resolve(_dfd.promise).then(function(data) {
// `data`:`undefined`, `_dfd.promise`
// set `_dfd.active`:`false`,
// return `value` of "current", "next" `_dfd.promise`
_dfd.active = false;
return data
})
// handle `fulfilled` `Promise.reject(/* `reason` ? */),
// if reaches here ?
.catch(_dfd.err)
}
};
function log() {
var args = Array.prototype.slice.call(arguments).join(", ");
console.log(args);
var output = document.getElementById('output');
output.innerHTML += args + "<br/>";
};
var dumpContacts = function () {
log('Calling back-end to get contact list.');
return new Promise(function (resolve, reject) {
setTimeout(function () {
log('New data received from back-end.');
resolve(["Mary", "Frank", "Klaus"]);
}, 3000);
});
};
var getContacts = function () {
return dfd.async().then(function (contacts) {
for (var i = 0; i < contacts.length; i++) {
log("Contact " + (i + 1) + ": " + contacts[i]);
}
});
};
var contactExists = function (contactName) {
return dfd.async().then(function (contacts) {
return contacts.indexOf(contactName) >= 0 ? true : false;
});
};
var getFirstContact = function () {
return dfd.async().then(function (contacts) {
if (contacts.length > 0) {
return contacts[0];
}
return contacts
});
};
// Test:
// Show all contacts
dfd.async(dumpContacts)
.then(getContacts)
.then.apply(dfd.promise, [
// Does contact 'Jane' exist?
contactExists("Jane").then(function (exists) {
log("Contact 'Jane' exist: " + exists);
})
, getFirstContact().then(function (firstContact) {
log("first contact: " + firstContact);
})
]);
function log() {
var args = Array.prototype.slice.call(arguments).join(", ");
console.log(args);
var output = document.getElementById('output');
output.innerHTML += args + "<br/>";
return output
};
var dfd = {
"active": false,
"promise": void 0,
"process": function process(args) {
return args === "" || !!args
? args instanceof Function && args.call(this) || args
: this.active = true && this.promise
},
"err": function err(e) {
console.log("rejected `Promise` reason:", e || void 0);
},
"fn": function fn(args) {
var _dfd = this;
_dfd.promise = !_dfd.active
? _dfd.process(args)
: _dfd.promise.then(function(deferred) {
return deferred
})
.catch(_dfd.err);
return Promise.resolve(_dfd.promise).then(function(data) {
_dfd.active = false;
return data
})
.catch(_dfd.err)
}
};
var dumpContacts = function() {
log('Calling back-end to get contact list.');
return new Promise(function(resolve, reject) {
setTimeout(function() {
log('New data received from back-end.');
resolve(["Mary", "Frank", "Klaus"]);
}, 3000);
});
};
var getContacts = function() {
return dfd.fn().then(function(contacts) {
for (var i = 0; i < contacts.length; i++) {
log("Contact " + (i + 1) + ": " + contacts[i]);
}
});
};
var contactExists = function(contactName) {
return dfd.fn().then(function(contacts) {
return contacts.indexOf(contactName) >= 0 ? true : false;
});
};
var getFirstContact = function() {
return dfd.fn().then(function(contacts) {
if (contacts.length > 0) {
return contacts[0];
}
return contacts
});
};
// Test:
// Show all contacts
dfd.fn(dumpContacts)
.then(getContacts)
.then(function() {
// Does contact 'Jane' exist?
return contactExists("Jane").then(function(exists) {
log("Contact 'Jane' exist: " + exists);
})
})
.then(function() {
return getFirstContact().then(function(firstContact) {
log("first contact: " + firstContact);
})
});
<body>
Must use browser that supportes the Promises API, such as Chrome
<div id='output'>
<br/>
</div>
<hr>
</body>