$location service should utilize onhashchange events instead of polling

This commit is contained in:
Igor Minar 2011-01-04 17:54:37 -08:00
parent c0a26b1853
commit 16086aa37c
9 changed files with 187 additions and 49 deletions

View file

@ -3,6 +3,8 @@
### Performance ### Performance
- $location and $cookies services are now lazily initialized to avoid the polling overhead when - $location and $cookies services are now lazily initialized to avoid the polling overhead when
not needed. not needed.
- $location service now listens for `onhashchange` events (if supported by browser) instead of
constant polling.
### Breaking changes ### Breaking changes
- API for accessing registered services — `scope.$inject` — was renamed to - API for accessing registered services — `scope.$inject` — was renamed to

View file

@ -271,7 +271,7 @@ function jqLiteWrap(element) {
var div = document.createElement('div'); var div = document.createElement('div');
div.innerHTML = element; div.innerHTML = element;
element = new JQLite(div.childNodes); element = new JQLite(div.childNodes);
} else if (!(element instanceof JQLite) && isElement(element)) { } else if (!(element instanceof JQLite)) {
element = new JQLite(element); element = new JQLite(element);
} }
} }

View file

@ -10,13 +10,8 @@ var browserSingleton;
*/ */
angularService('$browser', function($log){ angularService('$browser', function($log){
if (!browserSingleton) { if (!browserSingleton) {
browserSingleton = new Browser( browserSingleton = new Browser(window, jqLite(window.document), jqLite(window.document.body),
window.location, XHR, $log);
jqLite(window.document),
jqLite(window.document.getElementsByTagName('head')[0]),
XHR,
$log,
window.setTimeout);
var addPollFn = browserSingleton.addPollFn; var addPollFn = browserSingleton.addPollFn;
browserSingleton.addPollFn = function(){ browserSingleton.addPollFn = function(){
browserSingleton.addPollFn = addPollFn; browserSingleton.addPollFn = addPollFn;

View file

@ -8,8 +8,29 @@ var XHR = window.XMLHttpRequest || function () {
throw new Error("This browser does not support XMLHttpRequest."); throw new Error("This browser does not support XMLHttpRequest.");
}; };
function Browser(location, document, head, XHR, $log, setTimeout) { /**
var self = this; * @private
* @name Browser
*
* @description
* Constructor for the object exposed as $browser service.
*
* This object has two goals:
*
* - hide all the global state in the browser caused by the window object
* - abstract away all the browser specific features and inconsistencies
*
* @param {object} window The global window object.
* @param {object} document jQuery wrapped document.
* @param {object} body jQuery wrapped document.body.
* @param {function()} XHR XMLHttpRequest constructor.
* @param {object} $log console.log or an object with the same interface.
*/
function Browser(window, document, body, XHR, $log) {
var self = this,
location = window.location,
setTimeout = window.setTimeout;
self.isMock = false; self.isMock = false;
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
@ -70,7 +91,7 @@ function Browser(location, document, head, XHR, $log, setTimeout) {
window[callbackId] = _undefined; window[callbackId] = _undefined;
callback(200, data); callback(200, data);
}; };
head.append(script); body.append(script);
} else { } else {
var xhr = new XHR(); var xhr = new XHR();
xhr.open(method, url, true); xhr.open(method, url, true);
@ -195,6 +216,39 @@ function Browser(location, document, head, XHR, $log, setTimeout) {
return location.href; return location.href;
}; };
/**
* @workInProgress
* @ngdoc method
* @name angular.service.$browser#onHashChange
* @methodOf angular.service.$browser
*
* @description
* Detects if browser support onhashchange events and register a listener otherwise registers
* $browser poller. The `listener` will then get called when the hash changes.
*
* The listener gets called with either HashChangeEvent object or simple object that also contains
* `oldURL` and `newURL` properties.
*
* NOTE: this is a api is intended for sole use by $location service. Please use
* {@link angular.service.$location $location service} to monitor hash changes in angular apps.
*
* @param {function(event)} listener Listener function to be called when url hash changes.
*/
self.onHashChange = function(listener) {
if ('onhashchange' in window) {
jqLite(window).bind('hashchange', listener);
} else {
var lastBrowserUrl = self.getUrl();
self.addPollFn(function() {
if (lastBrowserUrl != self.getUrl()) {
listener();
}
});
}
}
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
// Cookies API // Cookies API
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
@ -338,7 +392,7 @@ function Browser(location, document, head, XHR, $log, setTimeout) {
link.attr('rel', 'stylesheet'); link.attr('rel', 'stylesheet');
link.attr('type', 'text/css'); link.attr('type', 'text/css');
link.attr('href', url); link.attr('href', url);
head.append(link); body.append(link);
}; };
@ -359,6 +413,6 @@ function Browser(location, document, head, XHR, $log, setTimeout) {
script.attr('type', 'text/javascript'); script.attr('type', 'text/javascript');
script.attr('src', url); script.attr('src', url);
if (dom_id) script.attr('id', dom_id); if (dom_id) script.attr('id', dom_id);
head.append(script); body.append(script);
}; };
} }

View file

@ -47,14 +47,14 @@ function getStyle(element) {
} }
function JQLite(element) { function JQLite(element) {
if (isElement(element)) { if (!isElement(element) && isDefined(element.length) && element.item) {
this[0] = element;
this.length = 1;
} else if (isDefined(element.length) && element.item) {
for(var i=0; i < element.length; i++) { for(var i=0; i < element.length; i++) {
this[i] = element[i]; this[i] = element[i];
} }
this.length = element.length; this.length = element.length;
} else {
this[0] = element;
this.length = 1;
} }
} }
@ -81,7 +81,7 @@ JQLite.prototype = {
dealoc: function(){ dealoc: function(){
(function dealoc(element){ (function dealoc(element){
jqClearData(element); jqClearData(element);
for ( var i = 0, children = element.childNodes; i < children.length; i++) { for ( var i = 0, children = element.childNodes || []; i < children.length; i++) {
dealoc(children[i]); dealoc(children[i]);
} }
})(this[0]); })(this[0]);

View file

@ -68,19 +68,17 @@ angularServiceInject("$document", function(window){
<input type='text' name="$location.hash"/> <input type='text' name="$location.hash"/>
<pre>$location = {{$location}}</pre> <pre>$location = {{$location}}</pre>
*/ */
angularServiceInject("$location", function(browser) { angularServiceInject("$location", function($browser) {
var scope = this, var scope = this,
location = {toString:toString, update:update, updateHash: updateHash}, location = {toString:toString, update:update, updateHash: updateHash},
lastBrowserUrl = browser.getUrl(), lastBrowserUrl = $browser.getUrl(),
lastLocationHref, lastLocationHref,
lastLocationHash; lastLocationHash;
browser.addPollFn(function() { $browser.onHashChange(function() {
if (lastBrowserUrl != browser.getUrl()) { update(lastBrowserUrl = $browser.getUrl());
update(lastBrowserUrl = browser.getUrl()); updateLastLocation();
updateLastLocation(); scope.$eval();
scope.$eval();
}
}); });
this.$onEval(PRIORITY_FIRST, updateBrowser); this.$onEval(PRIORITY_FIRST, updateBrowser);
@ -219,7 +217,7 @@ angularServiceInject("$location", function(browser) {
updateLocation(); updateLocation();
if (location.href != lastLocationHref) { if (location.href != lastLocationHref) {
browser.setUrl(lastBrowserUrl = location.href); $browser.setUrl(lastBrowserUrl = location.href);
updateLastLocation(); updateLastLocation();
} }
} }

View file

@ -1,6 +1,6 @@
describe('browser', function(){ describe('browser', function(){
var browser, location, head, xhr, setTimeoutQueue; var browser, fakeWindow, xhr, logs, scripts, setTimeoutQueue;
function fakeSetTimeout(fn) { function fakeSetTimeout(fn) {
setTimeoutQueue.push(fn); setTimeoutQueue.push(fn);
@ -15,19 +15,31 @@ describe('browser', function(){
beforeEach(function(){ beforeEach(function(){
setTimeoutQueue = []; setTimeoutQueue = [];
scripts = [];
location = {href:"http://server", hash:""};
head = {
scripts: [],
append: function(node){head.scripts.push(node);}
};
xhr = null; xhr = null;
browser = new Browser(location, jqLite(window.document), head, function(){ fakeWindow = {
location: {href:"http://server"},
setTimeout: fakeSetTimeout
}
var fakeBody = {append: function(node){scripts.push(node)}};
var fakeXhr = function(){
xhr = this; xhr = this;
this.open = noop; this.open = noop;
this.setRequestHeader = noop; this.setRequestHeader = noop;
this.send = noop; this.send = noop;
}, undefined, fakeSetTimeout); }
logs = {log:[], warn:[], info:[], error:[]};
var fakeLog = {log: function() { logs.log.push(slice.call(arguments)); },
warn: function() { logs.warn.push(slice.call(arguments)); },
info: function() { logs.info.push(slice.call(arguments)); },
error: function() { logs.error.push(slice.call(arguments)); }};
browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, fakeXhr,
fakeLog);
}); });
it('should contain cookie cruncher', function() { it('should contain cookie cruncher', function() {
@ -60,13 +72,13 @@ describe('browser', function(){
browser.xhr('JSON', 'http://example.org/path?cb=JSON_CALLBACK', function(code, data){ browser.xhr('JSON', 'http://example.org/path?cb=JSON_CALLBACK', function(code, data){
log += code + ':' + data + ';'; log += code + ':' + data + ';';
}); });
expect(head.scripts.length).toEqual(1); expect(scripts.length).toEqual(1);
var url = head.scripts[0].src.split('?cb='); var url = scripts[0].src.split('?cb=');
expect(url[0]).toEqual('http://example.org/path'); expect(url[0]).toEqual('http://example.org/path');
expect(typeof window[url[1]]).toEqual($function); expect(typeof fakeWindow[url[1]]).toEqual($function);
window[url[1]]('data'); fakeWindow[url[1]]('data');
expect(log).toEqual('200:data;'); expect(log).toEqual('200:data;');
expect(typeof window[url[1]]).toEqual('undefined'); expect(typeof fakeWindow[url[1]]).toEqual('undefined');
}); });
}); });
}); });
@ -107,16 +119,8 @@ describe('browser', function(){
} }
} }
var browser, log, logs;
beforeEach(function() { beforeEach(function() {
deleteAllCookies(); deleteAllCookies();
logs = {log:[], warn:[], info:[], error:[]};
log = {log: function() { logs.log.push(slice.call(arguments)); },
warn: function() { logs.warn.push(slice.call(arguments)); },
info: function() { logs.info.push(slice.call(arguments)); },
error: function() { logs.error.push(slice.call(arguments)); }};
browser = new Browser({}, jqLite(document), undefined, XHR, log);
expect(document.cookie).toEqual(''); expect(document.cookie).toEqual('');
}); });
@ -334,4 +338,62 @@ describe('browser', function(){
expect(returnedFn).toBe(fn); expect(returnedFn).toBe(fn);
}); });
}); });
describe('url api', function() {
it('should use $browser poller to detect url changes when onhashchange event is unsupported',
function() {
fakeWindow = {location: {href:"http://server"}};
browser = new Browser(fakeWindow, {}, {});
var events = [];
browser.onHashChange(function() {
events.push('x');
});
fakeWindow.location.href = "http://server/#newHash";
expect(events).toEqual([]);
browser.poll();
expect(events).toEqual(['x']);
});
it('should use onhashchange events to detect url changes when supported by browser',
function() {
var onHashChngListener;
fakeWindow = {location: {href:"http://server"},
addEventListener: function(type, listener) {
expect(type).toEqual('hashchange');
onHashChngListener = listener;
},
removeEventListener: angular.noop
};
fakeWindow.onhashchange = true;
browser = new Browser(fakeWindow, {}, {});
var events = [],
event = {type: "hashchange"}
browser.onHashChange(function(e) {
events.push(e);
});
expect(events).toEqual([]);
onHashChngListener(event);
expect(events.length).toBe(1);
expect(events[0].originalEvent || events[0]).toBe(event); // please jQuery and jqLite
// clean up the jqLite cache so that the global afterEach doesn't complain
if (!jQuery) {
jqLite(fakeWindow).dealoc();
}
});
});
}); });

15
test/angular-mocks.js vendored
View file

@ -63,8 +63,23 @@ function MockBrowser() {
this.isMock = true; this.isMock = true;
self.url = "http://server"; self.url = "http://server";
self.lastUrl = self.url; // used by url polling fn
self.pollFns = []; self.pollFns = [];
// register url polling fn
self.onHashChange = function(listener) {
self.pollFns.push(
function() {
if (self.lastUrl != self.url) {
listener();
}
}
);
};
self.xhr = function(method, url, data, callback) { self.xhr = function(method, url, data, callback) {
if (angular.isFunction(data)) { if (angular.isFunction(data)) {
callback = data; callback = data;

View file

@ -128,6 +128,18 @@ describe("service", function(){
expect($location.hashSearch).toEqual({key: 'value', flag: true, key2: ''}); expect($location.hashSearch).toEqual({key: 'value', flag: true, key2: ''});
}); });
it('should update location when browser url changed', function() {
var origUrl = $location.href;
expect(origUrl).toEqual($browser.getUrl());
var newUrl = 'http://somenew/url#foo';
$browser.setUrl(newUrl);
$browser.poll();
expect($location.href).toEqual(newUrl);
});
it('toString() should return actual representation', function() { it('toString() should return actual representation', function() {
var href = 'http://host:123/p/a/t/h.html?query=value#path?key=value&flag&key2='; var href = 'http://host:123/p/a/t/h.html?query=value#path?key=value&flag&key2=';
$location.update(href); $location.update(href);