change to keydown from keyup; add delayed $updateView

- There was a perceived lag when typing do to the fact that we were
   listening on the keyup event instead of keydown. The issue with
   keydown is that we can not read the value of the input field. To
   solve this we schedule a defer call and perform the model update
   then.

 - To prevent calling $eval on root scope too many times as well as to
   prevent drowning the browser with too many updates we now call the
   $eval only after 25ms and any additional requests get ignored. The
   new update service is called $updateView
This commit is contained in:
Misko Hevery 2010-12-10 13:55:18 -08:00 committed by Igor Minar
parent 16086aa37c
commit 47c454a315
18 changed files with 204 additions and 51 deletions

3
.gitignore vendored
View file

@ -2,5 +2,6 @@ build/
angularjs.netrc
jstd.log
.DS_Store
regression/temp.html
regression/temp*.html
performance/temp*.html
.idea/workspace.xml

View file

@ -5,6 +5,13 @@
not needed.
- $location service now listens for `onhashchange` events (if supported by browser) instead of
constant polling.
- input widgets known listens on keydown events instead of keyup which improves perceived
performance
### API
- new service $updateView which should be used in favor of $root.$eval() to run a complete eval on
the entire document. This service bulks and throttles DOM updates to improve performance.
### Breaking changes
- API for accessing registered services — `scope.$inject` — was renamed to

View file

@ -244,7 +244,7 @@ var TAG = {
name: function(doc, name, value) {
var parts = value.split(/\./);
doc.name = value;
doc.shortName = parts.pop();
doc.shortName = parts.pop().replace('#', '.');
doc.depth = parts.length;
},
param: function(doc, name, value){
@ -378,6 +378,7 @@ function processNgDoc(documentation, doc) {
if (doc.methodOf) {
if (parent = documentation.byName[doc.methodOf]) {
(parent.method = parent.method || []).push(doc);
parent.method.sort(keywordSort);
} else {
throw 'Owner "' + doc.methodOf + '" is not defined.';
}

View file

@ -1,5 +1,3 @@
SyntaxHighlighter['defaults'].toolbar = false;
DocsController.$inject = ['$location', '$browser', '$window'];
function DocsController($location, $browser, $window) {
this.pages = NG_PAGES;
@ -38,10 +36,12 @@ function DocsController($location, $browser, $window) {
return "mailto:angular@googlegroups.com?" +
"subject=" + escape("Feedback on " + $location.href) + "&" +
"body=" + escape("Hi there,\n\nI read " + $location.href + " and wanted to ask ....");
}
};
}
angular.filter('short', function(name){
return (name||'').split(/\./).pop();
});
});
SyntaxHighlighter['defaults'].toolbar = false;

View file

@ -25,19 +25,23 @@
{{/requires}}
</ul>
{{#method.length}}
<h2>Methods</h2>
<ul>
{{#method}}
<li><tt>{{shortName}}</tt>: {{{description}}}</li>
<li><tt>{{shortName}}()</tt>: {{{description}}}</li>
{{/method}}
</ul>
{{/method.length}}
{{#property.length}}
<h2>Properties</h2>
<ul>
{{#property}}
<li><tt>{{name}}:{{#type}}{{type}}{{/type}}</tt>{{#description}}: {{{description}}}{{/description}}</li>
{{/property}}
</ul>
{{/property.length}}
{{#example}}
<h2>Example</h2>

19
perf/noangular.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html xmlns:ng="http://angularjs.org">
<head>
<script>
function el(id) {
return document.getElementById(id);
}
function update() {
el("output").innerHTML = el("input").value;
}
</script>
</head>
<body>
Your name: <input id="input" type="text" value="World"
onkeydown="setTimeout(update,0)"/>
<hr/>
Hello <span id="output">{{yourname}}</span>!
</body>
</html>

View file

@ -136,9 +136,7 @@ function Browser(window, document, body, XHR, $log) {
* @methodOf angular.service.$browser
*/
self.poll = function() {
foreach(pollFns, function(pollFn){
pollFn();
});
foreach(pollFns, function(pollFn){ pollFn(); });
};
/**
@ -319,22 +317,23 @@ function Browser(window, document, body, XHR, $log) {
/**
* @workInProgress
* @ngdoc
* @ngdoc method
* @name angular.service.$browser#defer
* @methodOf angular.service.$browser
* @param {function()} fn A function, who's execution should be defered.
* @param {int=} [delay=0] of milliseconds to defer the function execution.
*
* @description
* Executes a fn asynchroniously via `setTimeout(fn, 0)`.
* Executes a fn asynchroniously via `setTimeout(fn, delay)`.
*
* Unlike when calling `setTimeout` directly, in test this function is mocked and instead of using
* `setTimeout` in tests, the fns are queued in an array, which can be programaticaly flushed via
* `$browser.defer.flush()`.
*
* @param {function()} fn A function, who's execution should be defered.
*/
self.defer = function(fn) {
self.defer = function(fn, delay) {
outstandingRequestCount++;
setTimeout(function() { completeOutstandingRequest(fn); }, 0);
setTimeout(function() { completeOutstandingRequest(fn); }, delay || 0);
};
//////////////////////////////////////////////////////////////

View file

@ -74,6 +74,7 @@ Template.prototype = {
*/
function retrieveScope(element) {
var scope;
element = jqLite(element);
while (element && !(scope = element.data($$scope))) {
element = element.parent();
}

View file

@ -67,4 +67,12 @@ function createInjector(providerScope, providers, cache) {
}
return returnValue;
};
}
}
function injectService(services, fn) {
return extend(fn, {$inject:services});;
}
function injectUpdateView(fn) {
return injectService(['$updateView'], fn);
}

View file

@ -423,14 +423,14 @@ angularDirective("ng:bind-attr", function(expression){
* TODO: maybe we should consider allowing users to control event propagation in the future.
*/
angularDirective("ng:click", function(expression, element){
return function(element){
return injectUpdateView(function($updateView, element){
var self = this;
element.bind('click', function(event){
self.$tryEval(expression, element);
self.$root.$eval();
$updateView();
event.stopPropagation();
});
};
});
});
@ -471,14 +471,14 @@ angularDirective("ng:click", function(expression, element){
* server and reloading the current page).
*/
angularDirective("ng:submit", function(expression, element) {
return function(element) {
return injectUpdateView(function($updateView, element) {
var self = this;
element.bind('submit', function(event) {
self.$tryEval(expression, element);
self.$root.$eval();
$updateView();
event.preventDefault();
});
};
});
});

View file

@ -285,7 +285,7 @@ function browserTrigger(element, type) {
(function(fn){
var parentTrigger = fn.trigger;
fn.trigger = function(type) {
if (/(click|change|keyup)/.test(type)) {
if (/(click|change|keydown)/.test(type)) {
return this.each(function(index, node) {
browserTrigger(node, type);
});

View file

@ -405,6 +405,62 @@ angularServiceInject('$exceptionHandler', function($log){
};
}, ['$log'], EAGER);
/**
* @workInProgress
* @ngdoc service
* @name angular.service.$updateView
* @requires $browser
*
* @description
* Calling `$updateView` enqueues the eventual update of the view. (Update the DOM to reflect the
* model). The update is eventual, since there are often multiple updates to the model which may
* be deferred. The default update delayed is 25 ms. This means that the view lags the model by
* that time. (25ms is small enough that it is perceived as instantaneous by the user). The delay
* can be adjusted by setting the delay property of the service.
*
* <pre>angular.service('$updateView').delay = 10</pre>
*
* The delay is there so that multiple updates to the model which occur sufficiently close
* together can be merged into a single update.
*
* You don't usually call '$updateView' directly since angular does it for you in most cases,
* but there are some cases when you need to call it.
*
* - `$updateView()` called automatically by angular:
* - Your Application Controllers: Your controller code is called by angular and hence
* angular is aware that you may have changed the model.
* - Your Services: Your service is usually called by your controller code, hence same rules
* apply.
* - May need to call `$updateView()` manually:
* - Widgets / Directives: If you listen to any DOM events or events on any third party
* libraries, then angular is not aware that you may have changed state state of the
* model, and hence you need to call '$updateView()' manually.
* - 'setTimeout'/'XHR': If you call 'setTimeout' (instead of {@link angular.service.$defer})
* or 'XHR' (instead of {@link angular.service.$xhr}) then you may be changing the model
* without angular knowledge and you may need to call '$updateView()' directly.
*
* NOTE: if you wish to update the view immediately (without delay), you can do so by calling
* {@link scope.$eval} at any time from your code:
* <pre>scope.$root.$eval()</pre>
*
* In unit-test mode the update is instantaneous and synchronous to simplify writing tests.
*
*/
angularServiceInject('$updateView', extend(function factory($browser){
var rootScope = this;
var scheduled;
function update(){
scheduled = false;
rootScope.$eval();
}
return $browser.isMock ? update : function(){
if (!scheduled) {
scheduled = true;
$browser.defer(update, factory.delay);
}
};
}, {delay:25}), ['$browser']);
/**
* @workInProgress
* @ngdoc service
@ -815,7 +871,7 @@ angularServiceInject('$xhr.bulk', function($xhr, $error, $log){
*
* @param {function()} fn A function, who's execution should be deferred.
*/
angularServiceInject('$defer', function($browser, $exceptionHandler) {
angularServiceInject('$defer', function($browser, $exceptionHandler, $updateView) {
var scope = this;
return function(fn) {
@ -825,11 +881,11 @@ angularServiceInject('$defer', function($browser, $exceptionHandler) {
} catch(e) {
$exceptionHandler(e);
} finally {
scope.$eval();
$updateView();
}
});
};
}, ['$browser', '$exceptionHandler']);
}, ['$browser', '$exceptionHandler', '$updateView']);
/**

View file

@ -398,7 +398,7 @@ extend(angularValidator, {
$invalidWidgets.markValid(element);
}
element.data($$validate)();
scope.$root.$eval();
scope.$service('$updateView')();
});
} else if (inputState.inFlight) {
// request in flight, mark widget invalid, but don't show it to user

View file

@ -376,7 +376,7 @@ function optionsAccessor(scope, element) {
function noopAccessor() { return { get: noop, set: noop }; }
var textWidget = inputWidget('keyup change', modelAccessor, valueAccessor, initWidgetValue(), true),
var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, initWidgetValue(), true),
buttonWidget = inputWidget('click', noopAccessor, noopAccessor, noop),
INPUT_TYPE = {
'text': textWidget,
@ -454,8 +454,8 @@ function radioInit(model, view, element) {
expect(binding('checkboxCount')).toBe('1');
});
*/
function inputWidget(events, modelAccessor, viewAccessor, initFn, dirtyChecking) {
return function(element) {
function inputWidget(events, modelAccessor, viewAccessor, initFn, textBox) {
return injectService(['$updateView', '$defer'], function($updateView, $defer, element) {
var scope = this,
model = modelAccessor(scope, element),
view = viewAccessor(scope, element),
@ -464,25 +464,25 @@ function inputWidget(events, modelAccessor, viewAccessor, initFn, dirtyChecking)
if (model) {
initFn.call(scope, model, view, element);
this.$eval(element.attr('ng:init')||'');
// Don't register a handler if we are a button (noopAccessor) and there is no action
if (action || modelAccessor !== noopAccessor) {
element.bind(events, function (){
element.bind(events, function(event){
function handler(){
var value = view.get();
if (!dirtyChecking || value != lastValue) {
if (!textBox || value != lastValue) {
model.set(value);
lastValue = model.get();
scope.$tryEval(action, element);
scope.$root.$eval();
$updateView();
}
});
}
}
event.type == 'keydown' ? $defer(handler) : handler();
});
scope.$watch(model.get, function(value){
if (lastValue !== value) {
view.set(lastValue = value);
}
});
}
};
});
}
function inputWidgetSelector(element){

View file

@ -248,7 +248,8 @@ describe('Binder', function(){
assertEquals('b', second.val());
first.val('ABC');
browserTrigger(first, 'keyup');
browserTrigger(first, 'keydown');
c.scope.$service('$browser').defer.flush();
assertEquals(c.scope.items[0].x, 'ABC');
});

View file

@ -394,7 +394,7 @@ describe("service", function(){
it('should call eval even if an exception is thrown in callback', function() {
var eval = this.spyOn(scope, '$eval').andCallThrough();
$defer(function() {throw "Test Error"});
$defer(function() {throw "Test Error";});
expect(eval).wasNotCalled();
$browser.defer.flush();
@ -594,7 +594,7 @@ describe("service", function(){
$browser.defer.flush();
expect(eval).wasCalled();
})
});
});
});
@ -777,4 +777,57 @@ describe("service", function(){
expect(match[10]).toEqual('?book=moby');
});
});
describe('$updateView', function(){
var scope, browser, evalCount, $updateView;
beforeEach(function(){
browser = new MockBrowser();
// Pretend that you are real Browser so that we see the delays
browser.isMock = false;
browser.defer = jasmine.createSpy('defer');
scope = angular.scope(null, null, {$browser:browser});
$updateView = scope.$service('$updateView');
scope.$onEval(function(){ evalCount++; });
evalCount = 0;
});
it('should eval root scope after a delay', function(){
$updateView();
expect(evalCount).toEqual(0);
expect(browser.defer).toHaveBeenCalled();
expect(browser.defer.mostRecentCall.args[1]).toEqual(25);
browser.defer.mostRecentCall.args[0]();
expect(evalCount).toEqual(1);
});
it('should allow changing of delay time', function(){
var oldValue = angular.service('$updateView').delay;
angular.service('$updateView').delay = 50;
$updateView();
expect(evalCount).toEqual(0);
expect(browser.defer).toHaveBeenCalled();
expect(browser.defer.mostRecentCall.args[1]).toEqual(50);
angular.service('$updateView').delay = oldValue;
});
it('should ignore multiple requests for update', function(){
$updateView();
$updateView();
expect(evalCount).toEqual(0);
expect(browser.defer).toHaveBeenCalled();
expect(browser.defer.callCount).toEqual(1);
browser.defer.mostRecentCall.args[0]();
expect(evalCount).toEqual(1);
});
it('should update immediatelly in test/mock mode', function(){
scope = angular.scope();
scope.$onEval(function(){ evalCount++; });
expect(evalCount).toEqual(0);
scope.$service('$updateView')();
expect(evalCount).toEqual(1);
});
});
});

View file

@ -1,7 +1,7 @@
/**
* Here is the problem: http://bugs.jquery.com/ticket/7292
* basically jQuery treats change event on some browsers (IE) as a
* special event and changes it form 'change' to 'click/keyup' and
* special event and changes it form 'change' to 'click/keydown' and
* few others. This horrible hack removes the special treatment
*/
_jQuery.event.special.change = undefined;

View file

@ -22,7 +22,7 @@ describe("widget", function(){
describe("input", function(){
describe("text", function(){
it('should input-text auto init and handle keyup/change events', function(){
it('should input-text auto init and handle keydown/change events', function(){
compile('<input type="Text" name="name" value="Misko" ng:change="count = count + 1" ng:init="count=0"/>');
expect(scope.$get('name')).toEqual("Misko");
expect(scope.$get('count')).toEqual(0);
@ -32,7 +32,10 @@ describe("widget", function(){
expect(element.val()).toEqual("Adam");
element.val('Shyam');
browserTrigger(element, 'keyup');
browserTrigger(element, 'keydown');
// keydown event must be deferred
expect(scope.$get('name')).toEqual('Adam');
scope.$service('$browser').defer.flush();
expect(scope.$get('name')).toEqual('Shyam');
expect(scope.$get('count')).toEqual(1);
@ -46,7 +49,7 @@ describe("widget", function(){
compile('<input type="Text" name="name" value="Misko" ng:change="count = count + 1" ng:init="count=0"/>');
expect(scope.name).toEqual("Misko");
expect(scope.count).toEqual(0);
browserTrigger(element, 'keyup');
browserTrigger(element, 'keydown');
expect(scope.name).toEqual("Misko");
expect(scope.count).toEqual(0);
});
@ -69,7 +72,7 @@ describe("widget", function(){
expect(element.val()).toEqual("x, y, z");
element.val('1, 2, 3');
browserTrigger(element, 'keyup');
browserTrigger(element);
expect(scope.$get('list')).toEqual(['1', '2', '3']);
});
@ -191,7 +194,7 @@ describe("widget", function(){
expect(element.attr('ng-validation-error')).toBeFalsy();
element.val('x');
browserTrigger(element, 'keyup');
browserTrigger(element);
expect(element.hasClass('ng-validation-error')).toBeTruthy();
expect(element.attr('ng-validation-error')).toEqual('Not a number');
});
@ -245,7 +248,7 @@ describe("widget", function(){
expect(element.attr('ng-validation-error')).toBeFalsy();
element.val('');
browserTrigger(element, 'keyup');
browserTrigger(element);
expect(element.hasClass('ng-validation-error')).toBeTruthy();
expect(element.attr('ng-validation-error')).toEqual('Required');
});
@ -270,7 +273,7 @@ describe("widget", function(){
expect(element.attr('ng-validation-error')).toEqual('Required');
element.val('abc');
browserTrigger(element, 'keyup');
browserTrigger(element);
expect(element.hasClass('ng-validation-error')).toBeFalsy();
expect(element.attr('ng-validation-error')).toBeFalsy();
});
@ -284,11 +287,11 @@ describe("widget", function(){
expect(element.val()).toEqual("Adam");
element.val('Shyam');
browserTrigger(element, 'keyup');
browserTrigger(element);
expect(scope.$get('name')).toEqual('Shyam');
element.val('Kai');
browserTrigger(element, 'change');
browserTrigger(element);
expect(scope.$get('name')).toEqual('Kai');
});